/**
 * rigor/challenge-proof.test.js — js-rigor property tests for
 * PartialGraphContext.getChallengeProof.
 *
 * rigor.fn receives args positionally, not as a single destructured object.
 * getChallengeProof is a deterministic pure function on a Map of challenge
 * records; perfect target for property-based testing.
 * Properties verified:
 *   - skipped: proof with expiresAt <= now is never returned
 *   - skipped: proof with (now - issuedAt) > withinMs is never returned
 *   - pick: returned proof has the largest issuedAt among passes
 *   - null: when no proof passes filters, returns null
 *   - passthrough: when withinMs is null/undefined, time filter is off
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { PartialGraphContext } from '../../src/core/PartialGraphContext.js';

const CHALLENGE_NAMES = ['mfa', 'captcha', 'webauthn', 'password'];
const SUBJECTS = ['user:abc', 'user:def', 'user:ghi', 'user:jkl'];

/**
 * Brute-force oracle: re-implements getChallengeProof in 6 lines.
 * Used as the oracle in the campaign — if it ever disagrees with the
 * production code, that's a bug.
 */
function bruteForceOracle(proofs, name, subjectId, withinMs, now) {
  const matching = proofs.filter(p =>
    p.name === name && p.subject === subjectId
  );
  let best = null;
  for (const proof of matching) {
    if (proof.expiresAt != null && proof.expiresAt <= now) continue;
    if (withinMs != null && now - proof.issuedAt > withinMs) continue;
    if (!best || proof.issuedAt > best.issuedAt) best = proof;
  }
  return best;
}

/**
 * Build a fresh PartialGraphContext for the given subject + proofs.
 * PartialGraphContext._resolveNodeId reads `arbiter.nodeIdByKey` to
 * share IDs with the parent graph. Pass a stub arbiter so the lookup
 * doesn't crash when the campaign shrinks inputs down to edge cases.
 */
function buildContext({ subjectIds, proofs }) {
  const stubArbiter = { nodeIdByKey: new Map() };
  const context = new PartialGraphContext(stubArbiter, { skipContext: false });
  for (const id of subjectIds) {
    context._resolveNodeId(id);
  }
  for (const proof of proofs) {
    context._addChallengeProof(proof);
  }
  return context;
}

describe('PartialGraphContext.getChallengeProof (rigor)', () => {
  it('reference oracle matches production across fuzzed inputs', async () => {
    // Args are positional: (proofs, name, subject, withinMs, now).
    async function referenceCheck(proofs, name, subject, withinMs, now) {
      const ctx = buildContext({ subjectIds: [subject], proofs });
      const subjectId = ctx._resolveNodeId(subject);
      const actual = ctx.getChallengeProof(name, subjectId, withinMs, now);
      const expected = bruteForceOracle(proofs, name, subject, withinMs, now);
      if (expected === null) {
        if (actual !== null) {
          throw new Error(
            `expected null but got proof with issuedAt=${actual.issuedAt}`
          );
        }
        return null;
      }
      if (actual === null) {
        throw new Error(
          `expected proof with issuedAt=${expected.issuedAt} but got null`
        );
      }
      if (actual.issuedAt !== expected.issuedAt) {
        throw new Error(
          `wrong proof returned: expected issuedAt=${expected.issuedAt}, got ${actual.issuedAt}`
        );
      }
      return actual;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', referenceCheck,
          rigor.args(
            rigor.gen.array(
              rigor.gen.object({
                name: rigor.gen.enum(CHALLENGE_NAMES),
                subject: rigor.gen.enum(SUBJECTS),
                issuedAt: rigor.gen.int(0, 100000),
                expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
              }),
              0, 5
            ),
            rigor.gen.enum(CHALLENGE_NAMES),
            rigor.gen.enum(SUBJECTS),
            rigor.gen.option(rigor.gen.int(0, 100000)),
            rigor.gen.int(0, 200000)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant(
          'oracle-matches-brute-force',
          ({ actual }) => actual !== undefined
        )
      ])
    ).run({ seed: 'challenge-proof-oracle', effort: 1500 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') {
      console.log('TAP:', report.toTAP());
    }
    const oracle = report.crucibleVerdict?.invariants?.find(
      i => i.name === 'oracle-matches-brute-force'
    );
    assert.ok(oracle, 'oracle invariant reported');
    assert.equal(oracle.passed, true,
      `getChallengeProof disagrees with brute-force oracle: ${oracle.failureCount} failures`);
  });

  it('never returns an expired proof (expiresAt <= now)', async () => {
    async function expiredCheck(proof, now) {
      const ctx = buildContext({ subjectIds: [proof.subject], proofs: [proof] });
      const subjectId = ctx._resolveNodeId(proof.subject);
      const result = ctx.getChallengeProof(proof.name, subjectId, null, now);
      if (result && result.expiresAt != null && result.expiresAt <= now) {
        throw new Error(`returned expired proof: expiresAt=${result.expiresAt}, now=${now}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('expired', expiredCheck,
          rigor.args(
            rigor.gen.object({
              name: rigor.gen.enum(CHALLENGE_NAMES),
              subject: rigor.gen.enum(SUBJECTS),
              issuedAt: rigor.gen.int(0, 50000),
              expiresAt: rigor.gen.int(0, 100000)
            }),
            rigor.gen.int(50001, 200000)  // now is always after the issuedAt range
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('no-expired', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-proof-expired', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') {
      console.log('TAP:', report.toTAP());
    }
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'no-expired');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `getChallengeProof returned an expired proof in ${inv.failureCount} cases`);
  });

  it('FIXED: zero timestamps are valid, not sentinels', () => {
    const ctx = buildContext({ subjectIds: ['user:abc'], proofs: [
      { name: 'mfa', subject: 'user:abc', issuedAt: 42802, expiresAt: null },
      { name: 'mfa', subject: 'user:abc', issuedAt: 0, expiresAt: null }
    ] });
    const subjectId = ctx._resolveNodeId('user:abc');
    // issuedAt: 0 must not be replaced with the wall clock
    const best = ctx.getChallengeProof('mfa', subjectId, null, 167844);
    assert.equal(best.issuedAt, 42802, 'epoch-issued proof must not win via wall-clock stamping');
    // expiresAt: 0 is an already-expired proof, not "no expiry"
    const ctx2 = buildContext({ subjectIds: ['user:abc'], proofs: [
      { name: 'mfa', subject: 'user:abc', issuedAt: 100, expiresAt: 0 }
    ] });
    const best2 = ctx2.getChallengeProof('mfa', subjectId, null, 167844);
    assert.equal(best2, null, 'epoch-expired proof must stay expired');
  });

  it('returns the most-recently-issued proof when withinMs=null', async () => {
    async function recentCheck(proofs, name, subject, now) {
      const ctx = buildContext({ subjectIds: [subject], proofs });
      const subjectId = ctx._resolveNodeId(subject);
      const result = ctx.getChallengeProof(name, subjectId, null, now);
      const nonExpired = proofs.filter(p =>
        p.name === name && p.subject === subject &&
        (p.expiresAt == null || p.expiresAt > now)
      );
      if (nonExpired.length === 0) {
        if (result !== null) throw new Error('expected null, got result');
        return null;
      }
      let maxIssued = nonExpired[0].issuedAt;
      for (const p of nonExpired) {
        if (p.issuedAt > maxIssued) maxIssued = p.issuedAt;
      }
      if (!result || result.issuedAt !== maxIssued) {
        throw new Error(
          `expected issuedAt=${maxIssued}, got ${result ? result.issuedAt : 'null'}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('recent', recentCheck,
          rigor.args(
            rigor.gen.array(
              rigor.gen.object({
                name: rigor.gen.constant('mfa'),
                subject: rigor.gen.constant('user:abc'),
                issuedAt: rigor.gen.int(0, 100000),
                expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
              }),
              1, 5
            ),
            rigor.gen.constant('mfa'),
            rigor.gen.constant('user:abc'),
            rigor.gen.int(0, 200000)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('most-recent', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-proof-most-recent', effort: 800 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') {
      console.log('TAP:', report.toTAP());
    }
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'most-recent');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `getChallengeProof did not return the most-recent proof in ${inv.failureCount} cases: ` +
      JSON.stringify((report.failures || []).slice(0, 2).map(f => ({ name: f.name, msg: f.message, seq: (f.sequence || []).map(s => s.args) }))));
  });

  it('enforces withinMs freshness window', async () => {
    async function withinCheck(proof, withinMs, now) {
      const ctx = buildContext({
        subjectIds: [proof.subject],
        proofs: [proof]
      });
      const subjectId = ctx._resolveNodeId(proof.subject);
      const result = ctx.getChallengeProof(proof.name, subjectId, withinMs, now);
      const age = now - proof.issuedAt;
      const expired = proof.expiresAt != null && proof.expiresAt <= now;
      if (age > withinMs || expired) {
        if (result !== null) {
          throw new Error(
            `returned proof past withinMs window: age=${age}, withinMs=${withinMs}, expired=${expired}`
          );
        }
      } else {
        if (!result) {
          throw new Error(
            `expected non-null result, got null. age=${age}, withinMs=${withinMs}, expired=${expired}`
          );
        }
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('within', withinCheck,
          rigor.args(
            rigor.gen.object({
              name: rigor.gen.constant('mfa'),
              subject: rigor.gen.constant('user:abc'),
              issuedAt: rigor.gen.int(0, 50000),
              expiresAt: rigor.gen.option(rigor.gen.int(0, 100000))
            }),
            rigor.gen.int(1, 100000),
            rigor.gen.int(0, 100000)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('within-window', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'challenge-proof-within-ms', effort: 1500 , artifacts: { dir: '', persist: 'never' }});

    if (process.env.TEST_DEBUG === '1') {
      console.log('TAP:', report.toTAP());
    }
    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'within-window');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `withinMs window not enforced: ${inv.failureCount} failures`);
  });
});
