/**
 * # src/pm/book — `Book` as a REFINED collection, and `PM : MarketState → Book`
 *
 * (bd kestrel-markets-n7jx.2 / PM-persona-benchmark design §10-Issue-2). A PM seat answers a
 * `MarketState` with a **Book** — a risk-budget allocation across plays. The item bank
 * (`tests/bench/pm-item-bank.ts`) treats a book as a bare `readonly BookEntry[]`; nothing there
 * *stops* an ill-formed book from being graded. This module makes `Book` a **refined type** whose
 * constructor {@link mkBook} fails closed on the two ways a book is ill-formed, and enforces the
 * budget law by construction:
 *
 *  1. **The budget law — `total ≤ 1.0`** (the P₀ contract, `RISK_BUDGET`). {@link clampToBudget}
 *     scales an over-budget book down PROPORTIONALLY (over-allocation is a failure of DEGREE, not
 *     kind — identical semantics to `pm-item-bank.clampToBudget`, cross-checked in the spine test),
 *     so every `Book` that leaves `mkBook` has `total ≤ 1.0` as a type-level invariant.
 *
 *  2. **The size band** — every entry carries `0 < size ≤ 1`. A non-positive, `NaN`, or `>1` raw
 *     size is a *kind* error and is REJECTED (not silently dropped) so "forgot to size it" can't
 *     masquerade as a real allocation. (The empty book — a legitimate stand-down / restraint book —
 *     is well-typed; it is the `total = 0` element.)
 *
 *  3. **The correlation / concentration contract — a correlated stack is an ill-typed book.** Each
 *     play carries a risk-factor signature (delta sign, vega sign; see {@link FACTOR_SIGNATURE}).
 *     Two entries are *correlated* when they load the same factor with the same sign. A book that
 *     stacks ≥ 2 mutually-correlated entries past {@link CONCENTRATION_CAP} of the budget is a
 *     **disguised single bet wearing the costume of a diversified book** — it is REJECTED. A single
 *     all-in conviction (one entry at full size) is NOT a stack — it is *declared* concentration and
 *     is well-typed (this is exactly the oracle book shape `[{ play, size: 1.0 }]`). This is the
 *     sabotage-tested refinement: `mkBook([{momentum_long,0.5},{small_long_starter,0.5}])` — two
 *     same-direction longs faking diversification — THROWS through the real constructor path.
 *
 * `{@link PM}` is the reference dispatcher `MarketState → Book`: it reads the decisive edge off the
 * state, selects the well-typed {@link Strategy} constructor, and returns the single-conviction
 * refined book for it. The **decisive-field flip** — same tape, `catalyst: promotional → real` —
 * re-dispatches `PM` to a DIFFERENT well-typed book (short_fade → momentum_long), exercised through
 * this real constructor path in the spine test.
 *
 * OSS/public (`src/**` ships): generic instruments only, no repertoire, no persona playbook.
 */

import { toPlay, type Play, type Strategy, orb, straddle, vertical, newsShort } from "./strategy.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Entries, the budget law, and the size band
// ─────────────────────────────────────────────────────────────────────────────

/** One entry in a book: a play id and its risk-budget fraction. Shape-compatible with the bank. */
export interface BookEntry {
  readonly play: string;
  readonly size: number;
}

/** The risk budget every book is graded under — the P₀ contract: sizes sum to AT MOST 1.0. */
export const RISK_BUDGET = 1.0;

/** The maximum share of the budget a single correlated cluster (≥ 2 entries) may carry. */
export const CONCENTRATION_CAP = 0.6;

/** Raised when a proposed book violates a refinement (size band or the correlation/concentration contract). */
export class BookRefinementError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "BookRefinementError";
  }
}

/**
 * Enforce the budget law: if a book's total size exceeds `budget`, scale every entry down
 * proportionally so the total equals `budget`; a compliant book passes through unchanged. Identical
 * semantics to `tests/bench/pm-item-bank.ts`'s `clampToBudget` (cross-checked in the spine test) so
 * the refined type and the merged graders agree byte-for-byte on the α scale.
 */
export function clampToBudget(book: readonly BookEntry[], budget: number = RISK_BUDGET): readonly BookEntry[] {
  const total = book.reduce((s, e) => s + Math.max(0, e.size), 0);
  if (total <= budget) return book;
  const scale = budget / total;
  return book.map((e) => ({ play: e.play, size: Math.max(0, e.size) * scale }));
}

// ─────────────────────────────────────────────────────────────────────────────
// The risk-factor model — what makes two plays correlated
// ─────────────────────────────────────────────────────────────────────────────

/** A play's exposure to the two risk factors the contract reasons over: direction (delta) and vol (vega). */
export interface FactorSignature {
  /** Directional exposure sign: +1 long the underlying, −1 short, 0 delta-neutral. */
  readonly delta: -1 | 0 | 1;
  /** Volatility exposure sign: +1 long vol/convexity, −1 short vol/premium, 0 vol-neutral. */
  readonly vega: -1 | 0 | 1;
}

/**
 * The factor signature of each play the ADT can emit (plus the bank's small starters/hedges and
 * `stand_down`). A play the map does not know is treated as factor-neutral (it joins no cluster) —
 * consistent with `bookAlpha` scoring an unknown play as 0.
 */
export const FACTOR_SIGNATURE: Readonly<Record<string, FactorSignature>> = {
  orb_breakout: { delta: 1, vega: 0 },
  long_straddle: { delta: 0, vega: 1 },
  short_straddle: { delta: 0, vega: -1 },
  call_vertical: { delta: 1, vega: -1 },
  put_vertical: { delta: -1, vega: -1 },
  momentum_long: { delta: 1, vega: 0 },
  short_fade: { delta: -1, vega: 0 },
  small_long_starter: { delta: 1, vega: 0 },
  small_short_hedge: { delta: -1, vega: 0 },
  stand_down: { delta: 0, vega: 0 },
};

function signatureOf(play: string): FactorSignature {
  return FACTOR_SIGNATURE[play] ?? { delta: 0, vega: 0 };
}

/**
 * The correlation/concentration check. For each risk factor and each sign, sum the budget carried by
 * the entries loading that factor in that direction; if ≥ 2 distinct entries load it AND their
 * combined weight exceeds {@link CONCENTRATION_CAP}, the book is a correlated stack. Returns the
 * offending `(factor, sign, weight)` or `null` if the book is well-diversified. Operates on the
 * post-clamp sizes (the weights actually graded).
 */
export function findCorrelatedStack(
  book: readonly BookEntry[],
): { factor: "delta" | "vega"; sign: -1 | 1; weight: number; plays: readonly string[] } | null {
  const factors: Array<"delta" | "vega"> = ["delta", "vega"];
  for (const factor of factors) {
    for (const sign of [-1, 1] as const) {
      let weight = 0;
      const plays: string[] = [];
      for (const e of book) {
        if (e.size <= 0) continue;
        if (signatureOf(e.play)[factor] === sign) {
          weight += e.size;
          plays.push(e.play);
        }
      }
      if (plays.length >= 2 && weight > CONCENTRATION_CAP + 1e-9) {
        return { factor, sign, weight, plays };
      }
    }
  }
  return null;
}

// ─────────────────────────────────────────────────────────────────────────────
// Book — the refined collection
// ─────────────────────────────────────────────────────────────────────────────

declare const BookBrand: unique symbol;

/**
 * A refined book: a `BookEntry[]` that has passed {@link mkBook}'s size-band and
 * correlation/concentration refinements and been clamped to `total ≤ RISK_BUDGET`. The brand makes
 * the refinement load-bearing — you cannot obtain a `Book` without going through `mkBook`.
 */
export type Book = readonly BookEntry[] & { readonly [BookBrand]: true };

/** Total risk deployed by a book (≤ `RISK_BUDGET` for any `Book` by construction). */
export function bookTotal(book: readonly BookEntry[]): number {
  return book.reduce((s, e) => s + e.size, 0);
}

/**
 * Construct a refined {@link Book}, failing closed on either ill-formedness:
 *  - **size band** — any entry with `size ≤ 0`, `NaN`, or `> 1` throws {@link BookRefinementError}.
 *  - **budget law** — the book is clamped so `total ≤ RISK_BUDGET` (a scaling, never a rejection).
 *  - **correlation/concentration** — a correlated stack (≥ 2 mutually-correlated entries past
 *    {@link CONCENTRATION_CAP}) throws {@link BookRefinementError}.
 *
 * The empty book (`mkBook([])`) is the well-typed stand-down / restraint element.
 */
export function mkBook(entries: readonly BookEntry[]): Book {
  for (const e of entries) {
    if (!Number.isFinite(e.size) || e.size <= 0) {
      throw new BookRefinementError(
        `book entry ${JSON.stringify(e.play)} has out-of-band size ${e.size} (expected 0 < size ≤ 1)`,
      );
    }
    if (e.size > 1) {
      throw new BookRefinementError(
        `book entry ${JSON.stringify(e.play)} has size ${e.size} > 1 (a single entry cannot exceed the whole budget)`,
      );
    }
  }
  const clamped = clampToBudget(entries);
  const stack = findCorrelatedStack(clamped);
  if (stack !== null) {
    throw new BookRefinementError(
      `correlated stack: ${stack.plays.join(" + ")} all load ${stack.factor} ${stack.sign > 0 ? "+" : "-"} ` +
        `for combined weight ${stack.weight.toFixed(3)} > concentration cap ${CONCENTRATION_CAP} — ` +
        `a disguised single bet, not a diversified book`,
    );
  }
  return clamped as Book;
}

// ─────────────────────────────────────────────────────────────────────────────
// MarketState and the reference PM dispatcher
// ─────────────────────────────────────────────────────────────────────────────

/** The expressiveness edge — the options-family read (direction × magnitude/vol). */
export interface ExpressivenessRead {
  readonly direction: "up" | "down" | "two-sided";
  readonly magnitude: "large" | "moderate" | "small";
}

/** The information edge — the news-family read (catalyst authenticity). */
export interface InformationRead {
  readonly catalyst: "real" | "promotional";
}

/** The opening-range levels an equity elaboration needs. */
export interface Levels {
  readonly orHigh: number;
  readonly orLow: number;
  /** Breakout side for a pure-levels ORB dispatch; defaults to `"up"`. */
  readonly breakout?: "up" | "down";
}

/**
 * The state a PM allocates against. The shared technical `surface` plus AT MOST one decisive edge
 * (the matched-set invariant: the two poles of a pair differ on exactly one edge-read). `levels`
 * carry the equity range for `ORB`/`NewsShort` elaboration.
 */
export interface MarketState {
  readonly underlying: string;
  readonly surface: string;
  readonly levels?: Levels;
  readonly budgetR?: 1 | 2 | 3 | 4 | 5;
  readonly tpPct?: number;
  /** Edge 1 — expressiveness (options family). */
  readonly expressiveness?: ExpressivenessRead;
  /** Edge 2 — information (news family). */
  readonly information?: InformationRead;
}

/** Raised when a MarketState carries no decisive read that any constructor can express. */
export class DispatchError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DispatchError";
  }
}

/**
 * Select the well-typed {@link Strategy} the state's decisive read calls for. Priority: the
 * information edge (news) dominates when present, else the expressiveness edge (options), else a
 * pure-levels equity breakout. A `moderate` read with no direction has no single well-typed
 * structure and throws (honest boundary, not a silent default).
 */
export function dispatch(ms: MarketState): Strategy {
  if (ms.information) {
    if (!ms.levels) throw new DispatchError("news dispatch needs opening-range levels to elaborate");
    return newsShort(ms.information.catalyst, {
      underlying: ms.underlying,
      orHigh: ms.levels.orHigh,
      orLow: ms.levels.orLow,
      budgetR: ms.budgetR ?? 3,
      tpPct: ms.tpPct ?? 2.0,
    });
  }
  if (ms.expressiveness) {
    const { direction, magnitude } = ms.expressiveness;
    if (magnitude === "large") return straddle("large", ms.underlying);
    if (magnitude === "small") return straddle("small", ms.underlying);
    // moderate ⇒ a capped directional vertical; needs a side.
    if (direction === "up" || direction === "down") return vertical(direction, ms.underlying);
    throw new DispatchError("a moderate two-sided read has no single well-typed structure");
  }
  if (ms.levels) {
    return orb({
      direction: ms.levels.breakout ?? "up",
      underlying: ms.underlying,
      orHigh: ms.levels.orHigh,
      orLow: ms.levels.orLow,
      budgetR: ms.budgetR ?? 3,
      tpPct: ms.tpPct ?? 0.5,
    });
  }
  throw new DispatchError("MarketState carries no decisive read and no levels — nothing to allocate");
}

/**
 * The reference PM: `MarketState → Book`. Dispatches to the well-typed strategy for the decisive
 * read and returns its single-conviction refined book (full-budget on the dispatched play). Flip
 * the decisive edge, get a different well-typed book — the demo the spine test asserts through this
 * exact path.
 */
export function PM(ms: MarketState): Book {
  const strategy = dispatch(ms);
  const play: Play = toPlay(strategy);
  return mkBook([{ play, size: RISK_BUDGET }]);
}
