/**
 * rigor/traversal-parity.test.js — js-rigor property tests for chain
 * direction semantics, TTU multi-tuple aggregation, and the update path.
 *
 * Properties verified:
 *
 *   - CHAIN DIRECTION PARITY: for 2-3 step chains with arbitrary
 *     out/in directions, the engine's traversal (getRelationsFromSrc /
 *     getRelationsToDst) matches a BFS oracle: step 'out' walks
 *     src -r-> dst, step 'in' walks src <-r- dst; path possibility is
 *     the MIN along the path; multiple paths take the MAX.
 *   - TTU MULTI-TUPLE PARITY: possibility = max over tuple edges of
 *     min(tuplePossibility, memberPossibility) where membership is a
 *     DIRECT user->group lookup.
 *   - UPDATE PATH PARITY: overwriting an existing tuple
 *     (addRelation on an existing src/rel/dst) is last-write-wins:
 *     the check reflects the new possibility, the engine keeps exactly
 *     one tuple, and indices stay consistent — for direct, chain, and
 *     TTU configs alike.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const EPS = 1e-9;
const POS = [0, 0.25, 0.5, 0.75, 1];
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];

function fail(message) {
  throw new Error(message);
}

function mulberry32(seed) {
  let a = seed >>> 0;
  return {
    next() {
      a |= 0; a = (a + 0x6D2B79F5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    }
  };
}

// Edge universe: every possible directed edge on the 4-node graph
// (u, m1, m2, o) for relations r1 and r2.
const EDGE_UNIVERSE = {
  r1: [
    ['user:alice', 'mid:1'],
    ['mid:1', 'user:alice'],
    ['mid:1', 'mid:2'],
    ['doc:1', 'mid:2'],
    ['mid:2', 'doc:1']
  ],
  r2: [
    ['mid:1', 'doc:1'],
    ['doc:1', 'mid:1'],
    ['mid:2', 'user:alice'],
    ['user:alice', 'mid:2'],
    ['user:alice', 'doc:1'],
    ['mid:2', 'mid:1']
  ]
};

function randomEdges(rng, density = 0.5) {
  const edges = [];
  for (const rel of ['r1', 'r2']) {
    for (const [src, dst] of EDGE_UNIVERSE[rel]) {
      if (rng.next() < density) {
        edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
      }
    }
  }
  return edges;
}

function edgeKey(src, rel, dst) {
  return `${src}|${rel}|${dst}`;
}

function chainOracle(steps, edges) {
  const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]]));
  let frontier = new Map([['user:alice', 1.0]]);
  for (const step of steps) {
    const { relation, direction } = step;
    const next = new Map();
    for (const [node, p] of frontier) {
      for (const [key, ep] of em) {
        const [s, r, d] = key.split('|');
        if (r !== relation) continue;
        const matches = direction === 'out' ? s === node : d === node;
        if (!matches) continue;
        const nxt = direction === 'out' ? d : s;
        if (nxt === node) continue; // no self-loop progress
        const np = Math.min(p, ep);
        const cur = next.get(nxt);
        if (cur === undefined || np > cur) next.set(nxt, np);
      }
    }
    frontier = next;
    if (frontier.size === 0) break;
  }
  return frontier.get('doc:1') || 0;
}

function makeChainConfig(steps) {
  return { type: 'chain', steps };
}

function applyEdges(arb, edges, mode) {
  for (const [src, rel, dst, p] of edges) {
    if (mode === 'add') arb.addRelation(src, rel, dst, { possibility: p });
    else arb.removeRelation(src, rel, dst);
  }
}

function buildArbiter() {
  const arb = new Arbiter();
  for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
  arb.setRelationConfig('r1', { type: 'direct' });
  arb.setRelationConfig('r2', { type: 'direct' });
  arb.setRelationConfig('owner', { type: 'direct' });
  arb.setRelationConfig('member_of', { type: 'direct' });
  return arb;
}

describe('Traversal semantics parity (rigor)', () => {
  it('CHAIN DIRECTION PARITY: arbitrary out/in step mixes match the BFS oracle', async () => {
    async function check({ seed, dirs, threeSteps }) {
      const rng = mulberry32(seed);
      const edges = randomEdges(rng);
      const arb = buildArbiter();
      const d = dirs.split('-');
      const steps = threeSteps
        ? [
            { relation: 'r1', direction: d[0] },
            { relation: 'r2', direction: d[1] },
            { relation: 'r1', direction: d[2 % 2] }
          ]
        : [
            { relation: 'r1', direction: d[0] },
            { relation: 'r2', direction: d[1] }
          ];
      arb.setRelationConfig('target', makeChainConfig(steps));
      applyEdges(arb, edges, 'add');

      const expectedP = chainOracle(steps, edges);
      const res = arb.check('user:alice', 'target', 'doc:1', {});
      if (Math.abs(res.possibility - expectedP) > EPS) {
        fail(`chain mismatch dirs=${JSON.stringify(dirs)} steps=${steps.length} p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
      }

      // Mutations must keep parity
      const rels = ['r1', 'r2'];
      for (let i = 0; i < 3; i++) {
        const rel = rels[Math.floor(rng.next() * 2)];
        const [src, dst] = EDGE_UNIVERSE[rel][Math.floor(rng.next() * EDGE_UNIVERSE[rel].length)];
        const idx = edges.findIndex(e => e[0] === src && e[1] === rel && e[2] === dst);
        if (idx !== -1) {
          arb.removeRelation(src, rel, dst);
          edges.splice(idx, 1);
        } else {
          const p = POS[Math.floor(rng.next() * POS.length)];
          arb.addRelation(src, rel, dst, { possibility: p });
          edges.push([src, rel, dst, p]);
        }
        const eP = chainOracle(steps, edges);
        const r = arb.check('user:alice', 'target', 'doc:1', {});
        if (Math.abs(r.possibility - eP) > EPS) {
          fail(`chain mutation mismatch dirs=${JSON.stringify(dirs)} p=${eP} got=${r.possibility} edges=${JSON.stringify(edges)}`);
        }
      }
      return { steps: steps.length };
    }

    const dirPairs = ['out-out', 'out-in', 'in-out', 'in-in'];
    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            seed: rigor.gen.int(1, 100000),
            dirs: rigor.gen.oneOf(dirPairs),
            threeSteps: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('chain-direction-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1500, seed: 'traversal-chain-direction' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-direction-parity');
    assert.ok(inv, 'invariant missing');
    assert.equal(inv.passed, true, `chain direction parity violated in ${inv.failureCount} cases`);
  });

  it('TTU MULTI-TUPLE PARITY: max over tuples of min(tuple, membership)', async () => {
    async function check({ seed }) {
      const rng = mulberry32(seed);
      const arb = buildArbiter();
      arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
      const tuples = [
        ['doc:1', 'group:eng'],
        ['doc:1', 'group:design']
      ];
      arb.addNode('group:eng', 'group');
      arb.addNode('group:design', 'group');
      const edges = [];
      let expectedP = 0;
      for (const [doc, grp] of tuples) {
        if (rng.next() < 0.7) {
          const tp = POS[Math.floor(rng.next() * POS.length)];
          arb.addRelation(doc, 'owner', grp, { possibility: tp });
          edges.push([doc, 'owner', grp, tp]);
          let mp = 0;
          if (rng.next() < 0.8) {
            mp = POS[Math.floor(rng.next() * POS.length)];
            arb.addRelation('user:alice', 'member_of', grp, { possibility: mp });
            edges.push(['user:alice', 'member_of', grp, mp]);
          }
          expectedP = Math.max(expectedP, Math.min(tp, mp));
        }
      }
      const res = arb.check('user:alice', 'target', 'doc:1', {});
      if (Math.abs(res.possibility - expectedP) > EPS) {
        fail(`ttu mismatch p=${expectedP} got=${res.possibility} edges=${JSON.stringify(edges)}`);
      }

      // Mutation: flip one membership
      const grp = tuples[Math.floor(rng.next() * 2)][1];
      const hasMember = edges.some(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
      if (hasMember) {
        arb.removeRelation('user:alice', 'member_of', grp);
        const idx = edges.findIndex(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp);
        if (idx !== -1) edges.splice(idx, 1);
      } else {
        const p = POS[Math.floor(rng.next() * POS.length)];
        arb.addRelation('user:alice', 'member_of', grp, { possibility: p });
        edges.push(['user:alice', 'member_of', grp, p]);
      }
      let eP = 0;
      const tupleMap = new Map();
      for (const [s, r, d, p] of edges) {
        if (r === 'owner') tupleMap.set(d, p);
      }
      for (const [grp2, tp] of tupleMap) {
        const mem = edges.find(e => e[0] === 'user:alice' && e[1] === 'member_of' && e[2] === grp2);
        eP = Math.max(eP, Math.min(tp, mem ? mem[3] : 0));
      }
      const r2 = arb.check('user:alice', 'target', 'doc:1', {});
      if (Math.abs(r2.possibility - eP) > EPS) {
        fail(`ttu mutation mismatch p=${eP} got=${r2.possibility} edges=${JSON.stringify(edges)}`);
      }
      return { edges: edges.length };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({ seed: rigor.gen.int(1, 100000) })
        ))
      ],
      rigor.crucible([
        rigor.invariant('ttu-multi-tuple-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1200, seed: 'traversal-ttu-multituple' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-multi-tuple-parity');
    assert.ok(inv, 'invariant missing');
    assert.equal(inv.passed, true, `ttu parity violated in ${inv.failureCount} cases`);
  });

  it('UPDATE PATH PARITY: overwrites are last-write-wins with a single tuple and fresh checks', async () => {
    async function check({ seed, kind }) {
      const rng = mulberry32(seed);
      const arb = buildArbiter();
      let target;
      if (kind === 0) {
        arb.setRelationConfig('target', { type: 'direct', relation: 'r1' });
        target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
      } else if (kind === 1) {
        arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
        target = { check: (u, o) => arb.check(u, 'target', o), base: 'r1' };
      } else {
        arb.setRelationConfig('target', { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' });
        arb.addNode('group:eng', 'group');
        target = { check: (u, o) => arb.check(u, 'target', o), base: 'owner' };
      }

      const src = kind === 2 ? 'doc:1' : 'user:alice';
      const dst = kind === 0 ? 'mid:1' : kind === 1 ? 'mid:1' : 'group:eng';
      // Warm with the first value
      const p0 = POS[Math.floor(rng.next() * POS.length)];
      arb.addRelation(src, target.base, dst, { possibility: p0 });

      let lastP = p0;
      for (let i = 0; i < 4; i++) {
        const p = POS[Math.floor(rng.next() * POS.length)];
        arb.addRelation(src, target.base, dst, { possibility: p });
        lastP = p;
      }

      // Exactly one tuple in the engine (no duplicates from overwrites)
      const count = arb.relations.filter(r => r.rel === target.base && r.src === arb.resolveNodeId(src) && r.dst === arb.resolveNodeId(dst)).length;      if (count !== 1) {
        fail(`overwrite left ${count} tuples for ${target.base} (kind=${kind})`);
      }

      if (kind === 0) {
        const res = arb.check(src, 'target', kind === 0 ? 'mid:1' : 'doc:1');
        if (Math.abs(res.possibility - lastP) > EPS) {
          fail(`update path stale: expected ${lastP}, got ${res.possibility} (kind=${kind})`);
        }
      } else if (kind === 1) {
        // chain: seed an r2 edge so the path exists
        arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });
        const res = arb.check(src, 'target', 'doc:1');
        if (Math.abs(res.possibility - lastP) > EPS) {
          fail(`chain update path stale: expected ${lastP}, got ${res.possibility}`);
        }
      } else {
        arb.addRelation('user:alice', 'member_of', 'group:eng', { possibility: 1 });
        const res = arb.check('user:alice', 'target', 'doc:1');
        if (Math.abs(res.possibility - lastP) > EPS) {
          fail(`ttu update path stale: expected ${lastP}, got ${res.possibility}`);
        }
      }
      return { lastP };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            seed: rigor.gen.int(1, 50000),
            kind: rigor.gen.int(0, 2)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('update-path-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 600, seed: 'traversal-update-path' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'update-path-parity');
    assert.ok(inv, 'invariant missing');
    assert.equal(inv.passed, true, `update path parity violated in ${inv.failureCount} cases`);
  });
});
