/**
 * rigor/node-manager.test.js — js-rigor property tests for NodeManager.
 *
 * NodeManager owns the three index structures that map the graph:
 *   - nodes: Map<nodeId, { key, type, data, ... }>
 *   - nodeIdByKey: Map<key, nodeId>
 *   - keyByNodeId: Map<nodeId, key>
 *
 * Properties verified:
 *   - Inverse maps: getNodeKey(getNodeId(key)) === key, getNodeId(getNodeKey(id)) === id
 *   - Idempotence: addNode(key, ...) twice returns the same nodeId
 *   - Size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId|
 *   - Monotonic nextNodeId: nextNodeId is strictly increasing across distinct addNode calls
 *   - removeNode cleans all three indexes
 *   - updateNodeData merges data into existing node
 *   - clearNodes resets all three indexes and nextNodeId
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { NodeManager } from '../../src/core/NodeManager.js';

const NODE_TYPES = ['user', 'document', 'group', 'role', 'project'];

/**
 * Build a minimal arbiter stub that satisfies NodeManager's surface.
 * Records relationManager.removeRelation calls for inspection.
 */
function makeArbiter() {
  const arbiter = {
    nodeIdByKey: new Map(),
    keyByNodeId: new Map(),
    nodes: new Map(),
    nextNodeId: 0,
    relations: [],
    removedRelations: [],
    embeddingManager: null,
    similarityManager: null,
    dependencyIndex: null,
    decisionCache: null,
    relationManager: {
      removeRelation(srcKey, rel, dstKey) {
        arbiter.removedRelations.push({ srcKey, rel, dstKey });
        // Cascade: drop the matching entries from arbiter.relations
        for (let i = arbiter.relations.length - 1; i >= 0; i--) {
          const r = arbiter.relations[i];
          const srcId = arbiter.nodeIdByKey.get(srcKey);
          const dstId = arbiter.nodeIdByKey.get(dstKey);
          if (r.src === srcId && r.dst === dstId && r.rel === rel) {
            arbiter.relations.splice(i, 1);
          }
        }
      }
    }
  };
  return arbiter;
}

function makeManager() {
  const arbiter = makeArbiter();
  const manager = new NodeManager(arbiter);
  return { manager, arbiter };
}

describe('NodeManager index invariants (rigor)', () => {
  it('inverse maps: getNodeKey(getNodeId(key)) === key and back', async () => {
    async function check(key, type) {
      const { manager, arbiter } = makeManager();
      manager.addNode(key, type);
      const nodeId = arbiter.nodeIdByKey.get(key);
      if (nodeId === undefined) throw new Error(`addNode failed for key=${key}`);

      const backKey = manager.getNodeKey(nodeId);
      if (backKey !== key) {
        throw new Error(`round-trip mismatch: ${key} → ${nodeId} → ${backKey}`);
      }
      const backId = manager.getNodeId(key);
      if (backId !== nodeId) {
        throw new Error(`getNodeId(${key}) = ${backId}, expected ${nodeId}`);
      }
      return { nodeId, key };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.string(1, 30),
            rigor.gen.enum(NODE_TYPES)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('inverse-maps', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-inverse-maps', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'inverse-maps');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `inverse-map property violated in ${inv.failureCount} cases`);
  });

  it('addNode is idempotent for the same key', async () => {
    async function check(key, type) {
      const { manager, arbiter } = makeManager();
      const id1 = manager.addNode(key, type);
      const id2 = manager.addNode(key, type);  // duplicate
      if (id1 !== id2) {
        throw new Error(`addNode not idempotent: ${id1} vs ${id2} for key=${key}`);
      }
      // Only one entry in nodes
      if (manager.getNodeCount() !== 1) {
        throw new Error(`expected 1 node, got ${manager.getNodeCount()}`);
      }
      // nextNodeId should NOT have advanced for the duplicate
      if (arbiter.nextNodeId !== 1) {
        throw new Error(`expected nextNodeId=1 after idempotent add, got ${arbiter.nextNodeId}`);
      }
      return id1;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.string(1, 30),
            rigor.gen.enum(NODE_TYPES)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('addNode-idempotent', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-add-idempotent', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addNode-idempotent');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `addNode idempotence violated in ${inv.failureCount} cases`);
  });

  it('size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId| after every mutation', async () => {
    async function check(operations) {
      // Each operation is a tuple [op, ...args]. Op codes:
      //   0: addNode(key, type)
      //   1: removeNode(key)
      //   2: clearNodes()
      const { manager } = makeManager();
      for (const op of operations) {
        if (op[0] === 0) manager.addNode(op[1], op[2]);
        else if (op[0] === 1) manager.removeNode(op[1]);
        else if (op[0] === 2) manager.clearNodes();

        const n1 = manager.getAllNodes().length;
        const n2 = manager.getAllNodeKeys().length;
        const n3 = manager.arbiter.keyByNodeId.size;
        if (n1 !== n2 || n2 !== n3) {
          throw new Error(
            `size mismatch after op=${JSON.stringify(op)}: ` +
            `nodes=${n1} nodeIdByKey=${n2} keyByNodeId=${n3}`
          );
        }
      }
      return true;
    }

    // Build a generator that produces a sequence of operations.
    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.string(1, 10), rigor.gen.enum(NODE_TYPES)),
        rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.string(1, 10)),
        rigor.gen.tuple(rigor.gen.constant(2))
      ]),
      1, 8
    );

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(opGen)
        )
      ],
      rigor.crucible([
        rigor.invariant('size-invariant', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-size-invariant', effort: 1500 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'size-invariant');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `size invariant violated in ${inv.failureCount} cases`);
  });

  it('nextNodeId advances monotonically across distinct addNode calls', async () => {
    async function check(keys, types) {
      const { manager, arbiter } = makeManager();
      if (keys.length !== types.length) return { skipped: true };  // skip ill-formed
      const ids = [];
      for (let i = 0; i < keys.length; i++) {
        ids.push(manager.addNode(keys[i], types[i]));
      }
      const uniqueKeys = new Set(keys);
      // nextNodeId should equal uniqueKeys.size after all adds
      // (idempotence ensures duplicates don't bump nextNodeId)
      if (arbiter.nextNodeId !== uniqueKeys.size) {
        throw new Error(
          `expected nextNodeId=${uniqueKeys.size}, got ${arbiter.nextNodeId}`
        );
      }
      // IDs returned should be unique across unique keys
      const uniqueIds = new Set(ids);
      if (uniqueIds.size !== uniqueKeys.size) {
        throw new Error(
          `expected ${uniqueKeys.size} unique IDs, got ${uniqueIds.size}`
        );
      }
      // Each unique key should map to a non-decreasing ID
      const seen = new Map();
      for (let i = 0; i < keys.length; i++) {
        const id = arbiter.nodeIdByKey.get(keys[i]);
        if (seen.has(keys[i])) {
          if (seen.get(keys[i]) !== id) {
            throw new Error(`key ${keys[i]} mapped to different IDs`);
          }
        } else {
          seen.set(keys[i], id);
          // ID must equal current nextNodeId - 1 at time of first insertion
          if (id !== seen.size - 1) {
            throw new Error(`unexpected id ${id} for new key ${keys[i]}`);
          }
        }
      }
      return true;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.array(rigor.gen.string(1, 8), 1, 5),
            rigor.gen.array(rigor.gen.enum(NODE_TYPES), 1, 5)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('monotonic-ids', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-monotonic-ids', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'monotonic-ids');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `monotonic id assignment violated in ${inv.failureCount} cases`);
  });

  it('removeNode removes from all three indexes', async () => {
    async function check(addKeys, removeKey) {
      const { manager, arbiter } = makeManager();
      for (const k of addKeys) manager.addNode(k, 'user');
      if (!arbiter.nodeIdByKey.has(removeKey)) {
        // removeKey not in our adds; skip
        return true;
      }
      const removedId = arbiter.nodeIdByKey.get(removeKey);
      const result = manager.removeNode(removeKey);
      if (result !== true) {
        throw new Error(`removeNode returned ${result}, expected true`);
      }
      if (manager.getNode(removedId) !== undefined) {
        throw new Error(`nodes still has entry for ${removeKey}`);
      }
      if (arbiter.nodeIdByKey.has(removeKey)) {
        throw new Error(`nodeIdByKey still has ${removeKey}`);
      }
      if (arbiter.keyByNodeId.has(removedId)) {
        throw new Error(`keyByNodeId still has id ${removedId}`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.array(rigor.gen.string(1, 8), 1, 5),
            rigor.gen.string(1, 8)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('removeNode-cleanup', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-remove-cleanup', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'removeNode-cleanup');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `removeNode cleanup violated in ${inv.failureCount} cases`);
  });

  it('clearNodes resets all state', async () => {
    async function check(addKeys) {
      const { manager, arbiter } = makeManager();
      for (const k of addKeys) manager.addNode(k, 'user');
      manager.clearNodes();
      if (manager.getNodeCount() !== 0) {
        throw new Error(`getNodeCount=${manager.getNodeCount()} after clear, expected 0`);
      }
      if (manager.getAllNodeKeys().length !== 0) {
        throw new Error(`getAllNodeKeys non-empty after clear`);
      }
      if (arbiter.keyByNodeId.size !== 0) {
        throw new Error(`keyByNodeId non-empty after clear`);
      }
      if (arbiter.nextNodeId !== 0) {
        throw new Error(`nextNodeId=${arbiter.nextNodeId} after clear, expected 0`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.array(rigor.gen.string(1, 8), 1, 5)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('clearNodes-resets', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-clear-resets', effort: 500 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clearNodes-resets');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `clearNodes reset violated in ${inv.failureCount} cases`);
  });

  it('updateNodeData merges into existing node', async () => {
    async function check(initialData, updateData) {
      const { manager, arbiter } = makeManager();
      manager.addNode('user:1', 'user', initialData);
      const ok = manager.updateNodeData('user:1', updateData);
      if (!ok) throw new Error(`updateNodeData returned false`);
      const node = arbiter.nodes.get(arbiter.nodeIdByKey.get('user:1'));
      for (const [k, v] of Object.entries(updateData)) {
        if (node.data[k] !== v) {
          throw new Error(`data.${k} = ${node.data[k]}, expected ${v}`);
        }
      }
      // initialData fields not in updateData should still be present
      for (const k of Object.keys(initialData)) {
        if (!(k in updateData)) {
          if (node.data[k] !== initialData[k]) {
            throw new Error(`data.${k} was clobbered: ${node.data[k]} vs initial ${initialData[k]}`);
          }
        }
      }
      // updateNodeData should mark the node stale
      if (!node.stale) throw new Error('node should be stale after updateNodeData');
      return true;
    }

    // Use small object shapes that rigor can generate
    const initialGen = rigor.gen.object({
      role: rigor.gen.enum(['admin', 'user', 'guest']),
      age: rigor.gen.int(0, 100)
    });
    const updateGen = rigor.gen.object({
      role: rigor.gen.enum(['admin', 'user', 'guest']),  // can override
      email: rigor.gen.string(1, 30)  // adds new key
    });

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(initialGen, updateGen)
        )
      ],
      rigor.crucible([
        rigor.invariant('updateNodeData-merges', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'node-manager-update-merge', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'updateNodeData-merges');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `updateNodeData merge violated in ${inv.failureCount} cases`);
  });
});
