/**
 * rigor/direct-rule.test.js — js-rigor property tests for DirectRule.
 *
 * DirectRule is the simplest authorization rule: it asks the relation manager
 * for a direct (src, rel, dst) tuple and returns a standardized result with
 * raw possibility values. Properties verified:
 *
 *   - No relation → possibility=0, possibility_allow=0, possibility_deny=0,
 *     meta.ruleType='direct', reason='no_relation'
 *   - Relation present with strength s → possibility=s, possibility_allow=s,
 *     possibility_deny=0, reason='exists'
 *   - reverse=true routes the lookup to (objectId, rel, userId)
 *   - fastPath with minPossibility threshold sets meta.earlyExit on hit
 *   - collectValues=false suppresses collectedValues
 *   - relation field precedence: rule.relation > rule.rel > rule.label >
 *     rule.name > currentRelation
 *   - possibility ∈ [0,1] is preserved through the result
 *   - result shape is stable (always has the same keys)
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { DirectRule } from '../../src/authorization/rules/DirectRule.js';

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

/**
 * Build an arbiter stub whose relationManager.getDirectRelation returns
 * the value from a static relation table. Records calls for reverse/
 * non-reverse direction assertions.
 */
function makeArbiter(relations = {}) {
  const calls = [];
  const arbiter = {
    relationManager: {
      getDirectRelation(srcId, rel, dstId, options) {
        calls.push({ srcId, rel, dstId, reverse: options?.reverse });
        const key = `${srcId}|${rel}|${dstId}`;
        return relations[key] ?? null;
      }
    }
  };
  return { arbiter, calls };
}

describe('DirectRule evaluation (rigor)', () => {
  it('returns possibility=0 with reason=no_relation when no direct relation exists', async () => {
    async function check(userId, objectId, relName) {
      const { arbiter } = makeArbiter({});
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        {}
      );
      if (result.possibility !== 0) throw new Error(`possibility=${result.possibility}, expected 0`);
      if (result.possibility_allow !== 0) throw new Error(`possibility_allow=${result.possibility_allow}, expected 0`);
      if (result.possibility_deny !== 0) throw new Error(`possibility_deny=${result.possibility_deny}, expected 0`);
      // DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
      // it to top-level result.reason); assert on the direct contract.
      if (!result.meta || result.meta.ruleType !== 'direct') {
        throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
      }
      if (result.meta.reason !== 'no_relation') {
        throw new Error(`meta.reason=${result.meta.reason}, expected 'no_relation'`);
      }
      if (result.collectedValues.length !== 0) {
        throw new Error(`expected no collected values, got ${result.collectedValues.length}`);
      }
      return result;
    }

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

  it('returns relation strength as possibility/possibility_allow when present', async () => {
    async function check(userId, objectId, relName, strength) {
      const key = `${userId}|${relName}|${objectId}`;
      const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        {}
      );
      if (result.possibility !== strength) {
        throw new Error(`possibility=${result.possibility}, expected ${strength}`);
      }
      if (result.possibility_allow !== strength) {
        throw new Error(`possibility_allow=${result.possibility_allow}, expected ${strength}`);
      }
      if (result.possibility_deny !== 0) {
        throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
      }
      // DirectRule returns reason under meta.reason (AuthorizationChecker.check normalizes
      // it to top-level result.reason); assert on the direct contract.
      if (!result.meta || result.meta.ruleType !== 'direct') {
        throw new Error(`meta.ruleType=${result.meta?.ruleType}, expected 'direct'`);
      }
      if (result.meta.reason !== 'relation_exists') {
        throw new Error(`meta.reason=${result.meta.reason}, expected 'relation_exists'`);
      }
      // result.possibility should be in [0,1] (it is, by construction)
      return result;
    }

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

  it('reverse=true routes the lookup through (objectId, rel, userId)', async () => {
    async function check(userId, objectId, relName) {
      const key = `${objectId}|${relName}|${userId}`;
      const { arbiter, calls } = makeArbiter({ [key]: { possibility: 0.5 } });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName, reverse: true },
        new Set(),
        relName,
        {}
      );
      // First call should have been (userId, rel, objectId) IF reverse=false;
      // since reverse=true, the call should be (objectId, rel, userId)
      const last = calls[calls.length - 1];
      if (last.srcId !== objectId || last.dstId !== userId) {
        throw new Error(`expected lookup (objectId, rel, userId)=(${objectId}, ${relName}, ${userId}), got (${last.srcId}, ${last.rel}, ${last.dstId})`);
      }
      // And the result should reflect the found relation
      if (result.possibility !== 0.5) {
        throw new Error(`reverse lookup should find relation strength 0.5, got ${result.possibility}`);
      }
      if (result.meta.reverse !== true) {
        throw new Error(`meta.reverse=${result.meta.reverse}, expected true`);
      }
      return result;
    }

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

  it('fastPath with minPossibility threshold sets meta.earlyExit on hit', async () => {
    async function check(userId, objectId, relName, strength, threshold) {
      const key = `${userId}|${relName}|${objectId}`;
      const { arbiter } = makeArbiter({ [key]: { possibility: strength } });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        { fastPath: true, minPossibility: threshold }
      );
      // If strength >= threshold, earlyExit should be set
      if (strength >= threshold) {
        if (!result.meta?.earlyExit) {
          throw new Error(`expected meta.earlyExit=true when strength=${strength} >= threshold=${threshold}, got ${JSON.stringify(result.meta)}`);
        }
        if (result.meta.earlyExitReason !== 'strength_threshold_met') {
          throw new Error(`expected earlyExitReason='strength_threshold_met', got '${result.meta.earlyExitReason}'`);
        }
      } else {
        if (result.meta?.earlyExit) {
          throw new Error(`did not expect earlyExit when strength=${strength} < threshold=${threshold}`);
        }
      }
      return result;
    }

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

  it('collectValues=false suppresses collected values even when relation has them', async () => {
    async function check(userId, objectId, relName) {
      const key = `${userId}|${relName}|${objectId}`;
      const { arbiter } = makeArbiter({ [key]: { possibility: 0.8, value: 42 } });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        { collectValues: false }
      );
      if (result.collectedValues.length !== 0) {
        throw new Error(`expected no collected values when collectValues=false, got ${result.collectedValues.length}`);
      }
      // possibility is independent of collectValues
      if (result.possibility !== 0.8) {
        throw new Error(`possibility=${result.possibility}, expected 0.8`);
      }
      return result;
    }

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

  it('collectValues=true (default) includes collected value with full metadata', async () => {
    async function check(userId, objectId, relName, value) {
      const key = `${userId}|${relName}|${objectId}`;
      const { arbiter } = makeArbiter({ [key]: { possibility: 0.7, value, changed_last_at: 5000, source: 'persistent' } });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        {}
      );
      if (result.collectedValues.length !== 1) {
        throw new Error(`expected 1 collected value, got ${result.collectedValues.length}`);
      }
      const cv = result.collectedValues[0];
      if (cv.value !== value) throw new Error(`cv.value=${cv.value}, expected ${value}`);
      if (cv.possibility !== 0.7) throw new Error(`cv.possibility=${cv.possibility}, expected 0.7`);
      if (!Array.isArray(cv.path) || cv.path.length !== 2) {
        throw new Error(`cv.path malformed: ${JSON.stringify(cv.path)}`);
      }
      if (cv.path[0] !== `user:${userId}` || cv.path[1] !== `doc:${objectId}`) {
        throw new Error(`cv.path=${JSON.stringify(cv.path)}, expected [user:${userId}, doc:${objectId}]`);
      }
      if (cv.source.relation !== relName) {
        throw new Error(`cv.source.relation=${cv.source.relation}, expected ${relName}`);
      }
      if (cv.metadata.timestamp !== 5000) {
        throw new Error(`cv.metadata.timestamp=${cv.metadata.timestamp}, expected 5000`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [rigor.fn('check', check,
        rigor.args(
          rigor.gen.int(0, 1000),
          rigor.gen.int(0, 1000),
          rigor.gen.enum(RELATIONS),
          rigor.gen.float({ min: 0, max: 1000 })
        )
      )],
      rigor.crucible([
        rigor.invariant('collectValues-default', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'direct-rule-collect-on', 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 === 'collectValues-default');
    assert.ok(inv);
    assert.equal(inv.passed, true, `collectValues default violated in ${inv.failureCount} cases`);
  });

  it('relation field precedence: rule.relation beats rule.rel, label, name, currentRelation', async () => {
    // To verify precedence, we put a relation under each candidate name and assert
    // that DirectRule picks the highest-precedence one. When two candidates share
    // a name, the oracle can't distinguish them, so we skip those cases.
    async function check(userId, objectId, fromRule, fromRel, fromLabel, fromName, fromCurrent) {
      // Skip cases where any pair of candidates share a name — the test can't
      // distinguish precedence in that scenario
      const names = { rule: fromRule, rel: fromRel, label: fromLabel, name: fromName, current: fromCurrent };
      if (new Set(Object.values(names)).size !== 5) return null;

      // Build a relations table that has the relation under EVERY candidate name.
      const { arbiter, calls } = makeArbiter({
        [`${userId}|${fromRule}|${objectId}`]: { possibility: 0.1 },
        [`${userId}|${fromRel}|${objectId}`]: { possibility: 0.2 },
        [`${userId}|${fromLabel}|${objectId}`]: { possibility: 0.3 },
        [`${userId}|${fromName}|${objectId}`]: { possibility: 0.4 },
        [`${userId}|${fromCurrent}|${objectId}`]: { possibility: 0.5 }
      });
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: fromRule, rel: fromRel, label: fromLabel, name: fromName },
        new Set(),
        fromCurrent,
        {}
      );
      // DirectRule should pick fromRule (highest precedence)
      if (result.possibility !== 0.1) {
        throw new Error(`expected possibility=0.1 (from rule.relation=${fromRule}), got ${result.possibility}`);
      }
      // The relationManager call should have been made with fromRule
      if (calls.length !== 1 || calls[0].rel !== fromRule) {
        throw new Error(`expected single call with rel=${fromRule}, got ${JSON.stringify(calls)}`);
      }
      return result;
    }

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

  it('result shape is stable: all expected keys always present', async () => {
    async function check(userId, objectId, relName, hasRelation) {
      const key = `${userId}|${relName}|${objectId}`;
      const relations = hasRelation ? { [key]: { possibility: 0.6, value: 99 } } : {};
      const { arbiter } = makeArbiter(relations);
      const rule = new DirectRule(arbiter);
      const result = rule.evaluate(
        userId, `user:${userId}`,
        objectId, `doc:${objectId}`,
        { type: 'direct', relation: relName },
        new Set(),
        relName,
        {}
      );
      // _createStandardResult guarantees these keys
      const expectedKeys = ['possibility', 'reliability', 'possibility_allow', 'possibility_deny', 'collectedValues', 'meta', 'meta_allow', 'meta_deny', 'remediation', 'reason'];
      for (const k of expectedKeys) {
        if (!(k in result)) {
          throw new Error(`result missing key '${k}' (full result: ${JSON.stringify(result)})`);
        }
      }
      // possibility_allow and possibility must equal each other in DirectRule
      if (result.possibility_allow !== result.possibility) {
        throw new Error(`possibility_allow (${result.possibility_allow}) !== possibility (${result.possibility})`);
      }
      // possibility_deny is always 0 in DirectRule
      if (result.possibility_deny !== 0) {
        throw new Error(`possibility_deny=${result.possibility_deny}, expected 0 (DirectRule never denies)`);
      }
      // reliability defaults to 1.0
      if (result.reliability !== 1.0) {
        throw new Error(`reliability=${result.reliability}, expected 1.0`);
      }
      return result;
    }

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