import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  ARENA_CHECKSUM_DECIMALS,
  StudioOpsEnvironment,
  arenaChecksum,
  canonicalJson,
  quantizeForChecksum,
  quantizeToPlaces,
  studioOpsScriptedBaseline,
} from "../../index.ts";

describe("canonical json refuses what it cannot describe", () => {
  it("throws on Map, Set and Date rather than silently emitting an empty object", () => {
    // The three named in the build spec, and the reason this test exists: each
    // of them reached the `typeof value === "object"` branch, had no own
    // enumerable keys, and hashed as `{}`.
    assert.throws(() => canonicalJson(new Map()), /do not accept Map/);
    assert.throws(() => canonicalJson(new Set()), /do not accept Set/);
    assert.throws(() => canonicalJson(new Date()), /do not accept Date/);
  });

  it("would have hashed a populated Map identically to an empty object", () => {
    // The regression itself, stated as the thing that must never come back: if
    // this ever stops throwing, assert that the two do not agree.
    const populated = new Map([["a", 1], ["b", 2]]);
    assert.throws(() => arenaChecksum({ operations: populated }));
    assert.throws(() => arenaChecksum({ operations: new Map() }));
  });

  it("throws on every other non-plain object, and names it", () => {
    class Operations {
      readonly id = "sf";
    }
    assert.throws(() => canonicalJson(new Operations()), /do not accept Operations/);
    assert.throws(() => canonicalJson(new Float64Array(3)), /do not accept Float64Array/);
    assert.throws(() => canonicalJson(new WeakMap()), /do not accept WeakMap/);
    assert.throws(() => canonicalJson(/x/), /do not accept RegExp/);
    assert.throws(() => canonicalJson(Object.create({ inherited: true })), /do not accept/);
    // Nested, because the dangerous case is a field somebody added to a
    // snapshot rather than a value somebody handed to the hash directly.
    assert.throws(() => canonicalJson({ simulation: { stations: new Map() } }), /do not accept Map/);
    assert.throws(() => canonicalJson([1, { at: new Date(0) }]), /do not accept Date/);
  });

  it("throws on the primitives JSON has no room for", () => {
    assert.throws(() => canonicalJson(() => 1), /do not accept function/);
    assert.throws(() => canonicalJson(10n), /do not accept bigint/);
    assert.throws(() => canonicalJson(Symbol("x")), /do not accept symbol/);
    assert.throws(() => canonicalJson(Number.NaN), /finite/);
    assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite/);
  });

  it("still accepts everything a snapshot legitimately contains", () => {
    assert.equal(
      canonicalJson({ b: 1, a: [true, null, "x"], c: Object.create(null) }),
      '{"a":[true,null,"x"],"b":1,"c":{}}',
    );
    // Key order is canonical and `undefined` is omitted rather than encoded, so
    // two objects that differ only in those ways hash the same.
    assert.equal(arenaChecksum({ a: 1, b: 2 }), arenaChecksum({ b: 2, a: 1, c: undefined }));
  });

  it("refuses cycles instead of recursing forever", () => {
    const cyclic: Record<string, unknown> = {};
    cyclic.self = cyclic;
    assert.throws(() => canonicalJson(cyclic), /cycles/);
  });
});

describe("checksum quantisation survives a last-place float disagreement", () => {
  it("rounds non-integers to the documented decimals and leaves integers exact", () => {
    assert.equal(ARENA_CHECKSUM_DECIMALS, 9);
    assert.equal(quantizeForChecksum(1 / 3), 0.333333333);
    // Collapsed to a positive zero, not a negative one: `Object.is` is what
    // tells the two apart and `canonicalJson` must never emit "-0".
    assert.ok(Object.is(quantizeForChecksum(-0.0000000004), 0));
    assert.equal(quantizeToPlaces(1.2345678, 3), 1.235);
    // Integers pass through untouched, including ones that scaling by 1e9 would
    // push out of the safe-integer range and *lose* precision on.
    assert.equal(quantizeForChecksum(9_007_199_254_740_991), 9_007_199_254_740_991);
    assert.equal(quantizeForChecksum(0), 0);
    // And so does a magnitude whose own spacing is coarser than the quantum:
    // rounding it cannot be computed, so the input is returned rather than an
    // approximation of it.
    const huge = 1.5e300;
    assert.equal(quantizeForChecksum(huge), huge);
    assert.throws(() => quantizeForChecksum(Number.NaN), /finite/);
  });

  it("hashes two values a last place apart identically", () => {
    // The failure this defends against: `Math.sin` is not required to be
    // correctly rounded, so two conforming engines can return neighbouring
    // doubles for the same argument. Before quantisation that was a verifier
    // rejecting an honest rollout.
    const value = 0.8414709848078965;
    const neighbour = value + Number.EPSILON * value;
    assert.notEqual(value, neighbour, "the two doubles must genuinely differ");
    assert.equal(arenaChecksum({ sun: value }), arenaChecksum({ sun: neighbour }));
  });

  it("still separates values that differ by anything a reward can see", () => {
    assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.000001 }));
    assert.notEqual(arenaChecksum({ reward: 1 }), arenaChecksum({ reward: 1.00000001 }));
    assert.notEqual(arenaChecksum({ x: 0 }), arenaChecksum({ x: 1e-8 }));
  });

  it("collapses a negative zero so a vanishing quantity cannot change a hash", () => {
    assert.equal(arenaChecksum({ x: -0 }), arenaChecksum({ x: 0 }));
    assert.equal(arenaChecksum({ x: -1e-12 }), arenaChecksum({ x: 0 }));
  });
});

describe("restore pins the simulator sources, not only the manifest", () => {
  function checkpointed() {
    const environment = new StudioOpsEnvironment();
    let observation = environment.reset(23, "train-la-overcast-inspection").observation;
    for (let index = 0; index < 6; index += 1) {
      observation = environment.step(studioOpsScriptedBaseline(observation)).observation;
    }
    return { environment, snapshot: environment.snapshot() };
  }

  it("carries the environment's own source pins inside the checksummed core", () => {
    const { snapshot } = checkpointed();
    assert.match(snapshot.sourceHashes.environment, /^sha256:[0-9a-f]{64}$/);
    assert.match(snapshot.sourceHashes.simulator, /^sha256:[0-9a-f]{64}$/);
    const { checksum, ...core } = snapshot;
    assert.equal(arenaChecksum(core), checksum);
  });

  it("rejects a snapshot whose sourceHashes differ from the environment's own", () => {
    // A snapshot used to survive a change to the physics under it: `envHash`
    // covers the manifest, and the manifest does not move when a walker's
    // collision epsilon does. Re-signed with a valid checksum, so this can only
    // be caught by comparing the pins themselves.
    const { environment, snapshot } = checkpointed();
    for (const field of ["environment", "simulator"] as const) {
      const tampered = {
        ...snapshot,
        sourceHashes: { ...snapshot.sourceHashes, [field]: `sha256:${"9".repeat(64)}` },
      };
      const { checksum: _drop, ...core } = tampered;
      assert.throws(
        () => environment.restore({ ...core, checksum: arenaChecksum(core) }),
        /incompatible with this environment/,
        field,
      );
    }
  });

  it("still accepts its own snapshot", () => {
    const { environment, snapshot } = checkpointed();
    const restored = environment.restore(snapshot);
    assert.equal(restored.info.step, snapshot.step);
  });
});
