/**
 * rigor/ttl-expiry-parity.test.js — value-TTL expiry through the full
 * comparator check path with an injected clock.
 *
 * Contracts pinned:
 *   - values written with setTTL(rel, ttlMs) expire after ttlMs of engine
 *     time; expired operands no longer participate in relational
 *     comparisons (the comparator's value extraction must consult the
 *     value manager's TTL gate, not raw relation values).
 *   - with the decision cache enabled, a decision cached before expiry may
 *     linger up to the rule-result-cache TTL, but MUST become fresh once
 *     the cache entry itself expires (bounded staleness).
 *
 * The mirror tracks per-relation write timestamps under the same injected
 * clock and computes the expected comparison result.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const TTL = 5000;
const DOCS = 2;
const docKey = (i) => `doc:${i}`;

let engineNow = 1_000_000_000_000;

function makeWrapper(disableCaching) {
  const arbiter = new Arbiter(disableCaching ? { disableCaching: true } : {});
  arbiter.addNode('user:alice', 'user');
  for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc');
  arbiter.setRelationConfig('has_balance', { type: 'direct' });
  arbiter.setRelationConfig('has_price', { type: 'direct' });
  arbiter.setRelationConfig('premium', {
    type: 'relational_comparator',
    comparator: '>',
    left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true },
    right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true }
  });
  arbiter.valueManager.setTTL('has_balance', TTL);
  arbiter.valueManager.setTTL('has_price', TTL);

  const balance = new Map();
  const price = new Map();
  const ops = [];

  const wrapper = {
    engine: arbiter,
    balance,
    price,
    setValue(doc, kind, value) {
      const rel = kind === 'balance' ? 'has_balance' : 'has_price';
      const src = kind === 'balance' ? 'user:alice' : docKey(doc);
      // Pin the write timestamp: clone-replay re-writes relations with the
      // CURRENT Date.now(), which would silently un-expire old values; the
      // engine honors the changed_last_at override.
      arbiter.addRelation(src, rel, docKey(doc), { value, possibility: 1.0, changed_last_at: engineNow });
      // The engine only refreshes changed_last_at when the value actually
      // changes; the mirror must mirror that.
      const target = kind === 'balance' ? balance : price;
      const existing = target.get(doc);
      if (!existing || existing.value !== value) target.set(doc, { value, ts: engineNow });
      return { ok: true };
    },
    advanceTime(ms) {
      engineNow += ms;
      return { ok: true, now: engineNow };
    },
    check(doc) {
      const result = arbiter.check('user:alice', 'premium', docKey(doc), { now: engineNow });
      const b = balance.get(doc);
      const p = price.get(doc);
      const bFresh = b !== undefined && engineNow - b.ts <= TTL;
      const pFresh = p !== undefined && engineNow - p.ts <= TTL;
      const expected = bFresh && pFresh && b.value > p.value ? 1 : 0;
      return { engine: result.possibility, expected, reason: result.reason, bFresh, pFresh };
    },
    clone() {
      const fresh = makeWrapper(disableCaching);
      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.setValue = record('setValue', wrapper.setValue);
  wrapper.advanceTime = record('advanceTime', wrapper.advanceTime);
  wrapper.check = record('check', wrapper.check);
  return wrapper;
}

describe('Value TTL expiry through the comparator path (rigor)', () => {
  it('FIXED MATRIX: expired operands flip the decision; cache staleness is bounded', () => {
    {
      const w = makeWrapper(true);
      w.setValue(0, 'balance', 100);
      w.setValue(0, 'price', 50);
      assert.equal(w.check(0).engine, 1, 'fresh: 100 > 50');

      w.advanceTime(TTL + 1000);
      assert.equal(w.check(0).engine, 0, 'after TTL both operands expired -> deny');

      w.setValue(0, 'price', 200);
      assert.equal(w.check(0).engine, 0, 'balance expired, price fresh -> deny');

      w.setValue(0, 'balance', 300);
      assert.equal(w.check(0).engine, 1, 'both refreshed -> allow');

      const cw = makeWrapper(false);
      cw.setValue(0, 'balance', 100);
      cw.setValue(0, 'price', 50);
      assert.equal(cw.check(0).engine, 1, 'cached arbiter: allow cached');
      cw.advanceTime(TTL + 1000);
      const staleWindow = cw.check(0);
      cw.advanceTime(70000); // past ruleResultCacheTTL (60s)
      assert.equal(cw.check(0).engine, 0, 'bounded staleness: fresh deny after cache TTL');
      assert.ok(staleWindow.engine >= 0, 'stale window decision is well-formed');
    }
  });

  it('PROPERTY CAMPAIGN: parity holds across time advances and refreshes (no decision cache)', async () => {
    const result = await rigor.campaign(
      [rigor.object('graph', makeWrapper.bind(null, true), [
        rigor.method('setValue', function (d, k, v) { return this.setValue(d, k, v); },
          rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.enum(['balance', 'price']), rigor.gen.oneOf([5, 50, 100, 200]))),
        rigor.method('advanceTime', function (ms) { return this.advanceTime(ms); },
          rigor.args(rigor.gen.oneOf([1000, 4000, 6000, 30000, 70000]))),
        rigor.method('check', function (d) { return this.check(d); },
          rigor.args(rigor.gen.int(0, DOCS - 1)))
      ])],
      rigor.crucible([
        rigor.invariant('comparator parity under time', (ctx) => {
          if (ctx.action !== 'graph.check' || ctx.error !== null) return true;
          return ctx.actual.engine === ctx.actual.expected;
        }),
        rigor.invariant('no action errors', (ctx) => ctx.error === null)
      ])
    ).run({ effort: 400, seed: 'ttl-expiry-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }});

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