/**
 * Loading and scoring recorded runs.
 *
 * The one rule this module exists to enforce: **`null` is "not scored", and it
 * is never 0.0.** A run that failed to grade, a component the environment did
 * not emit, a truncated trace — all of those are absences. Rendering an absence
 * as a zero turns a missing measurement into a claim about the model, and this
 * whole site is an argument that the numbers are real.
 */

import type { DemoEpisode, RewardComponent, RewardValues, RunRef } from './types';

/**
 * True when a value must not be summed, averaged or drawn as a bar.
 *
 * The signature narrows the FALSE branch to `number`, which is the point — the
 * caller gets a real number without a second check. It is a small lie for
 * exactly one input: `NaN` is a `number` but returns `true` here, because a
 * corrupted fixture must be treated as unscored rather than poison every
 * downstream sum. Callers only ever reach the narrowed branch when the value is
 * finite, so the lie is unobservable.
 */
export function isNotScored(value: number | null | undefined): value is null | undefined {
  return value === null || value === undefined || !Number.isFinite(value);
}

/**
 * Total reward: sum of `score x weight` over the components that were scored.
 *
 * Unscored components are SKIPPED, not zeroed, and the weights are deliberately
 * NOT renormalised over the survivors — renormalising would quietly invent a
 * different reward function than the one the environment shipped. If nothing
 * was scored at all, the answer is `null`, not `0`.
 */
export function rewardTotal(
  values: RewardValues,
  components: readonly RewardComponent[],
): number | null {
  let total = 0;
  let scored = 0;

  for (const component of components) {
    const raw = values[component.key];
    if (isNotScored(raw)) continue;
    total += raw * component.weight;
    scored += 1;
  }

  return scored === 0 ? null : total;
}

/** How many of `components` the episode actually carries a number for. */
export function scoredCount(
  values: RewardValues,
  components: readonly RewardComponent[],
): number {
  return components.filter((c) => !isNotScored(values[c.key])).length;
}

/**
 * In-flight and settled fetches, keyed by path.
 *
 * The promise is cached, not the value, so two panels mounting in the same tick
 * share one request. A rejected promise is evicted, so a failed load can be
 * retried by simply calling again — a cached rejection would make one flaky
 * network moment permanent for the life of the tab.
 */
const episodeCache = new Map<string, Promise<DemoEpisode>>();

/** Fetch and cache one recorded run. */
export function loadEpisode(runRef: RunRef): Promise<DemoEpisode> {
  const cached = episodeCache.get(runRef.path);
  if (cached) return cached;

  const pending = fetch(runRef.path, { headers: { accept: 'application/json' } })
    .then(async (response) => {
      if (!response.ok) {
        throw new Error(`Could not load run "${runRef.id}" (${response.status} from ${runRef.path})`);
      }
      return assertEpisode(await response.json(), runRef);
    })
    .catch((error: unknown) => {
      episodeCache.delete(runRef.path);
      throw error;
    });

  episodeCache.set(runRef.path, pending);
  return pending;
}

/** Drop a cached run. Only useful in tests and the dev-time fixture watcher. */
export function clearEpisodeCache(path?: string): void {
  if (path === undefined) episodeCache.clear();
  else episodeCache.delete(path);
}

/**
 * Structural check on a fixture.
 *
 * Loud and early beats a board that renders half a run. Everything checked here
 * is something the surfaces read without a guard.
 */
function assertEpisode(raw: unknown, runRef: RunRef): DemoEpisode {
  if (raw === null || typeof raw !== 'object') {
    throw new Error(`Run "${runRef.id}" is not a JSON object.`);
  }
  const episode = raw as Partial<DemoEpisode>;

  if (!Array.isArray(episode.turns)) {
    throw new Error(`Run "${runRef.id}" has no \`turns\` array.`);
  }
  if (episode.rewards === null || typeof episode.rewards !== 'object') {
    throw new Error(`Run "${runRef.id}" has no \`rewards\` object.`);
  }
  if (typeof episode.outcome !== 'string') {
    throw new Error(`Run "${runRef.id}" has no \`outcome\`.`);
  }

  return episode as DemoEpisode;
}

/**
 * The run manifest, `public/traces/manifest.json`.
 *
 * `RunRef` says runs are "listed in public/traces/manifest.json", but nothing in
 * the contract hands the shell a `RunRef[]` — `DemoModule` has no `runs` field.
 * So the manifest is the only source, and this is its reader. Four shapes are
 * accepted — the same four `scripts/_lib.mjs` accepts, deliberately, because a
 * generator and a reader that disagree about the envelope produce a blank page
 * rather than a type error, and the gate would still be green:
 *
 *   { "wordle": [RunRef, ...] }                        <- what the repo ships
 *   { "wordle": { "runs": [RunRef, ...], ... } }
 *   { "demos": { "wordle": [RunRef, ...] } }
 *   { "runs":  [ { ...RunRef, "demo": "wordle" }, ... ] }  (or a bare array)
 *
 * The two envelope keys are unwrapped BEFORE the record branch, or `"runs"` is
 * read as a demo slug and every real demo reports zero runs.
 */
export const MANIFEST_PATH = '/traces/manifest.json';

export type RunManifest = Record<string, RunRef[]>;

let manifestPromise: Promise<RunManifest> | null = null;

export function loadManifest(path: string = MANIFEST_PATH): Promise<RunManifest> {
  if (manifestPromise) return manifestPromise;

  manifestPromise = fetch(path, { headers: { accept: 'application/json' } })
    .then(async (response) => {
      if (!response.ok) {
        throw new Error(`Could not load the run manifest (${response.status} from ${path}).`);
      }
      return normaliseManifest(await response.json());
    })
    .catch((error: unknown) => {
      manifestPromise = null;
      throw error;
    });

  return manifestPromise;
}

/** The runs recorded for one demo, in manifest order. Empty when there are none. */
export async function listRuns(slug: string): Promise<RunRef[]> {
  const manifest = await loadManifest();
  return manifest[slug] ?? [];
}

function normaliseManifest(raw: unknown): RunManifest {
  const out: RunManifest = {};

  const push = (slug: string, run: RunRef): void => {
    const bucket = out[slug];
    if (bucket) bucket.push(run);
    else out[slug] = [run];
  };

  /** A flat list of runs, each carrying its own `demo`/`slug`. */
  const flat = (entries: unknown[]): void => {
    for (const entry of entries) {
      if (entry === null || typeof entry !== 'object') continue;
      const record = entry as RunRef & { demo?: string; slug?: string };
      const slug = record.demo ?? record.slug;
      if (typeof slug !== 'string') continue;
      push(slug, record);
    }
  };

  if (Array.isArray(raw)) {
    flat(raw);
    return out;
  }
  if (raw === null || typeof raw !== 'object') return out;

  const envelope = raw as { demos?: unknown; runs?: unknown };
  let container: unknown = raw;
  if (envelope.demos !== null && typeof envelope.demos === 'object') container = envelope.demos;
  else if (Array.isArray(envelope.runs)) container = envelope.runs;

  if (Array.isArray(container)) {
    flat(container);
    return out;
  }
  if (container === null || typeof container !== 'object') return out;

  for (const [slug, value] of Object.entries(container as Record<string, unknown>)) {
    const runs = Array.isArray(value)
      ? value
      : value !== null && typeof value === 'object' && Array.isArray((value as { runs?: unknown }).runs)
        ? ((value as { runs: unknown[] }).runs)
        : null;
    if (!runs) continue;
    for (const run of runs) {
      if (run !== null && typeof run === 'object') push(slug, run as RunRef);
    }
  }

  return out;
}
