import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';

import {
  Engine,
  XorShift32,
  canonicalJson,
  civilFromDays,
  daysFromCivil,
  daysInMonth,
  extractCandidate,
  fnv1a32,
  generate,
  isHeldOut,
  isoDate,
  parseReply,
  protocolTrace,
  pyJsonDumps,
  pyJsonLoads,
  pyRepr,
  referenceFor,
  runPolicy,
  screenOf,
  scoreExact,
  worldForSeed,
  POLICIES,
} from '../engine';
import type { CorpusCase } from '../engine';

/*
 * The cross-language digest in envs/alert_triage/CONFORMANCE.txt is the real
 * gate (`pnpm conformance`). These vectors exist so that a break is LOCATED,
 * not merely detected: each one is pinned identically in
 * envs/alert_triage/tests/, or was produced by the Python on the day the port
 * landed, and names the primitive that drifted.
 */

/* ---------------------------------------------------------------- rng.py */

test('fnv1a32 vectors match test_triage_rng.py', () => {
  assert.equal(fnv1a32('0'), 0x350ca8af);
  assert.equal(fnv1a32('1'), 0x340ca71c);
  assert.equal(fnv1a32('42'), 0x87e38583);
  assert.equal(fnv1a32('4095'), 0x43875f5f);
});

test('xorshift32 from seed 0 is pinned', () => {
  const rng = new XorShift32(fnv1a32('0'));
  assert.deepEqual(Array.from({ length: 5 }, () => rng.next()), [2738490563, 3068243922, 3765331391, 3085691315, 2439018365]);
  assert.notEqual(new XorShift32(0).state, 0);
});

test('sample is a distinct partial shuffle', () => {
  const out = new XorShift32(7).sample([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 4);
  assert.equal(out.length, 4);
  assert.equal(new Set(out).size, 4);
});

test('civil dates round-trip and hit the known ones', () => {
  for (let day = daysFromCivil(1999, 12, 25); day < daysFromCivil(2030, 3, 2); day += 1) {
    const [y, m, d] = civilFromDays(day);
    assert.equal(daysFromCivil(y, m, d), day);
  }
  assert.equal(daysFromCivil(1970, 1, 1), 0);
  assert.equal(daysFromCivil(2026, 3, 1), 20513);
  assert.equal(daysFromCivil(2000, 2, 29), 11016);
  assert.equal(isoDate(20000), '2024-10-04');
  assert.equal(isoDate(0), '1970-01-01');
  assert.equal(isoDate(-1), '1969-12-31');
  assert.equal(daysInMonth(2024, 2), 29);
  assert.equal(daysInMonth(2026, 2), 28);
  assert.equal(daysInMonth(2026, 12), 31);
});

/* ---------------------------------------------------------- generator.py */

test('the held-out slice is fnv1a(seed) % 8 == 7', () => {
  const heldOut = Array.from({ length: 64 }, (_, s) => s).filter(isHeldOut);
  assert.deepEqual(heldOut, [0, 8, 11, 19, 20, 28, 37, 46, 55]);
});

// Produced by `world_for_seed` in Python the day the port landed. A different
// template or alert id here means an RNG draw moved; a different reference
// means a policy diverged.
const WORLD_VECTORS: [number, string, string, string | null, string, string, string, number, string, string][] = [
  [0, 'B6', 'hidden', 'funnel', 'suspicious', 'A-9571', '2026-04-18', 123, 'targeted', 'Emeka Zapata'],
  [1, 'B4', 'benign', null, 'benign', 'A-8971', '2026-04-26', 33, 'fast', 'Oakhill Kitchen Group'],
  [2, 'B6', 'benign', null, 'benign', 'A-9760', '2026-04-23', 42, 'fast', 'Gabriela Kowalczyk'],
  [3, 'B4', 'benign', null, 'benign', 'A-9453', '2026-04-23', 33, 'fast', 'Lakeside Kitchen LLC'],
  [4, 'S1', 'visible', null, 'suspicious', 'A-8199', '2026-06-14', 33, 'fast', 'Bilal Quintero'],
  [5, 'B3', 'benign', null, 'benign', 'A-6081', '2026-03-22', 42, 'fast', 'Andre Nakamura'],
  [6, 'B1', 'benign', null, 'benign', 'A-7014', '2026-05-15', 33, 'fast', 'Lakeside Landscaping Inc.'],
  [7, 'B6', 'benign', null, 'benign', 'A-5644', '2026-06-14', 42, 'fast', 'Leila Bergstrom'],
  [8, 'S3', 'visible', null, 'suspicious', 'A-5828', '2026-04-24', 33, 'fast', 'Dana Yilmaz'],
  [9, 'B3', 'hidden', 'funnel', 'suspicious', 'A-9537', '2026-04-19', 123, 'targeted', 'Samir Tanaka'],
  [10, 'S1', 'visible', null, 'suspicious', 'A-5846', '2026-06-26', 33, 'fast', 'Malik Marchetti'],
  [11, 'B3', 'benign', null, 'benign', 'A-9882', '2026-05-16', 42, 'fast', 'Naomi Abara'],
];

test('world vectors match the Python generator', () => {
  for (const [seed, template, tier, overlay, label, alertId, fired, minutes, policy, name] of WORLD_VECTORS) {
    const w = worldForSeed(seed);
    assert.deepEqual(
      [w.template, w.tier, w.overlay, w.label, w.alert.id, w.alert.fired, w.reference_minutes, w.reference_policy, w.customer.name],
      [template, tier, overlay, label, alertId, fired, minutes, policy, name],
      `seed ${seed}`,
    );
  }
});

test('the screen is byte-identical with a hidden overlay on or off', () => {
  for (let seed = 0; seed < 64; seed += 1) {
    const auto = generate(seed, 'auto');
    if (auto.tier === 'visible') continue;
    const twin = generate(seed, null);
    assert.equal(canonicalJson(screenOf(auto)), canonicalJson(screenOf(twin)), `seed ${seed}`);
  }
});

test('canonical JSON is sorted, compact and drops undefined', () => {
  assert.equal(canonicalJson({ b: 1, a: [null, true, 'x'], c: undefined }), '{"a":[null,true,"x"],"b":1}');
  assert.equal(canonicalJson('dash — “quotes”'), '"dash — “quotes”"');
  assert.throws(() => canonicalJson(1.5));
});

/* ----------------------------------------------------------- protocol.py */

test('python repr vectors', () => {
  const strings: [string, string][] = [
    ['a', "'a'"],
    ["it's", '"it\'s"'],
    ['say "hi"', "'say \"hi\"'"],
    ['it\'s "both"', "'it\\'s \"both\"'"],
    ['tab\there', "'tab\\there'"],
    ['nl\n', "'nl\\n'"],
    ['\x01\x7f\x80\xa0', "'\\x01\\x7f\\x80\\xa0'"],
    ['café   ​', "'café \\u2028 \\u200b'"],
    ['😀', "'😀'"],
    ['back\\slash', "'back\\\\slash'"],
  ];
  for (const [input, expected] of strings) assert.equal(pyRepr(input), expected, JSON.stringify(input));

  const numbers: [string, string][] = [
    ['1', '1'],
    ['-0', '0'],
    ['1.0', '1.0'],
    ['1.5', '1.5'],
    ['1e5', '100000.0'],
    ['1E16', '1e+16'],
    ['1e-5', '1e-05'],
    ['0.0001', '0.0001'],
    ['12345678901234567890', '12345678901234567890'],
    ['-2.5e-7', '-2.5e-07'],
    ['1e22', '1e+22'],
    ['123456789012345.6', '123456789012345.6'],
    ['1e400', 'inf'],
    ['0.1', '0.1'],
    ['100.0', '100.0'],
  ];
  for (const [raw, expected] of numbers) assert.equal(pyRepr(pyJsonLoads(raw)), expected, raw);

  assert.equal(pyRepr(null), 'None');
  assert.equal(pyRepr(undefined), 'None');
  assert.equal(pyRepr(true), 'True');
  assert.equal(pyRepr(pyJsonLoads('[1, "a", null]')), "[1, 'a', None]");
  assert.equal(pyRepr(pyJsonLoads('{"k": 2.0, "z": [true]}')), "{'k': 2.0, 'z': [True]}");
});

test('the JSON reader accepts what json.loads accepts and nothing more', () => {
  assert.throws(() => pyJsonLoads('{"a":1,}'));
  assert.throws(() => pyJsonLoads('{"a":NaN}'));
  assert.throws(() => pyJsonLoads('{"a":Infinity}'));
  assert.throws(() => pyJsonLoads('{"a":"x\ny"}'));
  assert.throws(() => pyJsonLoads('{"a":1} {"b":2}'));
  assert.throws(() => pyJsonLoads("{'a':1}"));
  assert.equal(pyJsonLoads(' \t"\\ud83d\\ude00\\u00e9" \n'), '😀é');
  assert.equal(pyJsonLoads('"\\ud800"'), '\ud800');
  const obj = pyJsonLoads('{"__proto__": 1, "a": {"b": [1e2]}}');
  assert.ok(obj instanceof Map);
  assert.equal(pyRepr(obj), "{'__proto__': 1, 'a': {'b': [100.0]}}");
});

test('the pinned candidate rules', () => {
  assert.equal(extractCandidate('{"a":1} ```json\n{"b":2}\n```'), '{"b":2}\n');
  assert.equal(extractCandidate('text {"a":"}"} {"b":2}'), '{"a":"}"}');
  assert.equal(extractCandidate('text {"a":"\\"}"} x'), '{"a":"\\"}"}');
  assert.equal(extractCandidate('{"a":1'), null);
  assert.equal(extractCandidate('no braces'), null);
  assert.equal(extractCandidate('[{"a":1}]'), '{"a":1}');
  assert.deepEqual(parseReply(''), [null, 'no JSON object found in the reply']);
  assert.deepEqual(parseReply(null), [null, 'no JSON object found in the reply']);
  assert.deepEqual(parseReply('{"a":1,} {"a":1}'), [null, 'the JSON object could not be parsed']);
  assert.deepEqual(parseReply('```json\n[1]\n```'), [null, 'the reply parsed but is not a JSON object']);
});

test('pyJsonDumps writes what json.dumps writes', () => {
  assert.equal(pyJsonDumps({ action: 'close', cites: ['a', 'b'], note: 'é\x7f"' }), '{"action": "close", "cites": ["a", "b"], "note": "\\u00e9\\u007f\\""}');
});

/* ------------------------------------------------------------- engine.py */

test('the rejection reasons quote Python repr', () => {
  const engine = new Engine(worldForSeed(1));
  assert.equal(engine.step('{"action":"Lookup"}').reason, "unknown action 'Lookup'; use lookup, close or escalate");
  assert.equal(engine.step('{"what":"documents"}').reason, 'unknown action None; use lookup, close or escalate');
  assert.equal(engine.step('{"action":"lookup","what":3.0}').reason, "unknown lookup 3.0; use history, counterparty, prior_alerts or documents");
  assert.equal(engine.step('{"action":"lookup","what":"history","month":["x"]}').reason, "month ['x'] is not in the twelve-month window");
  assert.equal(engine.step('{"action":"close","reason":"CONSISTENT_WITH_PROFILE","cites":["doc.x"]}').reason, 'cites an id you have not been shown: doc.x');
  assert.equal(engine.turns, 5);
  assert.equal(engine.rejected, 5);
  assert.equal(engine.minutes, 30 + 5 * 3);
});

test('aborted is exactly eight turns; fewer is truncated and unscored', () => {
  const aborted = new Engine(worldForSeed(3));
  for (let i = 0; i < 8; i += 1) aborted.step('thinking');
  assert.equal(aborted.outcome, 'aborted');
  assert.deepEqual(scoreExact(aborted.episode()), { caught: [0, 1], hours: [0, 1], evidence: [0, 1] });

  const truncated = new Engine(worldForSeed(3));
  truncated.step('thinking');
  assert.equal(truncated.outcome, null);
  assert.deepEqual(scoreExact(truncated.episode()), { caught: null, hours: null, evidence: null });
});

test('the reference is the cheapest correct shipped policy and never null', () => {
  for (let seed = 0; seed < 96; seed += 1) {
    const world = generate(seed);
    const [minutes, policy] = referenceFor(world);
    assert.notEqual(minutes, null, `seed ${seed}`);
    assert.equal(runPolicy(POLICIES[policy!]!, world).minutes, minutes);
    if (world.tier === 'hidden') {
      assert.notEqual(runPolicy(POLICIES.fast!, world).outcome, 'solved', `fast cannot see the hidden signal on seed ${seed}`);
      assert.equal(runPolicy(POLICIES.targeted!, world).outcome, 'solved', `targeted catches seed ${seed}`);
    }
  }
});

test('the committed corpus replays to the same rejection reasons the Python recorded', () => {
  const corpus: CorpusCase[] = JSON.parse(
    readFileSync(new URL('../../../../envs/alert_triage/conformance/replies.json', import.meta.url), 'utf8'),
  );
  const reasons = protocolTrace(corpus)
    .flatMap((entry) => entry.turns as { reason: string | null }[])
    .map((t) => t.reason)
    .filter((r): r is string => r !== null);
  assert.deepEqual(reasons, [
    "month 'BADMONTH' is not in the twelve-month window",
    'the JSON object could not be parsed',
    "unknown close reason 'consistent_with_profile'",
    "unknown action 'Lookup'; use lookup, close or escalate",
    'cites must be a non-empty list of ids',
    'cites must be a non-empty list of ids',
    "unknown typology 'structuring'",
    'cites must be a non-empty list of ids',
    'prior_alerts has already been looked up',
    "counterparty 'CP-99' has not appeared on your screen or in a fetched month",
    "counterparty 'cp-1' has not appeared on your screen or in a fetched month",
    "month '2024-01' is not in the twelve-month window",
    "month '2026-1' is not in the twelve-month window",
    'cites an id you have not been shown: doc.probate_letter',
    'no JSON object found in the reply',
    'no JSON object found in the reply',
    'no JSON object found in the reply',
    'the JSON object could not be parsed',
    'the reply parsed but is not a JSON object',
    'unknown action None; use lookup, close or escalate',
    'no JSON object found in the reply',
    'unknown action None; use lookup, close or escalate',
    "counterparty 'CP-3' has not appeared on your screen or in a fetched month",
    'no JSON object found in the reply',
    "unknown lookup 'emails'; use history, counterparty, prior_alerts or documents",
  ]);
});
