/**
 * # catalog/catalog-listing — the ONE canonical CATALOG DISCOVERY projection (kestrel-dnxq)
 *
 * The `catalog` op is subject-DISCOVERY: it returns a {@link CatalogListing}[] — the human-legible menu a
 * client browses to choose WHAT to open. Before dnxq the two transports served DISJOINT shapes for it: the
 * LOCAL transport dumped the reproducibility-complete djm.2 {@link CatalogEntry} (camelCase content roots /
 * engine versions / View identities), while the managed backend's `GET /catalog` shipped a snake_case listing
 * row (`artifact_id`, `frame_count`, `title`, …) with NO replay pins. Only `id` was shared, and cross-transport
 * ids did not resolve — a headline invariant ("the protocol objects are byte-identical across transports") was
 * false for a core op.
 *
 * This module is the single seam that makes the catalog op an EQUAL PROJECTION. BOTH transports build their
 * rows through {@link toCatalogListing}, which fixes the canonical key ORDER, so a local row and a
 * remote-decoded row for the same subject are BYTE-identical (not merely structurally equal). The managed
 * backend's wire listing is mapped here ({@link decodeCatalogListingWire}: `artifact_id → id`,
 * `frame_count → frameCount`, drop the `content_hash` replay pin) — a FAITHFUL rename of the same semantic
 * fields, never a fabricated value; a row that carries no addressable handle fails closed
 * ({@link CatalogListingDecodeError}) rather than returning an un-openable entry.
 *
 * ZERO heavy deps: pure data + pure functions over protocol type-only imports, so the light HTTP transport,
 * the light contract server, and the (heavy) local loader all share it without pulling the engine.
 */

import type { CatalogListing, CatalogPage, CatalogPricing } from "../protocol/catalog.ts";

/** A fail-closed decode error — the managed `GET /catalog` body was not a listing, or a row carried no
 *  addressable handle. Never returns an un-openable entry. */
export class CatalogListingDecodeError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "CatalogListingDecodeError";
  }
}

/** The DENORMALISED fields a caller supplies to build a canonical listing row. */
export interface CatalogListingFields {
  readonly id: string;
  readonly title: string;
  readonly description: string;
  readonly instrument: string;
  readonly periodStart: string;
  readonly periodEnd: string;
  readonly frameCount: number;
  readonly free: boolean;
}

/**
 * Build ONE {@link CatalogListing} in the CANONICAL key order (`id`, `title`, `description`, `instrument`,
 * `period` = `{ start, end }`, `frameCount`, `free`). The single constructor BOTH transports call — so
 * `JSON.stringify` of a local row and a remote-decoded row for the same subject produce identical bytes.
 */
export function toCatalogListing(f: CatalogListingFields): CatalogListing {
  return {
    id: f.id,
    title: f.title,
    description: f.description,
    instrument: f.instrument,
    period: { start: f.periodStart, end: f.periodEnd },
    frameCount: f.frameCount,
    free: f.free,
  };
}

/** The snake_case wire row the managed backend's `GET /catalog` serves (a `HostedCatalogEntry`; the live prod
 *  contract). `artifact_id` is the addressable handle; `content_hash` is an optional replay pin (not part of
 *  discovery). A light-side self-hosted mirror MAY instead emit the camelCase protocol `id`. */
export interface CatalogListingWireRow {
  readonly artifact_id?: string;
  readonly id?: string;
  readonly title?: string;
  readonly description?: string;
  readonly instrument?: string;
  readonly period?: { readonly start?: string; readonly end?: string };
  readonly frame_count?: number;
  readonly frameCount?: number;
  readonly free?: boolean;
  readonly content_hash?: string;
}

/** Encode a canonical {@link CatalogListing} back to the deployed snake_case wire row — the inverse of
 *  {@link decodeCatalogListingWire}. Used by the light contract server so its `GET /catalog` speaks the SAME
 *  dialect as live prod (never the old bare-camelCase mock that hid the divergence, kestrel-y9v1). */
export function toCatalogListingWire(l: CatalogListing): Record<string, unknown> {
  return {
    artifact_id: l.id,
    title: l.title,
    description: l.description,
    instrument: l.instrument,
    period: { start: l.period.start, end: l.period.end },
    frame_count: l.frameCount,
    free: l.free,
  };
}

/**
 * The DISCOVERY metadata for each offline-sample catalog subject (kestrel-dnxq), keyed by entry id — the ONE
 * source of the human-legible menu fields ({@link CatalogListing} minus `id`). Authored here (a light,
 * engine-free module) rather than on the hashed djm.2 entry, because these are DISCOVERY hints, not
 * reproducibility pins. Shared by BOTH the LOCAL loader (`catalogListing()`) and the light contract server's
 * `GET /catalog`, so a local row and a mock-served-then-decoded row for the same subject are byte-identical.
 *
 * All three subjects are a single generic QQQ intraday session (`2026-03-02`) presenting the two Backtest Wake
 * frames (`RECIPE.wakes.length === 2`, pinned by tests/catalog.session.test.ts); the regime and fill model
 * differ. Generic instrument only (no leak).
 */
/**
 * The number of decision Wake frames each offline sample presents. A LITERAL here because this module is
 * LIGHT (it must not import the engine-bearing `RECIPE`); its honesty is guarded by
 * tests/sdk.catalog-equal-projection.test.ts, which asserts it equals `RECIPE_WAKE_COUNT` (the real
 * `RECIPE.wakes.length` re-exported from the heavy `session-catalog.ts`). Change one → the guard reds.
 */
export const WAKE_FRAMES = 2;

export const LISTING_META: Readonly<Record<string, Omit<CatalogListing, "id">>> = {
  "choppy-1101-strict": {
    title: "Choppy intraday — strict-cross fills",
    description: "A range-bound QQQ intraday session graded under the strict-cross fill model.",
    instrument: "QQQ",
    period: { start: "2026-03-02", end: "2026-03-02" },
    frameCount: WAKE_FRAMES,
    free: true,
  },
  "spike-1102-maker-fair": {
    title: "Volatility spike — maker-fair fills",
    description: "A sharp QQQ intraday volatility spike graded under the maker-fair fill model.",
    instrument: "QQQ",
    period: { start: "2026-03-02", end: "2026-03-02" },
    frameCount: WAKE_FRAMES,
    free: true,
  },
  "trending-1103-maker-fair": {
    title: "Trending intraday — maker-fair fills",
    description: "A directional QQQ intraday trend graded under the maker-fair fill model.",
    instrument: "QQQ",
    period: { start: "2026-03-02", end: "2026-03-02" },
    frameCount: WAKE_FRAMES,
    free: true,
  },
};

/**
 * Build the canonical {@link CatalogListing}[] for a set of catalog ids, in order, from {@link LISTING_META}.
 * Fail-closed: an id with no metadata throws {@link CatalogListingDecodeError} (never a silently blank row).
 * The shared constructor for the LOCAL projection AND the light contract server's served listing.
 */
export function buildCatalogListing(ids: readonly string[]): readonly CatalogListing[] {
  return ids.map((id) => {
    const meta = LISTING_META[id];
    if (meta === undefined) {
      throw new CatalogListingDecodeError(`no LISTING_META for catalog id ${JSON.stringify(id)} — cannot list it`);
    }
    return toCatalogListing({
      id,
      title: meta.title,
      description: meta.description,
      instrument: meta.instrument,
      periodStart: meta.period.start,
      periodEnd: meta.period.end,
      frameCount: meta.frameCount,
      free: meta.free,
    });
  });
}

const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);

/**
 * Decode a `GET /catalog` body into the canonical {@link CatalogListing}[]. Accepts BOTH observed encodings:
 * the deployed `{ entries: [...], version }` wrapper AND a bare array (a self-hosted mirror). Maps each
 * snake_case row to the canonical camelCase shape via {@link toCatalogListing} (so the bytes match the LOCAL
 * projection exactly). Fail-closed: a body that is neither shape, or a row with no addressable handle
 * (`artifact_id`/`id`), throws {@link CatalogListingDecodeError} — never a silent un-openable entry.
 */
export function decodeCatalogListingWire(body: unknown): readonly CatalogListing[] {
  const rows: readonly unknown[] = Array.isArray(body)
    ? body
    : isRecord(body) && Array.isArray(body["entries"])
      ? (body["entries"] as readonly unknown[])
      : (() => {
          throw new CatalogListingDecodeError(
            "GET /catalog returned neither a listing array nor a { entries: [...] } wrapper",
          );
        })();
  return rows.map((row) => {
    if (!isRecord(row)) throw new CatalogListingDecodeError("a catalog listing row was not an object");
    // The addressable handle: the managed `artifact_id`, else a mirror's camelCase `id`. Fail closed if
    // NEITHER — never return a row a caller cannot address with openSession({ kind: "catalog", id }).
    const id = str(row["artifact_id"]) ?? str(row["id"]);
    if (id === undefined) {
      throw new CatalogListingDecodeError(
        "a catalog listing row carried neither `artifact_id` nor `id` — unaddressable for openSession",
      );
    }
    const period = isRecord(row["period"]) ? (row["period"] as Record<string, unknown>) : {};
    const frameCount =
      typeof row["frame_count"] === "number"
        ? (row["frame_count"] as number)
        : typeof row["frameCount"] === "number"
          ? (row["frameCount"] as number)
          : 0;
    return toCatalogListing({
      id,
      title: str(row["title"]) ?? "",
      description: str(row["description"]) ?? "",
      instrument: str(row["instrument"]) ?? "",
      periodStart: str(period["start"]) ?? "",
      periodEnd: str(period["end"]) ?? "",
      frameCount,
      free: row["free"] === true,
    });
  });
}

/**
 * Extract the optional ex-ante {@link CatalogPricing} block from a `GET /catalog` body (kestrel-adge).
 * ABSENT (a bare array, or a `{ entries }` wrapper with no `pricing`) ⇒ `undefined`: today's bare listing,
 * fully backward-compatible. When present, the producer's pricing object is surfaced VERBATIM — the whole
 * object is passed through (its `skus[].terms`, `settlement_methods[].params`, and any producer field are
 * carried unchanged, never rebuilt or dropped), so the platform is a CONSUMER of the OSS contract with no
 * divergence. A malformed `pricing` (present but missing a required scalar/array field) is DROPPED, not
 * fabricated and not thrown: pricing is an additive discovery HINT, so a bad block must never break the
 * listing itself (which stays fail-closed on unaddressable rows via {@link decodeCatalogListingWire}).
 */
export function decodeCatalogPricing(body: unknown): CatalogPricing | undefined {
  if (!isRecord(body)) return undefined;
  const pricing = body["pricing"];
  if (!isRecord(pricing)) return undefined;
  const okScalars =
    typeof pricing["paid_available"] === "boolean" &&
    typeof pricing["price_sheet_url"] === "string" &&
    typeof pricing["price_sheet_version"] === "string" &&
    typeof pricing["currency"] === "string" &&
    typeof pricing["note"] === "string";
  if (!okScalars || !Array.isArray(pricing["skus"]) || !Array.isArray(pricing["settlement_methods"])) {
    return undefined;
  }
  // Surface the producer's exact object (verbatim pass-through) — see the doc note above.
  return pricing as unknown as CatalogPricing;
}

/**
 * Decode a `GET /catalog` body into the canonical {@link CatalogPage} (kestrel-adge) — the wrapped
 * `{ entries, pricing? }` shape the SDK's `catalog()` returns. The `entries` decode is the SAME fail-closed
 * {@link decodeCatalogListingWire} (byte-identical listing rows across transports; throws on an unaddressable
 * row); `pricing` rides via {@link decodeCatalogPricing} when the body carries it. A body with no `pricing`
 * yields a page whose `entries` are today's bare listing — backward-compatible by construction.
 */
export function decodeCatalogPage(body: unknown): CatalogPage {
  const entries = decodeCatalogListingWire(body);
  const pricing = decodeCatalogPricing(body);
  return pricing === undefined ? { entries } : { entries, pricing };
}
