"""
APEX V16 — Core data contracts (dataclasses).

Single source of truth for ALL structured data shapes in V16.
Replaces V15's dict-based init_data / brain context with typed,
immutable-where-appropriate dataclasses.

Rule: NO module outside this file defines its own dict for trade,
brain context, or brain decision data. Always import from here.
"""

from __future__ import annotations

from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any


# ============================================================
# ENUMS — explicit, no magic strings
# ============================================================

class Direction(str, Enum):
    BUY = "BUY"
    SELL = "SELL"


class BrainName(str, Enum):
    TF = "TF"
    MR = "MR"


class TradeAction(str, Enum):
    HOLD = "HOLD"
    EXIT = "EXIT"
    PARTIAL_50 = "PARTIAL_50"
    MOVE_SL = "MOVE_SL"   # generic: target encoded in BrainDecision.metadata
                          # ("breakeven" | "trailing"). Renamed from MOVE_SL_BE
                          # in V16 once trailing-SL emerged in brain_mr.


class RiskRule(str, Enum):
    """
    Stable rule codes for risk_manager.check_entry() rejections.

    These are part of the V16 contract: never rename existing codes
    (calibration analytics + JSONL forensics depend on them). New rules
    can be appended; deprecation is an explicit project decision.
    """
    OK                                = "OK"
    SIZING_SKIP                       = "SIZING_SKIP"
    HALTED                            = "HALTED"
    DAILY_LOSS_HARD_STOP_HIT          = "DAILY_LOSS_HARD_STOP_HIT"
    DAILY_LOSS_SOFT_STOP_HIT          = "DAILY_LOSS_SOFT_STOP_HIT"
    DAILY_PROFIT_TARGET_REACHED       = "DAILY_PROFIT_TARGET_REACHED"
    MAX_OPEN_TRADES_REACHED           = "MAX_OPEN_TRADES_REACHED"
    MAX_DAILY_TRADES_REACHED          = "MAX_DAILY_TRADES_REACHED"
    LAST_FRIDAY_CUTOFF                = "LAST_FRIDAY_CUTOFF"
    FORCE_FLAT_TIME_REACHED           = "FORCE_FLAT_TIME_REACHED"
    COOLDOWN_ACTIVE                   = "COOLDOWN_ACTIVE"
    CORRELATION_BLOCKED               = "CORRELATION_BLOCKED"
    MAX_CONTRACTS_EXCEEDED            = "MAX_CONTRACTS_EXCEEDED"
    MAX_RISK_VS_DAILY_BUDGET_EXCEEDED = "MAX_RISK_VS_DAILY_BUDGET_EXCEEDED"


class MarketStructure(str, Enum):
    BULLISH_EXPANSION = "BULLISH_EXPANSION"
    BULLISH_COMPRESSION = "BULLISH_COMPRESSION"
    BEARISH_EXPANSION = "BEARISH_EXPANSION"
    BEARISH_COMPRESSION = "BEARISH_COMPRESSION"
    RANGING = "RANGING"
    UNKNOWN = "UNKNOWN"


class Regime(str, Enum):
    TRENDING = "TRENDING"
    RANGING = "RANGING"
    UNKNOWN = "UNKNOWN"


# ============================================================
# TRADE ENTRY — immutable snapshot at trade open
# ============================================================

@dataclass(frozen=True)
class TradeEntry:
    """
    Immutable snapshot of all data captured at the moment a trade is opened.

    Once created, NEVER modified. This is the contract that fixes V15-BUG-5
    (rsi_start saved but never propagated) and V15-BUG-2 (key name mismatch).

    All Brain decisions during the trade lifecycle reference THIS snapshot
    as the "ground truth at entry", not whatever tech_now happens to be.
    """

    # Identity
    symbol: str
    brain_name: str          # use BrainName enum values
    direction: str           # use Direction enum values
    contracts: int

    # Prices
    entry_price: float
    sl_price: float
    tp_price: float

    # Timing
    opened_at: datetime

    # Market snapshot at entry (the data V15 was missing for Brain)
    rsi_m5_at_entry: float
    rsi_h1_at_entry: float
    rsi_h4_at_entry: float
    atr_ratio_at_entry: float
    market_structure_at_entry: str   # use MarketStructure enum values
    regime_at_entry: str             # use Regime enum values
    h1_compat_at_entry: float
    confidence_at_entry: int         # 0-100

    # Broker order IDs (None for paper trades)
    entry_order_id: Optional[str] = None
    stop_order_id: Optional[str] = None
    target_order_id: Optional[str] = None

    # Special flags
    is_paper: bool = False
    is_orphan_recovered: bool = False

    # Diff between intended entry_price (Brain decision) and actual broker
    # avg_price when the trade was opened via orphan recovery (C2b safety
    # check). 0.0 for paper / normal live / no orphan. Used post-hoc to
    # detect slippage events where broker filled at a different price.
    orphan_entry_price_diff: float = 0.0

    def to_dict(self) -> dict:
        """Serialize to dict for state persistence (datetime -> isoformat)."""
        d = asdict(self)
        d["opened_at"] = self.opened_at.isoformat()
        return d

    @classmethod
    def from_dict(cls, data: dict) -> "TradeEntry":
        """Deserialize from dict (state load)."""
        d = dict(data)
        if isinstance(d.get("opened_at"), str):
            d["opened_at"] = datetime.fromisoformat(d["opened_at"])
        return cls(**d)


# ============================================================
# TRADE RUNTIME — mutable runtime state during trade life
# ============================================================

@dataclass
class TradeRuntime:
    """
    Mutable state that changes during the trade lifecycle.
    Updated by orchestrator at each tracking iteration.

    current_sl_price tracks the broker-side stop after MOVE_SL or
    set_be_after_partial. entry.sl_price stays at the original SL
    (immutable snapshot at fill time). Brains use entry.sl_price for
    "SL at entry" comparisons; the orchestrator uses current_sl_price
    when emitting the next modify_stop. 0.0 means "still at entry SL".

    partial_pnl_usd captures the realized P&L from a partial close
    (separate from net_profit_usd which tracks the unrealized delta
    on the still-open portion).
    """
    minutes_open: float = 0.0
    progress_pct: float = 0.0
    net_profit_usd: float = 0.0
    partial_done: bool = False
    partial_pnl_usd: float = 0.0
    current_sl_price: float = 0.0
    rsi50_partial_done: bool = False
    last_exit_eval_time: float = 0.0
    last_brain_action: str = ""
    last_brain_reason: str = ""
    # A/B test passivo SL (v18-dev): SL grezzo proposto dall'AI prima del
    # clamp MIN/MAX, popolato all'entry da decision.metadata. Mai inviato
    # al broker. Il manage loop logga "[ab_test] HIT" la prima volta che
    # il prezzo lo tocca, per confrontare a posteriori l'esito di un SL
    # senza clamp vs. l'attuale cappato.
    sl_price_ai_raw: float = 0.0
    sl_ai_raw_hit: bool = False

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> "TradeRuntime":
        # Tollera state files vecchi senza i nuovi campi (default applicato);
        # ignora chiavi sconosciute per evitare crash sul rollback di schema.
        known = {f for f in cls.__dataclass_fields__}
        return cls(**{k: v for k, v in data.items() if k in known})


# ============================================================
# BRAIN CONTEXT — what Brain receives to decide HOLD/EXIT
# ============================================================

@dataclass
class BrainContext:
    """
    Bundle passed to Brain.manage_exit().

    Replaces V15's (tech, net_profit, trade_type, init_data) signature
    where init_data was a dict with hidden fallbacks (V15-BUG-3).

    entry: immutable snapshot — Brain compares "now vs entry" using this.
    runtime: current trade state.
    tech_now: TechSnapshot of current indicator state (typed dataclass —
              V15-BUG-3 fix at the indicator boundary). Annotated as
              `Any` here to avoid a circular import; concrete type is
              analysis.tech_snapshot.TechSnapshot. Brain code reads
              `ctx.tech_now.<field>` directly.
    """
    entry: TradeEntry
    runtime: TradeRuntime
    tech_now: Any


# ============================================================
# BRAIN DECISION — what Brain returns
# ============================================================

@dataclass
class BrainDecision:
    """
    Brain's response to manage_exit().

    action: see TradeAction enum.
    reason: human-readable rationale (logged).
    move_sl_to: only set when action == MOVE_SL.
                metadata["sl_target"] should be "breakeven" or "trailing".
    metadata: free-form dict for Brain-specific signals (logged but
              not interpreted by orchestrator).
    """
    action: str   # use TradeAction enum values
    reason: str
    move_sl_to: Optional[float] = None
    metadata: dict = field(default_factory=dict)


# ============================================================
# ENTRY DECISION — what Brain returns from evaluate_entry()
# ============================================================

@dataclass
class EntryDecision:
    """
    Brain's proposal to OPEN a new trade.
    None from evaluate_entry() means "no setup".

    TP variante γ:
      - Brain emits `rr_multiplier` (AI choice ∈ [0.17, 0.67]) and leaves
        `tp_price = 0.0` as sentinel "to be resolved post-sizing".
      - Orchestrator calls `resolve_tp_price(...)` after sizing/risk and
        replaces the decision via `dataclasses.replace(decision, tp_price=...)`.
      - The trade_opener still reads `decision.tp_price` as a finalized float;
        it never sees the 0.0 sentinel.
    """
    direction: str            # Direction enum
    entry_price: float
    sl_price: float
    tp_price: float           # 0.0 sentinel until orchestrator finalizes post-sizing
    rr_multiplier: float      # AI's chosen TP/SL multiplier on real $ risk
    confidence: int           # 0-100
    rationale: str
    metadata: dict = field(default_factory=dict)


# ============================================================
# ENTRY EVALUATION RESULT — Brain.evaluate_entry return type
# ============================================================

@dataclass
class EntryEvalResult:
    """
    Outcome of Brain.evaluate_entry.

    decision:
        EntryDecision (open trade) or None (no setup / pre-val rejection
        / AI rejection / transient AI failure / candle gate skip).

    evaluated_candle_time:
        M5 candle timestamp (UTC unix seconds, float) on which the AI
        was called and either responded (any parse outcome) or returned
        a permanent error (credit/invalid). When set, the orchestrator
        updates SessionState.entry_eval_cache so subsequent iterations
        on the same candle skip the AI call. None when:
          - dedup short-circuited the AI call, OR
          - candle-state gate short-circuited (not_closed / stabilizing
            / too_old), OR
          - pre-val rejected before AI, OR
          - AI failed with a transient error (unknown/overload) — retry
            on next iteration.

    dedup_skipped:
        True when same-candle dedup short-circuited the AI call.
        Mutually exclusive with a non-None decision.

    reject_reason:
        Stable string code emitted by candle-state gates ("CANDLE_NOT_CLOSED"
        / "CANDLE_STABILIZING" / "CANDLE_TOO_OLD") and propagated by the
        orchestrator into brain_log.jsonl scan_skip events for forensics.
        None when not a candle-state skip (pre-val / AI / dedup paths use
        their own logging channels).
    """
    decision: Optional["EntryDecision"]
    evaluated_candle_time: Optional[float] = None
    dedup_skipped: bool = False
    reject_reason: Optional[str] = None


# ============================================================
# Helpers
# ============================================================

def utc_now() -> datetime:
    """Single source of truth for timestamps in V16."""
    return datetime.now(timezone.utc)
