"""Strategy-agnostic forward-validation tracker — the only ground truth before capital.

`ForwardTracker` freezes any `ForwardStrategy` port at INCEPTION and thereafter books only realized
returns of days STRICTLY AFTER it — a genuine out-of-sample-in-time paper track (no capital risked).
The combined book is one (recomputable) strategy on it; in-sample backtest Sharpe means nothing until
the forward record confirms it.
"""
from __future__ import annotations

import datetime as _dt
import json
import os
from dataclasses import dataclass
from typing import Any, Protocol

import numpy as np

from fxhnt.domain.gauntlet import per_period_sharpe

_ANN = np.sqrt(365.0)


@dataclass
class ForwardStatus:
    inception: str
    last_date: str
    forward_days: int
    forward_return_pct: float
    forward_sharpe: float
    forward_max_drawdown: float
    booked_today: int


class ForwardStrategy(Protocol):
    def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]:
        """Return candidate (ISO-date, fractional-return) rows the strategy knows now, plus its updated
        carry-state. The tracker books only rows strictly after last_date and persists `extra` opaquely."""
        ...


def _today_iso() -> str:
    return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d")


class _StateCorrupt(Exception):
    """Internal: a state file exists on disk but cannot be parsed into a state dict (empty/partial/non-dict)."""


class ForwardTracker:
    """Strategy-agnostic forward-validation tracker. Freezes inception on first run (books nothing — no
    backfill), then books only days strictly after last_date. Owns the days-list state file."""

    def __init__(self, strategy: ForwardStrategy, state_path: str, *,
                 kill_dd: float | None = None, reenter_dd: float = 0.07) -> None:
        self._strategy = strategy
        self._path = state_path
        self._kill_dd = kill_dd            # regime kill-switch: flatten booked exposure beyond this DD
        self._reenter_dd = reenter_dd       # re-enter once the (shadow) drawdown recovers inside this

    @staticmethod
    def _read_state(path: str) -> dict[str, Any] | None:
        """Parse a state file. `None` = the file does not exist (genuinely absent). Raises `_StateCorrupt`
        when the file EXISTS but is unreadable (empty/partial/non-dict) — a lost-state signal we must NOT
        confuse with a first run."""
        if not os.path.exists(path):
            return None
        try:
            with open(path) as f:
                data = json.load(f)
        except (ValueError, OSError) as e:   # json.JSONDecodeError is a ValueError (empty/partial file)
            raise _StateCorrupt(path) from e
        if not isinstance(data, dict):
            raise _StateCorrupt(path)
        return data

    def _load(self) -> dict[str, Any] | None:
        """Load the last-good state, tolerant of a lost/corrupt main file.

        `None` is returned ONLY for a genuine first run (neither the state file nor a `.bak` has ever been
        written) — the sole case in which `step()` may inception. If the track HAS existed (a state or `.bak`
        is present) but the main file is missing/corrupt, we recover from the `.bak`; and if the `.bak` is
        also unusable we RAISE rather than silently re-inception (which would reset forward_days to 0 and
        burn the out-of-sample record — the failure mode that reset the live track three times)."""
        bak_path = self._path + ".bak"
        # genuine first run: nothing has ever been persisted for this track.
        if not os.path.exists(self._path) and not os.path.exists(bak_path):
            return None

        main_corrupt = False
        try:
            main = self._read_state(self._path)
        except _StateCorrupt:
            main, main_corrupt = None, True
        if main is not None:
            return main

        # main is missing or corrupt but the track existed → recover from the backup.
        try:
            bak = self._read_state(bak_path)
        except _StateCorrupt:
            bak = None
        if bak is not None:
            return bak

        raise RuntimeError(
            f"ForwardTracker state at {self._path} is "
            f"{'corrupt' if main_corrupt else 'missing'} and no readable '{bak_path}' backup exists; "
            f"refusing to silently re-inception (that would reset forward_days to 0 and discard the "
            f"forward record). Restore the state file or its .bak.")

    def _write_atomic(self, path: str, state: dict[str, Any]) -> None:
        """Durably replace `path` with `state`: write a temp file, fsync it, then atomically `os.replace`
        onto the real path. A crash mid-write leaves either the OLD file or the temp — never a truncated,
        readable-but-partial `path`."""
        tmp = path + ".tmp"
        with open(tmp, "w") as f:
            json.dump(state, f, indent=2)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)

    def _save(self, state: dict[str, Any]) -> None:
        """Atomically persist the state AND refresh a `.bak` mirror of it, so a later lost/corrupt main file
        can be recovered without re-inception. Both writes are atomic; if the main write fails the previous
        good state (and its .bak) are untouched."""
        os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
        self._write_atomic(self._path, state)
        self._write_atomic(self._path + ".bak", state)

    def step(self) -> ForwardStatus:
        loaded = self._load()
        prev_last = None if loaded is None else (loaded["last_date"] or None)
        prev_extra = {} if loaded is None else loaded.get("extra", {})
        rows, new_extra = self._strategy.advance(prev_last, prev_extra)
        rows = sorted(rows, key=lambda x: x[0])

        booked = 0
        if loaded is None:
            # RE-INCEPTION GUARD: `_load` returns None ONLY on a genuine first run (no state file and no .bak
            # has ever been written). A lost/corrupt main file for an EXISTING track recovers from .bak or
            # raises in `_load` — it never reaches here — so inception can no longer silently reset an
            # in-flight forward record to day 0.
            latest = rows[-1][0] if rows else _today_iso()
            state: dict[str, Any] = {"inception": latest, "last_date": latest, "nav": 1.0, "days": [], "extra": new_extra}
        else:
            state = loaded
            if self._kill_dd is not None:
                # lazy-init kill-switch state (supports tracks that predate the switch): the existing
                # un-throttled NAV IS the shadow equity; its running max is the shadow peak.
                shadow = float(state.get("shadow_nav", state["nav"]))
                speak = float(state.get("shadow_peak",
                                        max([d["nav"] for d in state["days"]] + [state["nav"]])))
                killed = bool(state.get("killed", False))
            for d, r in rows:
                if d > state["last_date"]:
                    if self._kill_dd is not None:
                        was_killed = killed
                        eff = 0.0 if was_killed else r        # decision uses prior-day kill-state
                        state["nav"] *= (1.0 + eff)
                        shadow *= (1.0 + r)                    # shadow tracks un-throttled = regime signal
                        speak = max(speak, shadow)
                        dd = (speak - shadow) / speak if speak > 0 else 0.0
                        if not killed and dd > self._kill_dd:
                            killed = True
                        elif killed and dd < self._reenter_dd:
                            killed = False
                        state["days"].append({"date": d, "ret": eff, "nav": state["nav"], "killed": was_killed})
                    else:
                        state["nav"] *= (1.0 + r)
                        state["days"].append({"date": d, "ret": r, "nav": state["nav"]})
                    state["last_date"] = d
                    booked += 1
            if self._kill_dd is not None:
                state["shadow_nav"], state["shadow_peak"], state["killed"] = shadow, speak, killed
            state["extra"] = new_extra
        self._save(state)

        rets = np.array([row["ret"] for row in state["days"]])
        nav = np.array([row["nav"] for row in state["days"]])
        sharpe = float(per_period_sharpe(rets) * _ANN) if len(rets) >= 3 else 0.0
        mdd = float((nav / np.maximum.accumulate(nav) - 1.0).min()) if len(nav) else 0.0
        return ForwardStatus(
            inception=state["inception"], last_date=state["last_date"], forward_days=len(state["days"]),
            forward_return_pct=100.0 * (state["nav"] - 1.0), forward_sharpe=sharpe,
            forward_max_drawdown=mdd, booked_today=booked)


