"""Dashboard read-model contract + view-models. The web adapter depends on this protocol, not on the
concrete service, so the rendering layer stays decoupled from persistence."""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Protocol

from fxhnt.application.exec_reconcile import ReconResult
from fxhnt.application.forward_models import ForwardNavRow
from fxhnt.application.periods import PeriodPoint


@dataclass(frozen=True, slots=True)
class FleetRow:
    strategy_id: str
    display_name: str
    sleeve: str
    days: int
    total_return_pct: float
    sharpe: float
    maxdd: float
    gate_status: str
    gate_reason: str
    as_of: str
    # historical-backtest verdict; only the equity-factor constructions have one. "no backtest" renders
    # as bt_status="" with bt_* numerics None (template shows an em-dash).
    bt_status: str = ""                 # "PASS" | "FAIL" | "" (none)
    bt_sharpe: float | None = None
    bt_dsr: float | None = None
    bt_cagr: float | None = None
    bt_maxdd: float | None = None
    bt_as_of: str = ""
    # honest deploy-readiness flag from the registry: e.g. status="not-live" + note explaining why
    # (cross-venue carry needs Bybit+Deribit execution). Empty for ordinary live tracks.
    status: str = ""
    note: str = ""
    # True when forward history is shorter than the gate's min_days — the forward ret/Sharpe/maxDD are a
    # tiny-sample artifact (e.g. Sharpe 22.96 on 4 days), so the cockpit DIMS them until the window fills.
    warming: bool = False
    # The gate's forward-window target (min_forward_days / min_days). Shown as the "days/min" denominator in
    # the forward column; 0 when the strategy has no gate window.
    gate_min_days: int = 0
    # registry SSOT metadata used by the Overview to group + badge: `venue` (e.g. "bybit-perp"),
    # `tier` ("deploy" = the live fund, "research" = not the fund), `execution` ("paper" | "live").
    venue: str = ""
    tier: str = "research"
    execution: str = "paper"
    # True when the track is NOT on an active execution venue (Bybit deploy / IBKR equities) — e.g. the
    # legacy-venue discovery/reference tracks + the 60/40 benchmark. Reference-only: observed, never traded live.
    reference_only: bool = False
    # Runtime HEALTH axis (forward_health), SEPARATE from the gate: "HEALTHY" | "STALE" | "NO_REF" | "BROKEN".
    # Anything but HEALTHY renders a LOUD red badge — a stale/broken track can never hide as a quiet WAIT.
    health: str = "HEALTHY"
    health_stale_days: int = 0
    health_reason: str = ""
    # B2a allocation (None when no strategy_allocation row).
    alloc_dollars: float | None = None
    alloc_weight: float | None = None
    alloc_policy_version: int | None = None
    # B3 funding health ("" when no strategy_funding row).
    funding_state: str = ""          # "" | "funded" | "de-funded"
    funding_label: str = ""          # "" | "funded" | "decaying" | "de-funded"
    funding_decay_run: int = 0
    funding_recovery_run: int = 0
    funding_first_pass_date: str | None = None
    funding_policy_version: int | None = None
    # C1/C2 executed twin (set only on a MODELED track that has an executed twin with forward data).
    exec_twin_sid: str = ""
    exec_return_pct: float | None = None
    exec_divergence_pct: float | None = None
    # D3 (Task 4) — the REAL IBKR paper-account "real trades" signal: "EXEC" only for the IBKR real-
    # account book (multistrat) once real fills have been captured (`exec_vs_sim(...).has_exec`); ""
    # (with `recon=None`) for every other strategy AND for an IBKR book with no captured data yet — the
    # cockpit's Overview sub-row only ever renders for a genuinely-executing row, never a fabricated one.
    exec_status: str = ""
    recon: ReconResult | None = None
    exec_trade_count: int = 0       # len(exec_fills) tagged this strategy — the "N trades" half of the sub-row
    exec_holdings_count: int = 0    # len(latest_positions()) — the account-wide "M holdings" half


@dataclass(frozen=True, slots=True)
class IbkrAccountRollup:
    """D3 (Task 4) account-wide rollup across the REAL IBKR paper-account books — derived by REUSING each
    fleet row's already-computed `recon` (no duplicate `exec_vs_sim` math), never its own read. NOT rendered
    as its own card on the Overview (Task 5's consolidated Paper-books page is the account home); carried on
    `OverviewModel` so that page can reuse it without recomputing."""
    total_nav: float           # the account's latest ibkr_account_nav.nlv
    n_executing: int           # count of fleet rows with exec_status == "EXEC"
    agg_divergence: float      # mean of the executing rows' recon.divergence; 0.0 when none are executing


@dataclass(frozen=True, slots=True)
class StratExecRow:
    """D4 (Task 5) — one row of the REAL IBKR paper account's per-strategy breakdown: a strategy registered
    against the shared account (`_IBKR_ACCOUNT_SIDS`). `executing=True` (with `recon`) is a strategy whose
    exec twin has captured real data — the template renders its real return + `plan_pill`; `executing=False`
    (`recon=None`) is a registered-but-not-yet-trading strategy — the template dims it as "not started yet".
    The table SCALES BY ROW: adding a strategy id to the account set adds one more row here, never a
    redesign — proven by the scale test seeding a synthetic second executing strategy."""
    strategy_id: str
    display_name: str
    executing: bool
    recon: ReconResult | None = None
    position_count: int = 0     # distinct symbols in this strategy's own tagged exec_fills
    fill_count: int = 0         # len(exec_fills) tagged this strategy


@dataclass(frozen=True, slots=True)
class IbkrAccountView:
    """D4 (Task 5) — the REAL IBKR paper account's account-wide view, produced by
    `DashboardService.ibkr_account_view()`. IBKR is ONE physical account shared by every strategy trading on
    it, so `total_nav_series`/`all_positions` are account-wide (not per-strategy) — only `per_strategy` is
    scoped by strategy-tagged `exec_fills`. Backs BOTH the consolidated /paper landing card (account value /
    N strategies trading / mini curve) and the account detail page's per-strategy breakdown table. None when
    no `IbkrAccountRepo` is wired or the account has no NAV snapshot yet (mirrors the existing
    `IbkrAccountRollup` guard) — the caller always renders a clean empty state, never a 500."""
    total_nav_series: list[tuple[str, float]]
    all_positions: dict[str, float]
    recent_fills: list[dict[str, Any]]
    per_strategy: list[StratExecRow]


@dataclass(frozen=True, slots=True)
class OverviewModel:
    """At-a-glance fund state for the home page. Composed from the same fleet rows + registry the other
    routes use — nothing new is computed here. `headline` is the live combined book (the deploy-venue
    forward track); `edges` are the per-sleeve trackers (the headline + the passive benchmark removed)."""
    headline: FleetRow | None             # the combined forward book (bybit_4edge, else combined)
    headline_t0: str                      # first forward day (ISO); "" if no history yet
    headline_gate_min_days: int           # gate_spec min_days for the headline book
    headline_days_remaining: int          # max(0, min_days - days); 0 once the gate window is reached
    edges: list[FleetRow]                 # the individual edges/sleeves in the book
    gross_deployed: float = 0.0
    ceiling: float = 0.0
    n_funded: int = 0
    n_defunded: int = 0
    # D3 (Task 4) — None when no IBKR repo is wired or the account has no NAV snapshot yet.
    ibkr_rollup: IbkrAccountRollup | None = None


@dataclass(frozen=True, slots=True)
class StrategyDetail:
    strategy_id: str
    display_name: str
    sleeve: str
    venue: str
    days: int
    total_return_pct: float
    sharpe: float
    maxdd: float
    gate_status: str
    gate_reason: str
    as_of: str
    history: list[ForwardNavRow]
    periods: dict[str, list[PeriodPoint]]   # raw per-period rolling means: keys "daily","weekly","monthly"
    # historical-backtest verdict; None when the strategy has no backtest row (most do not).
    bt_status: str = ""                 # "PASS" | "FAIL" | "" (none)
    bt_cagr: float | None = None
    bt_ann_vol: float | None = None
    bt_sharpe: float | None = None
    bt_maxdd: float | None = None
    bt_dsr: float | None = None
    bt_oos_sharpe: float | None = None
    bt_as_of: str = ""
    # honest deploy-readiness flag from the registry (e.g. status="not-live" + note explaining why).
    status: str = ""
    note: str = ""
    # B2a allocation (None when no strategy_allocation row).
    alloc_dollars: float | None = None
    alloc_weight: float | None = None
    alloc_policy_version: int | None = None
    # B3 funding health ("" when no strategy_funding row).
    funding_state: str = ""          # "" | "funded" | "de-funded"
    funding_label: str = ""          # "" | "funded" | "decaying" | "de-funded"
    funding_decay_run: int = 0
    funding_recovery_run: int = 0
    funding_first_pass_date: str | None = None
    funding_policy_version: int | None = None
    # Runtime HEALTH axis (forward_health), SEPARATE from the gate. See FleetRow.health.
    health: str = "HEALTHY"
    health_stale_days: int = 0
    health_reason: str = ""
    # C1/C2 executed twin (set only on a MODELED track that has an executed twin with forward data).
    exec_twin_sid: str = ""
    exec_return_pct: float | None = None
    exec_divergence_pct: float | None = None
    exec_venue: str | None = None
    # D2.2/2.3 (Task 3) — the REAL IBKR paper-account section: what the book is holding right now, its
    # recent trades, and the account's raw NLV series (for the value-over-time overlay curve). Empty for
    # every strategy that isn't the IBKR real-account book (multistrat) or when no account data
    # has been captured yet — the template renders "no trades recorded yet", never a 500.
    holdings: list[dict[str, Any]] = field(default_factory=list)
    # Held symbols outside the book's allowed universe (US + UCITS) — a legacy/stray position (e.g. an IBIT
    # leftover) the holdings view WARNS about, so it never inflates the account NLV unnoticed. [] = clean.
    stray_holdings: list[str] = field(default_factory=list)
    recent_trades: list[dict[str, Any]] = field(default_factory=list)
    account_nav: list[tuple[str, float]] = field(default_factory=list)
    # THE shared "does the real book track the plan" signal (`exec_reconcile.exec_vs_sim`). None for
    # strategies with no real IBKR account behind them.
    recon: ReconResult | None = None
    # D4 (Task 5) — the account's per-strategy breakdown (one row per strategy trading on the shared IBKR
    # account); [] for every strategy that isn't one of the IBKR real-account books.
    per_strategy: list[StratExecRow] = field(default_factory=list)


class DashboardReadModel(Protocol):
    def fleet(self) -> list[FleetRow]: ...
    def detail(self, strategy_id: str) -> StrategyDetail | None: ...
    def overview(self) -> OverviewModel: ...
    def exec_twin_sids(self) -> set[str]: ...
    def paper_books(self) -> dict[str, Any]: ...
    def ibkr_account_view(self) -> IbkrAccountView | None: ...
