"""Reconciliation-based forward gate — replaces the blind 60-day calendar wait.

RATIONALE. The 5-year backfill the edges were built on is IN-SAMPLE, so the live forward track is the only
true out-of-sample check. But sitting on a calendar countdown adds ~no statistical power: 60 daily obs vs 14
barely moves a Sharpe confidence interval, and the failure mode we actually care about ("the backtest was a
mirage" — e.g. a legacy-venue backtest of +7097% is -36% with real costs) shows up in ~2 weeks, not 2 months. So the
gate PASSES as soon as three things hold, and otherwise WAITs with the SPECIFIC failing reason:

  1. MINIMUM FORWARD WINDOW   — forward_days >= policy.min_forward_days (~2 weeks). A named policy field, not
                                 a magic 60.
  2. FORWARD RECONCILES        — the forward track does not DIVERGE from / CONTRADICT what the backtest predicts.
                                 This is a sanity/divergence check, NOT a tight Sharpe match (14 obs is far too
                                 noisy for that). Two cheap, documented sub-checks:
                                   (a) the forward cumulative return sits within a tolerance BAND of the
                                       backtest's expected return over the SAME window, and
                                   (b) the daily-return correlation with the backtest is NOT negative
                                       (the forward isn't winning exactly when the backtest loses).
                                 Either failing = the forward is contradicting the backtest = the edge did not
                                 reproduce OOS = stay in WAIT (this is the mirage-catcher).
  3. CLEAN EXECUTION           — the forward record is continuous: no missing expected days / holes in the
                                 daily sequence over the window (a gap = a silent pipeline failure, not a pass).

It only REPORTS — it never acts. Pure function over read-model DTOs; no I/O, no network.

Thresholds (min_forward_days, recon_tolerance_frac, recon_min_band, recon_min_corr, max_gap_days) are no
longer module constants here — they come from the resolved `GatePolicy` the caller passes in (see
gate_policy.py for the versioned defaults + gate_policy_resolve.py for the per-track override layering); this
module only holds the comparison logic.
"""
from __future__ import annotations

import datetime as _dt
import math
from dataclasses import dataclass

from fxhnt.application.forward_models import (
    CAT_DIVERGENCE,
    CAT_EXECUTION_GAP,
    CAT_IMMATURE,
    CAT_PASS,
    ForwardNavRow,
    ForwardSummary,
    GateVerdict,
)
from fxhnt.application.gate_policy_resolve import ResolvedPolicy


@dataclass(frozen=True, slots=True)
class BacktestReference:
    """What the backtest predicts over the forward window, for the reconciliation check. `daily_returns` is the
    backtest's per-day return series (most recent window) when available — enables the correlation sub-check;
    when absent only the cumulative-band sub-check runs. `expected_window_return` is the backtest's expected
    cumulative fractional return over a window of policy.min_forward_days-ish length (caller pro-rates CAGR)."""

    expected_window_return: float
    daily_returns: list[float] | None = None


def _has_gaps(rows: list[ForwardNavRow], max_gap_days: int = 1) -> bool:
    """True if the daily forward record has a hole — a missing expected day between consecutive rows, a
    duplicate/out-of-order date, or an unparseable date. `max_gap_days` is the largest ALLOWED step between
    consecutive booked days: 1 for a calendar-continuous (crypto, 24/7) track; larger for a weekday-only
    (US-equity) track where a Fri→Mon step is 3 days and a holiday long weekend up to 4 — those are NORMAL,
    not a pipeline failure. A step of 0/negative (duplicate/out-of-order) is always a hole; a step beyond
    `max_gap_days` is a real gap."""
    dates: list[_dt.date] = []
    for r in rows:
        try:
            dates.append(_dt.date.fromisoformat(r.date))
        except ValueError:
            return True
    for prev, cur in zip(dates, dates[1:], strict=False):
        step = (cur - prev).days
        if step <= 0 or step > max_gap_days:
            return True
    return False


def _corr(a: list[float], b: list[float]) -> float | None:
    """Pearson correlation of two equal-length series; None if undefined (too short or a zero-variance leg)."""
    n = min(len(a), len(b))
    if n < 2:
        return None
    a, b = a[:n], b[:n]
    ma, mb = sum(a) / n, sum(b) / n
    cov = sum((x - ma) * (y - mb) for x, y in zip(a, b, strict=False))
    va = sum((x - ma) ** 2 for x in a)
    vb = sum((y - mb) ** 2 for y in b)
    if va <= 0.0 or vb <= 0.0:
        return None
    return cov / math.sqrt(va * vb)


def evaluate_reconciliation_gate(summary: ForwardSummary, rows: list[ForwardNavRow],
                                 backtest: BacktestReference | None,
                                 policy: ResolvedPolicy, *, gap_rows: list[ForwardNavRow] | None = None) -> GateVerdict:
    """PASS only when ALL hold: >= policy.min_forward_days forward days, the forward reconciles with the
    backtest (cumulative return in-band AND daily correlation not negative), and execution is clean (no gaps).
    Otherwise WAIT with the SPECIFIC failing reason. `backtest=None` → WAIT (PASS withheld): a real-capital
    gate must never PASS a track it cannot reconcile, so a missing backtest reference is a WAIT, not a hollow
    PASS. `gap_rows`, if given, optionally overrides the rows used for the execution-gap/continuity check
    ONLY — `rows` (the full record) is still what `_corr` and the divergence-band check see."""
    min_days = policy.min_forward_days
    tol_frac = policy.recon_tolerance_frac
    min_band = policy.recon_min_band
    min_corr = policy.recon_min_corr

    # 1. Minimum forward window — still "building" the OOS record.
    if summary.days < min_days:
        return GateVerdict("WAIT", f"building ({summary.days}/{min_days} days)", category=CAT_IMMATURE)

    # 3. Clean execution — a hole in the daily record is a silent pipeline failure, not a pass. `max_gap_days`
    # (default 1 = calendar-continuous crypto) tolerates the normal Fri→Mon / holiday steps of a weekday-only
    # equity track so it is not falsely flagged as a gap.
    if _has_gaps(gap_rows if gap_rows is not None else rows, policy.max_gap_days):
        return GateVerdict("WAIT", "execution gap (missing/duplicate forward day in the record)",
                           category=CAT_EXECUTION_GAP)

    # 2. Forward reconciles with the backtest. A real-capital gate must NEVER PASS a track it cannot
    #    reconcile: with NO backtest reference there is nothing to catch a mirage, so a "PASS" here would be a
    #    bare min-window + clean-exec timer masquerading as a validated reconciliation — a false-PASS, the one
    #    dangerous error. So PASS is WITHHELD (WAIT) until a backtest_summary row exists. This is the
    #    false-WAIT-safe choice. (Previously this degraded to a hollow PASS labelled "reconciles with backtest".)
    if backtest is None:
        return GateVerdict(
            "WAIT",
            f"{summary.days}d: no backtest reference to reconcile against — PASS withheld "
            f"(persist a backtest_summary row for this strategy)",
            category=CAT_IMMATURE)

    # (a) cumulative-return tolerance band — catch the mirage (forward far BELOW backtest's expectation).
    # The band is TIGHT and FIXED (never widened by the forward's realized dispersion): this is a
    # real-capital gate, and in prod the correlation sub-check below does not run (daily_returns=None), so
    # the band is the only always-on divergence check. Loosening it would risk a false-PASS on a
    # non-reproducing edge — the dangerous error. The safe error (a false-WAIT on a lumpy dry window) is
    # handled by a longer, more representative window (bybit_4edge min 21d), not by loosening the threshold.
    expected = backtest.expected_window_return
    band = tol_frac * max(abs(expected), min_band)
    shortfall = expected - summary.total_return
    if shortfall > band:
        return GateVerdict(
            "WAIT",
            f"forward diverging from backtest (Δ {100.0 * shortfall:+.2f}% below expected, "
            f"band ±{100.0 * band:.2f}%)",
            category=CAT_DIVERGENCE)
    # (b) daily-return correlation — forward must not have the OPPOSITE shape to the validated edge.
    if backtest.daily_returns is not None:
        corr = _corr([r.ret for r in rows], backtest.daily_returns)
        if corr is not None and corr < min_corr:
            return GateVerdict(
                "WAIT",
                f"forward diverging from backtest (daily corr {corr:+.2f} < {min_corr:.2f})",
                category=CAT_DIVERGENCE)

    return GateVerdict(
        "PASS",
        f"{summary.days}d: +{summary.total_return_pct:.2f}%, reconciles with backtest, clean execution",
        category=CAT_PASS)
