/**
 * rigor/zanzibar-consistency.test.js — js-rigor property tests for
 * authorization state consistency under mutation, reconfiguration and
 * value collection.
 *
 * Properties verified:
 *
 *   - TTU MUTATION: removing either the membership edge or the tupleset
 *     edge revokes a previously-granting TTU check; restoring the edge
 *     re-grants (no stale cache in either direction).
 *   - CHAIN MUTATION: removing an intermediate edge in a chain revokes;
 *     re-adding re-grants.
 *   - CONFIG CHANGE: reconfiguring the same relation (direct → chain)
 *     changes the outcome exactly as the new config dictates, without
 *     stale results from the old config.
 *   - THRESHOLD: a minPossibility filter excludes paths below the
 *     threshold; results never exceed the strongest surviving path.
 *   - VALUE COLLECTION COMPLETENESS (regression): every parallel chain
 *     path contributes its value — the sum of authorized balances equals
 *     the sum of ALL path values, even when one path is weaker than
 *     another (this property fails on the old dedup-before-collect bug).
 *   - REPEATED CHECK DETERMINISM: identical checks return identical
 *     results across repetitions (no hidden state drift).
 */
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 POSSIBILITIES = [0.25, 0.5, 0.75, 1];

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

describe('Authorization state consistency (rigor)', () => {
  it('TTU MUTATION: membership or tupleset removal revokes; restore re-grants', async () => {
    async function check({ pm, po, mutate }) {
      const arbiter = new Arbiter();
      ['user:alice', 'group:eng', 'doc:1'].forEach((k) =>
        arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
      arbiter.setRelationConfig('member_of', { type: 'direct' });
      arbiter.setRelationConfig('owner', { type: 'direct' });
      arbiter.setRelationConfig('can_access', {
        type: 'tuple_to_userset',
        tuplesetRelation: 'owner',
        computedRelation: 'member_of'
      });
      arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
      arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po });

      const before = arbiter.check('user:alice', 'can_access', 'doc:1');
      const expected = Math.min(pm, po);
      if (Math.abs(before.possibility - expected) > EPS) {
        fail(`setup: expected ${expected}, got ${before.possibility}`);
      }

      // Remove one edge (warm caches first with a granting check)
      if (mutate === 0) {
        arbiter.removeRelation('user:alice', 'member_of', 'group:eng');
      } else {
        arbiter.removeRelation('doc:1', 'owner', 'group:eng');
      }
      const revoked = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (revoked.possibility !== 0) {
        fail(`revoked TTU still grants: ${revoked.possibility}`);
      }

      // Restore the edge → grant again
      if (mutate === 0) {
        arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
      } else {
        arbiter.addRelation('doc:1', 'owner', 'group:eng', { possibility: po });
      }
      const restored = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (Math.abs(restored.possibility - expected) > EPS) {
        fail(`restored TTU: expected ${expected}, got ${restored.possibility}`);
      }
      return { before, revoked, restored };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pm: rigor.gen.oneOf(POSSIBILITIES),
            po: rigor.gen.oneOf(POSSIBILITIES),
            mutate: rigor.gen.int(0, 1)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('ttu-mutation', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'consistency-ttu-mutation' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'ttu-mutation');
    assert.ok(inv);
    assert.equal(inv.passed, true, `TTU MUTATION violated in ${inv.failureCount} cases`);
  });

  it('CHAIN MUTATION: removing an intermediate edge revokes; re-adding re-grants', async () => {
    async function check({ p1, p2, p3 }) {
      const arbiter = new Arbiter();
      ['user:alice', 'group:eng', 'group:org', 'doc:1'].forEach((k) =>
        arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
      arbiter.setRelationConfig('member_of', { type: 'direct' });
      arbiter.setRelationConfig('viewer', { type: 'direct' });
      arbiter.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'member_of', direction: 'out' },
          { relation: 'viewer', direction: 'out' }
        ]
      });
      arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 });
      arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 });
      arbiter.addRelation('group:org', 'viewer', 'doc:1', { possibility: p3 });

      const expected = Math.min(p1, p2, p3);
      const before = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (Math.abs(before.possibility - expected) > EPS) {
        fail(`setup: expected ${expected}, got ${before.possibility}`);
      }

      // Break the middle hop
      arbiter.removeRelation('group:eng', 'member_of', 'group:org');
      const revoked = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (revoked.possibility !== 0) {
        fail(`broken chain still grants: ${revoked.possibility}`);
      }

      // Rebuild the hop
      arbiter.addRelation('group:eng', 'member_of', 'group:org', { possibility: p2 });
      const restored = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (Math.abs(restored.possibility - expected) > EPS) {
        fail(`restored chain: expected ${expected}, got ${restored.possibility}`);
      }
      return { before, revoked, restored };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p1: rigor.gen.oneOf(POSSIBILITIES),
            p2: rigor.gen.oneOf(POSSIBILITIES),
            p3: rigor.gen.oneOf(POSSIBILITIES)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('chain-mutation', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'consistency-chain-mutation' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'chain-mutation');
    assert.ok(inv);
    assert.equal(inv.passed, true, `CHAIN MUTATION violated in ${inv.failureCount} cases`);
  });

  it('CONFIG CHANGE: reconfiguring a relation flips outcomes exactly as the new config dictates', async () => {
    async function check({ pEdge, pMember, configOrder }) {
      const arbiter = new Arbiter();
      ['user:alice', 'group:eng', 'doc:1'].forEach((k) =>
        arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('group') ? 'group' : 'doc'));
      arbiter.setRelationConfig('member_of', { type: 'direct' });
      arbiter.setRelationConfig('viewer', { type: 'direct' });
      arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: pEdge });
      arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pMember });
      arbiter.addRelation('group:eng', 'viewer', 'doc:1', { possibility: pMember });

      const directConfig = () => arbiter.setRelationConfig('can_access', { type: 'direct', relation: 'viewer' });
      const chainConfig = () => arbiter.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'viewer', direction: 'out' }
        ]
      });

      const first = configOrder === 0 ? directConfig() : chainConfig();
      const directResult = arbiter.check('user:alice', 'can_access', 'doc:1');

      const second = configOrder === 0 ? chainConfig() : directConfig();
      const afterReconfig = arbiter.check('user:alice', 'can_access', 'doc:1');

      const expectedDirect = pEdge;
      const expectedChain = Math.min(pMember, pMember);
      if (first && second) {
        if (configOrder === 0) {
          if (Math.abs(directResult.possibility - expectedDirect) > EPS) {
            fail(`direct config: expected ${expectedDirect}, got ${directResult.possibility}`);
          }
          if (Math.abs(afterReconfig.possibility - expectedChain) > EPS) {
            fail(`after reconfig to chain: expected ${expectedChain}, got ${afterReconfig.possibility}`);
          }
        } else {
          if (Math.abs(directResult.possibility - expectedChain) > EPS) {
            fail(`chain config: expected ${expectedChain}, got ${directResult.possibility}`);
          }
          if (Math.abs(afterReconfig.possibility - expectedDirect) > EPS) {
            fail(`after reconfig to direct: expected ${expectedDirect}, got ${afterReconfig.possibility}`);
          }
        }
      }
      return { directResult, afterReconfig };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pEdge: rigor.gen.oneOf(POSSIBILITIES),
            pMember: rigor.gen.oneOf(POSSIBILITIES),
            configOrder: rigor.gen.int(0, 1)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('config-change', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'consistency-config-change' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'config-change');
    assert.ok(inv);
    assert.equal(inv.passed, true, `CONFIG CHANGE violated in ${inv.failureCount} cases`);
  });

  it('THRESHOLD: minPossibility excludes weaker paths', async () => {
    async function check({ strong, weak, threshold }) {
      const arbiter = new Arbiter();
      ['user:alice', 'mid:1', 'mid:2', 'doc:1'].forEach((k) =>
        arbiter.addNode(k, k.startsWith('user') ? 'user' : k.startsWith('mid') ? 'group' : 'doc'));
      arbiter.setRelationConfig('member_of', { type: 'direct' });
      arbiter.setRelationConfig('viewer', { type: 'direct' });
      arbiter.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'viewer', direction: 'out' }
        ]
      });
      arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: strong });
      arbiter.addRelation('mid:1', 'viewer', 'doc:1', { possibility: strong });
      arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: weak });
      arbiter.addRelation('mid:2', 'viewer', 'doc:1', { possibility: weak });

      const noThreshold = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (Math.abs(noThreshold.possibility - strong) > EPS) {
        fail(`no threshold: expected ${strong}, got ${noThreshold.possibility}`);
      }

      const filtered = arbiter.check('user:alice', 'can_access', 'doc:1', { minPossibility: threshold });
      const expected = threshold > weak ? strong : strong;
      if (Math.abs(filtered.possibility - expected) > EPS) {
        fail(`threshold ${threshold}: expected ${expected}, got ${filtered.possibility}`);
      }
      return { noThreshold, filtered };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            strong: rigor.gen.oneOf([0.75, 1]),
            weak: rigor.gen.oneOf([0.25, 0.5]),
            threshold: rigor.gen.oneOf([0, 0.4, 0.6, 0.9])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('threshold-excludes', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'consistency-threshold' , artifacts: { dir: '', persist: 'never' }});

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

  it('VALUE COLLECTION COMPLETENESS: all parallel chain paths contribute their values', async () => {
    async function check({ paths, value }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:1', 'doc');
      const mids = [];
      for (let i = 0; i < paths; i++) {
        const key = `mid:${i}`;
        mids.push(key);
        arbiter.addNode(key, 'account');
      }
      arbiter.setRelationConfig('can_debit', { type: 'direct' });
      arbiter.setRelationConfig('has_balance', { type: 'direct' });
      arbiter.setRelationConfig('authorized_balance', {
        type: 'chain',
        steps: [
          { relation: 'can_debit', direction: 'out' },
          { relation: 'has_balance', direction: 'out' }
        ]
      });

      // N parallel paths, each carrying the same value; the paths have
      // DIFFERENT possibilities (first strongest, then weakening) so the
      // old dedup-before-collect bug would drop the weaker paths' values.
      for (let i = 0; i < paths; i++) {
        const p = 1 - i * 0.15; // 1, 0.85, 0.7, ...
        arbiter.addRelation('user:alice', 'can_debit', mids[i], { possibility: p });
        arbiter.addRelation(mids[i], 'has_balance', 'doc:1', { possibility: p, value });
      }

      const result = arbiter.check('user:alice', 'authorized_balance', 'doc:1', {
        collectValues: true,
        includeMeta: true
      });

      const collected = result.collectedValues || [];
      if (collected.length !== paths) {
        fail(`expected ${paths} collected values, got ${collected.length}`);
      }
      const total = collected.reduce((sum, cv) => sum + (cv.value?.min ?? cv.value ?? 0), 0);
      const expectedTotal = paths * value;
      if (Math.abs(total - expectedTotal) > EPS) {
        fail(`value sum: expected ${expectedTotal}, got ${total} (${collected.length} values)`);
      }
      return { count: collected.length, total };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            paths: rigor.gen.int(2, 5),
            value: rigor.gen.int(10, 500)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('values-complete', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'consistency-value-completeness' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'values-complete');
    assert.ok(inv);
    assert.equal(inv.passed, true, `VALUE COLLECTION violated in ${inv.failureCount} cases`);
  });

  it('REPEATED CHECK DETERMINISM: identical checks never drift', async () => {
    async function check({ p, repeats }) {
      const arbiter = new Arbiter();
      ['user:alice', 'doc:1'].forEach((k) =>
        arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
      arbiter.setRelationConfig('viewer', { type: 'direct' });
      arbiter.addRelation('user:alice', 'viewer', 'doc:1', { possibility: p });

      const first = arbiter.check('user:alice', 'viewer', 'doc:1');
      for (let i = 0; i < repeats; i++) {
        const again = arbiter.check('user:alice', 'viewer', 'doc:1');
        if (again.possibility !== first.possibility) {
          fail(`check ${i + 1}: ${again.possibility} differs from first ${first.possibility}`);
        }
      }
      return first;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p: rigor.gen.oneOf(POSSIBILITIES),
            repeats: rigor.gen.int(1, 10)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('deterministic', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 300, seed: 'consistency-determinism' , artifacts: { dir: '', persist: 'never' }});

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