/**
 * rigor/challenge-rule.test.js — js-rigor property tests for ChallengeRule.evaluate.
 *
 * ChallengeRule.resolveSubjectKey and ChallengeRule.resolveWithinMs are
 * pure functions on rule config. Properties:
 *   - subjectKey explicit override beats subject type
 *   - subject=user → userKey
 *   - subject=object → objectKey
 *   - subject=session → sessionKey (else userKey)
 *   - withinMs/withinSeconds/withinMinutes/withinHours are equivalent (each unit * factor)
 *   - at most one of the four `within` keys is used (others ignored)
 *   - if none of the four is set, withinMs is null
 *
 * The proof lookup (ChallengeRule.evaluate path) is tested separately in
 * challenge-proof.test.js; here we focus on the resolver surface.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { ChallengeRule } from '../../src/authorization/rules/ChallengeRule.js';

const SUBJECT_TYPES = ['user', 'object', 'session', null, 'unknown'];

/**
 * Stub arbiter that satisfies BaseRule + ChallengeRule's surface needs.
 */
function makeStubArbiter() {
  return {
    nodeIdByKey: new Map(),
    keyByNodeId: new Map(),
    relations: [],
    nodes: new Map(),
    resolveNodeId(key /* , options */) { return 1; }
  };
}

function makeRule() {
  return new ChallengeRule(makeStubArbiter());
}

describe('ChallengeRule._resolveSubjectKey (rigor)', () => {
  it('explicit rule.subjectKey wins over rule.subject', async () => {
    async function check(subjectKey, subjectType, userKey, objectKey, sessionKey) {
      const rule = makeRule();
      const result = rule._resolveSubjectKey(
        { subjectKey, subject: subjectType },
        userKey, objectKey, { sessionKey }
      );
      if (result !== subjectKey) {
        throw new Error(
          `expected ${subjectKey}, got ${result}. subject=${subjectType}, userKey=${userKey}, objectKey=${objectKey}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.string(1, 20),
            rigor.gen.enum(SUBJECT_TYPES),
            rigor.gen.string(1, 20),
            rigor.gen.string(1, 20),
            rigor.gen.string(1, 20)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('subjectKey-wins', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-subject-key-wins', effort: 1000 , artifacts: { dir: '', persist: 'never' }});

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

  it('subject=user → userKey; subject=object → objectKey; subject=session → sessionKey or userKey', async () => {
    async function check(subjectType, userKey, objectKey, sessionKey, hasSession) {
      const rule = makeRule();
      const result = rule._resolveSubjectKey(
        { subject: subjectType },
        userKey, objectKey,
        hasSession ? { sessionKey } : {}
      );
      let expected;
      switch (subjectType) {
        case 'object': expected = objectKey; break;
        case 'session': expected = sessionKey || userKey; break;
        case 'user':
        case null:
        case 'unknown':
        default: expected = userKey; break;
      }
      if (result !== expected) {
        throw new Error(
          `subject=${subjectType}: expected ${expected}, got ${result}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.enum(SUBJECT_TYPES),
            rigor.gen.string(1, 20),
            rigor.gen.string(1, 20),
            rigor.gen.string(1, 20),
            rigor.gen.boolean()
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('subject-mapping', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-subject-mapping', 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 === 'subject-mapping');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `subject type mapping incorrect in ${inv.failureCount} cases`);
  });
});

describe('ChallengeRule._resolveWithinMs (rigor)', () => {
  /**
   * Custom generator: pick one of 5 candidate shapes and produce the
   * corresponding rule. Avoids the `undefined` field trap that
   * rigor.gen.object doesn't support.
   */
  const withinRuleGen = rigor.gen.oneOf([
    // Only withinMs set
    rigor.gen.object({
      withinMs: rigor.gen.int(1, 10000),
      comparator: rigor.gen.constant(null)
    }),
    // Only withinSeconds set (no withinMs)
    rigor.gen.object({
      withinMs: rigor.gen.constant(null),
      withinSeconds: rigor.gen.int(1, 100)
    }),
    // Only withinMinutes set
    rigor.gen.object({
      withinMs: rigor.gen.constant(null),
      withinSeconds: rigor.gen.constant(null),
      withinMinutes: rigor.gen.int(1, 10)
    }),
    // Only withinHours set
    rigor.gen.object({
      withinMs: rigor.gen.constant(null),
      withinSeconds: rigor.gen.constant(null),
      withinMinutes: rigor.gen.constant(null),
      withinHours: rigor.gen.int(1, 5)
    }),
    // Empty (no within key)
    rigor.gen.object({
      comparator: rigor.gen.string()
    })
  ]);

  it('withinMs/withinSeconds/withinMinutes/withinHours are equivalent', async () => {
    async function check(rule) {
      const r = makeRule();
      const result = r._resolveWithinMs(rule);
      // The rule produced by withinRuleGen may have a `null` value for
      // some within* keys. _resolveWithinMs treats both undefined AND
      // null as "absent" (its `!== undefined && !== null` check). So
      // for our generator, `null` and missing both count as absent.
      let expected = null;
      if (rule.withinMs !== undefined && rule.withinMs !== null) {
        expected = rule.withinMs;
      } else if (rule.withinSeconds !== undefined && rule.withinSeconds !== null) {
        expected = rule.withinSeconds * 1000;
      } else if (rule.withinMinutes !== undefined && rule.withinMinutes !== null) {
        expected = rule.withinMinutes * 60 * 1000;
      } else if (rule.withinHours !== undefined && rule.withinHours !== null) {
        expected = rule.withinHours * 60 * 60 * 1000;
      }
      if (result !== expected) {
        throw new Error(
          `rule=${JSON.stringify(rule)}: expected ${expected}, got ${result}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(withinRuleGen)
        )
      ],
      rigor.crucible([
        rigor.invariant('within-units', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-within-units', 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 === 'within-units');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `withinMs/withinSeconds/withinMinutes/withinHours conversion wrong in ${inv.failureCount} cases`);
  });

  it('priority order: withinMs > withinSeconds > withinMinutes > withinHours', async () => {
    async function check(rule) {
      const r = makeRule();
      const result = r._resolveWithinMs(rule);
      let expected = null;
      if (rule.withinMs != null) expected = rule.withinMs;
      else if (rule.withinSeconds != null) expected = rule.withinSeconds * 1000;
      else if (rule.withinMinutes != null) expected = rule.withinMinutes * 60 * 1000;
      else if (rule.withinHours != null) expected = rule.withinHours * 60 * 60 * 1000;
      if (result !== expected) {
        throw new Error(`expected ${expected}, got ${result} for ${JSON.stringify(rule)}`);
      }
      return result;
    }

    // Generate rules where all four keys are populated. The priority
    // chain must pick withinMs.
    const allFourSet = rigor.gen.object({
      withinMs: rigor.gen.int(100, 500),
      withinSeconds: rigor.gen.int(1, 100),
      withinMinutes: rigor.gen.int(1, 10),
      withinHours: rigor.gen.int(1, 5)
    });
    // withinMs=0 — must still win (it's "set", even if value is 0)
    const msZero = rigor.gen.object({
      withinMs: rigor.gen.constant(0),
      withinSeconds: rigor.gen.int(1, 100),
      withinMinutes: rigor.gen.int(1, 10),
      withinHours: rigor.gen.int(1, 5)
    });
    // withinMs absent, withinSeconds present
    const noMs = rigor.gen.object({
      withinMs: rigor.gen.constant(null),
      withinSeconds: rigor.gen.int(1, 100),
      withinMinutes: rigor.gen.int(1, 10),
      withinHours: rigor.gen.int(1, 5)
    });
    // only withinMinutes present
    const onlyMin = rigor.gen.object({
      withinMs: rigor.gen.constant(null),
      withinSeconds: rigor.gen.constant(null),
      withinMinutes: rigor.gen.int(1, 10),
      withinHours: rigor.gen.int(1, 5)
    });

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(rigor.gen.oneOf([allFourSet, msZero, noMs, onlyMin]))
        )
      ],
      rigor.crucible([
        rigor.invariant('within-priority', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-within-priority', 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 === 'within-priority');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `within key priority wrong in ${inv.failureCount} cases`);
  });

  it('returns null when no within key is set', async () => {
    async function check(rule) {
      const r = makeRule();
      const result = r._resolveWithinMs(rule);
      if (result !== null) {
        throw new Error(`expected null, got ${result} for ${JSON.stringify(rule)}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.object({
              other: rigor.gen.int(),
              comparator: rigor.gen.string()
            })
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('null-when-absent', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-null-when-absent', effort: 500 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'null-when-absent');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `_resolveWithinMs returned non-null in ${inv.failureCount} cases when no key was set`);
  });
});

describe('ChallengeRule._buildRequirement (rigor)', () => {
  it('preserves challenge, subject, withinMs, status fields', async () => {
    async function check(challenge, subject, withinMs, status) {
      const r = makeRule();
      const result = r._buildRequirement(challenge, subject, withinMs, status);
      const expected = {
        name: challenge,
        subject,
        withinMs: withinMs || null,
        status
      };
      assert.deepStrictEqual(result, expected,
        `mismatch: result=${JSON.stringify(result)} expected=${JSON.stringify(expected)}`);
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.string(1, 30),
            rigor.gen.string(1, 30),
            rigor.gen.option(rigor.gen.int(0, 100000)),
            rigor.gen.enum(['missing', 'missing_context', 'expired'])
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('buildRequirement', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-rule-build-requirement', 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 === 'buildRequirement');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `_buildRequirement contract violated in ${inv.failureCount} cases`);
  });
});
