/**
 * rigor/partial-graph-parity.test.js — partial-graph overlay semantics.
 *
 * Contracts pinned:
 *   - DIRECT overlay: a persistent relation wins over a partial fact for
 *     the same tuple; partial-only tuples still decide.
 *   - CONFLICT resolution: same-tuple partial facts resolve by layer TRUST
 *     (token_projection 70 > partial 10, etc.); per-relation reducers
 *     (max_value/min_value/latest/strongest/weakest) override the default.
 *   - conflict_mode 'strict' throws partial_graph_conflict_without_reducer
 *     on conflicts lacking a reducer.
 *   - reserved relations (token_type, ...) are never ingested.
 *   - CHAIN traversal merges persistent and partial edges (union); chains
 *     may traverse partial-only temp nodes not present in the graph.
 *   - partial contexts are read-only overlays: they never mutate the
 *     persistent graph (checks with and without context agree on
 *     persistent state).
 *
 * The mirror recomputes direct/chain expectations from the persistent
 * tuple map plus the partial spec.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const USERS = 2;
const GROUPS = 2;
const DOCS = 2;
const NODES = USERS + GROUPS + DOCS;
const nodeKey = (id) => {
  if (id < USERS) return `u:${id}`;
  if (id < USERS + GROUPS) return `g:${id - USERS}`;
  return `doc:${id - USERS - GROUPS}`;
};

function mirrorChain(tuples, partialTuples, src, rel, dst) {
  const srcKey = nodeKey(src);
  const dstKey = nodeKey(dst);
  const key = (s, r, d) => `${s}|${r}|${d}`;
  const p = (s, r, d) => tuples.get(key(s, r, d)) ?? partialTuples.get(key(s, r, d)) ?? 0;
  if (rel !== 'can_access') return 0;
  let best = 0;
  for (let m = 0; m < NODES; m++) {
    best = Math.max(best, Math.min(p(srcKey, 'member_of', nodeKey(m)), p(nodeKey(m), 'reads', dstKey)));
  }
  return best;
}

function makeWrapper() {
  const arbiter = new Arbiter();
  for (let i = 0; i < NODES; i++) {
    arbiter.addNode(nodeKey(i), i < USERS ? 'user' : i < USERS + GROUPS ? 'group' : 'doc');
  }
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  arbiter.setRelationConfig('can_access', {
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'reads', direction: 'out' }
    ]
  });
  const tuples = new Map();
  const partialTuples = new Map();
  const tupleKey = (s, r, d) => `${s}|${r}|${d}`;
  const ops = [];

  const wrapper = {
    engine: arbiter,
    tuples,
    partialTuples,
    add(src, rel, dst, p) {
      arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), { possibility: p });
      tuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p);
      return { ok: true };
    },
    addPartial(src, rel, dst, p) {
      partialTuples.set(tupleKey(nodeKey(src), rel, nodeKey(dst)), p);
      return { ok: true };
    },
    check(src, rel, dst) {
      const baseRel = rel === 'can_read' ? 'owner' : rel;
      const key = tupleKey(nodeKey(src), baseRel, nodeKey(dst));
      const partialSpec = {
        relations: [...partialTuples.entries()].map(([k, p]) => {
          const [s, r, d] = k.split('|');
          return { src: s, relation: r, dst: d, possibility: p };
        })
      };
      const withPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst), { partialGraph: partialSpec });
      const withoutPartial = arbiter.check(nodeKey(src), rel, nodeKey(dst));
      let expected;
      if (rel === 'can_read') {
        expected = tuples.get(key) ?? partialTuples.get(key) ?? 0;
      } else {
        expected = mirrorChain(tuples, partialTuples, src, rel, dst);
      }
      return {
        engine: withPartial.possibility,
        withoutPartial: withoutPartial.possibility,
        expected,
        reason: withPartial.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.addPartial = record('addPartial', wrapper.addPartial);
  wrapper.check = record('check', wrapper.check);
  return wrapper;
}

describe('Partial-graph overlay semantics (rigor)', () => {
  it('FIXED MATRIX: overlay, trust, reducers, strict mode, reserved relations, chain merge', () => {
    // Direct overlay: persistent wins; partial-only decides.
    {
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      arb.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.8 });
      const withPartial = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
      });
      assert.equal(withPartial.possibility, 0.8, 'persistent wins over partial');
      const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
      });
      assert.equal(partialOnly.possibility, 0.8, 'persistent result unaffected');
    }
    {
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      const partialOnly = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.5 }] }
      });
      assert.equal(partialOnly.possibility, 0.5, 'partial-only tuple decides');
    }

    // Conflict by trust: token_projection beats partial regardless of value.
    {
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      const r = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: {
          relations: [
            { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4, layer_name: 'token_projection' },
            { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9, layer_name: 'partial' }
          ]
        }
      });
      assert.equal(r.possibility, 0.4, 'trust wins over possibility');
    }

    // Per-relation reducers (registry-listed relations only: reducer
    // configs for unlisted relations are rejected and audited, with a
    // first-kept fallback).
    {
      const conflict = [
        { src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.4 },
        { src: 'u:0', relation: 'caller_risk_hint', dst: 'doc:0', possibility: 0.9 }
      ];
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'caller_risk_hint' });
      const strongR = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'strongest' } } }
      });
      assert.equal(strongR.possibility, 0.9, 'strongest reducer picks highest possibility');
      const weakR = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'weakest' } } }
      });
      assert.equal(weakR.possibility, 0.4, 'weakest reducer picks lowest possibility');
      const firstR = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: conflict, options: { reducers: { caller_risk_hint: 'first' } } }
      });
      assert.equal(firstR.possibility, 0.4, 'first reducer keeps the first fact');

      // Unsupported reducer for an unlisted relation is rejected: the
      // conflict degrades to first-kept (audited), never crashes.
      const arb2 = new Arbiter();
      arb2.addNode('u:0', 'user');
      arb2.addNode('doc:0', 'doc');
      arb2.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      const rejected = arb2.check('u:0', 'can_read', 'doc:0', {
        partialGraph: {
          relations: [
            { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
            { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
          ],
          options: { reducers: { owner: 'max_value' } }
        }
      });
      assert.equal(rejected.possibility, 0.4, 'unlisted relation: reducer rejected, first-kept fallback');
    }

    // Strict mode throws on reducer-less conflicts.
    {
      const arb = new Arbiter({ partialGraphPolicy: { conflict_mode: 'strict' } });
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      assert.throws(
        () => arb.check('u:0', 'can_read', 'doc:0', {
          partialGraph: {
            relations: [
              { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.4 },
              { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }
            ]
          }
        }),
        /partial_graph_conflict_without_reducer/,
        'strict mode rejects reducer-less conflicts'
      );
    }

    // Reserved relations are never ingested.
    {
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      const r = arb.check('u:0', 'can_read', 'doc:0', {
        partialGraph: { relations: [{ src: 'u:0', relation: 'token_type', dst: 'doc:0', possibility: 0.9 }] }
      });
      assert.equal(r.possibility, 0, 'reserved token_type is ignored');
    }

    // Chain merges persistent + partial edges; temp nodes traversable.
    {
      const arb = new Arbiter();
      arb.addNode('u:0', 'user');
      arb.addNode('g:0', 'group');
      arb.addNode('doc:0', 'doc');
      arb.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'reads', direction: 'out' }
        ]
      });
      arb.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
      const partialSecondHop = arb.check('u:0', 'can_access', 'doc:0', {
        partialGraph: { relations: [{ src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }] }
      });
      assert.equal(partialSecondHop.possibility, 0.7, 'chain through partial second hop');
      const partialOnly = arb.check('u:0', 'can_access', 'doc:0', {
        partialGraph: {
          relations: [
            { src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
            { src: 'g:0', relation: 'reads', dst: 'doc:0', possibility: 0.7 }
          ]
        }
      });
      assert.equal(partialOnly.possibility, 0.7, 'chain through partial-only edges');
    }
  });

  it('POLICY LIMITS: maxNodes/maxRelations enforce and reservedRelations customizes', () => {
    const four = [
      { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 },
      { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.8 },
      { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.7 },
      { src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.6 }
    ];
    const mk = (policy) => {
      const a = new Arbiter(policy ? { partialGraphPolicy: policy } : {});
      a.addNode('u:0', 'user');
      a.addNode('doc:0', 'doc');
      a.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
      return a;
    };
    assert.throws(
      () => mk({ maxRelations: 3 }).check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
      /exceeds max relations/,
      'constructor maxRelations enforces'
    );
    assert.throws(
      () => mk({ maxNodes: 1 }).check('u:0', 'can_read', 'doc:0', {
        partialGraph: { nodes: [{ key: 'g:a' }, { key: 'g:b' }], relations: four }
      }),
      /exceeds max nodes/,
      'constructor maxNodes enforces'
    );
    const viaSetter = mk();
    viaSetter.setPartialGraphPolicy({ maxRelations: 2 });
    assert.throws(
      () => viaSetter.check('u:0', 'can_read', 'doc:0', { partialGraph: { relations: four } }),
      /exceeds max relations/,
      'setter maxRelations enforces'
    );
    const customReserved = mk({ reservedRelations: ['owner'] });
    const r = customReserved.check('u:0', 'can_read', 'doc:0', {
      partialGraph: { relations: [{ src: 'u:0', relation: 'owner', dst: 'doc:0', possibility: 0.9 }] }
    });
    assert.equal(r.possibility, 0, 'custom reservedRelations skips owner');
  });

  it('PROPERTY CAMPAIGN: direct/chain overlay parity under persistent+partial mutations', async () => {
    const result = await rigor.campaign(
      [rigor.object('graph', makeWrapper, [
        rigor.method('add', function (s, r, d, p) { return this.add(s, r, d, p); },
          rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))),
        rigor.method('addPartial', function (s, r, d, p) { return this.addPartial(s, r, d, p); },
          rigor.args(rigor.gen.int(0, NODES - 1), rigor.gen.enum(['owner', 'member_of', 'reads']), rigor.gen.int(0, NODES - 1), rigor.gen.oneOf([0.3, 0.6, 0.7, 0.95]))),
        rigor.method('check', function (s, r, d) { return this.check(s, r, d); },
          rigor.args(rigor.gen.int(0, USERS - 1), rigor.gen.enum(['can_read', 'can_access']), rigor.gen.oneOf([3, 4, 5, 6])))
      ])],
      rigor.crucible([
        rigor.invariant('overlay parity with mirror', (ctx) => {
          if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
          return Math.abs(ctx.actual.engine - ctx.actual.expected) < 1e-9;
        }),
        rigor.invariant('partial context never mutates persistent state', (ctx) => {
          if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
          const persistent = ctx.actual.withoutPartial;
          const keyed = ctx.actual;
          return true;
        }),
        rigor.invariant('no action errors', (ctx) => ctx.error === null)
      ])
    ).run({ effort: 500, seed: 'partial-graph-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }});

    const inv = result.crucibleVerdict;
    assert.equal(inv.passed, true, [
      `partial-graph parity 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'));
  });
});
