/**
 * rigor/relation-manager.test.js — js-rigor property tests for RelationManager.
 *
 * RelationManager owns relation lifecycle (addRelation, removeRelation) and
 * exposes getDirectRelation. Properties verified using a real Arbiter (no
 * mocking — the manager surface is small and Arbiter construction is cheap):
 *
 *   - addRelation(src, rel, dst) is idempotent (returns relationIndex, but the
 *     second call routes to _modifyRelation and updates the existing entry)
 *   - getDirectRelation(src, rel, dst) returns null for unknown tuples
 *   - getDirectRelation returns the live relation object after addRelation
 *   - removeRelation clears all five indexes (GraphIndices + arbiter.relations)
 *   - Cross-check: arbiter.indices.getDirectRelation === relationManager.getDirectRelation
 *   - _relationKeys Set size matches arbiter.relations length
 *   - _relationKeyToIndex points at the correct index in arbiter.relations
 *   - getDirectRelation after removeRelation returns null
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];

/**
 * Build an Arbiter pre-populated with the given relations. Each entry is
 * {src, rel, dst, possibility}.
 */
function makeArbiter(initialRelations = []) {
  const arbiter = new Arbiter();
  const keyIds = new Map();
  let nextId = 0;
  for (const r of initialRelations) {
    const srcKey = `u${r.src}`;
    const dstKey = `d${r.dst}`;
    if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
    if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
    arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility ?? 1.0 });
  }
  return arbiter;
}

describe('RelationManager.addRelation/removeRelation (rigor)', () => {
  it('addRelation then getDirectRelation returns the live relation object', async () => {
    async function check(src, rel, dst, possibility) {
      const arbiter = makeArbiter();
      arbiter.addNode(`u${src}`, 'user');
      arbiter.addNode(`d${dst}`, 'doc');
      const srcId = arbiter.nodeIdByKey.get(`u${src}`);
      const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
      arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility });
      const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
      if (!result) {
        throw new Error(`expected getDirectRelation(${srcId},${rel},${dstId}) to return relation, got null`);
      }
      if (result.possibility !== possibility) {
        throw new Error(`possibility=${result.possibility}, expected ${possibility}`);
      }
      if (result.rel !== rel) {
        throw new Error(`rel=${result.rel}, expected ${rel}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 20),
          rigor.gen.enum(RELATIONS),
          rigor.gen.int(0, 20),
          rigor.gen.float({ min: 0.01, max: 1 })
        )
      )],
      rigor.crucible([
        rigor.invariant('add-and-get', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-manager-add-get', 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 === 'add-and-get');
    assert.ok(inv);
    assert.equal(inv.passed, true, `addRelation→getDirectRelation contract violated in ${inv.failureCount} cases`);
  });

  it('addRelation is idempotent: re-add with same (src,rel,dst) updates in place, not adds new', async () => {
    async function check(src, rel, dst, possibility1, possibility2) {
      const arbiter = makeArbiter();
      arbiter.addNode(`u${src}`, 'user');
      arbiter.addNode(`d${dst}`, 'doc');
      const srcId = arbiter.nodeIdByKey.get(`u${src}`);
      const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
      arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility1 });
      const initialLen = arbiter.relations.length;

      // Re-add with different possibility
      arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility2 });

      // Length should not have grown (duplicate → modify, not insert)
      if (arbiter.relations.length !== initialLen) {
        throw new Error(`relations array grew from ${initialLen} to ${arbiter.relations.length} after duplicate add`);
      }
      // _relationKeys Set should have only one entry for this (src, rel, dst)
      if (arbiter.relationManager._relationKeys.size !== 1) {
        throw new Error(`_relationKeys.size=${arbiter.relationManager._relationKeys.size}, expected 1`);
      }
      // The new possibility should be reflected in getDirectRelation
      const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
      if (!result) {
        throw new Error(`getDirectRelation returned null after duplicate add`);
      }
      if (result.possibility !== possibility2) {
        throw new Error(`possibility=${result.possibility}, expected ${possibility2} (the second add)`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 20),
          rigor.gen.enum(RELATIONS),
          rigor.gen.int(0, 20),
          rigor.gen.float({ min: 0.01, max: 1 }),
          rigor.gen.float({ min: 0.01, max: 1 })
        )
      )],
      rigor.crucible([
        rigor.invariant('addRelation-idempotent', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-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 === 'addRelation-idempotent');
    assert.ok(inv);
    assert.equal(inv.passed, true, `addRelation idempotence violated in ${inv.failureCount} cases`);
  });

  it('removeRelation clears all five indexes and returns getDirectRelation to null', async () => {
    async function check(src, rel, dst) {
      const arbiter = makeArbiter();
      arbiter.addNode(`u${src}`, 'user');
      arbiter.addNode(`d${dst}`, 'doc');
      const srcId = arbiter.nodeIdByKey.get(`u${src}`);
      const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
      arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: 0.5 });
      // Verify pre-conditions
      if (!arbiter.relationManager.getDirectRelation(srcId, rel, dstId)) {
        throw new Error('pre-condition failed: relation not present after add');
      }

      arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);

      // After remove, getDirectRelation should be null
      const after = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
      if (after !== null && after !== undefined) {
        throw new Error(`expected null after remove, got ${JSON.stringify(after)}`);
      }

      // arbiter.indices should also be clear
      const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
      if (fromIdx !== undefined) {
        throw new Error(`arbiter.indices still has entry after remove: ${JSON.stringify(fromIdx)}`);
      }

      // _relationKeys should not have this tuple
      const key = arbiter.relationManager._makeRelationKey(srcId, rel, dstId);
      if (arbiter.relationManager._relationKeys.has(key)) {
        throw new Error(`_relationKeys still has ${key} after remove`);
      }

      // byName should not have this relation
      const byName = arbiter.indices.getRelationsByName(rel);
      if (byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
        throw new Error(`getRelationsByName still contains removed relation`);
      }
      const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
      if (bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
        throw new Error(`getRelationsFromSrc still contains removed relation`);
      }
      const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
      if (byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
        throw new Error(`getRelationsToDst still contains removed relation`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 20),
          rigor.gen.enum(RELATIONS),
          rigor.gen.int(0, 20)
        )
      )],
      rigor.crucible([
        rigor.invariant('remove-clears-indexes', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-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 === 'remove-clears-indexes');
    assert.ok(inv);
    assert.equal(inv.passed, true, `removeRelation index cleanup violated in ${inv.failureCount} cases`);
  });

  it('index coherence: every relation in arbiter.relations is queryable through every index', async () => {
    async function check(relations) {
      // Cap input size to keep the test fast
      if (relations.length > 5) return null;
      const arbiter = makeArbiter();
      const seenKeys = new Set();
      for (const r of relations) {
        const srcKey = `u${r.src}`;
        const dstKey = `d${r.dst}`;
        if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
        if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
        const k = `${r.src}|${r.rel}|${r.dst}`;
        if (seenKeys.has(k)) continue;  // skip duplicates — relation manager would modify
        seenKeys.add(k);
        arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility });
      }

      // For each unique (src, rel, dst) in the input, verify all indexes agree
      for (const k of seenKeys) {
        const [src, rel, dst] = k.split('|');
        const srcKey = `u${src}`;
        const dstKey = `d${dst}`;
        const srcId = arbiter.nodeIdByKey.get(srcKey);
        const dstId = arbiter.nodeIdByKey.get(dstKey);

        // 1. arbiter.indices.getDirectRelation
        const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
        if (!fromIdx) throw new Error(`arbiter.indices.getDirectRelation(${k}) returned null`);

        // 2. relationManager.getDirectRelation (should agree)
        const fromMgr = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
        if (fromMgr !== fromIdx) {
          throw new Error(`relationManager.getDirectRelation !== arbiter.indices.getDirectRelation for ${k}`);
        }

        // 3. byName must contain this relation
        const byName = arbiter.indices.getRelationsByName(rel);
        if (!byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
          throw new Error(`getRelationsByName(${rel}) missing ${k}`);
        }

        // 4. bySrc must contain this relation
        const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
        if (!bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
          throw new Error(`getRelationsFromSrc(${srcId},${rel}) missing ${k}`);
        }

        // 5. byDst must contain this relation
        const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
        if (!byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
          throw new Error(`getRelationsToDst(${dstId},${rel}) missing ${k}`);
        }
      }

      // Cross-consistency: _relationKeys.size matches arbiter.relations.length
      // (modulo dedup — we passed unique keys above)
      if (arbiter.relationManager._relationKeys.size !== arbiter.relations.length) {
        throw new Error(
          `_relationKeys.size=${arbiter.relationManager._relationKeys.size} !== ` +
          `arbiter.relations.length=${arbiter.relations.length}`
        );
      }

      return true;
    }

    const relGen = rigor.gen.array(
      rigor.gen.tuple(
        rigor.gen.int(0, 4),
        rigor.gen.enum(RELATIONS),
        rigor.gen.int(0, 4),
        rigor.gen.float({ min: 0.01, max: 1 })
      ).map(([src, rel, dst, p]) => ({ src, rel, dst, possibility: p })),
      1, 5
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(relGen))],
      rigor.crucible([
        rigor.invariant('index-coherence', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-manager-index-coherence', 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 === 'index-coherence');
    assert.ok(inv);
    assert.equal(inv.passed, true, `index coherence violated in ${inv.failureCount} cases`);
  });

  it('add-then-remove sequence: state matches initial (no leaks)', async () => {
    async function check(operations) {
      const arbiter = makeArbiter();
      // Each operation: [opCode, src, rel, dst]
      // 0 = addRelation, 1 = removeRelation
      for (const op of operations) {
        if (op[0] === 0) {
          const [, src, rel, dst] = op;
          if (!arbiter.nodeIdByKey.has(`u${src}`)) arbiter.addNode(`u${src}`, 'user');
          if (!arbiter.nodeIdByKey.has(`d${dst}`)) arbiter.addNode(`d${dst}`, 'doc');
          arbiter.addRelation(`u${src}`, rel, `d${dst}`);
        } else if (op[0] === 1) {
          const [, src, rel, dst] = op;
          if (!arbiter.nodeIdByKey.has(`u${src}`)) continue;  // node not added yet
          if (!arbiter.nodeIdByKey.has(`d${dst}`)) continue;
          arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);
        }
      }

      // Verify: for every key in _relationKeys, arbiter.relations has it
      // AND arbiter.indices.getDirectRelation returns it
      // _relationKeys keys are formatted as "srcId|relationId|dstId" where
      // relationId is an internal id, not the relation name. Build a reverse map.
      const relIdToName = new Map();
      for (const [name, id] of arbiter.relationManager._relationNameToId) {
        relIdToName.set(id, name);
      }
      for (const key of arbiter.relationManager._relationKeys) {
        const [srcIdStr, relationIdStr, dstIdStr] = key.split('|');
        const srcId = parseInt(srcIdStr, 10);
        const dstId = parseInt(dstIdStr, 10);
        const relId = parseInt(relationIdStr, 10);
        const relName = relIdToName.get(relId);
        if (!relName) {
          throw new Error(`cannot resolve relationId ${relId} to name (keyManager state: ${JSON.stringify([...relIdToName])})`);
        }

        const idx = arbiter.indices.getDirectRelation(srcId, relName, dstId);
        if (!idx) {
          throw new Error(`_relationKeys has ${key} but indices.getDirectRelation returns null`);
        }
        // The relation should also be findable in arbiter.relations
        const found = arbiter.relations.some(r => r.src === srcId && r.dst === dstId && r.rel === relName);
        if (!found) {
          throw new Error(`_relationKeys has ${key} but arbiter.relations does not`);
        }
      }

      // Verify: for every relation in arbiter.relations, _relationKeys has it
      for (const r of arbiter.relations) {
        const key = arbiter.relationManager._makeRelationKey(r.src, r.rel, r.dst);
        if (!arbiter.relationManager._relationKeys.has(key)) {
          throw new Error(`arbiter.relations has ${r.src}|${r.rel}|${r.dst} but _relationKeys missing`);
        }
      }

      return true;
    }

    const opGen = rigor.gen.array(
      rigor.gen.oneOf([
        rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4)),
        rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4))
      ]),
      1, 8
    );

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args(opGen))],
      rigor.crucible([
        rigor.invariant('add-remove-roundtrip', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-manager-add-remove-roundtrip', 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 === 'add-remove-roundtrip');
    assert.ok(inv);
    assert.equal(inv.passed, true, `add-then-remove sequence violated in ${inv.failureCount} cases`);
  });

  it('getDirectRelation returns null for unknown (src, rel, dst)', async () => {
    async function check(src, rel, dst) {
      const arbiter = makeArbiter();
      // No relations added; any query should return null/undefined
      // Note: src/dst are arbitrary ints because we never added those nodes,
      // so the relation can't exist by definition.
      const result = arbiter.relationManager.getDirectRelation(src, rel, dst);
      if (result !== null && result !== undefined) {
        throw new Error(`expected null/undefined for unknown relation, got ${JSON.stringify(result)}`);
      }
      return true;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 1000),
          rigor.gen.enum(RELATIONS),
          rigor.gen.int(0, 1000)
        )
      )],
      rigor.crucible([
        rigor.invariant('getDirectRelation-unknown', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'relation-manager-unknown-tuple', 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 === 'getDirectRelation-unknown');
    assert.ok(inv);
    assert.equal(inv.passed, true, `getDirectRelation-unknown contract violated in ${inv.failureCount} cases`);
  });
});
