"""Read-only data access for the Streamlit GUI.

The GUI MUST NOT import ``runtime/`` modules nor make MCP calls. Every
piece of information shown on screen is derived from:

* SQLite (``data/state.sqlite``) via :class:`Repository`.
* The audit log (``data/audit.log``) via the parsing helpers in
  :mod:`cerbero_bite.safety.audit_log`.

The module exposes small frozen dataclasses purpose-built for rendering
so each Streamlit page can grab a snapshot in one call instead of
poking at the repository directly.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Literal
from uuid import UUID

from cerbero_bite.safety.audit_log import (
    AuditChainError,
    AuditEntry,
    iter_entries,
    verify_chain,
)
from cerbero_bite.state import Repository, connect, transaction
from cerbero_bite.state.models import (
    DecisionRecord,
    ManualAction,
    MarketSnapshotRecord,
    PositionRecord,
    SystemStateRecord,
)
from cerbero_bite.state.repository import _row_to_manual

__all__ = [
    "DEFAULT_AUDIT_PATH",
    "DEFAULT_DB_PATH",
    "AuditChainStatus",
    "EngineHealth",
    "EngineSnapshot",
    "EquityPoint",
    "MonthlyStats",
    "PayoffCurve",
    "PortfolioKpis",
    "PositionDistanceMetrics",
    "compute_distance_metrics",
    "compute_equity_curve",
    "compute_kpis",
    "compute_monthly_stats",
    "compute_payoff_curve",
    "enqueue_arm_kill",
    "enqueue_disarm_kill",
    "enqueue_run_cycle",
    "load_audit_chain_status",
    "load_audit_tail",
    "load_closed_positions",
    "load_decisions_for_position",
    "load_engine_snapshot",
    "load_market_snapshots",
    "load_open_positions",
    "load_pending_manual_actions",
    "load_position_by_id",
]


DEFAULT_DB_PATH = Path("data/state.sqlite")
DEFAULT_AUDIT_PATH = Path("data/audit.log")


EngineHealth = Literal["running", "degraded", "killed", "stopped", "unknown"]


@dataclass(frozen=True)
class EngineSnapshot:
    """One-shot snapshot used by the Status page."""

    health: EngineHealth
    kill_switch_armed: bool
    kill_reason: str | None
    kill_at: datetime | None
    last_health_check: datetime | None
    last_health_check_age_s: float | None
    started_at: datetime | None
    config_version: str | None
    last_audit_hash: str | None
    open_positions: int

    @property
    def health_label(self) -> str:
        return {
            "running": "ATTIVO",
            "degraded": "DEGRADATO",
            "killed": "KILL SWITCH ARMATO",
            "stopped": "FERMO",
            "unknown": "SCONOSCIUTO",
        }[self.health]


@dataclass(frozen=True)
class AuditChainStatus:
    """Result of calling ``verify_chain`` on the audit log."""

    ok: bool
    entries_verified: int
    error: str | None


def load_engine_snapshot(
    *,
    db_path: Path | str = DEFAULT_DB_PATH,
    now: datetime | None = None,
    stale_after_s: float = 600.0,
) -> EngineSnapshot:
    """Read system_state + open positions count and derive engine health.

    Health rules:

    * kill switch armed → ``killed``
    * no system_state row → ``unknown`` (engine never started)
    * last health check older than ``stale_after_s`` → ``stopped``
    * last health check older than 2× cycle (10 min) but younger than
      ``stale_after_s`` → ``degraded``
    * fresh health check → ``running``
    """
    db_path = Path(db_path)
    if not db_path.exists():
        return EngineSnapshot(
            health="unknown",
            kill_switch_armed=False,
            kill_reason=None,
            kill_at=None,
            last_health_check=None,
            last_health_check_age_s=None,
            started_at=None,
            config_version=None,
            last_audit_hash=None,
            open_positions=0,
        )

    repo = Repository()
    conn = connect(db_path)
    try:
        state: SystemStateRecord | None = repo.get_system_state(conn)
        open_pos = len(repo.list_open_positions(conn))
    finally:
        conn.close()

    if state is None:
        return EngineSnapshot(
            health="unknown",
            kill_switch_armed=False,
            kill_reason=None,
            kill_at=None,
            last_health_check=None,
            last_health_check_age_s=None,
            started_at=None,
            config_version=None,
            last_audit_hash=None,
            open_positions=open_pos,
        )

    reference = (now or datetime.now(UTC)).astimezone(UTC)
    last_check = state.last_health_check
    age = (reference - last_check).total_seconds() if last_check else None

    if state.kill_switch:
        health: EngineHealth = "killed"
    elif age is None:
        health = "unknown"
    elif age > stale_after_s:
        health = "stopped"
    elif age > 600:  # over 10 minutes since last health probe
        health = "degraded"
    else:
        health = "running"

    return EngineSnapshot(
        health=health,
        kill_switch_armed=bool(state.kill_switch),
        kill_reason=state.kill_reason,
        kill_at=state.kill_at,
        last_health_check=last_check,
        last_health_check_age_s=age,
        started_at=state.started_at,
        config_version=state.config_version,
        last_audit_hash=state.last_audit_hash,
        open_positions=open_pos,
    )


def load_open_positions(
    *, db_path: Path | str = DEFAULT_DB_PATH
) -> list[PositionRecord]:
    db_path = Path(db_path)
    if not db_path.exists():
        return []
    repo = Repository()
    conn = connect(db_path)
    try:
        return repo.list_open_positions(conn)
    finally:
        conn.close()


def load_closed_positions(
    *,
    db_path: Path | str = DEFAULT_DB_PATH,
    start: datetime | None = None,
    end: datetime | None = None,
) -> list[PositionRecord]:
    """Return positions with status ``closed`` (sorted oldest → newest).

    The optional ``start`` / ``end`` window filters by ``closed_at``.
    Positions still in flight (open / awaiting_fill / closing /
    cancelled) are excluded. ``cancelled`` positions are also excluded
    since they never had P&L impact.
    """
    db_path = Path(db_path)
    if not db_path.exists():
        return []
    repo = Repository()
    conn = connect(db_path)
    try:
        rows = repo.list_positions(conn, status="closed")
    finally:
        conn.close()
    out: list[PositionRecord] = []
    for r in rows:
        if r.closed_at is None:
            continue
        if start is not None and r.closed_at < start:
            continue
        if end is not None and r.closed_at > end:
            continue
        out.append(r)
    out.sort(key=lambda p: p.closed_at)  # type: ignore[arg-type, return-value]
    return out


# ---------------------------------------------------------------------------
# Analytics
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class EquityPoint:
    """One point on the cumulative-PnL curve."""

    timestamp: datetime
    realized_pnl_usd: Decimal
    cumulative_pnl_usd: Decimal
    drawdown_usd: Decimal
    drawdown_pct: float


@dataclass(frozen=True)
class MonthlyStats:
    """Aggregated stats for a calendar month."""

    year_month: str  # "2026-04"
    n_trades: int
    n_wins: int
    win_rate: float
    pnl_usd: Decimal
    avg_pnl_usd: Decimal


@dataclass(frozen=True)
class PortfolioKpis:
    """High-level KPI strip for the History/Equity pages."""

    n_trades: int
    n_wins: int
    win_rate: float
    total_pnl_usd: Decimal
    avg_win_usd: Decimal
    avg_loss_usd: Decimal
    edge_per_trade_usd: Decimal
    max_drawdown_usd: Decimal
    max_drawdown_pct: float


def compute_equity_curve(positions: list[PositionRecord]) -> list[EquityPoint]:
    """Build a cumulative PnL series from closed positions.

    Drawdown is measured against the running peak of cumulative PnL
    (so it accounts for past wins). ``drawdown_pct`` is expressed
    relative to the peak — undefined when peak ≤ 0 (returns 0.0).
    """
    if not positions:
        return []

    points: list[EquityPoint] = []
    cumulative = Decimal(0)
    peak = Decimal(0)
    for pos in positions:
        if pos.pnl_usd is None or pos.closed_at is None:
            continue
        cumulative += pos.pnl_usd
        peak = max(peak, cumulative)
        dd_usd = peak - cumulative
        dd_pct = float(dd_usd / peak) if peak > 0 else 0.0
        points.append(
            EquityPoint(
                timestamp=pos.closed_at,
                realized_pnl_usd=pos.pnl_usd,
                cumulative_pnl_usd=cumulative,
                drawdown_usd=dd_usd,
                drawdown_pct=dd_pct,
            )
        )
    return points


def compute_kpis(positions: list[PositionRecord]) -> PortfolioKpis:
    """Aggregate KPI strip across the supplied closed positions."""
    pnls = [p.pnl_usd for p in positions if p.pnl_usd is not None]
    n = len(pnls)
    if n == 0:
        zero = Decimal(0)
        return PortfolioKpis(
            n_trades=0,
            n_wins=0,
            win_rate=0.0,
            total_pnl_usd=zero,
            avg_win_usd=zero,
            avg_loss_usd=zero,
            edge_per_trade_usd=zero,
            max_drawdown_usd=zero,
            max_drawdown_pct=0.0,
        )

    wins = [p for p in pnls if p > 0]
    losses = [p for p in pnls if p < 0]
    total = sum(pnls, Decimal(0))
    avg_win = sum(wins, Decimal(0)) / Decimal(len(wins)) if wins else Decimal(0)
    avg_loss = sum(losses, Decimal(0)) / Decimal(len(losses)) if losses else Decimal(0)

    curve = compute_equity_curve(positions)
    if curve:
        max_dd = max((p.drawdown_usd for p in curve), default=Decimal(0))
        max_dd_pct = max((p.drawdown_pct for p in curve), default=0.0)
    else:  # pragma: no cover — defensive, curve is empty iff pnls empty
        max_dd = Decimal(0)
        max_dd_pct = 0.0

    return PortfolioKpis(
        n_trades=n,
        n_wins=len(wins),
        win_rate=len(wins) / n,
        total_pnl_usd=total,
        avg_win_usd=avg_win,
        avg_loss_usd=avg_loss,
        edge_per_trade_usd=total / Decimal(n),
        max_drawdown_usd=max_dd,
        max_drawdown_pct=max_dd_pct,
    )


def compute_monthly_stats(positions: list[PositionRecord]) -> list[MonthlyStats]:
    """Aggregate per calendar month (UTC), oldest → newest."""
    buckets: dict[str, list[Decimal]] = {}
    for pos in positions:
        if pos.pnl_usd is None or pos.closed_at is None:
            continue
        key = pos.closed_at.astimezone(UTC).strftime("%Y-%m")
        buckets.setdefault(key, []).append(pos.pnl_usd)

    out: list[MonthlyStats] = []
    for key in sorted(buckets):
        pnls = buckets[key]
        n = len(pnls)
        wins = sum(1 for p in pnls if p > 0)
        total = sum(pnls, Decimal(0))
        out.append(
            MonthlyStats(
                year_month=key,
                n_trades=n,
                n_wins=wins,
                win_rate=wins / n if n else 0.0,
                pnl_usd=total,
                avg_pnl_usd=total / Decimal(n) if n else Decimal(0),
            )
        )
    return out


def load_position_by_id(
    proposal_id: UUID,
    *,
    db_path: Path | str = DEFAULT_DB_PATH,
) -> PositionRecord | None:
    db_path = Path(db_path)
    if not db_path.exists():
        return None
    repo = Repository()
    conn = connect(db_path)
    try:
        return repo.get_position(conn, proposal_id)
    finally:
        conn.close()


def load_decisions_for_position(
    proposal_id: UUID,
    *,
    db_path: Path | str = DEFAULT_DB_PATH,
    limit: int = 200,
) -> list[DecisionRecord]:
    """Decisions for ``proposal_id`` newest-first."""
    db_path = Path(db_path)
    if not db_path.exists():
        return []
    repo = Repository()
    conn = connect(db_path)
    try:
        return repo.list_decisions(conn, proposal_id=proposal_id, limit=limit)
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Payoff math (pure, no live data)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class PayoffCurve:
    """At-expiry P&L curve for a credit spread."""

    spreads_type: str  # "bull_put" / "bear_call" / "iron_condor"
    spot_grid: list[float]
    pnl_grid_usd: list[float]
    breakeven: float | None
    max_profit_usd: float
    max_loss_usd: float
    short_strike: float
    long_strike: float
    spot_at_entry: float


def compute_payoff_curve(
    position: PositionRecord,
    *,
    grid_points: int = 60,
    margin_pct: float = 0.15,
) -> PayoffCurve:
    """Build the at-expiry payoff for a credit spread.

    Supported spreads (Cerbero Bite scope):

    * ``bull_put``: short put @ ``short_strike``, long put @
      ``long_strike`` (lower). Max profit = credit. Max loss = width −
      credit. Breakeven = short_strike − credit_per_contract.
    * ``bear_call``: short call @ ``short_strike``, long call @
      ``long_strike`` (higher). Symmetric to bull_put around the strikes.
    * Other types fall back to a flat zero curve to avoid breaking the
      page if/when iron condors are implemented later.
    """
    short = float(position.short_strike)
    long_ = float(position.long_strike)
    n = position.n_contracts
    width_usd = float(position.spread_width_usd)
    credit_total_usd = float(position.credit_usd)
    credit_per_contract = credit_total_usd / n if n > 0 else 0.0
    spot = float(position.eth_price_at_entry)

    lo = min(short, long_, spot) * (1 - margin_pct)
    hi = max(short, long_, spot) * (1 + margin_pct)
    step = (hi - lo) / max(grid_points - 1, 1)
    grid = [lo + i * step for i in range(grid_points)]

    if position.spread_type == "bull_put":
        # short put at higher strike, long put at lower strike
        max_profit = credit_total_usd
        max_loss = -(width_usd - credit_total_usd) * n  # signed (negative)
        breakeven = short - credit_per_contract
        pnl = []
        for s in grid:
            if s >= short:
                pnl.append(max_profit)
            elif s <= long_:
                pnl.append(max_loss)
            else:
                frac = (s - long_) / (short - long_)
                pnl.append(max_loss + frac * (max_profit - max_loss))
    elif position.spread_type == "bear_call":
        # short call at lower strike, long call at higher strike
        max_profit = credit_total_usd
        max_loss = -(width_usd - credit_total_usd) * n
        breakeven = short + credit_per_contract
        pnl = []
        for s in grid:
            if s <= short:
                pnl.append(max_profit)
            elif s >= long_:
                pnl.append(max_loss)
            else:
                frac = (s - short) / (long_ - short)
                pnl.append(max_profit + frac * (max_loss - max_profit))
    else:
        max_profit = credit_total_usd
        max_loss = -(width_usd - credit_total_usd) * n
        breakeven = None
        pnl = [0.0 for _ in grid]

    return PayoffCurve(
        spreads_type=position.spread_type,
        spot_grid=grid,
        pnl_grid_usd=pnl,
        breakeven=breakeven,
        max_profit_usd=max_profit,
        max_loss_usd=max_loss,
        short_strike=short,
        long_strike=long_,
        spot_at_entry=spot,
    )


@dataclass(frozen=True)
class PositionDistanceMetrics:
    """Quick distance summary for the position drilldown."""

    short_strike_otm_pct: float | None
    days_to_expiry: int | None
    days_held: int | None
    delta_at_entry: float
    width_pct_of_spot: float


def compute_distance_metrics(
    position: PositionRecord,
    *,
    now: datetime | None = None,
) -> PositionDistanceMetrics:
    spot = float(position.spot_at_entry)
    short = float(position.short_strike)
    if spot > 0:
        if position.spread_type == "bull_put":
            otm_pct = (spot - short) / spot
        elif position.spread_type == "bear_call":
            otm_pct = (short - spot) / spot
        else:
            otm_pct = None
    else:
        otm_pct = None

    reference = (now or datetime.now(UTC)).astimezone(UTC)
    days_to_expiry = (
        (position.expiry - reference).days if position.expiry else None
    )
    days_held = (
        (reference - position.opened_at).days if position.opened_at else None
    )

    return PositionDistanceMetrics(
        short_strike_otm_pct=otm_pct,
        days_to_expiry=days_to_expiry,
        days_held=days_held,
        delta_at_entry=float(position.delta_at_entry),
        width_pct_of_spot=float(position.spread_width_pct),
    )


# ---------------------------------------------------------------------------
# Manual actions queue (the GUI's only write path)
# ---------------------------------------------------------------------------


def _enqueue_action(
    *,
    db_path: Path | str,
    kind: str,
    payload: dict[str, object],
    proposal_id: UUID | None = None,
) -> int:
    """Insert a row in ``manual_actions``. The engine consumer applies it."""
    db_path = Path(db_path)
    repo = Repository()
    now = datetime.now(UTC)
    conn = connect(db_path)
    try:
        with transaction(conn):
            return repo.enqueue_manual_action(
                conn,
                ManualAction(
                    kind=kind,  # type: ignore[arg-type]
                    proposal_id=proposal_id,
                    payload_json=json.dumps(payload),
                    created_at=now,
                ),
            )
    finally:
        conn.close()


def enqueue_arm_kill(
    *, reason: str, db_path: Path | str = DEFAULT_DB_PATH
) -> int:
    """Queue an ``arm_kill`` action for the engine consumer."""
    if not reason or not reason.strip():
        raise ValueError("reason is required")
    return _enqueue_action(
        db_path=db_path,
        kind="arm_kill",
        payload={"reason": reason.strip()},
    )


def enqueue_disarm_kill(
    *, reason: str, db_path: Path | str = DEFAULT_DB_PATH
) -> int:
    """Queue a ``disarm_kill`` action for the engine consumer."""
    if not reason or not reason.strip():
        raise ValueError("reason is required")
    return _enqueue_action(
        db_path=db_path,
        kind="disarm_kill",
        payload={"reason": reason.strip()},
    )


def enqueue_run_cycle(
    *, cycle: str, db_path: Path | str = DEFAULT_DB_PATH
) -> int:
    """Queue a ``run_cycle`` action — engine must be running.

    ``cycle`` must be one of ``entry``, ``monitor``, ``health``. The
    engine consumer dispatches the corresponding ``Orchestrator.run_*``
    method on the next minute tick.
    """
    cycle_norm = cycle.strip().lower()
    if cycle_norm not in {"entry", "monitor", "health", "market_snapshot"}:
        raise ValueError(
            f"cycle must be entry|monitor|health|market_snapshot, "
            f"got '{cycle}'"
        )
    return _enqueue_action(
        db_path=db_path,
        kind="run_cycle",
        payload={"cycle": cycle_norm},
    )


def load_market_snapshots(
    *,
    asset: str,
    db_path: Path | str = DEFAULT_DB_PATH,
    start: datetime | None = None,
    end: datetime | None = None,
    limit: int = 5000,
) -> list[MarketSnapshotRecord]:
    """Return market_snapshots rows for the asset, newest-first."""
    db_path = Path(db_path)
    if not db_path.exists():
        return []
    repo = Repository()
    conn = connect(db_path)
    try:
        return repo.list_market_snapshots(
            conn, asset=asset, start=start, end=end, limit=limit
        )
    finally:
        conn.close()


def load_pending_manual_actions(
    *, db_path: Path | str = DEFAULT_DB_PATH
) -> list[ManualAction]:
    """All unconsumed actions, oldest first (used for the pending strip)."""
    db_path = Path(db_path)
    if not db_path.exists():
        return []
    conn = connect(db_path)
    try:
        rows = conn.execute(
            "SELECT * FROM manual_actions WHERE consumed_at IS NULL "
            "ORDER BY created_at ASC"
        ).fetchall()
    finally:
        conn.close()

    return [_row_to_manual(row) for row in rows]


def load_audit_tail(
    *,
    audit_path: Path | str = DEFAULT_AUDIT_PATH,
    limit: int = 100,
    event_filter: str | None = None,
) -> list[AuditEntry]:
    """Return the most recent audit entries (newest first).

    For the GUI we walk the entire file (the audit log is append-only and
    bounded by daily rotation; reading 100 lines stays cheap). The
    optional ``event_filter`` matches by exact event name.
    """
    audit_path = Path(audit_path)
    entries: list[AuditEntry] = []
    if not audit_path.exists():
        return entries
    for entry in iter_entries(audit_path):
        if event_filter and entry.event != event_filter:
            continue
        entries.append(entry)
    entries.reverse()  # newest first
    return entries[:limit]


def load_audit_chain_status(
    *, audit_path: Path | str = DEFAULT_AUDIT_PATH
) -> AuditChainStatus:
    audit_path = Path(audit_path)
    try:
        n = verify_chain(audit_path)
    except AuditChainError as exc:
        return AuditChainStatus(ok=False, entries_verified=0, error=str(exc))
    except Exception as exc:  # pragma: no cover — surface unexpected IO errors
        return AuditChainStatus(ok=False, entries_verified=0, error=str(exc))
    return AuditChainStatus(ok=True, entries_verified=n, error=None)


def humanize_age(seconds: float | None) -> str:
    if seconds is None:
        return "—"
    if seconds < 60:
        return f"{int(seconds)}s fa"
    if seconds < 3600:
        return f"{int(seconds / 60)}m fa"
    if seconds < 86400:
        return f"{seconds / 3600:.1f}h fa"
    return f"{seconds / 86400:.1f}g fa"


def humanize_dt(value: datetime | None) -> str:
    if value is None:
        return "—"
    return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")


def humanize_timedelta(value: timedelta | None) -> str:  # pragma: no cover
    if value is None:
        return "—"
    return f"{value.total_seconds() / 3600:.1f}h"
