"""Pure walk-forward equity-factor backtest domain: point-in-time universe
reconstruction and rebalance-date math. No I/O, no live network."""
from __future__ import annotations

import datetime as dt

from fxhnt.domain.strategies.equity_factor import book_return

_SECONDS_PER_DAY = 86_400


def epoch_day(iso_date: str) -> int:
    """ISO 'YYYY-MM-DD' -> epoch-day (matches DuckDbFeatureStore.read_panel keys)."""
    d = dt.date.fromisoformat(iso_date)
    return (d - dt.date(1970, 1, 1)).days


def universe_asof(
    members: list[tuple[str, str, str, str]],
    dollar_vol: dict[str, float],
    asof: str,
    n: int,
) -> list[str]:
    """Point-in-time top-N universe at `asof` (ISO date).

    A name is eligible iff it was listed at `asof` (start_date <= asof <= end_date)
    AND has strictly positive trailing dollar-volume. Eligible names are ranked by
    `dollar_vol` descending; the top `n` symbols are returned. Including names that
    were alive at `asof` but later delisted is what makes this survivorship-free.
    """
    eligible = [
        sym
        for (sym, _exch, start, end) in members
        if start <= asof <= end and dollar_vol.get(sym, 0.0) > 0.0
    ]
    eligible.sort(key=lambda s: dollar_vol[s], reverse=True)
    return eligible[:n]


def pit_universe_by_month(
    monthly: list[tuple[str, int, float, int, float]],   # (symbol, ym, avg_dv, bars, avg_close)
    rebalance_yms: list[int],
    *,
    window_months: int,
    top_n: int,
    min_price: float,
    min_bars: int,
) -> dict[int, list[str]]:
    """For each rebalance ym, the POINT-IN-TIME top-`top_n` universe.

    A name qualifies at rebalance month `ym` iff its TRAILING window — the
    `window_months` months ending at AND including `ym`, i.e. the half-open range
    `(ym - window_months, ym]` — contains at least `min_bars` total daily bars
    (alive/liquid at the rebalance) AND its most-recent in-window monthly `avg_close`
    is >= `min_price`. Qualifying names are ranked by their TRAILING mean
    `avg_dollar_vol` (mean over the in-window months) DESC, ties broken by symbol
    ASC; the top `top_n` symbols are returned.

    Uses ONLY months <= ym — strictly point-in-time, NO future data. This is the
    survivorship/lookahead-safe replacement for ranking by PEAK full-history
    dollar-volume: at a 1995 rebalance a name we only know became liquid by 2020 is
    invisible, because its 2020 months are > the 1995 `ym`. Deterministic.

    `monthly` rows are `(symbol, ym, avg_dv, bars, avg_close)` (one per
    (symbol, month)); they need not be pre-sorted.
    """
    by_symbol: dict[str, dict[int, tuple[float, int, float]]] = {}
    for sym, ym, avg_dv, bars, avg_close in monthly:
        by_symbol.setdefault(sym, {})[ym] = (avg_dv, bars, avg_close)

    out: dict[int, list[str]] = {}
    for rebal in rebalance_yms:
        lo = rebal - window_months  # exclusive lower bound; window is (lo, rebal]
        ranked: list[tuple[float, str]] = []
        for sym, months in by_symbol.items():
            in_window = [(m, months[m]) for m in months if lo < m <= rebal]
            if not in_window:
                continue
            total_bars = sum(v[1] for _, v in in_window)
            if total_bars < min_bars:
                continue
            latest_m = max(m for m, _ in in_window)
            latest_close = months[latest_m][2]
            if latest_close < min_price:
                continue
            trailing_dv = sum(v[0] for _, v in in_window) / len(in_window)
            ranked.append((trailing_dv, sym))
        # rank by trailing dv DESC, tiebreak symbol ASC (negate dv for a stable
        # single-key ascending sort that is deterministic on ties)
        ranked.sort(key=lambda t: (-t[0], t[1]))
        out[rebal] = [sym for _dv, sym in ranked[:top_n]]
    return out


def month_end_rebalance_dates(dates: list[str]) -> list[str]:
    """Given ascending ISO trading dates, return the last available trading date
    within each calendar (year, month) — the monthly rebalance schedule."""
    last_by_month: dict[tuple[int, int], str] = {}
    for d in dates:
        y, m, _ = d.split("-")
        last_by_month[(int(y), int(m))] = d
    return [last_by_month[k] for k in sorted(last_by_month)]


def daily_returns_for_weights(
    weights: dict[str, float],
    closes: dict[str, dict[int, float]],
    days: list[int],
    borrow_daily: float,
    min_day_ret: float = -0.9,
    max_day_ret: float = 10.0,
    delist_day: dict[str, int] | None = None,
    delisting_return: float = 0.0,
) -> list[float]:
    """Daily book returns of fixed `weights` over consecutive `days` (epoch-days,
    ascending). For each adjacent (prev, cur) pair, both prices must exist for a
    symbol to contribute; a step with no usable price pair is dropped (not 0.0).

    Missing-price hold-flat convention: when a name lacks a price on either `prev`
    or `cur`, it is dropped from that day's contribution — its weight effectively
    earns 0 for the day. The remaining names KEEP their absolute weights (the book
    is NOT renormalized to sum to 1 over the surviving names). This is a deliberate
    "held-but-unpriced => flat for that day" convention; renormalizing would
    introduce its own bias by silently rescaling exposure.

    Per-day return sanity guard: a name's single-day return `cur/prev - 1` must lie
    within `[min_day_ret, max_day_ret]` (default [-90%, +1000%]) to contribute.
    A present-but-implausible ratio (e.g. a NASDAQ test ticker ramping 0.0001 ->
    200000, or a bad tick / unhandled corporate action) is a DATA ERROR, not a real
    move, and is treated EXACTLY like a missing price: the offending name is dropped
    from that day's contribution (hold-flat), the surviving names keep their absolute
    weights, and the step is skipped only if no name survives. Real equity daily
    moves essentially never exceed +1000% or -90%, so this bound removes data errors
    without discarding any genuine return — it makes the gauntlet verdict robust to
    junk prices that would otherwise blow `ann_vol` to millions of percent.

    Delisting terminal-return booking: a name that delists mid-period stops having
    prices. WITHOUT `delist_day` this is held flat (the historical bias — delisted
    losers never realize their loss, inflating a long-only book). WITH `delist_day`
    (a {symbol: epoch-day-of-its-LAST-price} map the CALLER builds only for genuine
    delistings, NOT dataset-end terminations), when a held name `s` has a price at
    `prev` but NOT at `cur` and `delist_day.get(s) == prev` (i.e. `prev` is its last
    bar), a ONE-TIME terminal return `weights[s] * delisting_return` is booked on
    THAT step and added to the step's `book_return(...)` of the still-priced names.
    `delisting_return` defaults to 0.0 (== the legacy hold-flat behavior). The
    `weights[s] * delisting_return` product handles BOTH directions: a long
    (weight>0) with delisting_return=-0.30 books a loss; a short (weight<0) books a
    gain (shorts profit when a name craters). The name contributes nothing on
    subsequent steps (it has no further prices and no further delist_day match). If a
    step has NO priced names but DOES have terminal delisting contributions, that
    step is still emitted (= sum of the terminal contributions); only steps with
    neither a priced pair nor a terminal contribution are dropped."""
    delist_day = delist_day or {}
    out: list[float] = []
    for prev, cur in zip(days, days[1:]):
        prev_p: dict[str, float] = {}
        cur_p: dict[str, float] = {}
        terminal = 0.0
        has_terminal = False
        for s in weights:
            if s not in closes or prev not in closes[s]:
                continue
            if cur not in closes[s]:
                # name has a price at prev but not at cur: terminus or gap.
                if delist_day.get(s) == prev:
                    # prev is this name's delisting day -> book terminal return once.
                    terminal += weights[s] * delisting_return
                    has_terminal = True
                continue                       # else hold flat (gap or dataset-end)
            p0, p1 = closes[s][prev], closes[s][cur]
            if p0 <= 0:                       # non-positive prior price: cannot form a ratio
                continue
            day_ret = p1 / p0 - 1.0
            if day_ret < min_day_ret or day_ret > max_day_ret:
                continue                       # implausible -> hold flat (drop this name today)
            prev_p[s] = p0
            cur_p[s] = p1
        if not prev_p:
            if has_terminal:
                out.append(terminal)           # only terminal contributions this step
            continue
        w = {s: weights[s] for s in prev_p}
        out.append(book_return(w, prev_p, cur_p, borrow_daily=borrow_daily) + terminal)
    return out
