/**
 * rigor/value-freshness-parity.test.js — value-layer and batch-path cache
 * freshness.
 *
 * The needle class: queries that warm the rule-result / decision caches,
 * followed by mutations applied through the BATCH path (which bypasses
 * Arbiter.addRelation/removeRelation and their cache invalidation), must
 * still return fresh answers:
 *   - comparator decisions reflect the latest values after batch modify
 *     and batch remove+add;
 *   - direct checks reflect batch adds/removes with warm caches;
 *   - transactional batches either fully apply (success) or fully roll
 *     back (poisoned op -> state unchanged, checks unchanged).
 *
 * The mirror tracks balance/price per document; comparator expectation is
 * balance > price (engine returns 1/0 with reasons
 * allow_rule_matched / values_compared_comparison_false).
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const DOCS = 3;
const docKey = (i) => `doc:${i}`;

function makeWrapper() {
  const arbiter = new Arbiter();
  arbiter.addNode('user:alice', 'user');
  for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc');
  arbiter.setRelationConfig('has_balance', { type: 'direct' });
  arbiter.setRelationConfig('has_price', { type: 'direct' });
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  arbiter.setRelationConfig('premium', {
    type: 'relational_comparator',
    comparator: '>',
    left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
    right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
  });

  const balance = new Map();
  const price = new Map();
  const ops = [];

  const wrapper = {
    engine: arbiter,
    balance,
    price,
    setValue(doc, kind, value) {
      const rel = kind === 'balance' ? 'has_balance' : 'has_price';
      const src = kind === 'balance' ? 'user:alice' : docKey(doc);
      arbiter.addRelation(src, rel, docKey(doc), { value, possibility: 1.0 });
      (kind === 'balance' ? balance : price).set(doc, value);
      return { ok: true };
    },
    batchSetValue(doc, kind, value) {
      const rel = kind === 'balance' ? 'has_balance' : 'has_price';
      const src = kind === 'balance' ? 'user:alice' : docKey(doc);
      const target = kind === 'balance' ? balance : price;
      const batch = [{ operation: 'modify', srcKey: src, relation: rel, dstKey: docKey(doc), options: { value, possibility: 1.0 } }];
      arbiter.relationManager.updateRelationsBatch(batch);
      // A batch MODIFY of a relation that does not exist is a silent no-op
      // in the engine (mirrors _modifyRelation), so the mirror must only
      // record when the relation was already present.
      if (target.has(doc)) target.set(doc, value);
      return { ok: true };
    },
    batchSwap(doc, kind, value) {
      const rel = kind === 'balance' ? 'has_balance' : 'has_price';
      const src = kind === 'balance' ? 'user:alice' : docKey(doc);
      const batch = [
        { operation: 'remove', srcKey: src, relation: rel, dstKey: docKey(doc), options: {} },
        { operation: 'add', srcKey: src, relation: rel, dstKey: docKey(doc), options: { value, possibility: 1.0 } }
      ];
      arbiter.relationManager.updateRelationsBatch(batch);
      (kind === 'balance' ? balance : price).set(doc, value);
      return { ok: true };
    },
    txBatch(doc, kind, value, poison) {
      const rel = kind === 'balance' ? 'has_balance' : 'has_price';
      const src = kind === 'balance' ? 'user:alice' : docKey(doc);
      const batch = [{
        operation: 'modify',
        srcKey: src,
        relation: rel,
        dstKey: docKey(doc),
        options: poison ? { value, possibility: 1.5 } : { value, possibility: 1.0 }
      }];
      const result = arbiter.relationManager.updateRelationsBatchTransactional(batch);
      if (result.success) {
        const target = kind === 'balance' ? balance : price;
        if (target.has(doc)) target.set(doc, value);
      }
      return { success: result.success, error: result.error };
    },
    check(doc) {
      const result = arbiter.check('user:alice', 'premium', docKey(doc));
      const b = balance.get(doc);
      const p = price.get(doc);
      const expected = b !== undefined && p !== undefined && b > p ? 1 : 0;
      return {
        engine: result.possibility,
        expected,
        reason: result.reason,
        balance: b,
        price: p
      };
    },
    checkRead(doc) {
      const result = arbiter.check('user:alice', 'can_read', docKey(doc));
      return { engine: result.possibility, reason: result.reason };
    },
    clone() {
      const fresh = makeWrapper();
      for (const op of ops) {
        const [name, ...args] = op;
        fresh[name](...args);
      }
      return fresh;
    }
  };

  const record = (name, fn) => (...args) => {
    const res = fn(...args);
    ops.push([name, ...args]);
    return res;
  };
  wrapper.setValue = record('setValue', wrapper.setValue);
  wrapper.batchSetValue = record('batchSetValue', wrapper.batchSetValue);
  wrapper.batchSwap = record('batchSwap', wrapper.batchSwap);
  wrapper.txBatch = record('txBatch', wrapper.txBatch);
  wrapper.check = record('check', wrapper.check);
  wrapper.checkRead = record('checkRead', wrapper.checkRead);
  return wrapper;
}

const docArg = rigor.gen.int(0, DOCS - 1);
const kindArg = rigor.gen.enum(['balance', 'price']);
const valueArg = rigor.gen.oneOf([5, 10, 50, 100, 200]);

describe('Value-layer and batch-path freshness (rigor)', () => {
  it('FIXED MATRIX: batch modify, batch swap, and transactional rollback stay fresh', () => {
    const w = makeWrapper();
    w.setValue(0, 'balance', 100);
    w.setValue(0, 'price', 50);
    assert.equal(w.check(0).engine, 1, 'v1: 100 > 50');

    w.batchSetValue(0, 'balance', 10);
    assert.equal(w.check(0).engine, 0, 'batch modify must be fresh: 10 > 50 is false');

    w.batchSwap(0, 'balance', 200);
    assert.equal(w.check(0).engine, 1, 'batch remove+add must be fresh: 200 > 50');

    const tx = w.txBatch(0, 'balance', 1, true);
    assert.equal(tx.success, false, 'poisoned tx must report failure');
    assert.equal(w.check(0).engine, 1, 'tx rollback must leave state unchanged: 200 > 50');

    const txOk = w.txBatch(0, 'balance', 5, false);
    assert.equal(txOk.success, true, 'clean tx must succeed');
    assert.equal(w.check(0).engine, 0, 'clean tx must apply: 5 > 50 is false');

    w.engine.addRelation('user:alice', 'owner', 'doc:0', { possibility: 0.9 });
    assert.equal(w.checkRead(0).engine, 0.9, 'direct check before batch');
    w.engine.relationManager.updateRelationsBatch([
      { operation: 'remove', srcKey: 'user:alice', relation: 'owner', dstKey: 'doc:0', options: {} }
    ]);
    assert.equal(w.checkRead(0).engine, 0, 'batch remove must invalidate warm direct caches');
  });

  it('PROPERTY CAMPAIGN: interleaved single/batch/tx mutations keep comparator parity', async () => {
    const result = await rigor.campaign(
      [rigor.object('graph', makeWrapper, [
        rigor.method('setValue', function (d, k, v) { return this.setValue(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
        rigor.method('batchSetValue', function (d, k, v) { return this.batchSetValue(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
        rigor.method('batchSwap', function (d, k, v) { return this.batchSwap(d, k, v); }, rigor.args(docArg, kindArg, valueArg)),
        rigor.method('txBatch', function (d, k, v, poison) { return this.txBatch(d, k, v, poison); }, rigor.args(docArg, kindArg, valueArg, rigor.gen.boolean())),
        rigor.method('check', function (d) { return this.check(d); }, rigor.args(docArg))
      ])],
      rigor.crucible([
        rigor.invariant('comparator parity after every mutation', (ctx) => {
          if (ctx.action !== 'graph.check') return true;
          if (ctx.error !== null) return true;
          return ctx.actual.engine === ctx.actual.expected;
        }),
        rigor.invariant('no action errors', (ctx) => {
          if (ctx.action === 'graph.txBatch') return true;
          return ctx.error === null;
        }),
        rigor.after('graph.txBatch', ({ actual }) => {
          return typeof actual.success === 'boolean';
        })
      ])
    ).run({ effort: 400, seed: 'value-freshness-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }});

    const inv = result.crucibleVerdict;
    assert.equal(inv.passed, true, [
      `value freshness violated in ${inv.failureCount} cases:`,
      ...result.failures.slice(0, 3).map((f) =>
        `  [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
      )
    ].join('\n'));
  });
});
