"""OOS test of a PURE drawdown-kill on the combined Bybit 4-edge book — READ-ONLY, cheap.

The combined Bybit book (naive equal-weight of tstrend+unlock+carry+positioning) is the deployable book
(OOS Sharpe ~2.43, maxDD ~-13%). The full risk OVERLAY (`bybit_overlay_ab`) was REJECTED — on the Bybit book
it over-levers and loses OOS. A PURE drawdown-kill is a DIFFERENT, simpler thing: capital preservation only.

  * The rejected overlay-kill rode on TOP of vol-target LEVERAGE — it scaled notional up (~2.79×) and the kill
    only zeroed that levered exposure. Sharpe collapsed because the LEVERAGE was the problem, not the kill.
  * This PURE kill changes NO leverage. Exposure is binary {0, 1}: hold the naive eq-wt book (1) or flatten to
    cash (0) on a tail drawdown, re-entering per the existing `DrawdownKillSwitch` hysteresis. There is no
    vol-target, no inverse-vol, no Kelly — the ONLY knob is the kill drawdown threshold.

It is judged on BOTH Sharpe AND maxDD (a kill is for capital preservation, so cutting Sharpe to cut DD is the
trade): a kill is adopted ONLY if, OUT-OF-SAMPLE (held-out TEST), it reduces maxDD MEANINGFULLY (>= `dd_bar`
relative DD reduction, default 25%) AND costs little Sharpe (<= `sharpe_bar` OOS Sharpe drop, default 0.3). If
every kill either barely triggers (no DD benefit) or whipsaws (Sharpe loss without enough DD benefit), the
verdict is STAY NAIVE — the book's drawdown is already contained.

Data is CHEAP: the precomputed `bybit_sleeve_ret` table (read via `PaperRepo.bybit_sleeve_returns()`) — no edge
recompute, no panel reads, no OOM. The naive eq-wt combined book daily return series is assembled from it via
the SAME `_equal_weight_metrics` engine the deployed book uses, so the `naive` baseline is bit-identical.

REUSE: the kill is the LIVE `DrawdownKillSwitch` (peak/drawdown tracking + hysteresis re-entry), driven by ONE
causal forward loop — kill state for day d is read from returns STRICTLY BEFORE d (no look-ahead), identical to
the overlay A/B's causal convention. READ-ONLY: only `bybit_sleeve_returns()` is read; nothing is written.
"""
from __future__ import annotations

import math
import statistics as st
from typing import Any

from fxhnt.application.bybit_book_eval import _DEFAULT_BYBIT_SLEEVES, _equal_weight_metrics
from fxhnt.application.paper_book import DrawdownKillSwitch

_PERIODS_PER_YEAR = 365

# The pure-kill drawdown thresholds (positive fractions): flatten when running shadow DD breaches the level.
# The DrawdownKillSwitch re-enters via its existing hysteresis default (reenter_dd=_REENTER_DD=0.12), so a kill
# is never permanent. -10/-15/-20/-25% span "tight" (whipsaw-prone) to "loose" (only true tails).
_KILL_THRESHOLDS: dict[str, float] = {
    "kill_10": 0.10,
    "kill_15": 0.15,
    "kill_20": 0.20,
    "kill_25": 0.25,
}

KILL_CONFIG_NAMES: tuple[str, ...] = tuple(_KILL_THRESHOLDS)
CONFIG_NAMES: tuple[str, ...] = ("naive", *KILL_CONFIG_NAMES)

# default adoption bars (Sharpe AND DD): a kill must cut OOS maxDD by >= 25% relative AND cost <= 0.3 Sharpe.
_DEFAULT_DD_BAR = 0.25
_DEFAULT_SHARPE_BAR = 0.30


# --- build the naive eq-wt combined book daily return series -----------------------------------

def _naive_book_returns(returns_by_sleeve: dict[str, dict[int, float]], sleeves: list[str], *,
                        cost_bps: float) -> dict[int, float]:
    """The naive EQUAL-WEIGHT combined book daily NET return series `{epoch_day: ret}` — the SAME static
    equal-weight, re-normalised-over-present-today, turnover-costed construction `_equal_weight_metrics` uses
    (so the `naive` baseline here is bit-identical to the deployed book's naive number). Pure."""
    present = [s for s in sleeves if returns_by_sleeve.get(s)]
    if not present:
        return {}
    n = len(present)
    all_days = sorted({d for s in present for d in returns_by_sleeve[s]})
    out: dict[int, float] = {}
    prev_w = {s: 0.0 for s in present}
    for d in all_days:
        today = [s for s in present if d in returns_by_sleeve[s]]
        if not today:
            continue
        w = 1.0 / n
        gross = sum(w * returns_by_sleeve[s][d] for s in today)
        if cost_bps:
            cur_w = {s: (w if d in returns_by_sleeve[s] else 0.0) for s in present}
            turnover = sum(abs(cur_w[s] - prev_w[s]) for s in present)
            gross -= turnover * cost_bps / 1e4
            prev_w = cur_w
        out[d] = gross
    return out


# --- the pure kill: exposure ∈ {0, 1}, NO leverage ---------------------------------------------

def _pure_kill_curve(book: dict[int, float], *, kill_dd: float,
                     reenter_dd: float | None = None) -> tuple[dict[int, float], dict[int, float]]:
    """Apply a PURE drawdown-kill to a naive book daily return series.

    Returns `(killed_returns, exposure)`: for each day d, exposure[d] ∈ {0.0, 1.0} (1 = hold the book, 0 =
    flattened to cash) and killed_returns[d] = exposure[d] * book[d]. NO leverage — the exposure is binary.

    Causal: the kill state applied to day d is read from `DrawdownKillSwitch.killed` BEFORE observing day d's
    return (peak/drawdown tracked on the UN-throttled shadow equity), so re-entry follows the live hysteresis
    and there is no look-ahead. `reenter_dd=None` uses the live `DrawdownKillSwitch` default."""
    ks = (DrawdownKillSwitch(kill_dd=kill_dd) if reenter_dd is None
          else DrawdownKillSwitch(kill_dd=kill_dd, reenter_dd=reenter_dd))
    killed_returns: dict[int, float] = {}
    exposure: dict[int, float] = {}
    for d in sorted(book):
        exp = 0.0 if ks.killed else 1.0
        exposure[d] = exp
        killed_returns[d] = exp * book[d]
        # the kill observes the UN-throttled book return (the regime signal), exactly like the live overlay.
        ks.observe(book[d])
    return killed_returns, exposure


def _kill_triggers(exposure: dict[int, float]) -> int:
    """Number of distinct kill EVENTS = transitions from exposure 1 -> 0 (a flatten). A single multi-day kill
    counts once; a re-enter then re-kill counts twice."""
    triggers = 0
    prev = 1.0
    for d in sorted(exposure):
        if prev == 1.0 and exposure[d] == 0.0:
            triggers += 1
        prev = exposure[d]
    return triggers


# --- metrics + split (mirror bybit_overlay_ab) -------------------------------------------------

def _metrics(series: dict[int, float], *, triggers: int) -> dict[str, Any]:
    """Sharpe (√365) / CAGR / maxDD (<=0) / total-return / days / kill-triggers of a `{day: ret}` series."""
    days = sorted(series)
    rets = [series[d] for d in days]
    if len(rets) < 2:
        return {"sharpe": 0.0, "cagr": 0.0, "max_dd": 0.0, "total_return": 0.0,
                "days": len(rets), "triggers": triggers}
    sd = st.pstdev(rets)
    sharpe = (st.mean(rets) / sd * math.sqrt(_PERIODS_PER_YEAR)) if sd > 0 else 0.0
    equity = 1.0
    peak = 1.0
    max_dd = 0.0
    for r in rets:
        equity *= (1.0 + r)
        peak = max(peak, equity)
        if peak > 0:
            max_dd = min(max_dd, equity / peak - 1.0)
    span = days[-1] - days[0]
    years = span / _PERIODS_PER_YEAR if span > 0 else 0.0
    cagr = (equity ** (1.0 / years) - 1.0) if years > 0 and equity > 0 else 0.0
    return {"sharpe": sharpe, "cagr": cagr, "max_dd": max_dd, "total_return": equity - 1.0,
            "days": len(rets), "triggers": triggers}


def _split_days(book: dict[int, float], train_frac: float) -> tuple[int, int]:
    """(train_hi, test_lo) epoch-days: TRAIN = first `train_frac` of the date axis, TEST = the rest."""
    days = sorted(book)
    if len(days) < 4:
        return (days[-1] if days else 0), ((days[-1] + 1) if days else 1)
    idx = max(1, min(len(days) - 1, int(len(days) * train_frac)))
    return days[idx - 1], days[idx]


def _window(series: dict[int, float], *, day_hi: int | None = None,
            day_lo: int | None = None) -> dict[int, float]:
    return {d: r for d, r in series.items()
            if (day_hi is None or d <= day_hi) and (day_lo is None or d >= day_lo)}


# --- the harness -------------------------------------------------------------------------------

def drawdown_kill_test(repo_or_returns: Any, *, train_frac: float = 0.6, cost_bps: float = 5.5,
                       sleeves: list[str] | None = None, dd_bar: float = _DEFAULT_DD_BAR,
                       sharpe_bar: float = _DEFAULT_SHARPE_BAR) -> dict[str, Any]:
    """OOS test of a PURE drawdown-kill on the combined Bybit book. READ-ONLY.

    `repo_or_returns` may be a `{sleeve: {epoch_day: ret}}` dict (the pure path) OR a `PaperRepo`-like object
    exposing `bybit_sleeve_returns()` (the precomputed `bybit_sleeve_ret` table is read — no edge recompute).

    Pipeline:
      1. Build the naive eq-wt combined book daily return series from the 4 sleeves (bit-identical to the
         deployed book's naive baseline).
      2. Configs: `naive` (no kill) + pure-kill at drawdown thresholds -10/-15/-20/-25% (REUSING the live
         `DrawdownKillSwitch` for trigger + hysteresis re-entry; exposure 0 when killed, 1 otherwise — NO
         leverage). Re-entry uses the live default `reenter_dd=0.12`.
      3. Split dates TRAIN (first `train_frac`) / TEST (rest); score each config on TRAIN, TEST and FULL:
         Sharpe, CAGR, maxDD, total, days, kill-triggers.
      4. VERDICT (Sharpe AND DD): adopt a kill ONLY if OUT-OF-SAMPLE it reduces maxDD by >= `dd_bar` relative
         AND costs <= `sharpe_bar` OOS Sharpe. Among kills clearing BOTH bars, pick the one with the largest
         OOS relative DD reduction. Else STAY NAIVE.

    Returns `{available, sleeves, days, table {config: {train, test, full}}, verdict, cost_bps, train_frac,
    split_train_hi, split_test_lo}`. Writes NOTHING."""
    returns_by_sleeve = _assemble_returns(repo_or_returns)
    candidate = list(sleeves) if sleeves is not None else list(_DEFAULT_BYBIT_SLEEVES)
    active = [s for s in candidate if returns_by_sleeve.get(s)]
    # the pure path may pass arbitrary sleeve names; keep any with data.
    for s in returns_by_sleeve:
        if returns_by_sleeve[s] and s not in active:
            active.append(s)

    book = _naive_book_returns(returns_by_sleeve, active, cost_bps=cost_bps)
    if len(book) < 4:
        return {"available": False, "sleeves": active, "days": len(book),
                "reason": "need >= 1 sleeve-with-data and >= 4 combined-book days for a train/test split "
                          "(run `fxhnt bybit-persist-sleeve-ret` first)"}

    train_hi, test_lo = _split_days(book, train_frac)

    table: dict[str, dict[str, Any]] = {}
    # naive baseline: NO kill (exposure always 1, 0 triggers). The FULL metric matches the deployed book.
    table["naive"] = {
        "train": _metrics(_window(book, day_hi=train_hi), triggers=0),
        "test": _metrics(_window(book, day_lo=test_lo), triggers=0),
        "full": _metrics(book, triggers=0),
    }
    for name, kdd in _KILL_THRESHOLDS.items():
        killed, exposure = _pure_kill_curve(book, kill_dd=kdd)
        tr = _window(killed, day_hi=train_hi)
        te = _window(killed, day_lo=test_lo)
        table[name] = {
            "train": _metrics(tr, triggers=_kill_triggers(_window(exposure, day_hi=train_hi))),
            "test": _metrics(te, triggers=_kill_triggers(_window(exposure, day_lo=test_lo))),
            "full": _metrics(killed, triggers=_kill_triggers(exposure)),
        }

    verdict = _verdict(table, dd_bar=dd_bar, sharpe_bar=sharpe_bar)
    return {"available": True, "sleeves": active, "days": len(book), "table": table,
            "verdict": verdict, "cost_bps": cost_bps, "train_frac": train_frac,
            "split_train_hi": train_hi, "split_test_lo": test_lo}


def _verdict(table: dict[str, dict[str, Any]], *, dd_bar: float, sharpe_bar: float) -> dict[str, Any]:
    """The Sharpe-AND-DD capital-preservation verdict, judged OUT-OF-SAMPLE (TEST split).

    A kill is a CANDIDATE iff, on TEST: (a) relative maxDD reduction vs naive >= `dd_bar`, AND (b) Sharpe drop
    vs naive <= `sharpe_bar`. Among candidates, adopt the one with the LARGEST OOS relative DD reduction (most
    capital preserved at acceptable Sharpe cost). If none qualifies, STAY NAIVE — the book's drawdown is
    already contained / the kill only whipsaws."""
    naive_te = table["naive"]["test"]
    naive_dd = naive_te["max_dd"]
    naive_sh = naive_te["sharpe"]

    candidates: list[tuple[str, float, float]] = []  # (name, rel_dd_reduction, sharpe_drop)
    for name in KILL_CONFIG_NAMES:
        te = table[name]["test"]
        rel_dd_red = ((naive_dd - te["max_dd"]) / abs(naive_dd)) if naive_dd < 0 else 0.0
        sharpe_drop = naive_sh - te["sharpe"]
        if rel_dd_red >= dd_bar - 1e-9 and sharpe_drop <= sharpe_bar + 1e-9:
            candidates.append((name, rel_dd_red, sharpe_drop))

    if candidates:
        best = max(candidates, key=lambda c: c[1])  # largest OOS relative DD reduction
        name, rel_dd_red, sharpe_drop = best
        return {
            "recommendation": name, "dd_bar": dd_bar, "sharpe_bar": sharpe_bar,
            "naive_test_sharpe": naive_sh, "naive_test_max_dd": naive_dd,
            "rec_rel_dd_reduction": rel_dd_red, "rec_sharpe_drop": sharpe_drop,
            "candidates": [c[0] for c in candidates],
            "note": (f"adopt '{name}': OUT-OF-SAMPLE it cuts maxDD {rel_dd_red*100:.0f}% (>= "
                     f"{dd_bar*100:.0f}% bar) at only {sharpe_drop:.2f} Sharpe cost (<= {sharpe_bar:.2f} bar) "
                     f"— a pure capital-preservation kill, NO leverage."),
        }

    return {
        "recommendation": "naive", "dd_bar": dd_bar, "sharpe_bar": sharpe_bar,
        "naive_test_sharpe": naive_sh, "naive_test_max_dd": naive_dd,
        "rec_rel_dd_reduction": 0.0, "rec_sharpe_drop": 0.0, "candidates": [],
        "note": (f"STAY NAIVE — no pure drawdown-kill clears BOTH bars OUT-OF-SAMPLE (need >= "
                 f"{dd_bar*100:.0f}% relative DD reduction AND <= {sharpe_bar:.2f} Sharpe drop). Every kill "
                 f"either barely triggers (no DD benefit) or whipsaws (Sharpe loss). The combined book's "
                 f"drawdown ({naive_dd*100:.1f}% OOS) is already contained."),
    }


def _assemble_returns(repo_or_returns: Any) -> dict[str, dict[int, float]]:
    """Either pass through a prebuilt `{sleeve: {day: ret}}` dict, or read the precomputed sleeve returns from
    a repo via `bybit_sleeve_returns()`. A dict is detected by its values being dicts; anything with a
    `bybit_sleeve_returns` method is treated as the repo."""
    obj = repo_or_returns
    if isinstance(obj, dict) and (not obj or all(isinstance(v, dict) for v in obj.values())):
        return {s: dict(v) for s, v in obj.items()}
    return {s: dict(v) for s, v in obj.bybit_sleeve_returns().items()}
