/**
 * Canonical JSON and a small cross-runtime checksum (no Node or Web APIs).
 *
 * Two properties matter here and they pull in opposite directions.
 *
 * **Nothing may hash to the same string as something it is not.** A checksum
 * that silently accepts a value it cannot describe is worse than one that
 * refuses: `canonical` used to reach `typeof value === "object"` for a `Map`, a
 * `Set` and a `Date` alike, read their *own enumerable* keys — of which those
 * three have none — and emit `{}`. So a `Map` with a thousand entries in it
 * checksummed identically to an empty object, and to every other `Map`. Nothing
 * in the five shipped environments carries one, but `ResolvedRobotOperations`
 * already holds `ReadonlyMap`s and `studio-ops-v1` reaches into it: the
 * environment was one careless snapshot field away from a trace that verified
 * against a state it had never seen. Anything that is not a plain object, an
 * array, a string, a boolean, a finite number or `null` now throws.
 *
 * **Two honest runs of the same rollout on different hardware must agree.**
 * That is the harder one, and it is a float problem rather than a shape
 * problem. `Math.sin`, `Math.atan2`, `Math.pow` and friends are not required by
 * IEEE-754 or by ECMA-262 to be correctly rounded — only `+`, `-`, `*`, `/` and
 * `Math.sqrt` are — so two conforming engines may return results a unit in the
 * last place apart for the same input. `studio-ops-v1` runs a solar position
 * and a set of bearings through its observation on every step. Against an
 * *exact-equality* checksum that is a verifier on different hardware rejecting
 * an honest rollout, which is the single worst failure this file can have: it
 * is silent, it looks like fraud, and it only happens to somebody else.
 *
 * The same argument rules out `Math.hypot`, which is a library function with no
 * correctly-rounded guarantee and which every environment in this package used
 * to reach for. All of them now compute `Math.sqrt(a * a + b * b)` instead:
 * identical arithmetic, one guarantee more, and marginally faster.
 *
 * `quantizeToPlaces` is the answer, applied in two places:
 *
 *   1. here, to every non-integer that is hashed, so last-place noise on a
 *      value that is *reported* but never fed back cannot change a checksum;
 *   2. and — this is the load-bearing one — at the point a transcendental is
 *      called, by the environment itself, so that the quantised value is the
 *      one that propagates. See `quantizeObservable` in `studioOps.ts`.
 *
 * Be clear about what (1) alone cannot do: once a simulation has *accumulated*
 * a divergence, no amount of rounding at the boundary brings the two runs back
 * together, because the difference grows with every step rather than staying in
 * the last place. Quantising at the source is what keeps the divergence from
 * ever starting; quantising at the checksum is what keeps a value that is
 * computed fresh each step, reported and then discarded from tripping the
 * comparison. Both, or neither is worth much.
 */

/**
 * Decimal places kept when a number is hashed.
 *
 * Nine, which is a quantum of 1e-9 in the units of whatever is being hashed —
 * a nanometre for a position, a nano-newton-metre of reward. Every quantity
 * this package hashes is comfortably inside ±9e6, where a double's own spacing
 * is at most ~2e-9 and the ratio of that spacing to the quantum bounds how
 * often two values a last place apart can land either side of a rounding
 * boundary. Coarser would buy a wider margin and start throwing away signal a
 * reward shaper can see; finer stops being a quantisation at all.
 */
export const ARENA_CHECKSUM_DECIMALS = 9;

/**
 * `value` rounded to `places` decimals, or returned unchanged where rounding
 * cannot be done exactly.
 *
 * Integers pass through untouched: they are already exact, and scaling a large
 * one by 1e9 would push it out of the safe-integer range and *lose* precision
 * in the name of adding some. The same bail-out covers a magnitude so large
 * that the requested quantum is finer than the double's own spacing, where
 * rounding is a no-op that cannot be computed. Both cases return the input
 * rather than an approximation of it.
 *
 * `Math.round` and the multiply/divide either side of it are all exact
 * operations that every conforming engine performs identically, which is the
 * entire reason this is decimal scaling and not `Math.log2`-based mantissa
 * surgery: the fix must not be built out of the family of functions it exists
 * to defend against.
 */
export function quantizeToPlaces(value: number, places: number): number {
  if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
  if (Number.isInteger(value)) return value;
  const scale = 10 ** places;
  const scaled = value * scale;
  if (!Number.isFinite(scaled) || Math.abs(scaled) > Number.MAX_SAFE_INTEGER) return value;
  // `+ 0` collapses a rounded -0 back to 0 so the sign of a vanishing quantity
  // cannot change a checksum. `canonical` below does the same for a literal -0.
  return Math.round(scaled) / scale + 0;
}

/** `quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS)`. */
export function quantizeForChecksum(value: number): number {
  return quantizeToPlaces(value, ARENA_CHECKSUM_DECIMALS);
}

/**
 * Whether `value` is a plain data object rather than an instance of something.
 *
 * Prototype identity rather than a `constructor` name or a `Symbol.toStringTag`
 * sniff, because the question is not "what does this call itself" but "are its
 * own enumerable keys the whole of it". `Object.create(null)` passes for the
 * same reason a `{}` literal does.
 */
function isPlainObject(value: object): boolean {
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

/**
 * The name to put in the refusal.
 *
 * Worth the eight lines: "arena checksums do not accept Map" sends the reader
 * to the field they added, and "arena checksums do not accept object" sends
 * them here to find out what this function meant.
 */
function describe(value: object): string {
  const named = (value as { constructor?: { name?: unknown } }).constructor;
  const name = typeof named?.name === "string" && named.name.length > 0 ? named.name : null;
  return name ?? "a non-plain object";
}

function canonical(value: unknown, stack: Set<object>): string {
  if (value === null) return "null";
  if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
  if (typeof value === "number") {
    if (!Number.isFinite(value)) throw new TypeError("arena checksums require finite numbers");
    if (Object.is(value, -0)) return "0";
    return JSON.stringify(quantizeForChecksum(value));
  }
  if (Array.isArray(value)) {
    if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
    stack.add(value);
    const result = `[${value.map((entry) => canonical(entry, stack)).join(",")}]`;
    stack.delete(value);
    return result;
  }
  if (typeof value === "object") {
    // The refusal that gives this module its point. A `Map`, a `Set`, a `Date`,
    // a typed array and a class instance all reach here, all have no own
    // enumerable keys worth reading, and all used to hash as `{}`.
    if (!isPlainObject(value)) {
      throw new TypeError(`arena checksums do not accept ${describe(value)}`);
    }
    if (stack.has(value)) throw new TypeError("arena checksums do not accept cycles");
    stack.add(value);
    const record = value as Record<string, unknown>;
    const entries = Object.keys(record)
      .filter((key) => record[key] !== undefined)
      .sort()
      .map((key) => `${JSON.stringify(key)}:${canonical(record[key], stack)}`);
    stack.delete(value);
    return `{${entries.join(",")}}`;
  }
  throw new TypeError(`arena checksums do not accept ${typeof value}`);
}

export function canonicalJson(value: unknown): string {
  return canonical(value, new Set());
}

/** 64-bit FNV-1a over UTF-8, encoded with an algorithm prefix. */
export function arenaChecksum(value: unknown): string {
  const bytes = new TextEncoder().encode(canonicalJson(value));
  let hash = 0xcbf29ce484222325n;
  for (const byte of bytes) {
    hash ^= BigInt(byte);
    hash = BigInt.asUintN(64, hash * 0x100000001b3n);
  }
  return `fnv1a64:${hash.toString(16).padStart(16, "0")}`;
}
