/**
 * rigor/authorization-graph.test.js — js-rigor property tests for the
 * authorization graph semantics.
 *
 * Properties verified (the core authorization-graph contract):
 *
 *   - DIRECT: an existing edge grants with EXACTLY its possibility;
 *     a different relation on the same pair denies (0).
 *   - BOUNDS: every check result possibility is ∈ [0, 1].
 *   - ABSENT: no edges → 0 for any relation.
 *   - CHAIN (weakest link): a chain's possibility equals the MIN of the
 *     edge possibilities along the traversed path (transitivity holds).
 *   - MULTI-PATH (disjunctive): with parallel paths the possibility is the
 *     MAX over paths of the per-path minimum.
 *   - TUPLE-TO-USERSET: group membership grants the group's owned objects
 *     at the weakest-link possibility.
 *   - MUTATION: removing an edge invalidates a previously-granting check
 *     (no stale cache grant).
 *
 * Each property runs through js-rigor's generator + bandit pipeline, so
 * boundary values (possibility 0/1, self-loops, multi-hop chains) are
 * exercised automatically, with shrinking on failure.
 */
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;

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

describe('Authorization graph semantics (rigor)', () => {
  it('DIRECT: existing edge grants with its exact possibility; other relations deny', async () => {
    async function check({ p, wrongRel }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:secret', 'doc');
      arbiter.setRelationConfig('can_read', { type: 'direct' });
      arbiter.setRelationConfig('can_write', { type: 'direct' });
      arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });

      const grant = arbiter.check('user:alice', 'can_read', 'doc:secret');
      if (Math.abs(grant.possibility - p) > EPS) {
        fail(`direct grant: expected ${p}, got ${grant.possibility}`);
      }
      if (grant.possibility < 0 || grant.possibility > 1) {
        fail(`possibility out of bounds: ${grant.possibility}`);
      }
      const deny = arbiter.check('user:alice', 'can_write', 'doc:secret');
      if (deny.possibility !== 0) {
        fail(`different relation should deny, got ${deny.possibility}`);
      }
      return { grant, deny };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
            wrongRel: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('direct-exact', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'authz-graph-direct' , artifacts: { dir: '', persist: 'never' }});

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

  it('BOUNDS + ABSENT: no edges → 0; every possibility ∈ [0,1]', async () => {
    async function check(nodes) {
      const arbiter = new Arbiter();
      const keys = [];
      for (let i = 0; i < nodes; i++) {
        keys.push(`node:${i}`);
        arbiter.addNode(`node:${i}`, 'entity');
      }
      arbiter.setRelationConfig('rel_x', { type: 'direct' });
      const src = keys[0];
      const dst = keys[keys.length - 1];
      const result = arbiter.check(src, 'rel_x', dst);
      if (result.possibility !== 0) {
        fail(`empty graph must deny, got ${result.possibility}`);
      }
      if (result.possibility < 0 || result.possibility > 1) {
        fail(`possibility out of bounds: ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(rigor.gen.int(2, 6)))
      ],
      rigor.crucible([
        rigor.invariant('absent-denies', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 300, seed: 'authz-graph-absent' , artifacts: { dir: '', persist: 'never' }});

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

  it('CHAIN (weakest link): transitivity with min possibility along the path', async () => {
    async function check({ p1, p2 }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('group:eng', 'group');
      arbiter.addNode('doc:secret', 'doc');
      arbiter.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'group_reads', direction: 'out' }
        ]
      });
      arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: p1 });
      arbiter.addRelation('group:eng', 'group_reads', 'doc:secret', { possibility: p2 });

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

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p1: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1]),
            p2: rigor.gen.oneOf([0, 0.1, 0.5, 0.9, 1])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('weakest-link', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'authz-graph-chain' , artifacts: { dir: '', persist: 'never' }});

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

  it('MULTI-PATH (disjunctive): max over paths of the per-path minimum', async () => {
    async function check({ p1a, p2a, p1b, p2b }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('mid:1', 'group');
      arbiter.addNode('mid:2', 'group');
      arbiter.addNode('doc:secret', 'doc');
      arbiter.setRelationConfig('can_access', {
        type: 'chain',
        steps: [
          { relation: 'member_of', direction: 'out' },
          { relation: 'group_reads', direction: 'out' }
        ]
      });
      // Path 1: alice → mid:1 → doc
      arbiter.addRelation('user:alice', 'member_of', 'mid:1', { possibility: p1a });
      arbiter.addRelation('mid:1', 'group_reads', 'doc:secret', { possibility: p2a });
      // Path 2: alice → mid:2 → doc
      arbiter.addRelation('user:alice', 'member_of', 'mid:2', { possibility: p1b });
      arbiter.addRelation('mid:2', 'group_reads', 'doc:secret', { possibility: p2b });

      const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
      const expected = Math.max(Math.min(p1a, p2a), Math.min(p1b, p2b));
      if (Math.abs(result.possibility - expected) > EPS) {
        fail(`multi-path: expected ${expected}, got ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            p1a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
            p2a: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
            p1b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
            p2b: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('disjunctive-max', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 500, seed: 'authz-graph-multipath' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'disjunctive-max');
    assert.ok(inv);
    assert.equal(inv.passed, true, `MULTI-PATH contract violated in ${inv.failureCount} cases`);
  });

  it('TUPLE-TO-USERSET: group membership grants owned objects at weakest-link possibility', async () => {
    async function check({ pm, po }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('group:eng', 'group');
      arbiter.addNode('doc:secret', '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',
        reverse: false
      });
      arbiter.addRelation('user:alice', 'member_of', 'group:eng', { possibility: pm });
      // Tupleset edge: object → group via 'owner' (document owns the group),
      // matching the Zanzibar tupleset convention used by the engine.
      arbiter.addRelation('doc:secret', 'owner', 'group:eng', { possibility: po });

      const result = arbiter.check('user:alice', 'can_access', 'doc:secret');
      const expected = Math.min(pm, po);
      if (Math.abs(result.possibility - expected) > EPS) {
        fail(`tuple-to-userset: expected ${expected}, got ${result.possibility}`);
      }
      // A user outside the group must not gain access via the same object
      arbiter.addNode('user:eve', 'user');
      const denied = arbiter.check('user:eve', 'can_access', 'doc:secret');
      if (denied.possibility !== 0) {
        fail(`non-member must deny, got ${denied.possibility}`);
      }
      return { result, denied };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pm: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1]),
            po: rigor.gen.oneOf([0, 0.25, 0.5, 0.75, 1])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('tus-weakest-link', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'authz-graph-tus' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'tus-weakest-link');
    assert.ok(inv);
    assert.equal(inv.passed, true, `TUPLE-TO-USERSET contract violated in ${inv.failureCount} cases`);
  });

  it('MUTATION: removing an edge revokes a previously-granting check (no stale cache)', async () => {
    async function check({ p }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:secret', 'doc');
      arbiter.setRelationConfig('can_read', { type: 'direct' });
      arbiter.addRelation('user:alice', 'can_read', 'doc:secret', { possibility: p });

      // Warm the caches with a granting check
      const before = arbiter.check('user:alice', 'can_read', 'doc:secret');
      if (before.possibility <= 0) {
        fail(`setup: expected grant, got ${before.possibility}`);
      }

      // Mutate the graph: remove the edge, then re-check
      arbiter.removeRelation('user:alice', 'can_read', 'doc:secret');
      const after = arbiter.check('user:alice', 'can_read', 'doc:secret');
      if (after.possibility !== 0) {
        fail(`revoked access still granted: ${after.possibility}`);
      }
      return { before, after };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({ p: rigor.gen.oneOf([0.25, 0.5, 0.75, 1]) })
        ))
      ],
      rigor.crucible([
        rigor.invariant('revoke-invalidates', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 300, seed: 'authz-graph-mutation' , artifacts: { dir: '', persist: 'never' }});

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