/**
 * rigor/batch-order-parity.test.js — batch update ordering semantics.
 *
 * updateRelationsBatch applies operations in the GIVEN order
 * (last-write-wins per tuple). Contracts pinned:
 *   - [add v1, remove] ends absent; [remove, add v1] ends v1.
 *   - [modify, add, modify] ends with the LAST modify's value.
 *   - modify of a tuple that does not exist (yet) is a silent no-op.
 *   - decisions after a mixed batch equal the in-order mirror.
 *
 * The mirror is a plain last-write-wins map with the modify-no-op rule;
 * the engine must agree after every mixed batch.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const KEY = 'u:0|owner|doc:0';
const VALUES = [0.2, 0.5, 0.8, 0.9];
const RELIABILITIES = [0.3, 0.6, 0.9];

function applyMirror(tuples, ops) {
  for (const op of ops) {
    if (op.operation === 'remove') {
      tuples.delete(KEY);
    } else if (op.operation === 'modify' && !tuples.has(KEY)) {
      continue; // engine no-op on missing tuple
    } else {
      // Last write wins for the whole tuple: reliability rides along, so
      // the mirror carries it and the decision invariant can check it too.
      tuples.set(KEY, { p: op.value, r: op.reliability });
    }
  }
}

function makeWrapper() {
  const arbiter = new Arbiter();
  arbiter.addNode('u:0', 'user');
  arbiter.addNode('doc:0', 'doc');
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  const tuples = new Map();
  const ops = [];

  const wrapper = {
    engine: arbiter,
    tuples,
    add(value) {
      arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: value });
      // add-on-existing is a modify: reliability is preserved, not reset.
      const existing = tuples.get(KEY);
      tuples.set(KEY, { p: value, r: existing ? existing.r : 1.0 });
      return { ok: true };
    },
    remove() {
      arbiter.removeRelation('u:0', 'owner', 'doc:0');
      tuples.delete(KEY);
      return { ok: true };
    },
    mixedBatch(batchOps) {
      const batch = batchOps.map((op) => ({
        operation: op.operation,
        srcKey: 'u:0',
        relation: 'owner',
        dstKey: 'doc:0',
        options: op.operation === 'remove' ? {} : { possibility: op.value, reliability: op.reliability }
      }));
      arbiter.relationManager.updateRelationsBatch(batch);
      applyMirror(tuples, batchOps);
      return { ok: true, count: batchOps.length };
    },
    check() {
      const result = arbiter.check('u:0', 'can_read', 'doc:0');
      const entry = tuples.get(KEY);
      const expected = entry ? entry.p : 0;
      const expectedReliability = entry ? entry.r : 0;
      return { engine: result.possibility, expected, expectedReliability, reliability: result.reliability, 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.add = record('add', wrapper.add);
  wrapper.remove = record('remove', wrapper.remove);
  wrapper.mixedBatch = record('mixedBatch', wrapper.mixedBatch);
  wrapper.check = record('check', wrapper.check);
  return wrapper;
}

const opGen = rigor.gen.record({
  operation: rigor.gen.enum(['add', 'modify', 'remove']),
  value: rigor.gen.oneOf(VALUES),
  reliability: rigor.gen.oneOf(RELIABILITIES)
});

describe('Batch update ordering semantics (rigor)', () => {
  it('FIXED MATRIX: mixed-kind batches preserve last-write-wins', () => {
    const w = makeWrapper();
    w.add(0.9);
    w.mixedBatch([
      { operation: 'add', value: 0.5 },
      { operation: 'remove', value: 0.5 }
    ]);
    assert.equal(w.check().engine, 0, '[add, remove] ends absent');

    w.mixedBatch([
      { operation: 'remove', value: 0.5 },
      { operation: 'add', value: 0.5 }
    ]);
    assert.equal(w.check().engine, 0.5, '[remove, add] ends 0.5');

    w.mixedBatch([
      { operation: 'modify', value: 0.6 },
      { operation: 'add', value: 0.8 },
      { operation: 'modify', value: 0.9 }
    ]);
    assert.equal(w.check().engine, 0.9, '[modify, add, modify] ends with last value');

    w.mixedBatch([
      { operation: 'remove', value: 0.9 },
      { operation: 'modify', value: 0.7 }
    ]);
    assert.equal(w.check().engine, 0, 'modify after remove (missing tuple) is a no-op');
  });

  it('PROPERTY CAMPAIGN: engine agrees with the in-order mirror after every mixed batch', async () => {
    const result = await rigor.campaign(
      [rigor.object('graph', makeWrapper, [
        rigor.method('add', function (v) { return this.add(v); }, rigor.args(rigor.gen.oneOf(VALUES))),
        rigor.method('remove', function () { return this.remove(); }),
        rigor.method('mixedBatch', function (batchOps) { return this.mixedBatch(batchOps); },
          rigor.args(rigor.gen.array(opGen, 2, 4))),
        rigor.method('check', function () { return this.check(); })
      ])],
      rigor.crucible([
        rigor.invariant('decision parity after every step', (ctx) => {
          if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
          return ctx.actual.engine === ctx.actual.expected;
        }),
        rigor.invariant('reliability parity after every step', (ctx) => {
          if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
          if (ctx.actual.expectedReliability === 0) return true; // denied decision: reliability 0
          return Math.abs((ctx.actual.reliability ?? 1.0) - ctx.actual.expectedReliability) < 1e-9;
        }),
        rigor.invariant('no action errors', (ctx) => ctx.error === null)
      ])
    ).run({ effort: 400, seed: 'batch-order-parity', maxTraceLength: 25 , artifacts: { dir: '', persist: 'never' }});

    const inv = result.crucibleVerdict;
    assert.equal(inv.passed, true, [
      `batch ordering violated in ${inv.failureCount} cases:`,
      ...result.failures.slice(0, 3).map((f) =>
        `  [${f.name}] action=${f.actionName} seq=${JSON.stringify((f.sequence || []).map(s => s.args).filter(a => a && a.length))} actual=${JSON.stringify(f.actual)} error=${f.error}`
      )
    ].join('\n'));
  });
});
