/**
 * rigor/qualitative-rule-helpers.test.js — js-rigor property tests for
 * QualitativeRelationalComparatorRule's pure-function helpers.
 *
 * These helpers are pure math on qualitative scales. Properties:
 *   - _getQualitativeScale: known names → specific scales; unknown → DEFAULT
 *   - _calculatePeriodsElapsed: future timestamp → negative or 0; known
 *     period → elapsed/periodMs; unknown period → falls back to HOUR
 *   - _calculateDecayedPossibility: direction='stable' → identity;
 *     direction='down' → ≤ initial; direction='up' → ≥ initial;
 *     periodsElapsed=0 → identity; result ∈ scale.values
 *   - _calculatePossibilityLossSteps: ≥ 0; symmetric; 0 when initial===decayed
 *   - _createQualitativeInterval: lower ≤ upper; lower ≤ pointValue ≤ upper;
 *     bounds clamped to scale
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { QualitativeRelationalComparatorRule } from '../../src/authorization/rules/QualitativeRelationalComparatorRule.js';
import { QualitativeScale, DEFAULT_QUALITATIVE_SCALE } from '../../src/qualitative/QualitativeScale.js';

const KNOWN_SCALE_NAMES = ['binary', 'ternary', 'five-point', 'ten-point'];
const PERIODS = ['MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'];
const DIRECTIONS = ['down', 'up', 'neutral', 'stable'];

/**
 * Stub arbiter that satisfies BaseRule's constructor. The helpers
 * tested here don't actually call arbiter methods, but the constructor
 * stores a reference.
 */
function makeStubArbiter() {
  return {
    nodeIdByKey: new Map(),
    keyByNodeId: new Map(),
    relations: [],
    nodes: new Map()
  };
}

function makeRule() {
  return new QualitativeRelationalComparatorRule(makeStubArbiter(), {});
}

/**
 * Pick a scale and a value that's IN that scale.
 */
function scaleValuePairGen() {
  const scaleArb = rigor.gen.oneOf([
    rigor.gen.constant(QualitativeScale.binary()),
    rigor.gen.constant(QualitativeScale.ternary()),
    rigor.gen.constant(QualitativeScale.fivePoint()),
    rigor.gen.constant(QualitativeScale.tenPoint())
  ]);
  // Pick a value from the chosen scale. Use oneOf-constant for the
  // index, then map to the value.
  const valueArb = rigor.gen.int(0, 9).map(i => Math.min(i, 9));
  return rigor.gen.tuple(scaleArb, valueArb).map(([scale, idx]) =>
    [scale, scale.at(Math.min(idx, scale.size - 1))]
  );
}

describe('QualitativeRelationalComparatorRule._getQualitativeScale (rigor)', () => {
  it('known scale names return their corresponding scale', async () => {
    async function check(scaleName) {
      const rule = makeRule();
      const result = rule._getQualitativeScale(scaleName);
      const expectedName = scaleName;
      if (result.name !== expectedName) {
        throw new Error(
          `_getQualitativeScale(${scaleName}) returned scale named ${result.name}`
        );
      }
      // Confirm values match
      const expectedScale = {
        'binary': QualitativeScale.binary(),
        'ternary': QualitativeScale.ternary(),
        'five-point': QualitativeScale.fivePoint(),
        'ten-point': QualitativeScale.tenPoint()
      }[scaleName];
      if (result.size !== expectedScale.size) {
        throw new Error(
          `size mismatch: ${result.size} vs ${expectedScale.size}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(rigor.gen.enum(KNOWN_SCALE_NAMES))
        )
      ],
      rigor.crucible([
        rigor.invariant('known-scales', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-known-scales', 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 === 'known-scales');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `_getQualitativeScale returned wrong scale in ${inv.failureCount} cases`);
  });

  it('unknown scale name falls back to DEFAULT_QUALITATIVE_SCALE', async () => {
    async function check(scaleName) {
      const rule = makeRule();
      const result = rule._getQualitativeScale(scaleName);
      // The fallback uses DEFAULT_QUALITATIVE_SCALE which is fivePoint.
      if (result.name !== DEFAULT_QUALITATIVE_SCALE.name) {
        throw new Error(
          `_getQualitativeScale(${scaleName}) did not fall back. got ${result.name}`
        );
      }
      return result;
    }

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

describe('QualitativeRelationalComparatorRule._calculateDecayedPossibility (rigor)', () => {
  it('direction=stable is the identity function', async () => {
    async function check(scaleAndValue, periodsElapsed, decaySteps) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'stable', scale);
      if (result !== value) {
        throw new Error(
          `stable changed value: ${value} → ${result} (periods=${periodsElapsed}, steps=${decaySteps})`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 100),
            rigor.gen.int(0, 10)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('stable-identity', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-stable-identity', 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 === 'stable-identity');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `stable was not identity in ${inv.failureCount} cases`);
  });

  it('periodsElapsed=0 yields the original value', async () => {
    async function check(scaleAndValue, direction, decaySteps) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._calculateDecayedPossibility(value, 0, decaySteps, direction, scale);
      if (result !== value) {
        throw new Error(
          `periodsElapsed=0 changed value: ${value} → ${result}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.enum(DIRECTIONS),
            rigor.gen.int(0, 10)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('zero-periods', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-zero-periods', 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 === 'zero-periods');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `periodsElapsed=0 was not identity in ${inv.failureCount} cases`);
  });

  it('direction=down never increases the scale index', async () => {
    async function check(scaleAndValue, periodsElapsed, decaySteps) {
      const [scale, value] = scaleAndValue;
      const initialIndex = scale.indexOf(value);
      const rule = makeRule();
      const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'down', scale);
      const resultIndex = scale.indexOf(result);
      if (resultIndex > initialIndex) {
        throw new Error(
          `down went UP: ${value} (${initialIndex}) → ${result} (${resultIndex})`
        );
      }
      // Should be clamped to >= 0
      if (resultIndex < 0) {
        throw new Error(`down produced out-of-range index ${resultIndex}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 100),
            rigor.gen.int(0, 10)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('down-monotone', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-down-monotone', 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 === 'down-monotone');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `direction=down violated monotonicity in ${inv.failureCount} cases`);
  });

  it('direction=up never decreases the scale index', async () => {
    async function check(scaleAndValue, periodsElapsed, decaySteps) {
      const [scale, value] = scaleAndValue;
      const initialIndex = scale.indexOf(value);
      const rule = makeRule();
      const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, 'up', scale);
      const resultIndex = scale.indexOf(result);
      if (resultIndex < initialIndex) {
        throw new Error(
          `up went DOWN: ${value} (${initialIndex}) → ${result} (${resultIndex})`
        );
      }
      if (resultIndex >= scale.size) {
        throw new Error(`up produced out-of-range index ${resultIndex}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 100),
            rigor.gen.int(0, 10)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('up-monotone', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-up-monotone', 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 === 'up-monotone');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `direction=up violated monotonicity in ${inv.failureCount} cases`);
  });

  it('result is always a member of the scale', async () => {
    async function check(scaleAndValue, periodsElapsed, decaySteps, direction) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._calculateDecayedPossibility(value, periodsElapsed, decaySteps, direction, scale);
      if (!scale.contains(result)) {
        throw new Error(
          `result ${result} is not in scale ${scale.name}: ${scale.values}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 100),
            rigor.gen.int(0, 10),
            rigor.gen.enum(DIRECTIONS)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('result-in-scale', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-result-in-scale', 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 === 'result-in-scale');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `decayed value was not in scale in ${inv.failureCount} cases`);
  });
});

describe('QualitativeRelationalComparatorRule._createQualitativeInterval (rigor)', () => {
  it('lower ≤ upper always', async () => {
    async function check(scaleAndValue, blurSteps, direction) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
      if (scale.indexOf(result.lower) > scale.indexOf(result.upper)) {
        throw new Error(
          `lower (${result.lower}) > upper (${result.upper})`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 20),
            rigor.gen.enum(DIRECTIONS)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('lower-le-upper', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-lower-le-upper', 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 === 'lower-le-upper');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `lower > upper in ${inv.failureCount} cases`);
  });

  it('pointValue is contained in [lower, upper]', async () => {
    async function check(scaleAndValue, blurSteps, direction) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
      const lowerIdx = scale.indexOf(result.lower);
      const upperIdx = scale.indexOf(result.upper);
      const valueIdx = scale.indexOf(value);
      if (valueIdx < lowerIdx || valueIdx > upperIdx) {
        throw new Error(
          `point ${value} (${valueIdx}) not in [${result.lower} (${lowerIdx}), ${result.upper} (${upperIdx})]`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 20),
            rigor.gen.enum(DIRECTIONS)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('point-contained', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-point-contained', 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 === 'point-contained');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `point not in [lower, upper] in ${inv.failureCount} cases`);
  });

  it('bounds stay within scale', async () => {
    async function check(scaleAndValue, blurSteps, direction) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._createQualitativeInterval(value, blurSteps, direction, scale);
      if (!scale.contains(result.lower)) {
        throw new Error(`lower ${result.lower} not in scale`);
      }
      if (!scale.contains(result.upper)) {
        throw new Error(`upper ${result.upper} not in scale`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.int(0, 50),  // large blurSteps to exercise clamping
            rigor.gen.enum(DIRECTIONS)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('bounds-in-scale', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-bounds-in-scale', 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 === 'bounds-in-scale');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `bounds exceeded scale in ${inv.failureCount} cases`);
  });

  it('blurSteps=0 yields degenerate interval (lower=upper=pointValue)', async () => {
    async function check(scaleAndValue, direction) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._createQualitativeInterval(value, 0, direction, scale);
      if (result.lower !== value || result.upper !== value) {
        throw new Error(
          `blurSteps=0 did not collapse to point: ${JSON.stringify(result)} for ${value}`
        );
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            scaleValuePairGen(),
            rigor.gen.enum(DIRECTIONS)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('zero-blur', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-zero-blur', 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 === 'zero-blur');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `blurSteps=0 was not degenerate in ${inv.failureCount} cases`);
  });
});

describe('QualitativeRelationalComparatorRule._calculatePossibilityLossSteps (rigor)', () => {
  it('returns 0 when initial === decayed', async () => {
    async function check(scaleAndValue) {
      const [scale, value] = scaleAndValue;
      const rule = makeRule();
      const result = rule._calculatePossibilityLossSteps(value, value, scale);
      if (result !== 0) {
        throw new Error(`expected 0, got ${result} for value=${value}`);
      }
      return result;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(scaleValuePairGen())
        )
      ],
      rigor.crucible([
        rigor.invariant('loss-is-zero', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-loss-is-zero', 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 === 'loss-is-zero');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `loss was non-zero in ${inv.failureCount} cases`);
  });

  it('is symmetric: loss(a, b) === loss(b, a)', async () => {
    async function check(scale, valueA, valueB) {
      // Pick two valid scale values
      const a = scale.at(Math.min(valueA, scale.size - 1));
      const b = scale.at(Math.min(valueB, scale.size - 1));
      const rule = makeRule();
      const ab = rule._calculatePossibilityLossSteps(a, b, scale);
      const ba = rule._calculatePossibilityLossSteps(b, a, scale);
      if (ab !== ba) {
        throw new Error(`loss not symmetric: ${ab} vs ${ba}`);
      }
      if (ab < 0) {
        throw new Error(`loss was negative: ${ab}`);
      }
      return ab;
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check,
          rigor.args(
            rigor.gen.constant(QualitativeScale.fivePoint()),
            rigor.gen.int(0, 4),
            rigor.gen.int(0, 4)
          )
        )
      ],
      rigor.crucible([
        rigor.invariant('loss-symmetric', ({ actual }) => actual !== undefined)
      ])
    ).run({ seed: 'qualitative-loss-symmetric', 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 === 'loss-symmetric');
    assert.ok(inv);
    assert.equal(inv.passed, true,
      `loss not symmetric in ${inv.failureCount} cases`);
  });
});
