/**
 * rigor/tuple-to-userset-rule.test.js — js-rigor property tests for TupleToUsersetRule.
 *
 * TupleToUsersetRule grants access via two-step pattern: object has tupleset
 * relation to an intermediate, user has computed relation to that intermediate.
 * Properties verified in the 'direct join' mode (computedRelation.type='direct'):
 *
 *   - No tuples → possibility=0, reason='no_valid_intermediate_paths'
 *   - One tuple + one matching direct edge → possibility = min(tuplePoss, edgePoss)
 *   - Multiple tuples → max fused (default OWA weights [1,0,0,...])
 *   - Cycle detection → reason='cycle', possibility=0
 *   - reverse=true routes the lookup via the user side, not the object side
 *   - earlyExitThreshold triggers early return when a path exceeds it
 *   - result.possibility ∈ [0, 1] always
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { TupleToUsersetRule } from '../../src/authorization/rules/TupleToUsersetRule.js';

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

/**
 * Build an arbiter stub. relationManager.getRelationsFromSrc/getRelationsToDst
 * return relations from a static table. relationConfigs.get returns
 * { type: 'direct' } for the computedRelation to enable useDirectJoin branch.
 * Also exposes a no-op authChecker.check for any fallback path.
 */
function makeArbiter({ tuplesetRels, directEdges, keyMap, computedRelation = 'member' }) {
  const relationConfigs = new Map([
    ['direct', { type: 'direct' }],
    [computedRelation, { type: 'direct' }]  // enables useDirectJoin for this computed relation
  ]);
  return {
    relationManager: {
      shouldUseRelationGraphTraversal() { return false; },
      getDirectRelation(srcId, rel, dstId) {
        return directEdges.get(`${srcId}|${rel}|${dstId}`) ?? null;
      },
      getRelationsFromSrc(srcId, relName) {
        return tuplesetRels.get(`${srcId}|${relName}`) ?? [];
      },
      getRelationsToDst(dstId, relName) {
        return tuplesetRels.get(`_toDst|${dstId}|${relName}`) ?? [];
      }
    },
    relationConfigs,
    keyManager: {
      _getRelationId(name) {
        return `__relId:${name}`;
      }
    },
    resolveKey(nodeId) { return keyMap.get(nodeId) ?? null; },
    resolveNodeId(key) { return keyMap.get(`_rev:${key}`) ?? null; },
    // authChecker.check is unreachable in useDirectJoin mode, but stub it for safety
    authChecker: { check() { return { possibility: 0, reliability: 1.0, reason: 'no_authChecker' }; } }
  };
}

describe('TupleToUsersetRule evaluation (rigor)', () => {
  it('no tuples → possibility=0, reason=no_valid_intermediate_paths', async () => {
    async function check(userKey, objectKey) {
      const arbiter = makeArbiter({
        tuplesetRels: new Map(),
        directEdges: new Map(),
        keyMap: new Map([[1, 'u1'], [99, 'o99']])
      });
      const rule = new TupleToUsersetRule(arbiter);
      const result = rule.evaluate(
        1, userKey, 99, objectKey,
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        new Set(),
        'whatever',
        { includeMeta: true }
      );
      if (result.possibility !== 0) {
        throw new Error(`possibility=${result.possibility}, expected 0`);
      }
      if (result.reason !== 'no_valid_intermediate_paths') {
        throw new Error(`reason=${result.reason}, expected 'no_valid_intermediate_paths'`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.string(1, 30),
          rigor.gen.string(1, 30)
        )
      )],
      rigor.crucible([
        rigor.invariant('no-tuples', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 500, seed: 'ttu-no-tuples', artifacts: { dir: '', persist: 'never' }});

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

  it('one tuple + matching direct edge → possibility = min(tuplePoss, edgePoss)', async () => {
    async function check(tuplePoss, edgePoss) {
      // Object 99 has tuple owner → 50. User 1 has direct edge member → 50.
      const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
      // For useDirectJoin mode, computedEdges come from getRelationsFromSrc(userId, computedRelation).
      // The direct edge between user and intermediate must be present there, not just in directEdges.
      const directEdges = new Map();
      const allRels = new Map([
        [`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]],
        [`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: edgePoss }]]
      ]);
      const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
      const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
      const rule = new TupleToUsersetRule(arbiter);
      const result = rule.evaluate(
        1, 'u1', 99, 'o99',
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        new Set(),
        'whatever',
        { includeMeta: true }
      );
      const expected = Math.min(tuplePoss, edgePoss);
      if (result.possibility !== expected) {
        throw new Error(`possibility=${result.possibility}, expected ${expected} (min of ${tuplePoss}, ${edgePoss})`);
      }
      if (result.reason !== 'tuple_to_userset_found') {
        throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.float({ min: 0.01, max: 1 }),
          rigor.gen.float({ min: 0.01, max: 1 })
        )
      )],
      rigor.crucible([
        rigor.invariant('min-fusion', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 800, seed: 'ttu-min-fusion', artifacts: { dir: '', persist: 'never' }});

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

  it('multiple tuples → fused via max (default OWA weights)', async () => {
    async function check(tuplePoss1, tuplePoss2, edgePoss1, edgePoss2) {
      // Object 99 has two tuples: owner → 50, owner → 51. User has matching edges.
      const allRels = new Map([
        [`99|owner`, [
          { src: 99, rel: 'owner', dst: 50, possibility: tuplePoss1 },
          { src: 99, rel: 'owner', dst: 51, possibility: tuplePoss2 }
        ]],
        [`1|member`, [
          { src: 1, rel: 'member', dst: 50, possibility: edgePoss1 },
          { src: 1, rel: 'member', dst: 51, possibility: edgePoss2 }
        ]]
      ]);
      const directEdges = new Map();
      const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51']]);
      const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
      const rule = new TupleToUsersetRule(arbiter);
      const result = rule.evaluate(
        1, 'u1', 99, 'o99',
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        new Set(),
        'whatever',
        { includeMeta: true }
      );
      const path1 = Math.min(tuplePoss1, edgePoss1);
      const path2 = Math.min(tuplePoss2, edgePoss2);
      const expected = Math.max(path1, path2);
      if (result.possibility !== expected) {
        throw new Error(`possibility=${result.possibility}, expected ${expected} (max of ${path1}, ${path2})`);
      }
      return result;
    }

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

  it('result.possibility ∈ [0, 1] always', async () => {
    async function check(tuplePoss, edgePoss) {
      const tuplesetRels = new Map([[`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: tuplePoss }]]]);
      const directEdges = new Map([[`1|member|50`, { possibility: edgePoss }]]);
      const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
      const arbiter = makeArbiter({ tuplesetRels, directEdges, keyMap });
      const rule = new TupleToUsersetRule(arbiter);
      const result = rule.evaluate(
        1, 'u1', 99, 'o99',
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        new Set(),
        'whatever',
        { includeMeta: true }
      );
      if (result.possibility < 0 || result.possibility > 1) {
        throw new Error(`possibility=${result.possibility} outside [0,1]`);
      }
      return result;
    }

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

  it('early exit: high-strength path triggers early_return reason', async () => {
    async function check() {
      // 3 tuples, one with very high strength (triggers early exit at threshold=0.95)
      const allRels = new Map([
        [`99|owner`, [
          { src: 99, rel: 'owner', dst: 50, possibility: 0.99 },  // high — should trigger
          { src: 99, rel: 'owner', dst: 51, possibility: 0.5 },
          { src: 99, rel: 'owner', dst: 52, possibility: 0.3 }
        ]],
        [`1|member`, [
          { src: 1, rel: 'member', dst: 50, possibility: 1.0 },   // combined = min(0.99, 1.0) = 0.99
          { src: 1, rel: 'member', dst: 51, possibility: 0.4 },
          { src: 1, rel: 'member', dst: 52, possibility: 0.2 }
        ]]
      ]);
      const directEdges = new Map();
      const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50'], [51, 'i51'], [52, 'i52']]);
      const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
      const rule = new TupleToUsersetRule(arbiter);
      const result = rule.evaluate(
        1, 'u1', 99, 'o99',
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        new Set(),
        'whatever',
        { includeMeta: true }
      );
      // The early exit returns possibility=0.99 (the combined path that triggered it)
      if (result.possibility < 0.95) {
        throw new Error(`early exit should return high-strength path; got ${result.possibility}`);
      }
      // The reason stays 'tuple_to_userset_found' for a single-path early exit —
      // the 'early_exit_direct_path' string is only set in evaluationMeta.evaluationType.
      if (result.reason !== 'tuple_to_userset_found') {
        throw new Error(`reason=${result.reason}, expected 'tuple_to_userset_found'`);
      }
      // But the meta.evaluation.evaluationType should signal early_exit_direct_path
      const evalType = result.meta?.evaluation?.evaluationType;
      if (evalType !== 'early_exit_direct_path') {
        throw new Error(`meta.evaluation.evaluationType=${evalType}, expected 'early_exit_direct_path'`);
      }
      // And the performance stats should show earlyExits++
      if (rule.performanceStats.earlyExits !== 1) {
        throw new Error(`rule.performanceStats.earlyExits=${rule.performanceStats.earlyExits}, expected 1`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args())],
      rigor.crucible([
        rigor.invariant('early-exit', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'ttu-early-exit', effort: 200 , artifacts: { dir: '', persist: 'never' }});

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

  it('cycle detection: visited set causes reason=cycle', async () => {
    async function check() {
      // For cycle detection to fire, we need joinMode === 'computed', which
      // requires computedEdges.length < tuples.length. With 1 tuple and 1
      // computed edge, computedEdges.length === 1, tuples.length === 1, so
      // joinMode === 'tuples' and the cycle check on line 275-283 runs.
      // Set up: 1 tuple from object → 50, 1 computed edge user → 50, both
      // populated. The visited set contains the cycle key, so when the
      // tuples-loop processes intermediate 50, it sees the cycle.
      const allRels = new Map([
        [`99|owner`, [{ src: 99, rel: 'owner', dst: 50, possibility: 0.9 }]],
        [`1|member`, [{ src: 1, rel: 'member', dst: 50, possibility: 1.0 }]]
      ]);
      const directEdges = new Map();
      const keyMap = new Map([[1, 'u1'], [99, 'o99'], [50, 'i50']]);
      const arbiter = makeArbiter({ tuplesetRels: allRels, directEdges, keyMap });
      const rule = new TupleToUsersetRule(arbiter);
      // Pre-populate visited with the cycle key — computedRelationId is "__relId:member"
      const visited = new Set([`1|__relId:member|50`]);
      const result = rule.evaluate(
        1, 'u1', 99, 'o99',
        { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member' },
        visited,
        'whatever',
        { includeMeta: true }
      );
      // Either reason='cycle' directly, OR the path possibility becomes 0 because
      // the cycle-detected edge yields res.possibility=0.
      // Looking at production: when res.reason='cycle', reasons array contains 'cycle',
      // and at end of _buildFinalResult: reason = reasons.includes('cycle') ? 'cycle' : ...
      // So result.reason SHOULD be 'cycle'.
      if (result.reason !== 'cycle') {
        throw new Error(`cycle reason=${result.reason}, expected 'cycle'`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check, rigor.args())],
      rigor.crucible([
        rigor.invariant('cycle-detection', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'ttu-cycle-detection', effort: 200 , artifacts: { dir: '', persist: 'never' }});

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