/**
 * rigor/snapshot-quantization-parity.test.js — condensed-snapshot round-trip
 * parity for non-dyadic possibility values.
 *
 * The condensed snapshot encodes each edge possibility as a 16-bit uniform
 * quantizer on the 65535 scale (_floatToBits = round(p * 65535),
 * _bitsToFloat = bits / 65535). Contract:
 *   - endpoints 0 and 1 are exact; every other value round-trips with
 *     absolute error <= 0.5 / 65535 (~7.63e-6).
 *   - restored values never leave [0, 1].
 *   - binary-mode decisions can only flip when the live value sits inside
 *     the quantization band of the threshold; outside the band decisions
 *     must agree exactly.
 *   - restoring the same buffer twice is deterministic; snapshot-of-snapshot
 *     (serialize a restored arbiter, restore again) preserves values.
 *   - the graph binary round-trips self-consistently (toBinary(restored)
 *     is byte-stable across generations).
 *
 * The oracle is the live arbiter's own pre-enable check results.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { ArbiterSnapshot } from '../../src/core/arbiter/ArbiterSnapshot.js';
import { serializeArbiterSnapshot } from '../../src/core/SnapshotBinary.js';

const QUANT_STEP = 0.5 / 65535; // max absolute quantization error
const TOL = QUANT_STEP + 1e-9;

const NONDYADIC = [0.1, 0.3, 0.7, 0.9, 0.111, 0.333, 0.999, 0.001, 0.8999999, 0.5000001];

function nodeKey(id) {
  if (id < 2) return `u:${id}`;
  if (id < 3) return 'g:0';
  return `doc:${id - 3}`;
}

function buildGraph(edges, values) {
  const arb = new Arbiter();
  for (let i = 0; i < 6; i++) arb.addNode(nodeKey(i), i < 2 ? 'user' : i === 2 ? 'group' : 'doc');
  arb.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  arb.setRelationConfig('can_access', {
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'reads', direction: 'out' }
    ]
  });
  for (let i = 0; i < edges.length; i++) {
    arb.addRelation(nodeKey(edges[i][0]), edges[i][1], nodeKey(edges[i][2]), {
      possibility: values[i],
      // deterministic per-edge reliability so the round-trip pins the
      // codec's reliability channel, not just possibility
      reliability: 0.1 + ((i * 7 + 3) % 9) / 10
    });
  }
  return arb;
}

const QUERIES = [];
for (const u of [0, 1]) {
  for (const d of [3, 4, 5]) {
    QUERIES.push(['can_read', u, d]);
    QUERIES.push(['can_access', u, d]);
  }
}

function roundTripReport(edges, values) {
  const live = buildGraph(edges, values);
  const before = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).possibility);
  const beforeReliability = QUERIES.map(([rel, u, d]) => live.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0);

  live.enableCondensedSnapshot();
  const buffer = serializeArbiterSnapshot(live);

  const r1 = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
  const after1 = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).possibility);
  const after1Reliability = QUERIES.map(([rel, u, d]) => r1.check(nodeKey(u), rel, nodeKey(d)).reliability ?? 0);

  const r1b = ArbiterSnapshot.fromSnapshotBinary(buffer, {}, () => new Arbiter());
  const after1b = QUERIES.map(([rel, u, d]) => r1b.check(nodeKey(u), rel, nodeKey(d)).possibility);

  const buffer2 = serializeArbiterSnapshot(r1);
  const r2 = ArbiterSnapshot.fromSnapshotBinary(buffer2, {}, () => new Arbiter());
  const after2 = QUERIES.map(([rel, u, d]) => r2.check(nodeKey(u), rel, nodeKey(d)).possibility);

  return { before, after1, after1b, after2, beforeReliability, after1Reliability, graphBytes: live.snapshotGraph.toBinary().byteLength };
}

describe('Condensed snapshot quantization parity (rigor)', () => {
  it('FIXED VALUES: quantization error band, bounds, determinism, snapshot-of-snapshot', () => {
    const edges = [
      [0, 'owner', 3],
      [1, 'owner', 4],
      [0, 'member_of', 2],
      [2, 'reads', 3],
      [1, 'member_of', 2],
      [2, 'reads', 4],
      [0, 'reads', 5],
      [1, 'owner', 5]
    ];
    const values = [0.9, 0.1, 0.7, 0.333, 0.999, 0.111, 0.5000001, 0.001];
    const r = roundTripReport(edges, values);

    for (let i = 0; i < r.before.length; i++) {
      const live = r.before[i];
      assert.ok(
        Math.abs(r.after1[i] - live) <= TOL,
        `query ${i}: live=${live} restored=${r.after1[i]} exceeds tolerance ${TOL}`
      );
      assert.ok(r.after1[i] >= 0 && r.after1[i] <= 1, `restored value ${r.after1[i]} outside [0,1]`);
    }
    assert.deepEqual(r.after1, r.after1b, 'restoring the same buffer is deterministic');
    assert.deepEqual(r.after1, r.after2, 'snapshot-of-snapshot preserves values');
    // Reliability passes through the same 16-bit quantization; chain
    // reliabilities multiply two quantized inputs, so the band is 4x the
    // single-value tolerance (still far tighter than any codec loss).
    const reliabilityTol = TOL * 4;
    for (let i = 0; i < r.beforeReliability.length; i++) {
      assert.ok(
        Math.abs(r.after1Reliability[i] - r.beforeReliability[i]) <= reliabilityTol,
        `query ${i}: live reliability=${r.beforeReliability[i]} restored=${r.after1Reliability[i]} exceeds tolerance ${reliabilityTol}`
      );
    }

    const g1 = buildGraph(edges, values);
    g1.enableCondensedSnapshot();
    const b1 = g1.snapshotGraph.toBinary();
    const g2 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g1), {}, () => new Arbiter());
    const b2 = g2.snapshotGraph.toBinary();
    const g3 = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(g2), {}, () => new Arbiter());
    const b3 = g3.snapshotGraph.toBinary();
    assert.equal(b2.byteLength, b3.byteLength, 'reserialized graph binary is byte-stable');
  });

  it('PROPERTY CAMPAIGN: random graphs keep parity under quantization and threshold decisions', async () => {
    const edgeSet = rigor.gen.oneOf([
      [[0, 'owner', 3]],
      [[0, 'owner', 3], [1, 'owner', 4]],
      [[0, 'owner', 3], [0, 'member_of', 2], [2, 'reads', 3]],
      [[0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4]],
      [[0, 'owner', 3], [1, 'owner', 4], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3], [2, 'reads', 4], [0, 'reads', 5], [1, 'owner', 5]],
      [[0, 'owner', 5], [2, 'reads', 5], [0, 'member_of', 2], [1, 'member_of', 2], [2, 'reads', 3]]
    ]);
    const valuesGen = rigor.gen.array(rigor.gen.oneOf(NONDYADIC), 0, 12);

    const result = await rigor.campaign(
      [rigor.fn('roundtrip', (edges, values) => roundTripReport(edges, values),
        rigor.args(edgeSet, valuesGen))],
      rigor.crucible([
        rigor.invariant('values within quantization tolerance', (ctx) => {
          const { before, after1 } = ctx.actual;
          for (let i = 0; i < before.length; i++) {
            if (Math.abs(after1[i] - before[i]) > TOL) return false;
          }
          return true;
        }),
        rigor.invariant('restored values stay in [0,1]', (ctx) => {
          const { after1 } = ctx.actual;
          for (const v of after1) {
            if (!(v >= 0 && v <= 1)) return false;
          }
          return true;
        }),
        rigor.invariant('deterministic restore', (ctx) => {
          const { after1, after1b } = ctx.actual;
          return after1.every((v, i) => Math.abs(v - after1b[i]) <= TOL);
        }),
        rigor.invariant('snapshot-of-snapshot preserves values', (ctx) => {
          const { after1, after2 } = ctx.actual;
          return after1.every((v, i) => Math.abs(v - after2[i]) <= TOL);
        })
      ])
    ).run({ effort: 300, seed: 'snapshot-quantization-parity' , artifacts: { dir: '', persist: 'never' }});

    const inv = result.crucibleVerdict;
    assert.equal(inv.passed, true, [
      `quantization parity violated in ${inv.failureCount} cases:`,
      ...result.failures.slice(0, 3).map((f) =>
        `  [${f.invariant}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)}`
      )
    ].join('\n'));
  });

  it('THRESHOLD BAND: binary decisions only flip inside the quantization band', () => {
    const thresholds = [0.2, 0.5, 0.8, 0.9];
    const edges = [[0, 'owner', 3], [1, 'owner', 4]];
    for (const t of thresholds) {
      for (const v of NONDYADIC) {
        const live = buildGraph([[0, 'owner', 3]], [v]);
        const before = live.check('u:0', 'can_read', 'doc:3').possibility;
        const beforeDecision = before >= t;
        live.enableCondensedSnapshot();
        const restored = ArbiterSnapshot.fromSnapshotBinary(serializeArbiterSnapshot(live), {}, () => new Arbiter());
        const after = restored.check('u:0', 'can_read', 'doc:3').possibility;
        const afterDecision = after >= t;
        const delta = Math.abs(before - t);
        if (delta >= QUANT_STEP) {
          assert.equal(afterDecision, beforeDecision,
            `threshold ${t}: value ${v} (live ${before}, restored ${after}) flips outside the band`);
        }
      }
    }
  });
});
