/**
 * rigor/cache-parity.test.js — js-rigor property tests for cache
 * correctness under mutation.
 *
 * Properties verified:
 *
 *   - CACHE ON/OFF PARITY: two identical arbiters — one with caching
 *     enabled, one with `disableCaching: true` — driven through IDENTICAL
 *     random mutation sequences (adds/removes across direct, override and
 *     chain configs). After EVERY mutation, every check must agree
 *     EXACTLY. Any divergence means a stale direct-check, rule-result or
 *     chain cache survived a mutation.
 *   - TTL CONTRACT: with an injected fake clock, cached entries expire at
 *     the configured TTL — an entry read after its TTL is reported
 *     expired, never hit.
 *   - OVERRIDE + CACHE: relation-override configs (can_read → viewer)
 *     participate in invalidation — mutations on the base relation flip
 *     cached override checks immediately (regression for the stale-grant
 *     bug found by the model-based campaign).
 */
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];

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

function buildArbiter({ caching }) {
  const arbiter = new Arbiter({
    disableCaching: !caching,
    disableChainCaching: !caching,
    disableDirectCaching: !caching
  });
  arbiter.addNode('user:alice', 'user');
  arbiter.addNode('group:eng', 'group');
  arbiter.addNode('doc:1', 'doc');
  arbiter.setRelationConfig('viewer', { type: 'direct' });
  arbiter.setRelationConfig('member_of', { type: 'direct' });
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
  arbiter.setRelationConfig('can_access', {
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'viewer', direction: 'out' }
    ]
  });
  return arbiter;
}

const RELS = ['viewer', 'member_of'];
const RELATIONS = [
  ['add', 'user:alice', 'viewer', 'doc:1'],
  ['add', 'user:alice', 'member_of', 'group:eng'],
  ['add', 'group:eng', 'viewer', 'doc:1'],
  ['remove', 'user:alice', 'viewer', 'doc:1'],
  ['remove', 'user:alice', 'member_of', 'group:eng'],
  ['remove', 'group:eng', 'viewer', 'doc:1']
];

function applyOp(arbiter, op, p) {
  const [kind, src, rel, dst] = op;
  if (kind === 'add') {
    arbiter.addRelation(src, rel, dst, { possibility: p });
  } else {
    arbiter.removeRelation(src, rel, dst);
  }
}

function allChecks(arbiter) {
  const results = {};
  for (const rel of ['can_read', 'can_access']) {
    results[rel] = arbiter.check('user:alice', rel, 'doc:1').possibility;
  }
  return results;
}

describe('Cache correctness under mutation (rigor)', () => {
  it('CACHE ON/OFF PARITY: cached and uncached arbiters never diverge through mutation sequences', async () => {
    async function check(ops) {
      const cached = buildArbiter({ caching: true });
      const uncached = buildArbiter({ caching: false });

      for (const [kind, src, rel, dst, p] of ops) {
        applyOp(cached, [kind, src, rel, dst], p);
        applyOp(uncached, [kind, src, rel, dst], p);

        const c = allChecks(cached);
        const u = allChecks(uncached);
        for (const rel of Object.keys(c)) {
          if (Math.abs(c[rel] - u[rel]) > EPS) {
            fail(`diverged on ${rel} after ${kind}(${src},${rel},${dst},${p}): cached=${c[rel]}, uncached=${u[rel]}`);
          }
        }
      }
      return { ops: ops.length };
    }

    const opGen = rigor.gen.array(
      rigor.gen.tuple(
        rigor.gen.oneOf([0, 1, 2, 3, 4, 5]), // index into RELATIONS
        rigor.gen.oneOf(POS)
      ),
      1, 12
    ).map((pairs) => pairs.map(([idx, p]) => [...RELATIONS[idx], p]));

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(opGen))
      ],
      rigor.crucible([
        rigor.invariant('cache-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 600, seed: 'cache-onoff-parity' , artifacts: { dir: '', persist: 'never' }});

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

  it('OVERRIDE + CACHE: base-relation mutations immediately flip cached override checks', async () => {
    async function check({ p1, p2 }) {
      const arbiter = buildArbiter({ caching: true });

      // Warm the override-path cache with a grant
      arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p1 });
      const granted = arbiter.check('user:alice', 'can_read', 'doc:1');
      if (Math.abs(granted.possibility - p1) > EPS) {
        fail(`setup: expected ${p1}, got ${granted.possibility}`);
      }

      // Mutate the BASE relation — the cached override check must flip NOW
      arbiter.removeRelation('user:alice', 'viewer', 'doc:1');
      const revoked = arbiter.check('user:alice', 'can_read', 'doc:1');
      if (revoked.possibility !== 0) {
        fail(`override grant survived base removal: ${revoked.possibility}`);
      }

      // Re-add with a different possibility — must flip again immediately
      arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p2 });
      const regranted = arbiter.check('user:alice', 'can_read', 'doc:1');
      if (Math.abs(regranted.possibility - p2) > EPS) {
        fail(`override grant did not update to ${p2}: ${regranted.possibility}`);
      }
      return { granted, revoked, regranted };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p1: rigor.gen.oneOf(POS),
            p2: rigor.gen.oneOf(POS)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('override-cache-fresh', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'cache-override-freshness' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'override-cache-fresh');
    assert.ok(inv);
    assert.equal(inv.passed, true, `OVERRIDE CACHE violated in ${inv.failureCount} cases`);
  });

  it('TTL CONTRACT: injected clock reports entries expired after the TTL window', async () => {
    async function check({ ttl, delay }) {
      const arbiter = new Arbiter({ directCheckCacheTTL: ttl });
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:1', 'doc');
      arbiter.setRelationConfig('can_read', { type: 'direct' });
      arbiter.addRelation('user:alice', 'can_read', 'doc:1', { possibility: 1 });

      let now = 1000;
      arbiter.decisionCache.clock = () => now;

      arbiter.check('user:alice', 'can_read', 'doc:1');
      now += delay;

      const cache = arbiter.decisionCache;
      const key = arbiter.authChecker._getDirectCheckCacheKey('user:alice', 'can_read', 'doc:1');
      const [result, status] = cache.peekDirect(key);
      const expectedStatus = delay >= ttl ? 'expired' : 'hit';
      if (status !== expectedStatus) {
        fail(`ttl=${ttl}, delay=${delay}: expected '${expectedStatus}', got '${status}'`);
      }
      if (expectedStatus === 'hit' && result?.possibility !== 1) {
        fail(`hit entry lost its result: ${JSON.stringify(result)}`);
      }
      return status;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            ttl: rigor.gen.oneOf([100, 500, 1000]),
            delay: rigor.gen.oneOf([0, 50, 100, 400, 600, 1500])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('ttl-contract', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 300, seed: 'cache-ttl-contract' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttl-contract');
    assert.ok(inv);
    assert.equal(inv.passed, true, `TTL CONTRACT violated in ${inv.failureCount} cases`);
  });
});
