/**
 * rigor/input-range-parity.test.js — write-boundary possibility validation
 * and id-hygiene contracts.
 *
 * The engine contract for addRelation()/removeRelation() writes:
 *   - possibility must be a finite number in [0, 1]; anything else THROWS
 *     and leaves the graph untouched (no partial writes).
 *   - undefined possibility defaults to 1.0 (pre-existing contract).
 *   - node keys and relation names are exact-match strings: a numeric key
 *     is a DIFFERENT node than its string form (missing_node), and '|'
 *     inside keys/relation names is harmless (keys are numeric ids in the
 *     cache layer, so delimiter injection is structurally impossible).
 *
 * Two arms:
 *   1. FIXED MATRIX — every invalid shape is rejected, every boundary value
 *      accepted, graph state preserved across failed writes.
 *   2. STATE PROPERTY CAMPAIGN — random add/check sequences against a mirror
 *      model; the mirror independently classifies each possibility as
 *      valid/invalid and the engine must agree exactly, plus check()
 *      results always stay in [0, 1].
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const U = 2; // users
const D = U + 2; // docs
const NODES = D + 1;

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

function isValid(p) {
  return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1;
}

function makeWrapper() {
  const arbiter = new Arbiter();
  for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i < U ? 'user' : 'doc');
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  const tuples = new Map();
  const tupleKey = (src, rel, dst) => `${src}|${rel}|${dst}`;

  return {
    tuples,
    arbiter,
    add(src, rel, dst, p) {
      const key = nodeKey(src);
      const dstKey = nodeKey(dst);
      if (p === undefined || isValid(p)) {
        arbiter.addRelation(key, rel, dstKey, p === undefined ? undefined : { possibility: p });
        tuples.set(tupleKey(key, rel, dstKey), p === undefined ? 1.0 : p);
        return { accepted: true };
      }
      return { accepted: false };
    },
    check(src, rel, dst) {
      const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
      let expected = 0;
      for (const [k, p] of tuples) {
        if (k === tupleKey(nodeKey(src), 'owner', nodeKey(dst))) expected = Math.max(expected, p);
      }
      const engine = typeof result.possibility === 'number' && Number.isFinite(result.possibility) ? result.possibility : -1;
      return { engine: Math.round(engine * 10000) / 10000, expected: Math.round(expected * 10000) / 10000 };
    },
    checkNumericId(src, rel, dst) {
      const result = arbiter.check(src, rel, dst);
      return result.reason;
    },
    clone() {
      const fresh = makeWrapper();
      for (const [k, p] of tuples) {
        const [src, rel, dst] = k.split('|');
        fresh.arbiter.addRelation(src, rel, dst, { possibility: p });
        fresh.tuples.set(k, p);
      }
      return fresh;
    }
  };
}

describe('Possibility write-boundary validation (rigor)', () => {
  it('FIXED MATRIX: invalid possibilities throw and leave the graph untouched; boundaries accepted', () => {
    const w = makeWrapper();
    const invalid = [2.0, -1, -0.0001, 1.0001, NaN, Infinity, -Infinity, null];
    for (const p of invalid) {
      assert.throws(
        () => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p }),
        /Invalid possibility/,
        `possibility ${String(p)} must be rejected`
      );
      assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0, 'graph must stay untouched');
    }
    for (const p of [0, 1, 0.001, 0.99999, 0.3]) {
      w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: p });
      const got = w.arbiter.check('u:0', 'can_read', 'doc:0').possibility;
      assert.ok(Math.abs(got - p) < 1e-12, `boundary value ${p} accepted and returned (got ${got})`);
    }
    w.arbiter.addRelation('u:0', 'owner', 'doc:1', {});
    assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:1').possibility, 1.0, 'undefined possibility defaults to 1.0 on create');
    assert.throws(
      () => w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: -0.5 }),
      /Invalid possibility/
    );
    assert.equal(w.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'failed modify keeps old value');

    const fresh = makeWrapper();
    fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.3 });
    fresh.arbiter.addRelation('u:0', 'owner', 'doc:0', {});
    assert.equal(fresh.arbiter.check('u:0', 'can_read', 'doc:0').possibility, 0.3, 'modify with undefined possibility preserves old value');
  });

  it('ID HYGIENE: numeric keys are distinct nodes; pipe characters are harmless', () => {
    const w = makeWrapper();
    w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.9 });
    assert.equal(w.checkNumericId('u:0', 'can_read', 'doc:0'), 'direct_match', 'string keys resolve');
    assert.equal(w.checkNumericId(0, 'can_read', 2), 'missing_node', 'numeric keys are different nodes');
    assert.equal(w.arbiter.keyManager.getStringId('u:0') !== w.arbiter.keyManager.getStringId(0), true, 'string/number ids never collide');
    assert.equal(w.arbiter.keyManager.getStringId('a|b') !== w.arbiter.keyManager.getStringId('a'), true, 'pipe-bearing keys are distinct');
    w.arbiter.setRelationConfig('r|x', { type: 'direct', relation: 'owner' });
    w.arbiter.addRelation('u:0', 'owner', 'doc:0', { possibility: 0.7 });
    assert.equal(w.arbiter.check('u:0', 'r|x', 'doc:0').possibility, 0.7, 'pipe in relation name works');
    assert.equal(w.arbiter.check('u:0', 'r', 'doc:0').possibility, 0, 'relation r is NOT r|x');
  });

  it('PROPERTY CAMPAIGN: engine write contract agrees with the independent classifier', async () => {
    const addArgs = rigor.gen.tuple(
      rigor.gen.int(0, NODES - 1),
      rigor.gen.enum(['owner', 'member_of', 'reads']),
      rigor.gen.int(0, NODES - 1),
      rigor.gen.oneOf([0.1, 0.5, 0.9, NaN, Infinity, -Infinity, 2.5, -0.5, 1.001, null, undefined])
    );
    const checkArgs = rigor.gen.tuple(
      rigor.gen.int(0, U - 1),
      rigor.gen.constant('can_read'),
      rigor.gen.constant(D)
    );

    const result = await rigor.campaign(
      [rigor.object('graph', makeWrapper, [
        rigor.method('add', function (src, rel, dst, p) { return this.add(src, rel, dst, p); },
          rigor.args(addArgs)),
        rigor.method('check', function (src, rel, dst) { return this.check(src, rel, dst); },
          rigor.args(checkArgs))
      ])],
      rigor.crucible([
        rigor.invariant('rejected iff invalid', (ctx) => {
          if (ctx.action !== 'graph.add') return true;
          const p = ctx.args[3];
          return ctx.actual.accepted === (p === undefined || isValid(p));
        }),
        rigor.invariant('no partial writes on rejection', (ctx) => {
          if (ctx.action !== 'graph.add') return true;
          if (ctx.actual.accepted) return true;
          return ctx.error === null;
        }),
        rigor.invariant('check parity with mirror', (ctx) => {
          if (ctx.action !== 'graph.check') return true;
          return ctx.actual.engine === ctx.actual.expected;
        }),
        rigor.invariant('possibility always in [0,1] or absent', (ctx) => {
          if (ctx.action !== 'graph.check') return true;
          return ctx.actual.engine >= 0 && ctx.actual.engine <= 1;
        })
      ])
    ).run({ effort: 400, seed: 'input-range-contract' , artifacts: { dir: '', persist: 'never' }});

    const inv = result.crucibleVerdict;
    assert.equal(inv.passed, true, [
      `engine diverged from contract in ${inv.failureCount} cases:`,
      ...result.failures.slice(0, 3).map((f) =>
        `  [${f.action}] args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}`
      )
    ].join('\n'));
  });
});
