/**
 * rigor/zanzibar-semantics.test.js — js-rigor property tests for
 * Zanzibar-style authorization graph semantics.
 *
 * Zanzibar's core model (Google's global authorization system):
 * relation tuples (object, relation, user), usersets, and rewrite rules
 * (direct, tuple-to-userset, union, intersection, exclusion).
 *
 * Properties verified:
 *
 *   - UNION: a grant from ANY child grants at the child's possibility
 *     (max over children); no child grants → deny.
 *   - INTERSECTION: ALL children must grant; result is the min.
 *   - EXCLUSION (A AND NOT B): the denied set blocks the allowed set;
 *     p = pA * (1 - pB).
 *   - EXPAND PARITY (flagship): a brute-force reference implementation of
 *     Zanzibar's Expand RPC (userset expansion) is compared against
 *     arbiter.check() over RANDOM graphs with random rewrite configs —
 *     direct, chain (transitive member_of), tuple-to-userset, union and
 *     exclusion — with random edge possibilities including 0.
 *   - NESTED CHAIN: membership transitivity through N nested groups honors
 *     the weakest link (min possibility along the full path).
 *   - CYCLE SAFETY: cyclic membership graphs terminate and never create
 *     spurious access.
 */
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, 0.25, 0.5, 0.75, 1];

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

/**
 * Reference implementation of Zanzibar's Expand RPC over a graph of
 * users / groups / one object, for the rewrite configs the engine supports.
 * Returns the possibility that `user` is in the userset of (object, rel).
 *
 * Semantics mirrored from the engine (verified empirically):
 *   - direct: only edges whose subject is the user itself (no implicit
 *     group expansion on direct tuples); possibility = edge possibility.
 *   - chain: transitive `member_of` traversal (min along path, max over
 *     paths), then a final edge of the terminal relation into the object.
 *   - tuple_to_userset: object's tuplesetRelation edges (doc → group),
 *     joined with a DIRECT computedRelation edge from the user; min.
 *   - union: max over children.  intersection: min over children.
 *   - exclusion: pA * (1 - pB).
 */
export function buildExpandOracle(graph, config) {
  const { memberOf, edgesByRelation } = graph;
  const edges = (rel) => edgesByRelation.get(rel) || [];

  function directUserset(rel) {
    return (userId) => {
      let best = 0;
      // direct edges point FROM the subject TO the object (src=subject, dst=object)
      for (const e of edges(rel)) {
        if (e.from === userId && e.subject === graph.objectId) {
          if (e.p > best) best = e.p;
        }
      }
      return best;
    };
  }

  function transitiveMembers(userId) {
    // BFS over member_of edges; min possibility along each path; max over paths.
    const best = new Map([[userId, 1]]);
    const queue = [userId];
    while (queue.length) {
      const node = queue.shift();
      const nodeP = best.get(node);
      for (const e of edges('member_of')) {
        if (e.from !== node) continue;
        const nextP = Math.min(nodeP, e.p);
        const prev = best.get(e.subject);
        if (prev === undefined || nextP > prev) {
          best.set(e.subject, nextP);
          queue.push(e.subject);
        }
      }
    }
    return best;
  }

  function reachAtExactHops(userId, hops) {
    // Nodes reachable in EXACTLY `hops` member_of traversals, with the
    // best (max) possibility per node. Mirrors the engine's per-step
    // path extension (paths always traverse every step).
    let frontier = new Map([[userId, 1]]);
    for (let h = 0; h < hops; h++) {
      const next = new Map();
      for (const [node, nodeP] of frontier) {
        for (const e of edges('member_of')) {
          if (e.from !== node) continue;
          const nextP = Math.min(nodeP, e.p);
          const prev = next.get(e.subject);
          if (prev === undefined || nextP > prev) {
            next.set(e.subject, nextP);
          }
        }
      }
      frontier = next;
      if (frontier.size === 0) break;
    }
    return frontier;
  }

  function evalRule(rule, userId) {
    if (rule.type === 'direct') {
      return directUserset(rule.relation)(userId);
    }
    if (rule.type === 'chain') {
      // steps: N member_of hops then a terminal relation into the object.
      // The engine extends paths through EVERY step, so the terminal edge
      // may only originate from nodes reachable in exactly N member_of hops
      // (the starting user alone never satisfies the chain).
      const steps = rule.steps;
      const terminal = steps[steps.length - 1].relation;
      const memberHops = steps.length - 1;
      let best = 0;
      if (memberHops > 0) {
        for (const [node, p] of reachAtExactHops(userId, memberHops)) {
          for (const e of edges(terminal)) {
            if (e.from === node && e.subject === graph.objectId) {
              const pathP = Math.min(p, e.p);
              if (pathP > best) best = pathP;
            }
          }
        }
      }
      return best;
    }
    if (rule.type === 'tuple_to_userset') {
      let best = 0;
      for (const t of edges(rule.tuplesetRelation)) {
        if (t.from !== graph.objectId) continue;
        // computed side is a DIRECT relation lookup (verified semantics)
        let memberP = 0;
        for (const m of edges(rule.computedRelation)) {
          if (m.from === userId && m.subject === t.subject) {
            if (m.p > memberP) memberP = m.p;
          }
        }
        const combined = Math.min(t.p, memberP);
        if (combined > best) best = combined;
      }
      return best;
    }
    if (rule.union) {
      return Math.max(0, ...rule.union.map((r) => evalRule(r, userId)));
    }
    if (rule.intersection) {
      return Math.min(...rule.intersection.map((r) => evalRule(r, userId)));
    }
    if (rule.exclusion) {
      const [a, b] = rule.exclusion;
      return evalRule(a, userId) * (1 - evalRule(b, userId));
    }
    return 0;
  }

  return { evalRule };
}

/**
 * Build a random Zanzibar-style graph + rewrite config, and the matching
 * Arbiter instance. Config shapes are chosen from a fixed family so the
 * oracle and the engine stay in sync by construction.
 */
export function buildRandomZanzibarCase(seedCase) {
  const { userCount, groupCount, configKind } = seedCase;
  const users = Array.from({ length: userCount }, (_, i) => `user:${i}`);
  const groups = Array.from({ length: groupCount }, (_, i) => `group:${i}`);
  const allSubjects = [...users, ...groups];
  const doc = 'doc:1';

  const arbiter = new Arbiter();
  for (const u of users) arbiter.addNode(u, 'user');
  for (const g of groups) arbiter.addNode(g, 'group');
  arbiter.addNode(doc, 'doc');
  arbiter.setRelationConfig('member_of', { type: 'direct' });

  const edgesByRelation = new Map();
  const addEdge = (rel, from, subject, p) => {
    let list = edgesByRelation.get(rel);
    if (!list) {
      list = [];
      edgesByRelation.set(rel, list);
    }
    // The engine dedups relations per (src, rel, dst) with last-write-wins
    // (addRelation updates an existing tuple). Mirror that so the oracle
    // and the engine observe identical edge data.
    const existing = list.find((e) => e.from === from && e.subject === subject);
    if (existing) {
      existing.p = p;
      arbiter.removeRelation(from, rel, subject);
    }
    list.push(existing || { from, subject, p });
    arbiter.addRelation(from, rel, subject, { possibility: p });
  };

  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];

  // member_of edges: users AND groups can be members (nesting), subjects can be groups
  const memberEdges = Math.max(1, Math.floor((userCount + groupCount) / 2));
  for (let i = 0; i < memberEdges; i++) {
    const from = pick(allSubjects);
    const subject = pick(groups);
    addEdge('member_of', from, subject, pick(POSSIBILITIES));
  }

  // Two direct relations (for union / exclusion / direct configs)
  const d1 = 'viewer_allowed';
  const d2 = 'viewer_staff';
  const denied = 'viewer_denied';
  for (const rel of [d1, d2, denied]) {
    arbiter.setRelationConfig(rel, { type: 'direct' });
  }
  const directEdgeCount = Math.max(1, Math.floor(userCount / 2) + 1);
  for (let i = 0; i < directEdgeCount; i++) {
    // direct edges point FROM the subject TO the object (src=subject, dst=doc)
    addEdge(pick([d1, d2]), pick(allSubjects), doc, pick(POSSIBILITIES));
    addEdge(denied, pick(allSubjects), doc, pick(POSSIBILITIES));
  }

  // Tupleset relation: doc → group (for tuple_to_userset)
  const t1 = 'owner';
  arbiter.setRelationConfig(t1, { type: 'direct' });
  if (groupCount > 0) {
    for (let i = 0; i < groupCount; i++) {
      addEdge(t1, doc, pick(groups), pick(POSSIBILITIES));
    }
  }

  const directRule = () => ({ type: 'direct', relation: pick([d1, d2]) });
  const chainRule = () => ({
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'member_of', direction: 'out' },
      { relation: pick([d1, d2]), direction: 'out' }
    ]
  });
  const ttuRule = () => ({
    type: 'tuple_to_userset',
    tuplesetRelation: t1,
    computedRelation: 'member_of',
    reverse: false
  });
  const allowRule = () => pick([directRule(), chainRule(), ttuRule()]);

  let config;
  if (configKind === 0) config = allowRule();
  else if (configKind === 1) config = { union: [allowRule(), allowRule()] };
  else if (configKind === 2) config = { intersection: [allowRule(), allowRule()] };
  else if (configKind === 3) config = { exclusion: [allowRule(), { type: 'direct', relation: denied }] };
  else config = { union: [allowRule(), { exclusion: [allowRule(), { type: 'direct', relation: denied }] }] };

  arbiter.setRelationConfig('viewer', config);
  return {
    arbiter,
    graph: {
      objectId: doc,
      memberOf: edgesByRelation.get('member_of') || [],
      edgesByRelation
    },
    config,
    users
  };
}

describe('Zanzibar rewrite-rule semantics (rigor)', () => {
  it('UNION: max over children — any grant grants, none denies', async () => {
    async function check({ pa, pb, hasA, hasB }) {
      const arbiter = new Arbiter();
      ['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
      arbiter.setRelationConfig('rel_a', { type: 'direct' });
      arbiter.setRelationConfig('rel_b', { type: 'direct' });
      arbiter.setRelationConfig('viewer', {
        union: [
          { type: 'direct', relation: 'rel_a' },
          { type: 'direct', relation: 'rel_b' }
        ]
      });
      if (hasA) arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
      if (hasB) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });

      const result = arbiter.check('user:u', 'viewer', 'doc:1');
      const expected = Math.max(hasA ? pa : 0, hasB ? pb : 0);
      if (Math.abs(result.possibility - expected) > EPS) {
        fail(`union: expected ${expected}, got ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pa: rigor.gen.oneOf(POSSIBILITIES),
            pb: rigor.gen.oneOf(POSSIBILITIES),
            hasA: rigor.gen.boolean(),
            hasB: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('union-max', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'zanzibar-union' , artifacts: { dir: '', persist: 'never' }});

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

  it('INTERSECTION: min over children — all must grant', async () => {
    async function check({ pa, pb, hasA, hasB }) {
      const arbiter = new Arbiter();
      ['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
      arbiter.setRelationConfig('rel_a', { type: 'direct' });
      arbiter.setRelationConfig('rel_b', { type: 'direct' });
      arbiter.setRelationConfig('viewer', {
        intersection: [
          { type: 'direct', relation: 'rel_a' },
          { type: 'direct', relation: 'rel_b' }
        ]
      });
      if (hasA) arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
      if (hasB) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });

      const result = arbiter.check('user:u', 'viewer', 'doc:1');
      const expected = Math.min(hasA ? pa : 0, hasB ? pb : 0);
      if (Math.abs(result.possibility - expected) > EPS) {
        fail(`intersection: expected ${expected}, got ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pa: rigor.gen.oneOf(POSSIBILITIES),
            pb: rigor.gen.oneOf(POSSIBILITIES),
            hasA: rigor.gen.boolean(),
            hasB: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('intersection-min', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'zanzibar-intersection' , artifacts: { dir: '', persist: 'never' }});

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

  it('EXCLUSION (A AND NOT B): denied set blocks the allowed set', async () => {
    async function check({ pa, pb, denied }) {
      const arbiter = new Arbiter();
      ['user:u', 'doc:1'].forEach((k) => arbiter.addNode(k, k.startsWith('user') ? 'user' : 'doc'));
      arbiter.setRelationConfig('rel_a', { type: 'direct' });
      arbiter.setRelationConfig('rel_b', { type: 'direct' });
      arbiter.setRelationConfig('viewer', {
        exclusion: [
          { type: 'direct', relation: 'rel_a' },
          { type: 'direct', relation: 'rel_b' }
        ]
      });
      arbiter.addRelation('user:u', 'rel_a', 'doc:1', { possibility: pa });
      if (denied) arbiter.addRelation('user:u', 'rel_b', 'doc:1', { possibility: pb });

      const result = arbiter.check('user:u', 'viewer', 'doc:1');
      const expected = pa * (1 - (denied ? pb : 0));
      if (Math.abs(result.possibility - expected) > EPS) {
        fail(`exclusion: expected ${expected}, got ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            pa: rigor.gen.oneOf(POSSIBILITIES),
            pb: rigor.gen.oneOf(POSSIBILITIES),
            denied: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('exclusion-blocks', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'zanzibar-exclusion' , artifacts: { dir: '', persist: 'never' }});

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

  it('EXPAND PARITY: check() agrees with the Expand-RPC oracle on random graphs', async () => {
    async function check(caseParams) {
      const { arbiter, graph, config, users } = buildRandomZanzibarCase(caseParams);
      const oracle = buildExpandOracle(graph, config);

      for (const user of users) {
        const expected = oracle.evalRule(config, user);
        const actual = arbiter.check(user, 'viewer', graph.objectId);
        if (Math.abs(actual.possibility - expected) > EPS) {
          fail(`expand parity for ${user}: oracle=${expected}, engine=${actual.possibility}`);
        }
      }
      return { checked: users.length };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            userCount: rigor.gen.int(1, 4),
            groupCount: rigor.gen.int(0, 4),
            configKind: rigor.gen.int(0, 4)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('expand-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'zanzibar-expand-parity' , artifacts: { dir: '', persist: 'never' }});

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

  it('NESTED CHAIN: transitivity through N groups honors the weakest link', async () => {
    async function check({ depth, ps }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:1', 'doc');
      const groupKeys = [];
      for (let i = 0; i < depth; i++) {
        const key = `group:${i}`;
        groupKeys.push(key);
        arbiter.addNode(key, 'group');
      }
      arbiter.setRelationConfig('member_of', { type: 'direct' });
      arbiter.setRelationConfig('viewer', { type: 'direct' });
      const steps = [];
      for (let i = 0; i < depth; i++) {
        steps.push({ relation: 'member_of', direction: 'out' });
      }
      steps.push({ relation: 'viewer', direction: 'out' });
      arbiter.setRelationConfig('can_access', { type: 'chain', steps });

      let from = 'user:alice';
      let expected = 1;
      for (let i = 0; i < depth; i++) {
        arbiter.addRelation(from, 'member_of', groupKeys[i], { possibility: ps[i] });
        expected = Math.min(expected, ps[i]);
        from = groupKeys[i];
      }
      arbiter.addRelation(from, 'viewer', 'doc:1', { possibility: ps[depth] });
      expected = Math.min(expected, ps[depth]);

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

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            depth: rigor.gen.int(1, 5),
            ps: rigor.gen.array(rigor.gen.oneOf(POSSIBILITIES), 2, 6)
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('nested-weakest-link', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 500, seed: 'zanzibar-nested-chain' , artifacts: { dir: '', persist: 'never' }});

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

  it('CYCLE SAFETY: cyclic memberships terminate and never fabricate access', async () => {
    async function check({ cycleSize, memberP }) {
      const arbiter = new Arbiter();
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('doc:1', 'doc');
      const groups = [];
      for (let i = 0; i < cycleSize; i++) {
        const key = `group:${i}`;
        groups.push(key);
        arbiter.addNode(key, 'group');
      }
      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' }
        ]
      });

      // Closed cycle: g0 → g1 → ... → g0 (no user attached, no doc access)
      for (let i = 0; i < cycleSize; i++) {
        const next = groups[(i + 1) % cycleSize];
        arbiter.addRelation(groups[i], 'member_of', next, { possibility: memberP });
      }
      // A user attached to the cycle must not gain access to the doc
      arbiter.addRelation('user:alice', 'member_of', groups[0], { possibility: memberP });

      const result = arbiter.check('user:alice', 'can_access', 'doc:1');
      if (result.possibility !== 0) {
        fail(`cycle fabricated access: ${result.possibility}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            cycleSize: rigor.gen.int(2, 6),
            memberP: rigor.gen.oneOf([0.25, 0.5, 1])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('cycle-safe', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'zanzibar-cycle-safety' , artifacts: { dir: '', persist: 'never' }});

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