"""Runtime staleness/health axis — the anti-silent-rot backbone.

WHY: a dead/archived strategy was once silently broken for 2 weeks because a data failure degraded
QUIETLY — it recomputed off a frozen-but-stale `xsp_option_bars` table, its forward "updated" every night,
and only a log line marked the rot. The gate never noticed (a stale track just sits as a quiet WAIT). This
module adds a HEALTH axis that is
SEPARATE from the gate: a track can be legitimately WAIT/building AND healthy; STALE/NO_REF/BROKEN is
orthogonal. Any live/gated track that stops updating surfaces LOUDLY on day 1 (red cockpit badge + ERROR log),
not a footnote two weeks later.

The health-check line is `record_mode`, which faithfully encodes "is this track EXPECTED to advance nightly":
  - `recompute` tracks (sixtyforty/xsfunding/unlock/multistrat/bybit_4edge/…): the engine recomputes from
    fresh source data EVERY run, so a non-advancing `as_of` (or a frozen freeze table) is a real STALE fault.
    A `recompute` track lives here → an incident like the one above is caught.
  - `observed` tracks (the executed twins multistrat_exec/bybit_4edge_exec): advance ONLY when a
    human/executor books a fill, so they are legitimately DORMANT when execution is suspended (an executed
    twin can lack IBKR options permission, for example). Staleness/no-ref do NOT apply — they must not
    false-alarm. This is the
    "intentionally-dormant reference_only" carve-out. (BROKEN — a health-compute error — still applies, since
    a track whose health we cannot even determine is not healthy.)

States (worst wins, precedence BROKEN > STALE > NO_REF > HEALTHY):
  - HEALTHY — advancing within threshold, or dormant-by-design, or a GENUINELY new recompute track (its active
    `forward_anchor.t0` is within the threshold, so "no data yet" is expected, not rot).
  - STALE(n) — a recompute track's source `as_of` OR its freeze table's max date is > threshold business days
    behind the run date. The freeze axis catches the exact failure mode described above: forward "updates"
    yet the frozen input table is stale. Also STALE (not the HEALTHY "no data yet" carve-out): a recompute track with ZERO
    forward data whose anchor `t0` is OLDER than the threshold (broken-from-inception — never advanced since
    it was created), or which has no active anchor at all (should have one; a live track with no anchor is a
    real signal, never silently HEALTHY).
  - NO_REF — a reconciliation-gated recompute track with NO resolvable backtest reference: the gate can NEVER
    reconcile (it silently degrades to the min-window/clean-exec checks only). The M6 static invariant that
    missed the incident described above, made a real runtime check.
  - BROKEN — the PER-TRACK health computation raised (e.g. a malformed registry entry for that strategy):
    surfaced for that track only, not swallowed. This does NOT cover the repo-wide reads `evaluate_health`
    does once up front (`migrate`/`all_summaries`/`all_backtest_summaries`/`xsp_freeze_max_date`) — those are
    hoisted OUTSIDE the per-track try block, so a failure there raises out of `evaluate_health` entirely and
    fails the whole `cockpit_forward` Dagster asset (loud, but not a per-track BROKEN row).
"""
from __future__ import annotations

import datetime as dt
import logging
from dataclasses import dataclass
from typing import Any

import numpy as np

from fxhnt.registry import STRATEGY_REGISTRY

_log = logging.getLogger(__name__)

HEALTHY = "HEALTHY"
STALE = "STALE"
NO_REF = "NO_REF"
BROKEN = "BROKEN"

# freeze-based sources → the table whose max date bounds the recompute. Extend as new freeze substrates land.
_FREEZE_SOURCES = frozenset({"opra-pit"})


@dataclass(frozen=True, slots=True)
class HealthVerdict:
    strategy_id: str
    health: str          # HEALTHY | STALE | NO_REF | BROKEN
    stale_days: int      # business days stale (0 unless STALE)
    reason: str

    @property
    def ok(self) -> bool:
        return self.health == HEALTHY


def _busdays(since: str | None, run_date: dt.date) -> int:
    """Business days elapsed between `since` (ISO yyyy-mm-dd) and `run_date`, exclusive of `run_date`'s open
    interval semantics (numpy `busday_count` counts [begin, end)). 0 when `since` is absent or in the future."""
    if not since:
        return 0
    return max(0, int(np.busday_count(dt.date.fromisoformat(since), run_date)))


def compute_health(strategy_id: str, *, record_mode: str, is_reconciliation: bool, as_of: str | None,
                   ref_available: bool, freeze_max_date: str | None, run_date: dt.date,
                   threshold: int, anchor_t0: str | None) -> HealthVerdict:
    """PURE health verdict for one track (no I/O — trivially unit-testable). See module docstring for the
    record_mode carve-out and the state precedence.

    `anchor_t0` is the track's active `forward_anchor.t0` (None if it has no active anchor). It disambiguates
    the "zero forward data" case: a track with NO data because it was JUST inceptioned (t0 within threshold)
    is legitimately HEALTHY/building, but a track with NO data whose anchor is OLDER than the threshold — or
    which has no anchor at all — has never advanced since inception and is silently rotten, not new."""
    if record_mode != "recompute":
        # observed / externally-driven → dormant-tolerant (never staleness/no-ref alarmed).
        return HealthVerdict(strategy_id, HEALTHY, 0, "observed track — externally driven (dormant-tolerant)")

    data_stale = _busdays(as_of, run_date) if as_of else 0
    freeze_stale = _busdays(freeze_max_date, run_date) if freeze_max_date else 0
    stale_days = max(data_stale, freeze_stale)
    has_data = bool(as_of) or freeze_max_date is not None
    if has_data and stale_days > threshold:
        # name the binding axis so the operator sees WHY (source-data vs frozen-input freeze).
        if freeze_max_date is not None and freeze_stale >= data_stale:
            reason = f"freeze table stale {stale_days} business days (xsp_option_bars max {freeze_max_date})"
        else:
            reason = f"source data stale {stale_days} business days (as_of {as_of})"
        return HealthVerdict(strategy_id, STALE, stale_days, reason)

    if not has_data:
        # Zero forward data AND (freeze-based tracks) an empty freeze table. Only a track inceptioned
        # WITHIN the threshold gets the legitimate "still building" carve-out below — an old anchor with
        # zero data since inception, or no anchor at all, is the silent-rot hole a reviewer found: closed
        # here rather than falling through to a false HEALTHY.
        if anchor_t0 is None:
            return HealthVerdict(strategy_id, STALE, 0,
                                 "no forward anchor for a live recompute track with zero forward data — "
                                 "track was never (or is no longer) inceptioned")
        anchor_age = _busdays(anchor_t0, run_date)
        if anchor_age > threshold:
            return HealthVerdict(strategy_id, STALE, anchor_age,
                                 f"no forward data since inception t0={anchor_t0} ({anchor_age} business "
                                 "days) — track has never advanced")

    if is_reconciliation and not ref_available:
        return HealthVerdict(strategy_id, NO_REF, 0,
                             "reconciliation gate has no backtest reference — can never reconcile")

    return HealthVerdict(strategy_id, HEALTHY, stale_days, "advancing within threshold")


def evaluate_health(dsn: str, at: dt.datetime | None = None) -> list[HealthVerdict]:
    """Compute + persist the health verdict for every registry track, then log LOUDLY (ERROR) if any track is
    unhealthy so it shows in Dagster + pod logs the FIRST night a track goes stale. Called by the
    `cockpit_forward` nightly asset AFTER `ingest_forward_state` (so the summaries it reads are fresh).
    Idempotent: migrate() is a no-op if tables exist; the health snapshot is replaced each run."""
    from fxhnt.adapters.persistence.cockpit_models import StrategyHealthRow
    from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
    from fxhnt.application.forward_ingest import CockpitBacktestRefProvider
    from fxhnt.config import get_settings

    at = at or dt.datetime.now(dt.UTC).replace(tzinfo=None)
    run_date = at.date()
    threshold = int(get_settings().stale_after_business_days)

    repo = ForwardNavRepo(dsn)
    repo.migrate()
    summaries = {s.strategy_id: s for s in repo.all_summaries()}
    backtest_sids = {b.strategy_id for b in repo.all_backtest_summaries()}
    ref_alias = CockpitBacktestRefProvider._REF_ALIAS   # the SAME alias the gate uses to resolve its ref
    freeze_max = repo.xsp_freeze_max_date()             # one query for the frozen-input freeze axis

    verdicts: list[HealthVerdict] = []
    for sid, meta in STRATEGY_REGISTRY.items():
        try:
            defn: dict[str, Any] = meta.get("definition", {})
            params: dict[str, Any] = defn.get("params", {})
            record_mode = str(defn.get("record_mode", "recompute"))
            is_recon = meta.get("gate_spec", {}).get("gate_type") == "reconciliation"
            summary = summaries.get(sid)
            as_of = summary.as_of if summary else None
            ref_available = ref_alias.get(sid, sid) in backtest_sids
            freeze_max_date = freeze_max if params.get("source") in _FREEZE_SOURCES else None
            anchor = repo.active_anchor(sid)
            anchor_t0 = anchor.t0 if anchor else None
            verdicts.append(compute_health(
                sid, record_mode=record_mode, is_reconciliation=is_recon, as_of=as_of,
                ref_available=ref_available, freeze_max_date=freeze_max_date, run_date=run_date,
                threshold=threshold, anchor_t0=anchor_t0))
        except Exception as e:  # noqa: BLE001 — a track whose health can't be computed is BROKEN, never silent
            _log.error("health-eval BROKEN for %s: %s", sid, e)
            verdicts.append(HealthVerdict(sid, BROKEN, 0, f"health computation failed: {e}"))

    repo.replace_health(
        [StrategyHealthRow(strategy_id=v.strategy_id, health=v.health, stale_days=v.stale_days,
                           reason=v.reason) for v in verdicts], at)

    unhealthy = [v for v in verdicts if not v.ok]
    if unhealthy:
        _log.error("forward health: %d UNHEALTHY track(s) — %s", len(unhealthy),
                   "; ".join(f"{v.strategy_id}={v.health}({v.reason})" for v in unhealthy))
    else:
        _log.info("forward health: all %d track(s) healthy", len(verdicts))
    return verdicts
