"""Equity allocation-inputs provider: multistrat's cost-netted sizing series + real-ADV capacity ceiling,
merged into `record_allocation` — the equity analog of `allocation_honest_inputs.honest_allocation_inputs`.

STEP-1 FINDING (multistrat shapes, read before touching this file):
  * `multistrat.book_series(closes, instruments, drop_incomplete_today=...)` returns
    `list[tuple[str, float]]` — ISO-DATE-STRING keyed (the same "YYYY-MM-DD" keys as the forward-nav record,
    since both derive from the Yahoo PIT close dict's own keys). `dict(book_series(...))` is a direct,
    zero-adaptation `book_ret_by_day`.
  * `multistrat.target_weights(closes, instruments, drop_incomplete_today=...)` returns ONLY the LATEST
    booked day's `{instrument: weight}` snapshot — the live position vector used to place today's orders
    (see its docstring and `cli.py`'s multistrat exec command). There is NO per-day weights HISTORY exposed.
    `_weights_by_day` below reconstructs one by REPLAYING `target_weights` on the closes truncated to each
    day in turn — exactly what the live exec path already does once for "today", just repeated for every
    historical day. multistrat.py is out of scope for this task (Task 3 only touches this file +
    allocation_ingest.py), so the replay lives here rather than as a new multistrat.py function.

CEILING RULE — `first_crossing_ceiling`, NOT `allocation_engine.capacity_ceiling_from_curve`: the equity
`equity_honest_returns` capacity approximation (`scale_hit`, a book-level, not per-symbol, attribution — see
`equity_honest.py`'s docstring) can make the net-Sharpe-vs-AUM curve NON-MONOTONE once capacity saturates at
extreme AUM (Sharpe dips below `min_sharpe`, then can tick back UP as the book scales fully out into deep
saturation, where cost shrinks alongside the collapsing position). `capacity_ceiling_from_curve`'s
largest-viable-AUM rule would then OVER-STATE the ceiling by picking that later, spuriously-viable AUM.
`first_crossing_ceiling` instead stops at the FIRST grid AUM (ascending) that drops below `min_sharpe`.

Pure w.r.t. computation; the only I/O is the repo's PIT reads (`yahoo_pit_closes`/`yahoo_pit_volumes`),
read-only, no writes.

TASK 2 (2026-07-16 UCITS re-base plan): `regime` sources each sleeve's ADV + spread from the instrument the
EU account ACTUALLY trades: `"us"` (post-MiFID-II professional, deep US-ETF liquidity) uses the US ticker's
own `yahoo_pit_volumes(sym)` + `ETF_HALF_SPREAD_BPS` (via `equity_honest._spread_bps`'s internal fallback --
`spread_bps=None` is byte-identical to the pre-Task-2 lookup); `"ucits"` (retail-EU DEFAULT) uses the
UCITS-mapped ticker's `yahoo_pit_volumes(ucits_map[sym].yahoo_ticker)` + that line's `half_spread_bps`.
Returns (`book_series`/`target_weights`) are UNCHANGED in both regimes -- only `adv_usd`/`spread_bps` differ.

Documented approximation (ucits regime): `dollar_adv` needs price x volume; the VOLUME comes from the UCITS
line, but the PRICE proxy stays the US close (`closes[sym]`, i.e. `repo.yahoo_pit_closes(sym)`) -- the
USD-denominated UCITS lines track the US underlying's USD price closely, so `adv_usd` ends up keyed by the
US sleeve `sym` with the UCITS ticker's volume. This is a small basis approximation, acceptable for a
capacity ESTIMATE; a follow-on could fetch the real UCITS price. A missing/thin UCITS line (empty volume,
e.g. a not-yet-backfilled UCITS listing) still flows through `dollar_adv` -> an empty/zero ADV series for
that symbol -> `_tradeable_weight_fraction` correctly discounts JUST that sleeve's book-weight share (no
fabricated capacity for the dead sleeve, via `equity_honest_returns`'s participation cap), rather than
zeroing the whole book — the guard below only fires when there's essentially NOTHING tradeable."""
from __future__ import annotations

from typing import Any

from fxhnt.application.equity_honest import dollar_adv, equity_capacity_curve
from fxhnt.application.honest_gates import sharpe_of
from fxhnt.config import get_settings
from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS, book_series, target_weights

# DEGENERATE guard, NOT a functional cap: below this fraction of average tradeable GROSS WEIGHT, we treat
# `yahoo_pit_volumes` as effectively empty/pre-backfill (or a nightly volume snapshot stuck swallow-and-warn
# failing) and fall back to nav rather than trust the series at all. A book with ONE thin/dead sleeve (e.g.
# DBMF ~20% of weight) is still ~0.8 tradeable and correctly EMITS -- `equity_honest_returns`'s per-sleeve
# participation cap already scales that dead sleeve down to ~0 ("use as much as there is"); this guard only
# catches the case where there's essentially NOTHING tradeable (empty/near-empty volume store), not a single
# thin line dragging down an otherwise-liquid book.
_MIN_TRADEABLE_WEIGHT = 0.5


def first_crossing_ceiling(curve: dict[float, float], min_sharpe: float) -> float | None:
    """The largest AUM such that EVERY grid AUM <= it is viable (Sharpe >= min_sharpe) — i.e. stop at the
    FIRST AUM (ascending) that drops below `min_sharpe`, rather than `capacity_ceiling_from_curve`'s
    largest-viable-AUM rule (see module docstring for why). `None` only for an EMPTY curve (no capacity
    information at all — the caller/`target_capital` then treats it as unbounded, same convention as
    `capacity_ceiling_from_curve`). A curve that is never viable returns 0.0 (zero capacity), not None."""
    if not curve:
        return None
    ceiling = 0.0
    for aum in sorted(curve):
        if curve[aum] < min_sharpe:
            break
        ceiling = aum
    return ceiling


def _tradeable_weight_fraction(weights_by_day: dict[str, dict[str, float]],
                               adv_usd: dict[str, dict[str, float]]) -> float:
    """Average, over TRADED book days (rows with any non-zero weight), of the FRACTION OF GROSS BOOK WEIGHT
    that is tradeable that day: `sum(|w| for symbols with positive dollar-ADV that day) / sum(|w| for every
    symbol held that day)`. A day with no (or all-zero) weights is skipped -- pre-warmup, doesn't count either
    way. No traded days at all -> 0.0 (treat as insufficient, the safe default) rather than vacuously 1.0.

    This is a WEIGHT-based metric, not the prior all-or-nothing "every held symbol must be covered" check:
    one thin/dead sleeve (e.g. DBMF at ~20% of book weight) drags this to ~0.8, not to 0 -- see
    `_MIN_TRADEABLE_WEIGHT`'s docstring for why that's the correct "use as much as there is" behavior."""
    fractions: list[float] = []
    for d, day_weights in weights_by_day.items():
        gross = sum(abs(w) for w in day_weights.values())
        if gross <= 0.0:
            continue
        tradeable = sum(abs(w) for sym, w in day_weights.items()
                        if adv_usd.get(sym, {}).get(d, 0.0) > 0.0)
        fractions.append(tradeable / gross)
    if not fractions:
        return 0.0
    return sum(fractions) / len(fractions)


def _weights_by_day(closes: dict[str, dict[str, float]],
                    instruments: list[str]) -> dict[str, dict[str, float]]:
    """Reconstruct the per-day weights HISTORY `target_weights` doesn't expose (see the Step-1 finding above)
    by replaying it on the closes truncated to each day in turn. O(N^2) day-by-day (each replay itself is
    O(N)) — ACCEPTABLE: multistrat's live PIT-close history is short (weeks/months, not years), and this
    runs once per nightly `record_allocation`, not on any hot path."""
    dates = sorted(set.intersection(*(set(closes[s]) for s in instruments))) if instruments else []
    out: dict[str, dict[str, float]] = {}
    for i, d in enumerate(dates):
        truncated = {s: {dd: closes[s][dd] for dd in dates[: i + 1]} for s in instruments}
        w = target_weights(truncated, instruments, drop_incomplete_today=False)
        if w:
            out[d] = w
    return out


def equity_allocation_inputs(
    repo: Any, deploy_sids: set[str], *, target_aum: float, aums: list[float],
    participation_cap: float = 0.03, impact_k: float = 1.0, min_sharpe: float = 1.0,
    regime: str = "ucits", ucits_map: dict[str, Any] | None = None,
    us_spread_bps: dict[str, float] | None = None, ucits_spread_bps: dict[str, float] | None = None,
    volume_source: str = "yahoo",
) -> tuple[dict[str, dict[str, float]], dict[str, float | None]]:
    """`(returns_by_strategy, capacity_ceiling_by_strategy)` for the `ibkr-equity` deploy sids — the
    honest-reference sizing analog of `honest_allocation_inputs` for the crypto sids.

    Today only `multistrat` qualifies (registry `venue == "ibkr-equity"`), and the model (FUND_INSTRUMENTS +
    `book_series`/`target_weights`) is the same for any sid passed here — there is only ever one equity book.
    `drop_incomplete_today=False` matches the nightly `multistrat_nav` asset's convention: this provider
    reads PIT-frozen closes (today's close, if present, is already final), so today should be BOOKED, not
    dropped as an intraday-incomplete bar.

    Absent/short PIT data (no closes, or too little history for book_series/target_weights' ~66-day warmup)
    -> no series for ANY sid (the caller falls back to the sid's own nav record), ceiling `None`.

    `regime` (TASK 2, 2026-07-16 UCITS re-base plan): `"us"` sources ADV from each sleeve's OWN
    `yahoo_pit_volumes(sym)` + spread from `us_spread_bps` (`None` -> `ETF_HALF_SPREAD_BPS` via
    `equity_honest`'s internal fallback — BYTE-IDENTICAL to the pre-Task-2 model). `"ucits"` (the settings
    default) sources ADV from the UCITS-mapped ticker's volume (US close as the price proxy — see module
    docstring) + spread from that line's `half_spread_bps` (or `ucits_spread_bps` override). `ucits_map`
    defaults to `get_settings().ucits.map` when `None` (kept injectable for tests). A missing/thin UCITS
    line still flows through the SAME `_tradeable_weight_fraction` guard below — it does not get its own
    special case, but (being weight-based, not all-or-nothing) a single thin/dead sleeve no longer drops the
    whole book — see `_MIN_TRADEABLE_WEIGHT`'s docstring.

    `volume_source` (TASK 2, 2026-07-16 IBKR UCITS volume source plan): which STORE the `"ucits"` regime's
    ADV reads from — `"yahoo"` (the default, byte-identical to the pre-this-task behavior) reads
    `repo.yahoo_pit_volumes(resolved_map[sym].yahoo_ticker)`; `"ibkr"` reads
    `repo.ibkr_pit_volumes(resolved_map[sym].yahoo_ticker)` instead (real on-exchange volume, fetched by the
    `backfill-ucits-volume-ibkr` CLI in the rebalancer context). Keyed by the SAME UCITS `yahoo_ticker`
    string in both stores (see the plan's Global Constraints) — only the table differs. Has no effect in the
    `"us"` regime, which always reads `repo.yahoo_pit_volumes(sym)` (Databento is a separate, out-of-scope
    future US source)."""
    if regime not in ("ucits", "us"):
        # Fail loud: `regime` is a plain string, so a typo ("US", "UCITS", "") would otherwise silently fall
        # into the `else` branch below (the aggressive US-liquidity model) — dangerous in the EU retail
        # default. Never silently pick the aggressive `us` model for an unrecognized value.
        raise ValueError(f"unknown equity_execution_regime: {regime!r}")
    if volume_source not in ("yahoo", "ibkr"):
        # Same fail-loud discipline as `regime` above: a typo must not silently pick either read path.
        raise ValueError(f"unknown ucits_volume_source: {volume_source!r}")
    if not deploy_sids:
        return {}, {}
    # Load-once: closes/volumes/book/weights/adv are AUM-INDEPENDENT — computed ONCE, then repriced per AUM
    # by equity_capacity_curve (mirrors the crypto provider's load-once/reprice discipline).
    closes = {sym: repo.yahoo_pit_closes(sym) for sym in FUND_INSTRUMENTS}
    book_ret_by_day = dict(book_series(closes, FUND_INSTRUMENTS, drop_incomplete_today=False))

    if not book_ret_by_day:
        return {}, {sid: None for sid in deploy_sids}

    weights_by_day = _weights_by_day(closes, FUND_INSTRUMENTS)

    if regime == "ucits":
        resolved_map = ucits_map if ucits_map is not None else get_settings().ucits.map
        # adv_usd keyed by the US sleeve `sym`, VOLUME from the UCITS-mapped ticker, PRICE from the US close
        # (documented small-basis approximation — see module docstring). `volume_source` picks WHICH store
        # that volume comes from — the ibkr table mirrors the yahoo one exactly (same yahoo_ticker key).
        vol_read = repo.ibkr_pit_volumes if volume_source == "ibkr" else repo.yahoo_pit_volumes
        ucits_volumes = {sym: vol_read(resolved_map[sym].yahoo_ticker)
                         for sym in FUND_INSTRUMENTS if sym in resolved_map}
        adv = dollar_adv(closes, ucits_volumes)
        spread = (ucits_spread_bps if ucits_spread_bps is not None
                  else {sym: resolved_map[sym].half_spread_bps for sym in FUND_INSTRUMENTS if sym in resolved_map})
    else:  # "us" — byte-identical to the pre-Task-2 model
        volumes = {sym: repo.yahoo_pit_volumes(sym) for sym in FUND_INSTRUMENTS}
        adv = dollar_adv(closes, volumes)
        # None -> equity_honest_returns/_spread_bps falls back to the module-constant ETF_HALF_SPREAD_BPS,
        # the EXACT pre-Task-2 lookup (see equity_honest.py). Do NOT build a dict here even when it would be
        # equivalent — passing None preserves the identical code path, not just an equivalent one.
        spread = us_spread_bps

    if _tradeable_weight_fraction(weights_by_day, adv) < _MIN_TRADEABLE_WEIGHT:
        # Degenerate (empty/near-empty) volume store, any regime -> don't emit a garbage near-zero-capacity
        # series; fall back to nav. A single thin/dead sleeve does NOT trip this -- see the guard's docstring.
        return {}, {sid: None for sid in deploy_sids}

    all_aums = sorted({float(target_aum), *(float(a) for a in aums)})
    curve = equity_capacity_curve(book_ret_by_day, weights_by_day, adv, aums=all_aums,
                                  participation_cap=participation_cap, impact_k=impact_k,
                                  price_by_day=closes, spread_bps=spread)
    base = curve.get(float(target_aum), {})
    # SAME annualized Sharpe the crypto path uses (`allocation_honest_inputs.py`'s `sharpe_of`), NOT the raw
    # per-period Sharpe `min_sharpe` (= `pol.capacity_min_sharpe`, on the annualized x sqrt(365) scale) was
    # wrongly compared against before -- see module docstring / whole-branch-review fix.
    sharpe_curve = {float(aum): sharpe_of(curve.get(float(aum), {})) for aum in aums}
    ceiling = first_crossing_ceiling(sharpe_curve, min_sharpe)

    returns_by_strategy = {sid: dict(base) for sid in deploy_sids} if base else {}
    ceilings = {sid: ceiling for sid in deploy_sids}
    return returns_by_strategy, ceilings


__all__ = ["equity_allocation_inputs", "first_crossing_ceiling"]
