"""Stage-1 KILL-SWITCH for the China/India (EM-Asia) diversifier hunt — a backtest-only, decided-from-history
LOO marginal-Sharpe gate, run BEFORE any forward tracker is wired. This is the ghost-filter: a 20-day
reconciliation gate on two EM-beta ETFs would PASS trivially (the forward NAV just tracks the backtest NAV)
and prove NOTHING about edge. The real question is decision-theoretic and answerable now from ~8-10y of
history: does adding CNYA.L / NDIA.L to the fund's ETF book RAISE the book's risk-adjusted return after
costs, or is it a co-crashing beta sleeve that fails the marginal gate — the SAME bar that killed prior failed
diversifier candidates, including the token-unlock diversifier (see pearl_fxhnt_diversifier_hunt_2026_07_15).

PASS bar (predetermined, not chosen after seeing the numbers): marginal Sharpe > 0 after cost.
  > 0  -> proceed to Stage 2 (wire the `em_asia` registry sleeve + reconciliation forward gate).
  <= 0 -> KILL: never build a forward tracker (co-crashing EM beta, not a diversifier).

Windows (per operator, 2026-07-19): each candidate judged on its OWN max window against the book over the
overlapping dates — CNYA.L ~10y, NDIA.L ~8y. Per-name verdicts (not a cross-name ranking), so differing
windows are acceptable.

Two marginal measures are reported per name:
  1. `marginal_sharpe` (SSOT weight-blend, domain/diversification/marginal.py) — DIRECTLY comparable to
     prior failed diversifier candidates' verdicts (e.g. unlock), which used this exact primitive.
  2. In-book delta-Sharpe — `book_series(FUND + candidate) - book_series(FUND)` through the FULL adaptive
     machinery (vol-norm -> trust -> correlation de-risk), i.e. the candidate's effect where it would
     actually live. The truer measure; if the two disagree, that disagreement is itself signal.

Context columns (NOT the gate — they explain WHY it passed/failed, without moving the goalpost): correlation
to the book, drawdown in known crash windows, and cost sensitivity (marginal Sharpe at 5/10/20 bp).

No I/O beyond the Yahoo daily fetch (the same free chart endpoint the fund's `multistrat_nav` uses). Pure
numpy math otherwise.
"""
from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np

from fxhnt.domain.diversification.marginal import _sharpe, marginal_sharpe
from fxhnt.domain.strategies import multistrat

# The two live-verified USD-denominated Accumulating UCITS lines (probed on Yahoo 2026-07-19). Acc (not Dist)
# so adjClose = true total return with no distribution gaps; USD so no GBp/FX mismatch with the USD book.
#   CNYA.L — iShares MSCI China A UCITS ETF USD Acc — MSCI China A-shares (mainland Shanghai/Shenzhen), the
#            retail-dominated inefficient market the fund cannot reach directly but can proxy via this wrapper.
#   NDIA.L — iShares MSCI India UCITS ETF USD Acc.
_CANDIDATES: dict[str, str] = {"CNYA.L": "china_a", "NDIA.L": "india"}

# Per config.py class default for a UCITS line without a tighter probed estimate — thinner EM UCITS liquidity.
# Cost per REBALANCE = 2 * half_spread (buy+sell the traded notional). See config.UcitsListing.half_spread_bps.
_DEFAULT_HALF_SPREAD_BPS: float = 10.0

# Calendar-rebalance floor: even a low-turnover stream pays UCITS spread on a periodic re-balance. The book
# vol-normalizes daily but a real sleeve reconciles weights on a cadence; charging AT LEAST one round-trip per
# this many trading days prevents a co-crashing beta sleeve from "surviving" only because the modeled
# vol-norm turnover happened to be near-zero. Monthly (~21 trading days) matches a standard ETF rebalance.
_REBALANCE_FLOOR_DAYS: int = 21

# Known systemic crash windows the fund already reasons about — a diversifier must not merely co-crash here.
_CRASH_WINDOWS: dict[str, tuple[str, str]] = {
    "covid_2020q1": ("2020-02-19", "2020-03-23"),
    "bear_2022": ("2022-01-03", "2022-10-14"),
    "yen_carry_2024": ("2024-07-31", "2024-08-07"),
}


@dataclass
class CandidateVerdict:
    symbol: str
    sleeve_id: str
    window_start: str
    window_end: str
    n_days: int
    marginal_sharpe_blend: float          # SSOT weight-blend, cost-inclusive (comparable to prior candidates/unlock)
    delta_sharpe_inbook: float            # book_series(FUND+cand) - book_series(FUND), cost-inclusive
    corr_to_book: float
    crash_drawdowns: dict[str, float] = field(default_factory=dict)
    cost_sensitivity: dict[str, float] = field(default_factory=dict)  # half_spread_bps -> marginal_sharpe_blend

    @property
    def passes(self) -> bool:
        """PASS iff BOTH marginal measures are > 0 after cost. Either <= 0 => KILL (co-crashing EM beta)."""
        a, b = self.marginal_sharpe_blend, self.delta_sharpe_inbook
        return np.isfinite(a) and np.isfinite(b) and a > 0.0 and b > 0.0


def _aligned_returns(closes: dict[str, dict[str, float]], symbols: list[str]) -> tuple[list[str], np.ndarray]:
    """Intersect dates across `symbols` and return (dates, simple-return matrix R[T, S]) with R[0]=0.
    Mirrors multistrat._build's intersection but WITHOUT dropping today (this is a pure historical eval)."""
    dates = sorted(set.intersection(*[set(closes[s]) for s in symbols]))
    R = np.zeros((len(dates), len(symbols)))
    for j, sym in enumerate(symbols):
        s = np.array([closes[sym][d] for d in dates])
        if len(s) > 1:
            R[1:, j] = s[1:] / s[:-1] - 1
    return dates, R


def _candidate_net_returns(
    closes: dict[str, dict[str, float]],
    symbol: str,
    *,
    half_spread_bps: float,
) -> tuple[list[str], np.ndarray]:
    """The candidate sleeve's per-day NET (cost-inclusive) return series, as a single vol-normalized stream
    treated the SAME way `book_series` treats each stream: raw simple returns -> `multistrat._volnorm` (the
    `min(5, TARGET_VOL/realized_vol)` multiplier over a 63d window) -> MINUS the turnover cost implied by the
    day-over-day change in the vol-normalization scale (the only thing that moves position size for a
    single-instrument sleeve). Returns (dates, net_ret[T]); the warmup window (< 63d) is 0 and is dropped by
    the caller via alignment against the book. Cost = `_turnover_cost` (vol-norm re-sizing OR a monthly
    rebalance floor, whichever is larger) — see that function."""
    dates = sorted(closes[symbol])
    px = np.array([closes[symbol][d] for d in dates], dtype=float)
    raw = np.zeros(len(px))
    if len(px) > 1:
        raw[1:] = px[1:] / px[:-1] - 1.0
    scale = multistrat._volnorm_scale(raw)          # per-day vol-normalization multiplier (0 in warmup)
    gross = raw * scale                              # vol-normalized stream return (pre-cost)
    cost = _turnover_cost(scale, half_spread_bps=half_spread_bps)
    net = gross - cost
    return dates, net


def _turnover_cost(scale: np.ndarray, *, half_spread_bps: float) -> np.ndarray:
    """Per-day cost drag (as a return) for a single vol-normalized stream whose position size on day t is
    `scale[t]`. Charged as the MAXIMUM of two components, so neither a re-sizing spike nor a quiet hold escapes
    cost:

      1. Vol-normalization re-sizing turnover — the traded notional on day t is |scale[t] - scale[t-1]|; for a
         SINGLE-instrument sleeve `scale` is the only thing that moves the position, so this is the exact
         turnover from re-sizing. Each unit of traded notional pays a round-trip `2 * half_spread_bps`.
      2. A calendar-rebalance FLOOR — one full round-trip of the average held position every
         `_REBALANCE_FLOOR_DAYS` (monthly), amortized per day. Without this, a co-crashing beta sleeve whose
         vol-norm turnover happens to be near-zero would "survive" the gate at near-zero cost — the ghost this
         whole eval exists to kill. Wider EM UCITS spreads make erring conservative the right call.

    Both are in RETURN units (fraction of the position's notional). Result is a per-day array aligned to
    `scale`. Convention matches the fund's other turnover-cost models (spread_overlay.py: `turnover * bps/1e4`).
    """
    n = len(scale)
    if n == 0:
        return np.zeros(0)
    rt = 2.0 * half_spread_bps / 1e4                                # round-trip cost per unit traded notional
    resize_turnover = np.abs(np.diff(scale, prepend=scale[0]))      # |Δscale| per day (day 0 -> 0)
    resize_cost = resize_turnover * rt
    # Floor: one round-trip of the held position amortized over the rebalance cadence. Use the day's own scale
    # as the held notional so the floor scales with position size (0 in the warmup window where scale==0).
    floor_cost = scale * rt / float(_REBALANCE_FLOOR_DAYS)
    return np.maximum(resize_cost, floor_cost)


def _book_returns(closes: dict[str, dict[str, float]], instruments: list[str]) -> dict[str, float]:
    """The adaptive multistrat book's per-day return for `instruments`, as {date: ret} — reuses
    `book_series` verbatim so 'the book' is genuinely the fund's book (full risk overlay), not a proxy blend.
    drop_incomplete_today=False: pure historical eval, no live/today concern."""
    return dict(multistrat.book_series(closes, instruments, drop_incomplete_today=False))


def _crash_drawdown(dates: list[str], ret: np.ndarray, window: tuple[str, str]) -> float:
    """Peak-to-trough return of the candidate stream inside [start, end] (fraction; negative = drawdown).
    Returns nan if the window has < 2 days of overlap with the series."""
    lo, hi = window
    idx = [i for i, d in enumerate(dates) if lo <= d <= hi]
    if len(idx) < 2:
        return float("nan")
    seg = ret[idx[0]: idx[-1] + 1]
    eq = np.cumprod(1.0 + seg)
    peak = np.maximum.accumulate(eq)
    return float((eq / peak - 1.0).min())


def evaluate_candidate(
    closes: dict[str, dict[str, float]],
    symbol: str,
    sleeve_id: str,
    *,
    fund_instruments: list[str] | None = None,
    blend_weight: float = 0.2,
    half_spread_bps: float = _DEFAULT_HALF_SPREAD_BPS,
) -> CandidateVerdict:
    """Run the Stage-1 LOO marginal-Sharpe gate for one candidate against the fund's ETF book, on the
    candidate's own max window (intersected with the book's dates). `closes` must already contain adjClose
    series for `fund_instruments` + `symbol`."""
    fund = list(fund_instruments if fund_instruments is not None else multistrat.FUND_INSTRUMENTS)

    # Book WITH vs WITHOUT the candidate (in-book delta-Sharpe, through the full adaptive machinery).
    book_without = _book_returns(closes, fund)
    book_with = _book_returns(closes, fund + [symbol])
    common = sorted(set(book_without) & set(book_with))
    bw = np.array([book_without[d] for d in common])
    bwith = np.array([book_with[d] for d in common])
    delta_inbook = _sharpe(bwith) - _sharpe(bw)

    # SSOT weight-blend marginal Sharpe: book (without) vs the candidate's NET vol-normalized stream, aligned.
    cand_dates, cand_net = _candidate_net_returns(closes, symbol, half_spread_bps=half_spread_bps)
    cand_map = dict(zip(cand_dates, cand_net, strict=True))
    blend_dates = sorted(set(book_without) & set(cand_map))
    b_series = np.array([book_without[d] for d in blend_dates])
    c_series = np.array([cand_map[d] for d in blend_dates])
    m_blend = marginal_sharpe(b_series, c_series, weight=blend_weight)

    corr = float(np.corrcoef(b_series, c_series)[0, 1]) if len(b_series) >= 2 else float("nan")

    crash = {name: _crash_drawdown(cand_dates, cand_net, win) for name, win in _CRASH_WINDOWS.items()}

    sens: dict[str, float] = {}
    for hs in (5.0, 10.0, 20.0):
        cd, cn = _candidate_net_returns(closes, symbol, half_spread_bps=hs)
        cm = dict(zip(cd, cn, strict=True))
        bd = sorted(set(book_without) & set(cm))
        sens[f"{hs:g}bp"] = marginal_sharpe(
            np.array([book_without[d] for d in bd]),
            np.array([cm[d] for d in bd]),
            weight=blend_weight,
        )

    return CandidateVerdict(
        symbol=symbol,
        sleeve_id=sleeve_id,
        window_start=blend_dates[0] if blend_dates else "",
        window_end=blend_dates[-1] if blend_dates else "",
        n_days=len(blend_dates),
        marginal_sharpe_blend=m_blend,
        delta_sharpe_inbook=delta_inbook,
        corr_to_book=corr,
        crash_drawdowns=crash,
        cost_sensitivity=sens,
    )


def run_stage1(fetch_closes) -> list[CandidateVerdict]:  # type: ignore[no-untyped-def]
    """Fetch each candidate + the fund instruments via `fetch_closes(symbol) -> {date: adjClose}` and return
    the per-name verdicts. `fetch_closes` is injected (real = YahooDailyClient.adj_closes; tests pass a stub),
    so this function is pure orchestration with no hard Yahoo dependency."""
    syms = list(multistrat.FUND_INSTRUMENTS) + list(_CANDIDATES)
    closes = {s: fetch_closes(s) for s in syms}
    return [evaluate_candidate(closes, sym, sleeve_id) for sym, sleeve_id in _CANDIDATES.items()]
