/**
 * # ledger/memory — the Worker-portable in-memory run / plan / lineage store (kestrel-alw.17)
 *
 * The {@link ./index.ts durable ledger} is backed by `bun:sqlite`, a **Bun-only** module that does not
 * exist inside a Cloudflare Worker isolate — the very environment the grade path must also run in
 * (ADR-0006, EPIC kestrel-markets-alw). This module is the Worker-portable twin: the exact same
 * record / query surface, but over plain in-memory arrays and Maps, importing NOTHING Bun-only (it
 * shares the storage-agnostic {@link ./core.ts core} — the run-identity hash, the report→rows
 * reduction, and the row types — with the sqlite backend, so both agree byte-for-byte on WHAT a run
 * is). No persistence: the store lives for the life of the isolate; a Durable Object binds durability
 * separately (kestrel-alw.18).
 *
 * ## The semantics it holds (identical to the sqlite backend)
 * - **Idempotent upsert.** Re-recording the same run (same `run_id`) replaces its row and rebuilds its
 *   child rows — never a duplicate (RUNTIME §0 / ADR-0007).
 * - **Injected clock.** `recorded_at` is the caller's `now`; nothing reads a wall clock here.
 * - **Deterministic ordering.** `listRuns`/`lineage` are recent-first (`recorded_at` desc, then
 *   `run_id`/`name` asc); `leaderboard` is `realized_sum` desc then `name` asc — matching the SQL
 *   `ORDER BY` clauses exactly.
 */

import {
  aggregatePlans,
  compareCodePoint,
  deriveRunId,
  plansSha256Of,
  type LeaderboardFilter,
  type LeaderboardRow,
  type LineageInstance,
  type ListRunsFilter,
  type OrderSummaryRow,
  type PlanInstanceRow,
  type RecordOptions,
  type RecordResult,
  type RunRow,
  type RunWithPlans,
} from "./core.ts";
import type { EpisodeReport } from "../session/sim.ts";

// ─────────────────────────────────────────────────────────────────────────────
// The store
// ─────────────────────────────────────────────────────────────────────────────

/**
 * An in-memory ledger store — the Worker-portable stand-in for a `bun:sqlite` {@link Database}. Opaque
 * to callers; pass it to {@link record} / {@link listRuns} / {@link getRun} / {@link lineage} /
 * {@link leaderboard}, mirroring the durable backend's function-plus-handle shape so a call site can
 * swap backends by import alone.
 */
export interface MemoryLedger {
  readonly runs: Map<string, RunRow>;
  readonly planInstances: PlanInstanceRow[];
  readonly ordersSummary: OrderSummaryRow[];
}

/** Open a fresh, empty in-memory ledger. Nothing to close (no file, no handle) — GC reclaims it. */
export function openMemoryLedger(): MemoryLedger {
  return { runs: new Map(), planInstances: [], ordersSummary: [] };
}

// ─────────────────────────────────────────────────────────────────────────────
// Record
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Record one graded {@link EpisodeReport} into the in-memory store, idempotently keyed by the derived
 * `run_id` (re-recording the same run replaces its row and rebuilds its child rows — never a duplicate).
 * Byte-for-byte identical mapping to the sqlite backend's {@link ./index.ts record} (it shares the same
 * {@link ./core.ts core} reduction + run-identity hash). Returns the run identity.
 */
export function record(store: MemoryLedger, report: EpisodeReport, opts: RecordOptions): RecordResult {
  const plansSha256 = plansSha256Of(opts.plansText);
  const busSha256 = report.session.bus_sha256;
  const fillModel = report.session.fill_model;
  const runId = deriveRunId(busSha256, plansSha256, fillModel, opts.rUsd);

  const perPlan = aggregatePlans(report);

  // Idempotent upsert: replace the run row and clear this run's child rows before re-inserting, so a
  // plan/role that vanished between records leaves no stale row behind.
  store.runs.set(runId, {
    run_id: runId,
    recorded_at: opts.now,
    session_date: report.session.date,
    mode: report.session.mode,
    instruments: report.session.instruments.join(","),
    bus_sha256: busSha256,
    plans_sha256: plansSha256,
    fill_model: fillModel,
    calibrated: opts.calibrated ? 1 : 0,
    determinism_hash: report.session.determinism_hash,
    r_usd: opts.rUsd,
    realized_floor_usd: report.totals.realized_floor_usd,
    expected_usd: report.totals.expected_usd,
    premium_spent: report.totals.premium_spent,
    report_path: opts.reportPath ?? null,
    bus_events: report.session.bus_events,
  });
  removeInPlace(store.planInstances, (r) => r.run_id === runId);
  removeInPlace(store.ordersSummary, (r) => r.run_id === runId);

  for (const p of perPlan) {
    store.planInstances.push({
      run_id: runId,
      name: p.name,
      // Per-plan canonical text is not surfaced on the Report yet; the document-canonical plans hash
      // stands in (one authored document → one canonical hash per run) — matching the sqlite backend.
      canonical_sha256: plansSha256,
      final_state: p.finalState,
      outcome: p.outcome ?? null,
      fired: p.fired ? 1 : 0,
      orders: p.orders,
      fills: p.fills,
      realized_usd: p.realizedUsd,
      expected_usd: p.expectedUsd,
    });
    for (const r of p.roles) {
      store.ordersSummary.push({
        run_id: runId,
        name: p.name,
        role: r.role,
        count: r.count,
        esc_stages_max: r.escStagesMax,
        // Reprice is a session-wide tally on the Report, not per-plan; stamp the session value on each
        // plan/role row so the summary is self-contained (matching the sqlite backend).
        reprice_count: report.session.reprice_count,
      });
    }
  }

  return { runId, plansSha256 };
}

/** Remove every element matching `pred` from `arr`, in place. */
function removeInPlace<T>(arr: T[], pred: (x: T) => boolean): void {
  for (let i = arr.length - 1; i >= 0; i--) {
    if (pred(arr[i]!)) arr.splice(i, 1);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Query API — the same shapes + ordering as the sqlite backend
// ─────────────────────────────────────────────────────────────────────────────

/** Runs recent-first (`recorded_at` desc, then `run_id` asc), narrowed by any of the given filters
 * (`lineage` matches runs that contain a plan instance of that name). */
export function listRuns(store: MemoryLedger, filter: ListRunsFilter = {}): RunRow[] {
  const lineageRunIds =
    filter.lineage === undefined
      ? undefined
      : new Set(store.planInstances.filter((p) => p.name === filter.lineage).map((p) => p.run_id));

  return [...store.runs.values()]
    .filter((r) => {
      if (filter.sessionDate !== undefined && r.session_date !== filter.sessionDate) return false;
      if (filter.mode !== undefined && r.mode !== filter.mode) return false;
      if (filter.fillModel !== undefined && r.fill_model !== filter.fillModel) return false;
      if (lineageRunIds !== undefined && !lineageRunIds.has(r.run_id)) return false;
      return true;
    })
    .sort(byRecentThen((r) => r.run_id));
}

/** Fetch one run with its plan instances (by `name` asc), or `undefined` if the run is unknown. */
export function getRun(store: MemoryLedger, runId: string): RunWithPlans | undefined {
  const run = store.runs.get(runId);
  if (run === undefined) return undefined;
  const plans = store.planInstances
    .filter((p) => p.run_id === runId)
    .sort((a, b) => compareCodePoint(a.name, b.name));
  return { run, plans };
}

/** Every instance of a plan `name` across runs, recent-first, with run context (ADR-0006 lineage). */
export function lineage(store: MemoryLedger, name: string): LineageInstance[] {
  return store.planInstances
    .filter((p) => p.name === name)
    .map((p) => {
      const run = store.runs.get(p.run_id)!;
      return {
        ...p,
        recorded_at: run.recorded_at,
        session_date: run.session_date,
        mode: run.mode,
        fill_model: run.fill_model,
      };
    })
    .sort(byRecentThen((i) => i.run_id));
}

/** Per-lineage aggregate across all recorded instances, `realized_sum` desc then `name` asc — the
 * standing leaderboard over the lineage key (ADR-0006). `runs` counts distinct runs; sums/averages are
 * over the matching plan instances (matching the SQL `AVG`, which divides by row count, not run count). */
export function leaderboard(store: MemoryLedger, filter: LeaderboardFilter = {}): LeaderboardRow[] {
  interface Acc {
    name: string;
    runIds: Set<string>;
    rows: number;
    fired: number;
    fills: number;
    realized_sum: number;
    expected_sum: number;
  }
  const byName = new Map<string, Acc>();

  for (const p of store.planInstances) {
    const run = store.runs.get(p.run_id)!;
    if (filter.mode !== undefined && run.mode !== filter.mode) continue;
    if (filter.since !== undefined && run.recorded_at < filter.since) continue;

    let acc = byName.get(p.name);
    if (acc === undefined) {
      acc = { name: p.name, runIds: new Set(), rows: 0, fired: 0, fills: 0, realized_sum: 0, expected_sum: 0 };
      byName.set(p.name, acc);
    }
    acc.runIds.add(p.run_id);
    acc.rows += 1;
    acc.fired += p.fired;
    acc.fills += p.fills;
    acc.realized_sum += p.realized_usd;
    acc.expected_sum += p.expected_usd;
  }

  return [...byName.values()]
    .map((a) => ({
      name: a.name,
      runs: a.runIds.size,
      fired: a.fired,
      fills: a.fills,
      realized_sum: a.realized_sum,
      expected_sum: a.expected_sum,
      realized_avg: a.realized_sum / a.rows,
      expected_avg: a.expected_sum / a.rows,
    }))
    .sort((x, y) => (y.realized_sum - x.realized_sum) || compareCodePoint(x.name, y.name));
}

/** Recent-first comparator: `recorded_at` desc, then a caller-chosen ascending tiebreak (`run_id`). */
function byRecentThen<T extends { recorded_at: number }>(key: (x: T) => string): (a: T, b: T) => number {
  return (a, b) => b.recorded_at - a.recorded_at || compareCodePoint(key(a), key(b));
}
