/**
 * rigor/node-lifecycle.test.js — js-rigor property tests for node removal
 * and re-addition semantics.
 *
 * Properties verified:
 *
 *   - REMOVE CASCADE: removeNode(key) deletes every relation incident to
 *     the node (from the relations array AND the indices), and checks
 *     reflect the pruned graph exactly — chain results match a BFS oracle
 *     on the pruned edge set, and checks that never touched the removed
 *     node are unchanged.
 *   - MISSING USER: removing the user node makes its checks report
 *     missing_node semantics (0).
 *   - IDEMPOTENCE: removing a non-existent node returns false and leaves
 *     the state untouched.
 *   - RE-ADD: re-adding the key yields a fresh node with no stale
 *     relations; new edges take effect; old ids do not leak.
 */
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];
const NODES = ['user:alice', 'mid:1', 'mid:2', 'doc:1'];

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

function mulberry32(seed) {
  let a = seed >>> 0;
  return {
    next() {
      a |= 0; a = (a + 0x6D2B79F5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    }
  };
}

const EDGE_UNIVERSE = {
  r1: [
    ['user:alice', 'mid:1'],
    ['mid:1', 'user:alice'],
    ['mid:1', 'mid:2'],
    ['doc:1', 'mid:2'],
    ['mid:2', 'doc:1']
  ],
  r2: [
    ['mid:1', 'doc:1'],
    ['doc:1', 'mid:1'],
    ['mid:2', 'user:alice'],
    ['user:alice', 'mid:2'],
    ['user:alice', 'doc:1'],
    ['mid:2', 'mid:1']
  ]
};

function randomEdges(rng) {
  const edges = [];
  for (const rel of ['r1', 'r2']) {
    for (const [src, dst] of EDGE_UNIVERSE[rel]) {
      if (rng.next() < 0.5) {
        edges.push([src, rel, dst, POS[Math.floor(rng.next() * POS.length)]]);
      }
    }
  }
  return edges;
}

function edgeKey(src, rel, dst) {
  return `${src}|${rel}|${dst}`;
}

function chainOracle(steps, edges) {
  const em = new Map(edges.map(e => [edgeKey(e[0], e[1], e[2]), e[3]]));
  let frontier = new Map([['user:alice', 1.0]]);
  for (const step of steps) {
    const { relation, direction } = step;
    const next = new Map();
    for (const [node, p] of frontier) {
      for (const [key, ep] of em) {
        const [s, r, d] = key.split('|');
        if (r !== relation) continue;
        const matches = direction === 'out' ? s === node : d === node;
        if (!matches) continue;
        const nxt = direction === 'out' ? d : s;
        if (nxt === node) continue;
        const np = Math.min(p, ep);
        const cur = next.get(nxt);
        if (cur === undefined || np > cur) next.set(nxt, np);
      }
    }
    frontier = next;
    if (frontier.size === 0) break;
  }
  return frontier.get('doc:1') || 0;
}

function buildArbiter() {
  const arb = new Arbiter();
  for (const k of NODES) arb.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'mid' : 'doc');
  arb.setRelationConfig('r1', { type: 'direct' });
  arb.setRelationConfig('r2', { type: 'direct' });
  arb.setRelationConfig('target', { type: 'chain', steps: [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }] });
  return arb;
}

describe('Node lifecycle semantics (rigor)', () => {
  it('REMOVE CASCADE: removing a node prunes its edges exactly; checks match the pruned graph', async () => {
    async function check({ seed, removeTarget }) {
      const rng = mulberry32(seed);
      const edges = randomEdges(rng);
      const arb = buildArbiter();
      for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });

      const baseline = arb.check('user:alice', 'target', 'doc:1', {}).possibility;
      const baselineOracle = chainOracle(
        [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
        edges
      );
      if (Math.abs(baseline - baselineOracle) > EPS) {
        fail(`baseline mismatch: ${baseline} vs ${baselineOracle}`);
      }

      // Sanity: every edge lands in the index
      for (const [src, rel, dst] of edges) {
        const srcId = arb.resolveNodeId(src);
        const dstId = arb.resolveNodeId(dst);
        const found = arb.indices.getDirectRelation(srcId, rel, dstId);
        if (!found) {
          fail(`edge ${src} ${rel} ${dst} missing from index before removal`);
        }
      }

      const prunedEdges = edges.filter(e => e[0] !== removeTarget && e[2] !== removeTarget);

      // Remove twice: first cascade, then idempotent no-op
      const first = arb.nodeManager.removeNode(removeTarget);
      if (!first) fail(`removeNode(${removeTarget}) returned false on existing node`);

      // 1. No incident edges remain in the relations array
      const nodeId = null; // id was deleted; scan by key instead
      const incidentLeft = arb.relations.some(r => {
        const srcKey = arb.keyByNodeId.get(r.src);
        const dstKey = arb.keyByNodeId.get(r.dst);
        return srcKey === removeTarget || dstKey === removeTarget;
      });
      if (incidentLeft) {
        fail(`relations still reference removed node ${removeTarget}`);
      }

      // 2. Chain check matches the pruned-graph oracle
      const expectedP = chainOracle(
        [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
        prunedEdges
      );
      const res = arb.check('user:alice', 'target', 'doc:1', {});
      if (removeTarget === 'user:alice') {
        if (res.reason !== 'missing_node' && res.possibility !== 0) {
          fail(`removed user check: expected missing_node, got ${JSON.stringify(res)}`);
        }
      } else if (Math.abs(res.possibility - expectedP) > EPS) {
        fail(`post-removal mismatch: oracle=${expectedP} got=${res.possibility} removed=${removeTarget} edges=${JSON.stringify(prunedEdges)}`);
      }

      // 3. Direct checks on remaining edges still work
      for (const [src, rel, dst, p] of prunedEdges.slice(0, 3)) {
        const srcId = arb.resolveNodeId(src);
        const dstId = arb.resolveNodeId(dst);
        if (srcId === undefined || dstId === undefined) continue;
        const found = arb.indices.getDirectRelation(srcId, rel, dstId);
        if (!found || Math.abs(found.possibility - p) > EPS) {
          fail(`surviving edge ${src} ${rel} ${dst} lost (${JSON.stringify(found)})`);
        }
      }

      // 4. Idempotence
      const second = arb.nodeManager.removeNode(removeTarget);
      if (second !== false) fail(`second removeNode returned ${second}, expected false`);
      const afterSecond = arb.relations.length;
      if (afterSecond !== prunedEdges.length) {
        fail(`idempotent remove changed state: ${afterSecond} relations, expected ${prunedEdges.length}`);
      }

      return { pruned: prunedEdges.length };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            seed: rigor.gen.int(1, 80000),
            removeTarget: rigor.gen.oneOf(NODES)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('remove-cascade', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 1000, seed: 'node-lifecycle-remove' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-cascade');
    assert.ok(inv, 'invariant missing');
    assert.equal(inv.passed, true, `remove cascade violated in ${inv.failureCount} cases`);
  });

  it('RE-ADD: re-adding a removed node key is a fresh node with working edges and no stale state', async () => {
    async function check({ seed }) {
      const rng = mulberry32(seed);
      const edges = randomEdges(rng);
      const arb = buildArbiter();
      for (const [src, rel, dst, p] of edges) arb.addRelation(src, rel, dst, { possibility: p });

      arb.nodeManager.removeNode('mid:1');

      // Re-add the node and a fresh edge through it
      arb.addNode('mid:1', 'mid');
      const fresh = Math.random() < 0.5 ? 0.5 : 1;
      arb.addRelation('user:alice', 'r1', 'mid:1', { possibility: fresh });
      arb.addRelation('mid:1', 'r2', 'doc:1', { possibility: 1 });

      const pruned = edges.filter(e => e[0] !== 'mid:1' && e[2] !== 'mid:1');
      const viaOld = chainOracle(
        [{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }],
        [...pruned, ['user:alice', 'r1', 'mid:1', fresh], ['mid:1', 'r2', 'doc:1', 1]]
      );
      // The path through the re-added node is min(fresh, 1) = fresh
      const expected = Math.max(
        chainOracle([{ relation: 'r1', direction: 'out' }, { relation: 'r2', direction: 'out' }], pruned),
        fresh
      );
      const res = arb.check('user:alice', 'target', 'doc:1', {});
      if (Math.abs(res.possibility - expected) > EPS) {
        fail(`re-add mismatch: oracle=${expected} got=${res.possibility}`);
      }
      // Exactly one r1 edge user->mid:1
      const count = arb.relations.filter(r => r.rel === 'r1' && r.src === arb.resolveNodeId('user:alice') && r.dst === arb.resolveNodeId('mid:1')).length;
      if (count !== 1) {
        fail(`re-add left ${count} user->mid:1 r1 tuples`);
      }
      return { expected };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({ seed: rigor.gen.int(1, 60000) })
        ))
      ],
      rigor.crucible([
        rigor.invariant('node-readd', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 600, seed: 'node-lifecycle-readd' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'node-readd');
    assert.ok(inv, 'invariant missing');
    assert.equal(inv.passed, true, `re-add violated in ${inv.failureCount} cases`);
  });
});
