"""Risk-managed token-unlock dilution-short backtest. Market-neutral (short alt perp + long BTC),
anticipatory (short the PRE days before each insider/investor cliff), with the full risk stack that
tames the naive book's −74% DD: per-name inverse-vol sizing + weight cap + squeeze stop + portfolio
vol-target. Returns the same `TrendRunResult` shape so it plugs into the gauntlet + book allocator.

panel = {symbol_with_USDT: {epoch_day: (close, qvol, funding)}}; events = [{sym, cliff_day, n, cat}].
"""
from __future__ import annotations

import bisect
import math
from typing import Any

import numpy as np

from fxhnt.application._date_axis import as_of_day as _as_of_day
from fxhnt.application.trend_runner import TrendRunResult
from fxhnt.domain.unlock_shorts import inverse_vol_capped_weights, short_leg_pnl, supply_shock


# Fallback dollar-volume used when the warehouse lacks a real qvol/ADV series. Set just above the
# runner's default `min_qvol` (5e6) so a name with closes clears the liquidity gate, WITHOUT swamping
# the supply-shock ratio (= unlock_notional / qvol) — the cliff supply-shock + pre-window remain the
# real filter (a tiny unlock against this large qvol still fails `min_shock`). Documented approximation.
_WAREHOUSE_QVOL_FALLBACK = 1e7
_WAREHOUSE_FUNDING_FALLBACK = 0.0


def warehouse_unlock_panel(
    close_panel: dict[str, dict[int, float]],
) -> dict[str, dict[int, tuple[float, float, float]]]:
    """Adapt a warehouse close panel ({sym: {epoch_day: close}}) into the (close, qvol, funding) panel
    `UnlockShortRunner` needs. The warehouse `features` table may lack qvol/funding, so each day gets a
    fallback dollar-volume (`_WAREHOUSE_QVOL_FALLBACK`, large enough to clear `min_qvol` but small enough
    to keep the supply-shock gate meaningful) and neutral funding (0.0). Documented approximation; the
    cliff supply-shock + pre-window stay the real filter. A symbol with no closes is skipped (never
    crashes the panel build)."""
    panel: dict[str, dict[int, tuple[float, float, float]]] = {}
    for sym, series in close_panel.items():
        if not series:
            continue
        panel[sym] = {
            day: (float(close), _WAREHOUSE_QVOL_FALLBACK, _WAREHOUSE_FUNDING_FALLBACK)
            for day, close in series.items()
        }
    return panel


class UnlockShortRunner:
    def __init__(self, panel: dict[str, dict[int, tuple[float, float, float]]],
                 events: list[dict[str, Any]], *, market: str = "BTCUSDT", pre_window: int = 10,
                 min_shock: float = 0.1, min_qvol: float = 5e6, vol_lookback: int = 30,
                 vol_window_name: int = 20, target_vol: float = 0.15, max_name_weight: float = 0.25,
                 daily_stop: float = 0.25, max_leverage: float = 3.0, cost_bps: float = 10.0,
                 periods_per_year: int = 365) -> None:
        self._p = panel
        self._events = events
        self._mkt = market
        self._pre = pre_window
        self._min_shock = min_shock
        self._min_qvol = min_qvol
        self._vl = vol_lookback
        self._vwn = vol_window_name
        self._tv = target_vol
        self._maxw = max_name_weight
        self._stop = daily_stop
        self._maxlev = max_leverage
        self._cost = cost_bps / 1e4
        self._ppy = periods_per_year
        # Date-independent caches, computed once on first use. The panel + events are immutable after
        # construction and the runner is built fresh per backfill (the Bybit unlock-sleeve path), so a
        # single memoization is safe — there is no panel/events mutation that could stale these.
        self._events_norm_cache: list[tuple[str, int, float]] | None = None
        self._elig_cache: list[tuple[str, int]] | None = None
        self._all_days_cache: list[int] | None = None

    @property
    def _events_norm(self) -> list[tuple[str, int, float]]:
        """Normalized events (sym->USDT, int cliff_day, float n). Date-independent — memoized so it is
        built ONCE instead of on every `current_weights(as_of)` call across the backfill."""
        if self._events_norm_cache is None:
            self._events_norm_cache = [
                (e["sym"] + "USDT" if not e["sym"].endswith("USDT") else e["sym"],
                 int(e["cliff_day"]), float(e["n"])) for e in self._events]
        return self._events_norm_cache

    def _all_days(self) -> list[int]:
        """Sorted list of all distinct panel days (date-independent) — memoized so `current_weights`
        finds the latest day <= as_of via bisect instead of re-scanning+sorting the whole panel."""
        if self._all_days_cache is None:
            self._all_days_cache = sorted({x for s in self._p.values() for x in s})
        return self._all_days_cache

    def _name_vol(self, sym: str, d: int) -> float | None:
        s = self._p.get(sym)
        if not s:
            return None
        days = [x for x in sorted(s) if x <= d][-self._vwn - 1:]
        if len(days) < 5:
            return None
        rets = [s[days[i]][0] / s[days[i - 1]][0] - 1.0
                for i in range(1, len(days)) if s[days[i - 1]][0] > 0 and s[days[i]][0] > 0]
        if len(rets) < 4:
            return None
        v = float(np.std(rets))
        return v if v > 0 else None

    def _eligible(self) -> list[tuple[str, int]]:
        """(sym, cliff_day) for events that are liquid + supply-shock>min at the cliff. Depends only on
        events + panel (not as_of), so the result is memoized — computed ONCE instead of every call."""
        if self._elig_cache is not None:
            return self._elig_cache
        out = []
        for e in self._events_norm:
            sym, cd, n = e
            s = self._p.get(sym)
            if not s or cd not in s:
                continue
            close, qvol = s[cd][0], s[cd][1]
            if not (close > 0 and qvol and qvol >= self._min_qvol):
                continue
            sh = supply_shock(n, close, qvol)
            if sh is not None and sh > self._min_shock:
                out.append((sym, cd))
        self._elig_cache = out
        return out

    def run(self) -> TrendRunResult:
        elig = self._eligible()
        mkt = self._p.get(self._mkt, {})
        all_days = self._all_days()
        # pass 1: unlevered daily book return + snapshots for turnover
        unlev: list[float] = []
        snaps: list[dict[str, float]] = []
        dates: list[int] = []
        assets_seen = 0
        for d in all_days:
            active = [(sym, cd) for sym, cd in elig if cd - self._pre <= d < cd]
            if not active or d not in mkt or d + 1 not in mkt or mkt[d][0] <= 0:
                continue
            btc_ret = mkt[d + 1][0] / mkt[d][0] - 1.0
            vols = {}
            for sym, _cd in active:
                v = self._name_vol(sym, d)
                if v is not None:
                    vols[sym] = v
            if not vols:
                continue
            w = inverse_vol_capped_weights(vols, self._maxw)
            r = 0.0
            for sym, wt in w.items():
                s = self._p[sym]
                if d in s and d + 1 in s and s[d][0] > 0:
                    name_ret = s[d + 1][0] / s[d][0] - 1.0
                    fund = s[d][2] if s[d][2] is not None else 0.0
                    r += wt * short_leg_pnl(name_ret, btc_ret, fund, daily_stop=self._stop)
            unlev.append(r)
            snaps.append(dict(w))
            dates.append(int(d))
            assets_seen += len(w)
        n = len(unlev)
        if n == 0:
            return TrendRunResult(returns=np.zeros(0), n_assets_avg=0.0, n_rebalances=0, dates=[])
        arr = np.asarray(unlev, dtype=float)
        # pass 2: portfolio vol-target (trailing, no lookahead) + turnover cost on the levered book
        tgt_daily = self._tv / math.sqrt(self._ppy)
        rets: list[float] = []
        prev: dict[str, float] = {}
        for t in range(n):
            if t >= self._vl:
                rv = float(np.std(arr[t - self._vl:t]))
                lev = min(self._maxlev, tgt_daily / rv) if rv > 0 else 1.0
            else:
                lev = 1.0
            lp = {s: wv * lev for s, wv in snaps[t].items()}
            turn = sum(abs(lp.get(s, 0.0) - prev.get(s, 0.0)) for s in set(lp) | set(prev))
            rets.append(lev * arr[t] - turn * self._cost / 2.0)
            prev = lp
        return TrendRunResult(returns=np.asarray(rets, dtype=float),
                              n_assets_avg=assets_seen / n, n_rebalances=n, dates=dates)

    def weights_and_contributions(self) -> dict[int, dict[str, tuple[float, float]]]:
        """`{day: {symbol: (weight, short_leg_pnl)}}` reusing `run()`'s EXACT two passes (eligibility,
        active-window, inverse-vol weights, squeeze-stopped short_leg_pnl, AND the trailing portfolio
        vol-target leverage). The surfaced weight is `w·lev` and the symbol return is the per-unit-weight
        `short_leg_pnl(name_ret, btc_ret, funding)`, so `Σ_symbol weight·return == lev·Σ w·short_leg_pnl` —
        the GROSS (pre-cost) levered book return `run()` books before the turnover haircut. The long-BTC hedge
        + funding + squeeze stop are baked into `short_leg_pnl` (book-level legs, not separate per-symbol
        positions, exactly as the legacy venue's unlock sleeve's live view), and the turnover cost is applied
        identically by the live book's `trading_cost`. Pure.

        The weight is the POSITIVE inverse-vol size (paired with the short_leg_pnl return whose sign already
        encodes the short payoff); the live book treats it as a price-MTM lot sized by that weight — matching
        how the legacy venue's book marks unlock shorts. (`current_weights` instead returns NEGATED signed shorts for
        the live executable book; this method's pairing is for the reconciliation/contribution view.)"""
        elig = self._eligible()
        mkt = self._p.get(self._mkt, {})
        all_days = self._all_days()
        # Pass 1: replay the unlevered book to recover per-day (weights, short_leg_pnl) + the daily series for
        # the trailing vol-target (identical to run()).
        unlev: list[float] = []
        snaps: list[dict[str, float]] = []
        contribs: list[dict[str, tuple[float, float]]] = []   # {sym: (w_unlev, short_leg_pnl)}
        dates: list[int] = []
        for d in all_days:
            active = [(sym, cd) for sym, cd in elig if cd - self._pre <= d < cd]
            if not active or d not in mkt or d + 1 not in mkt or mkt[d][0] <= 0:
                continue
            btc_ret = mkt[d + 1][0] / mkt[d][0] - 1.0
            vols = {}
            for sym, _cd in active:
                v = self._name_vol(sym, d)
                if v is not None:
                    vols[sym] = v
            if not vols:
                continue
            w = inverse_vol_capped_weights(vols, self._maxw)
            r = 0.0
            row: dict[str, tuple[float, float]] = {}
            for sym, wt in w.items():
                s = self._p[sym]
                if d in s and d + 1 in s and s[d][0] > 0:
                    name_ret = s[d + 1][0] / s[d][0] - 1.0
                    fund = s[d][2] if s[d][2] is not None else 0.0
                    pnl = short_leg_pnl(name_ret, btc_ret, fund, daily_stop=self._stop)
                    r += wt * pnl
                    row[sym] = (wt, pnl)
            unlev.append(r)
            snaps.append(dict(w))
            contribs.append(row)
            dates.append(int(d))
        n = len(unlev)
        if n == 0:
            return {}
        arr = np.asarray(unlev, dtype=float)
        # Pass 2: the SAME trailing portfolio vol-target leverage run() applies (no lookahead). The surfaced
        # weight is w_unlev·lev so Σ weight·short_leg_pnl == lev·arr[t] == run()'s gross (pre-cost) day return.
        tgt_daily = self._tv / math.sqrt(self._ppy)
        out: dict[int, dict[str, tuple[float, float]]] = {}
        for t in range(n):
            if t >= self._vl:
                rv = float(np.std(arr[t - self._vl:t]))
                lev = min(self._maxlev, tgt_daily / rv) if rv > 0 else 1.0
            else:
                lev = 1.0
            out[dates[t]] = {sym: (wt * lev, pnl) for sym, (wt, pnl) in contribs[t].items()}
        return out

    def current_weights(self, as_of: str) -> dict[str, float]:
        """Signed (SHORT, negative) per-name target weights for the LATEST panel day <= `as_of`,
        reusing the SAME eligibility + active-window + inverse-vol logic as `run()`'s loop. The short
        book is `inverse_vol_capped_weights(...)` NEGATED (these positions are shorts). The long-BTC
        hedge + portfolio vol-target leverage are book-level accounting, not per-symbol target weights,
        so they are intentionally not surfaced here. Returns {} when no name is in an unlock window
        (or has usable vol) as of `as_of`."""
        elig = self._eligible()
        day_lim = _as_of_day(as_of)
        all_days = self._all_days()
        # latest panel day <= day_lim via bisect over the memoized sorted day axis (was a full-panel
        # scan+sort every call). bisect_right - 1 == index of the greatest day <= day_lim.
        idx = bisect.bisect_right(all_days, day_lim) - 1
        if idx < 0:
            return {}
        d = all_days[idx]
        active = [(sym, cd) for sym, cd in elig if cd - self._pre <= d < cd]
        vols: dict[str, float] = {}
        for sym, _cd in active:
            v = self._name_vol(sym, d)
            if v is not None:
                vols[sym] = v
        if not vols:
            return {}
        w = inverse_vol_capped_weights(vols, self._maxw)
        return {s: -wt for s, wt in w.items()}
