"""Pure in-memory book simulator — `simulate_book`.

The shared engine behind BOTH the interactive cockpit sim page (Phase 2) and the live multi-track
wiring (Phase 3). It consumes ONLY a cached `{sleeve: {epoch_day: ret}}` dict — no warehouse, no
network, no live prices — runs the established causal risk overlay forward, and returns an equity
curve + per-day state + summary metrics.

Causal convention (identical to `paper_book_returns`): the weights/leverage/killed applied to a day
`d` are computed from the returns STRICTLY BEFORE `d` (the overlay's trailing window). The active set
for `d` is every selected sleeve that has any prior return — the same faithful proxy the backfill +
snapshot use. The book's gross daily return is then `lev · Σ w[s]·return[s][d]` (0 when killed),
which is exactly the per-day book return `paper_book_returns` compounds. So with
`controller="killswitch", kelly_fraction=1.0, cost_bps=0` this engine reproduces the established book
math; the extra knobs (date window, sleeve selection, cost_bps, capital) layer on top without
changing it.

PURE: no I/O, deterministic. Reuses `IncrementalRiskOverlay` (O(1) amortised) for the forward loop —
which is golden-tested to equal the stateless `paper_risk_overlay` per date — rather than
reimplementing the overlay.
"""
from __future__ import annotations

import datetime as dt
import math
import statistics as st
from dataclasses import dataclass, field

from fxhnt.application.paper_book import IncrementalRiskOverlay

_PERIODS_PER_YEAR = 365


@dataclass
class SimResult:
    dates: list[int] = field(default_factory=list)              # epoch_days, ascending
    equity: list[float] = field(default_factory=list)           # equity curve from `capital`
    drawdown: list[float] = field(default_factory=list)         # running drawdown (<=0) at each date
    weights: list[dict[str, float]] = field(default_factory=list)  # per-day sleeve weights (post-overlay)
    leverage: list[float] = field(default_factory=list)         # per-day effective leverage (0 when killed)
    killed: list[bool] = field(default_factory=list)            # per-day killswitch state
    metrics: dict = field(default_factory=dict)                 # cagr, sharpe, max_dd, worst/best_year, ...


@dataclass
class OverlayPass:
    """The EXPENSIVE half of `simulate_book`, cached per `(sleeves, controller, kelly_fraction, window)`.

    Holds the per-in-window-day overlay outputs — `(weights, eff_lev, killed, gross_ret)` — which depend
    ONLY on the overlay inputs `(returns, sleeves, controller, kelly_fraction)` and the date window, NOT on
    `capital` or `cost_bps`. `gross_ret[i] = eff_lev[i] · Σ w[i]·return[s][dates[i]]` is the killed-aware
    gross book return for the day (0 when killed). `eff_w[i] = {s: w[i][s]·eff_lev[i]}` is precomputed so
    the cheap `cost_bps` turnover haircut needs no overlay state. Feed this to `apply_knobs(...)` with any
    `capital`/`cost_bps` to get a `SimResult` cheaply (kelly is NOT a cheap knob — see `overlay_pass`)."""
    dates: list[int] = field(default_factory=list)
    weights: list[dict[str, float]] = field(default_factory=list)
    leverage: list[float] = field(default_factory=list)         # eff_lev (0 when killed)
    killed: list[bool] = field(default_factory=list)
    gross_ret: list[float] = field(default_factory=list)        # eff_lev · Σ w·r[d] (0 when killed)
    eff_w: list[dict[str, float]] = field(default_factory=list)  # {s: w[s]·eff_lev} per day (for turnover)


def _epoch_day_to_year(epoch_day: int) -> int:
    """Calendar year of an epoch-day (days since 1970-01-01)."""
    return (dt.date(1970, 1, 1) + dt.timedelta(days=int(epoch_day))).year


def _metrics(dates: list[int], equity: list[float], net_rets: list[float], drawdown: list[float],
             capital: float) -> dict:
    """Summary stats over the simulated window. `net_rets[i]` is the book return realised ON
    `dates[i]` (the move that took equity from the prior point to `equity[i]`)."""
    end_value = equity[-1] if equity else capital
    total_return = (end_value / capital - 1.0) if capital else 0.0

    # CAGR annualised over the window's year-fraction (n_days / 365). n_days = span of the date window.
    n_days = (dates[-1] - dates[0]) if len(dates) >= 2 else 0
    years = n_days / _PERIODS_PER_YEAR if n_days > 0 else 0.0
    cagr = ((end_value / capital) ** (1.0 / years) - 1.0
            if years > 0 and capital > 0 and end_value > 0 else 0.0)

    # Sharpe = mean/pstdev of the daily net returns, annualised by √365.
    if len(net_rets) >= 2:
        sd = st.pstdev(net_rets)
        sharpe = (st.mean(net_rets) / sd * math.sqrt(_PERIODS_PER_YEAR)) if sd > 0 else 0.0
    else:
        sharpe = 0.0

    max_dd = min(drawdown) if drawdown else 0.0

    # Per-calendar-year compounded return from the daily net returns.
    year_factor: dict[int, float] = {}
    for d, r in zip(dates, net_rets, strict=True):
        y = _epoch_day_to_year(d)
        year_factor[y] = year_factor.get(y, 1.0) * (1.0 + r)
    year_returns = {y: f - 1.0 for y, f in year_factor.items()}
    worst_year = min(year_returns.values()) if year_returns else 0.0
    best_year = max(year_returns.values()) if year_returns else 0.0

    return {
        "cagr": cagr,
        "sharpe": sharpe,
        "max_dd": max_dd,
        "worst_year": worst_year,
        "best_year": best_year,
        "end_value": end_value,
        "total_return": total_return,
    }


def simulate_book(returns_by_sleeve: dict[str, dict[int, float]], *, capital: float,
                  sleeves: list[str], start: int | None = None, end: int | None = None,
                  controller: str = "killswitch", kelly_fraction: float = 1.0,
                  cost_bps: float = 0.0) -> SimResult:
    """Simulate a book's equity curve from cached per-sleeve daily returns. PURE — no I/O.

    Args:
      returns_by_sleeve: `{sleeve: {epoch_day: ret}}` — the only data source.
      capital: starting equity; the curve scales linearly with it (shape invariant).
      sleeves: which sleeves compose the book (intersected with `returns_by_sleeve`'s keys).
      start, end: inclusive epoch-day window (None = open on that side).
      controller: risk-overlay controller ("killswitch" default / "edge_health" / "combined").
      kelly_fraction: fractional-Kelly leverage knob passed to the overlay.
      cost_bps: per-day turnover cost in basis points of notional traded
                (`Σ_s |w[s]·lev − prev_w[s]·prev_lev| · cost_bps/1e4`); 0 → gross path.

    The overlay for day `d` is computed from returns strictly before `d` (causal); the book's gross
    return for `d` is `(0 if killed else lev)·Σ w[s]·return[s][d]`, the turnover cost is subtracted,
    and equity compounds from `capital`. Reuses `IncrementalRiskOverlay` (golden-equal to the
    stateless `paper_risk_overlay` per date).

    Internally split into the EXPENSIVE `overlay_pass` (the O(1)/day overlay forward loop — depends only
    on returns/sleeves/controller/kelly_fraction/window) and the CHEAP `apply_knobs` (capital + cost_bps).
    The split is exposed so a caller can cache the overlay pass and re-apply the cheap knobs instantly;
    composing them here keeps `simulate_book`'s signature + behaviour bit-identical.
    """
    op = overlay_pass(returns_by_sleeve, sleeves=sleeves, start=start, end=end,
                      controller=controller, kelly_fraction=kelly_fraction)
    return apply_knobs(op, capital=capital, cost_bps=cost_bps)


def simulate_naive_eqwt(returns_by_sleeve: dict[str, dict[int, float]], *, capital: float,
                        sleeves: list[str], start: int | None = None, end: int | None = None,
                        cost_bps: float = 0.0) -> SimResult:
    """Simulate the NAIVE EQUAL-WEIGHT book (NO risk overlay) from cached per-sleeve daily returns. PURE.

    This is the honest deployable Bybit number (the live overlay over-levers on Bybit — a separate re-tune):
    static `1/n` weight over the full present sleeve set, an absent-today sleeve contributes 0 (its weight
    idles, not re-normalised) — bit-identical to `bybit_book_eval._equal_weight_metrics` /
    `bybit_forward_track.naive_eqwt_daily_returns`. The cheap knobs layer on top exactly as `apply_knobs`
    does for the overlay path: `capital` is the equity base the curve compounds from (linear scale),
    `cost_bps` charges per-day turnover `Σ_s |w[s] − prev_w[s]| · cost_bps/1e4`, and `[start, end]` is the
    inclusive epoch-day window. `leverage` is reported as the static eq-wt 1.0 (never killed) so the
    `SimResult` shape — and the cockpit's curve/metrics/slider — is shared with the overlay path."""
    present = [s for s in sleeves if returns_by_sleeve.get(s)]
    result = SimResult()
    if not present or capital <= 0.0:
        result.metrics = _metrics([], [], [], [], capital)
        return result

    n = len(present)
    w = 1.0 / n
    all_days = sorted({d for s in present for d in returns_by_sleeve[s]})

    equity = capital
    peak = capital
    prev_w: dict[str, float] = {s: 0.0 for s in present}
    net_rets: list[float] = []

    for d in all_days:
        if (start is not None and d < start) or (end is not None and d > end):
            # Outside the window: still advance prev_w so the first in-window day's turnover is honest.
            prev_w = {s: (w if d in returns_by_sleeve[s] else 0.0) for s in present}
            continue
        today = [s for s in present if d in returns_by_sleeve[s]]
        if not today:
            continue
        gross = sum(w * returns_by_sleeve[s][d] for s in today)
        cur_w = {s: (w if d in returns_by_sleeve[s] else 0.0) for s in present}
        if cost_bps:
            turnover = sum(abs(cur_w[s] - prev_w[s]) for s in present)
            gross -= turnover * cost_bps / 1e4
        prev_w = cur_w

        equity *= (1.0 + gross)
        peak = max(peak, equity)
        dd = (equity - peak) / peak if peak > 0 else 0.0

        result.dates.append(d)
        result.equity.append(equity)
        result.drawdown.append(dd)
        result.weights.append({s: w for s in today})
        result.leverage.append(1.0)
        result.killed.append(False)
        net_rets.append(gross)

    result.metrics = _metrics(result.dates, result.equity, net_rets, result.drawdown, capital)
    return result


def overlay_pass(returns_by_sleeve: dict[str, dict[int, float]], *, sleeves: list[str],
                 start: int | None = None, end: int | None = None,
                 controller: str = "killswitch", kelly_fraction: float = 1.0) -> OverlayPass:
    """The EXPENSIVE half of the simulation: run the causal risk overlay forward and record, per
    in-window day, the `(weights, eff_lev, killed, gross_ret, eff_w)`. Depends ONLY on the overlay inputs
    `(returns, sleeves, controller, kelly_fraction)` and the `[start, end]` window — NOT on `capital` /
    `cost_bps`. PURE.

    `kelly_fraction` is part of THIS (expensive) pass on purpose: it does NOT factor out of the overlay
    linearly — it is clamped by `max_leverage` in `vol_target_leverage` (halving kelly at the cap leaves
    leverage at the cap) and it sizes the killswitch's un-throttled regime signal (`paper_book_returns`),
    so it can change `killed`. Only `capital` (a pure equity-base multiplier) and `cost_bps` (a post-hoc
    turnover haircut) are genuinely cheap knobs — they live in `apply_knobs`.

    Reuses `IncrementalRiskOverlay` (O(1)-amortised, golden-equal to the stateless `paper_risk_overlay`
    per date): the append-only `before_d` view holds exactly returns < d at the top of iteration d, so the
    overlay sizes day d from its strictly-prior history — identical to the live backfill."""
    # Filter to the selected sleeves that actually have cached returns (intersection).
    selected = [s for s in sleeves if s in returns_by_sleeve]
    filtered = {s: returns_by_sleeve[s] for s in selected}

    # All dates ascending across the selected sleeves, restricted to the [start, end] window.
    all_days = sorted({d for r in filtered.values() for d in r})
    window_lo = start
    window_hi = end

    op = OverlayPass()

    overlay = IncrementalRiskOverlay(controller=controller, kelly_fraction=kelly_fraction)
    # The append-only strictly-prior view fed to the overlay (mirrors the backfill's growing `acc`):
    # at the TOP of iteration d it holds exactly the returns < d, so overlay(before_d) sizes day d from
    # its strictly-prior history — identical to how the live backfill calls
    # `overlay.overlay(acc, active)` (acc = keys < date) and applies eff_lev to that date.
    before_d: dict[str, dict[int, float]] = {s: {} for s in filtered}

    for d in all_days:
        active = [s for s, r in before_d.items() if r]
        w, lev, killed = overlay.overlay(before_d, active)
        eff_lev = 0.0 if killed else lev

        if (window_lo is None or d >= window_lo) and (window_hi is None or d <= window_hi):
            gross_ret = eff_lev * sum(w.get(s, 0.0) * filtered[s].get(d, 0.0) for s in w)
            eff_w = {s: w.get(s, 0.0) * eff_lev for s in w}

            op.dates.append(d)
            op.weights.append(dict(w))
            op.leverage.append(eff_lev)
            op.killed.append(killed)
            op.gross_ret.append(gross_ret)
            op.eff_w.append(eff_w)

        # Append day d to the strictly-before view AFTER sizing into d (next day sees d as prior).
        for s, r in filtered.items():
            if d in r:
                before_d[s][d] = r[d]

    return op


def apply_knobs(op: OverlayPass, *, capital: float, cost_bps: float = 0.0) -> SimResult:
    """The CHEAP half: apply `capital` + `cost_bps` to a precomputed `OverlayPass` to build a `SimResult`.
    No overlay state, no rolling stats — O(days·sleeves). PURE.

    `capital` is the equity base the killed-aware gross returns compound from (the curve scales linearly
    with it). `cost_bps` charges per-day turnover `Σ_s |eff_w[s] − prev_eff_w[s]| · cost_bps/1e4`
    (notional turnover × bps) using the overlay pass's precomputed `eff_w`; 0 → gross path. The result is
    bit-identical to a full `simulate_book` with the same `capital`/`cost_bps`."""
    result = SimResult()
    if not op.dates or capital <= 0.0:
        result.metrics = _metrics([], [], [], [], capital)
        return result

    equity = capital
    peak = capital
    prev_eff: dict[str, float] = {}   # prior day's effective weights × leverage (for turnover)
    net_rets: list[float] = []

    for i, d in enumerate(op.dates):
        gross_ret = op.gross_ret[i]
        if cost_bps:
            eff_w = op.eff_w[i]
            turnover = sum(abs(eff_w.get(s, 0.0) - prev_eff.get(s, 0.0))
                           for s in set(eff_w) | set(prev_eff))
            cost = turnover * cost_bps / 1e4
            prev_eff = eff_w
        else:
            cost = 0.0

        net_ret = gross_ret - cost
        equity *= (1.0 + net_ret)
        peak = max(peak, equity)
        dd = (equity - peak) / peak if peak > 0 else 0.0

        result.dates.append(d)
        result.equity.append(equity)
        result.drawdown.append(dd)
        result.weights.append(op.weights[i])
        result.leverage.append(op.leverage[i])
        result.killed.append(op.killed[i])
        net_rets.append(net_ret)

    result.metrics = _metrics(result.dates, result.equity, net_rets, result.drawdown, capital)
    return result
