import { arenaChecksum } from "./checksum.ts";
import { ArenaRandom, deriveArenaSeed, normalizeArenaSeed } from "./random.ts";
import type { ArenaScenario, ArenaScenarioRequest, ArenaSplit } from "./types.ts";

export interface ArenaScenarioDefinition<P extends object> {
  id: string;
  split: ArenaSplit;
  parameters: P;
}

export type ScenarioSampler<P extends object> = (parameters: Readonly<P>, random: ArenaRandom) => P;

/** Immutable public scenario catalogue with seeded, reproducible materialization. */
export class ArenaScenarioRegistry<P extends object> {
  readonly envId: string;
  readonly definitions: readonly ArenaScenarioDefinition<P>[];
  private readonly byId = new Map<string, ArenaScenarioDefinition<P>>();
  private readonly sampler: ScenarioSampler<P>;

  constructor(
    envId: string,
    definitions: readonly ArenaScenarioDefinition<P>[],
    sampler: ScenarioSampler<P>,
  ) {
    if (definitions.length === 0) throw new RangeError("arena scenario registry cannot be empty");
    this.envId = envId;
    this.definitions = definitions.map((definition) => ({
      ...definition,
      parameters: structuredClone(definition.parameters),
    }));
    this.sampler = sampler;
    for (const definition of this.definitions) {
      if (!definition.id || this.byId.has(definition.id)) {
        throw new RangeError(`duplicate or empty scenario id: ${definition.id}`);
      }
      this.byId.set(definition.id, definition);
    }
    for (const split of ["train", "dev"] as const) {
      if (!this.definitions.some((definition) => definition.split === split)) {
        throw new RangeError(`${envId} must expose at least one ${split} scenario`);
      }
    }
  }

  /**
   * The scenario a bare `{ split }` request resolves to, chosen by hashing the
   * seed against each candidate's **id** rather than by indexing definition
   * order.
   *
   * This used to be `candidates[seed % candidates.length]`, which is one
   * character shorter and quietly binds every seed anybody has ever run to the
   * position of a scenario in an array literal. Insert a scenario in the middle
   * of a split — the most ordinary edit there is, and one a reviewer reads as
   * purely additive — and every seed past it silently resolves to a different
   * task. Nothing fails; the numbers in a results table just stop meaning what
   * they meant, and there is no artefact anywhere that records the change.
   *
   * Highest-random-weight selection instead: each candidate scores
   * `deriveArenaSeed(seed, "<env>:<split>:<id>")` and the highest wins. Adding a
   * scenario moves only the seeds the new id actually wins, which is the
   * irreducible minimum for a set that grew; removing one moves only the seeds
   * it held; reordering the literal moves nothing at all, because position is
   * not an input any more.
   *
   * The tie-break is the id rather than the array index, so that even a 32-bit
   * score collision between two ids in one split resolves the same way whatever
   * order they were declared in. Ties are the one case where "first wins" would
   * have smuggled definition order back in through the door it was shown out
   * of.
   */
  private select(seed: number, split: ArenaSplit): ArenaScenarioDefinition<P> | undefined {
    let best: ArenaScenarioDefinition<P> | undefined;
    let bestScore = -1;
    for (const candidate of this.definitions) {
      if (candidate.split !== split) continue;
      const score = deriveArenaSeed(seed, `${this.envId}:${split}:${candidate.id}`);
      if (score > bestScore || (score === bestScore && best !== undefined && candidate.id < best.id)) {
        best = candidate;
        bestScore = score;
      }
    }
    return best;
  }

  ids(split: ArenaSplit): readonly string[] {
    return this.definitions
      .filter((definition) => definition.split === split)
      .map((definition) => definition.id);
  }

  resolve(seedValue: number, request: string | ArenaScenarioRequest): ArenaScenario<P> {
    const seed = normalizeArenaSeed(seedValue);
    let definition: ArenaScenarioDefinition<P> | undefined;
    if (typeof request === "string") {
      definition = this.byId.get(request);
    } else if (request.id !== undefined) {
      definition = this.byId.get(request.id);
      if (definition?.split !== request.split) definition = undefined;
    } else {
      definition = this.select(seed, request.split);
    }
    if (!definition) throw new RangeError(`unknown ${this.envId} scenario`);
    const random = new ArenaRandom(deriveArenaSeed(seed, `${this.envId}:${definition.id}`));
    const parameters = this.sampler(definition.parameters, random);
    const hash = arenaChecksum({
      envId: this.envId,
      id: definition.id,
      split: definition.split,
      seed,
      parameters,
    });
    return { id: definition.id, split: definition.split, seed, parameters, hash };
  }
}
