/**
 * rigor/complex-graph-ttl-crucible.test.js — value-TTL expiry over the
 * complex graphs, with an injected clock.
 *
 * The graph generators ship relations WITHOUT changed_last_at, so TTL
 * gating is exercised on value-carrying edges written by the test with
 * pinned `changed_last_at` timestamps (the same mirror-friendly strategy
 * as ttl-expiry-parity). A relational-comparator policy consumes the
 * values, because TTL expiry only gates VALUE extraction — a plain direct
 * check returns the relation possibility regardless of age.
 *
 *   FRESHNESS-PARITY       — a value written at `now` grants immediately,
 *                            still grants at TTL-1, and denies at TTL+1;
 *                            the engine matches a mirror freshness rule
 *                            (fresh iff age <= TTL).
 *   MUTATION-WITH-TIME     — after every value rewrite (pinned
 *                            changed_last_at), binary mode agrees with
 *                            normal at the current pinned `now`, and both
 *                            agree with the mirror.
 *   SNAPSHOT-PRESERVES-TTL — the condensed snapshot round-trip preserves
 *                            the TTL config AND the expiry gate: both the
 *                            original engine (against its pinned clock) and
 *                            the restored engine (against its effective
 *                            write clock) grant within TTL and deny past it.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { makeCommunityGraph, makeScaleFreeGraph } from './complex-graphs.js';

const TTL = 60_000;
const BASE_NOW = 1_000_000_000_000;
// The restored engine reports value edges as written at snapshot-restore
// time; the deny check must sit comfortably past that wall clock.
const RESTORE_BUFFER = 5_000;

const GENERATORS = [
  { name: 'community', make: makeCommunityGraph, opts: {} },
  // Default scale-free (150 users / 400 edges) is ~6x slower to build;
  // a smaller power-law graph exercises the same TTL contract.
  { name: 'scale-free', make: makeScaleFreeGraph, opts: { users: 60, resources: 20, edges: 150 } }
];

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

function configureComparator(arbiter, graphRels) {
  arbiter.setRelationConfig('balance', { type: 'direct' });
  arbiter.setRelationConfig('price', { type: 'direct' });
  arbiter.setRelationConfig('premium_access', {
    type: 'relational_comparator',
    comparator: '>',
    left: { rule: { type: 'direct', relation: 'balance' }, extractValue: true },
    right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'price' }, extractValue: true }
  });
  arbiter.valueManager.setTTL('balance', TTL);
  arbiter.valueManager.setTTL('price', TTL);
  // The graph's own direct relations carry no values, so a TTL on them
  // only gates value extraction (never the plain check) — harmless, and
  // it pins the snapshot's TTL-config round-trip for those names too.
  for (const rel of graphRels) arbiter.valueManager.setTTL(rel, TTL);
}

function buildSnapshotEngine(spec, seed) {
  const g = spec.make(seed, spec.opts);
  const arbiter = g.arbiter;
  configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
  const u = g.users[0];
  const o = (g.resources || g.subGroups || g.groups)[0];
  arbiter.addRelation(u, 'balance', o, { value: 100, possibility: 1.0, changed_last_at: BASE_NOW });
  // price evaluates from the object, so the edge is object -> object.
  arbiter.addRelation(o, 'price', o, { value: 50, possibility: 1.0, changed_last_at: BASE_NOW });
  return { g, arbiter, u, o };
}

describe('Complex-graph value-TTL crucibles (rigor)', () => {
  it('FRESHNESS / MUTATION / SNAPSHOT-TTL: TTL expiry holds across complex graphs', async () => {
    async function check(args) {
      const { genKind, seed, mutationCount } = args;
      const spec = GENERATORS.find(g => g.name === genKind);
      const g = spec.make(seed, spec.opts);
      const arbiter = g.arbiter;
      configureComparator(arbiter, spec.name === 'community' ? ['direct_access', 'member'] : ['can_read']);
      const u = g.users[0];
      const o = (g.resources || g.subGroups || g.groups)[0];

      let engineNow = BASE_NOW;
      const bal = { value: 100, ts: engineNow };
      const prc = { value: 50, ts: engineNow };

      const write = (kind, value) => {
        const src = kind === 'balance' ? u : o;
        arbiter.addRelation(src, kind, o, { value, possibility: 1.0, changed_last_at: engineNow });
        const target = kind === 'balance' ? bal : prc;
        // The engine only refreshes changed_last_at when the value actually
        // changes; the mirror must mirror that or it un-expires old values.
        if (target.value !== value) { target.value = value; target.ts = engineNow; }
      };
      const expected = () => {
        const bFresh = engineNow - bal.ts <= TTL;
        const pFresh = engineNow - prc.ts <= TTL;
        return bFresh && pFresh && bal.value > prc.value ? 1 : 0;
      };
      const checkAt = () => arbiter.check(u, 'premium_access', o, { now: engineNow }).possibility;
      const checkAtBinary = () => arbiter.check(u, 'premium_access', o, { now: engineNow, binary: true }).possibility;

      write('balance', 100);
      write('price', 50);

      // FRESHNESS-PARITY
      if (checkAt() !== 1) fail(`[freshness] fresh write did not grant (${genKind} seed=${seed})`);
      engineNow += TTL - 1;
      if (checkAt() !== expected()) fail(`[freshness] TTL-1 mismatch (${genKind} seed=${seed})`);
      engineNow += 2; // now exactly TTL+1 since the write
      const expired = checkAt();
      if (expired !== 0) fail(`[freshness] expired value still grants (${genKind} seed=${seed}: ${expired})`);
      if (expired !== expected()) fail(`[freshness] mirror mismatch at expiry (${genKind} seed=${seed})`);

      // MUTATION-WITH-TIME: refresh both operands, then mutate and check.
      engineNow += 100_000;
      write('balance', 130);
      write('price', 40);
      for (let m = 0; m < mutationCount; m++) {
        engineNow += 1000 * (1 + m);
        const kind = m % 2 === 0 ? 'balance' : 'price';
        write(kind, [20, 60, 120][m % 3]);
        const normal = checkAt();
        const binary = checkAtBinary();
        if (normal !== binary) fail(`[mutation] binary=${binary} normal=${normal} disagree at now=${engineNow} (${genKind} seed=${seed})`);
        if (normal !== expected()) fail(`[mutation] engine=${normal} mirror=${expected()} disagree at now=${engineNow} (${genKind} seed=${seed})`);
      }

      // SNAPSHOT-PRESERVES-TTL on a dedicated never-mutated engine.
      const { arbiter: sArb, u: su, o: so } = buildSnapshotEngine(spec, seed);
      const expAt = BASE_NOW + TTL + 1;
      if (sArb.check(su, 'premium_access', so, { now: BASE_NOW }).possibility !== 1) {
        fail(`[snapshot] fresh snapshot engine did not grant (${genKind} seed=${seed})`);
      }
      if (sArb.check(su, 'premium_access', so, { now: expAt }).possibility !== 0) {
        fail(`[snapshot] original engine did not expire at TTL+1 (${genKind} seed=${seed})`);
      }
      sArb.enableCondensedSnapshot();
      const buf = sArb.toSnapshotBinary();
      const restored = Arbiter.fromSnapshotBinary(buf);
      if (restored.valueManager.getTTL('balance') !== TTL) {
        fail(`[snapshot] TTL config lost across restore (${genKind} seed=${seed})`);
      }
      const restoredTs = restored.relationManager.getDirectRelation(
        restored.nodeIdByKey.get(su), 'balance', restored.nodeIdByKey.get(so)
      ).changed_last_at;
      if (restored.check(su, 'premium_access', so, { now: restoredTs }).possibility !== 1) {
        fail(`[snapshot] restored value not fresh at its own write clock (${genKind} seed=${seed})`);
      }
      if (restored.check(su, 'premium_access', so, { now: restoredTs + TTL + RESTORE_BUFFER }).possibility !== 0) {
        fail(`[snapshot] restored value did not expire past TTL (${genKind} seed=${seed})`);
      }
      return { ok: true };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            genKind: rigor.gen.oneOf(GENERATORS.map(g => g.name)),
            seed: rigor.gen.int(1, 6),
            mutationCount: rigor.gen.int(2, 4)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('freshness-parity', ({ actual }) => actual !== undefined),
        rigor.invariant('mutation-with-time', ({ actual }) => actual !== undefined),
        rigor.invariant('snapshot-preserves-ttl', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 250, seed: 'complex-graph-ttl-crucible', artifacts: { dir: '', persist: 'never' } });

    for (const name of ['freshness-parity', 'mutation-with-time', 'snapshot-preserves-ttl']) {
      const inv = report.crucibleVerdict?.invariants?.find(i => i.name === name);
      assert.ok(inv, `invariant ${name} missing`);
      assert.equal(inv.passed, true, `TTL ${name} violated in ${inv.failureCount} cases`);
    }
  });
});
