"""
H4 bias pipeline.

Two layers:
  1. compute_algo_bias(df4) -> BiasData
     Deterministic EMA20/50 + HH/HL + RSI extremes (port V15
     bias_computer.compute_algo_bias). Pure function.
  2. compute_ai_bias(...) -> BiasData (async)
     AI override invoked ONLY when algo flags ambiguous=True
     (port V15 bias_computer.ai_override_bias). Silent fallback
     to algo on any error.

  3. BiasResolver
     Stateful facade: dispatches algo + AI, persists outcomes to
     SessionState.bias_cache (BiasEntry), respects TTL on cache hits.
     Used by the orchestrator. Brain modules consume the resolved
     BiasData via TechSnapshot.bias / allowed_direction populated upstream.

V16 layering rule: analysis/ does NOT import from brain/. The AI client
is injected; a tiny inline JSON-extract helper avoids the dependency.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

import pandas as pd

from analysis.indicators.atr import calc_atr
from analysis.indicators.rsi import calc_rsi
from core.contracts import utc_now
from core.json_parsing import extract_json_from_response
from persistence.state_store import BiasEntry


@dataclass(frozen=True)
class BiasData:
    """
    H4 bias output (typed).

    bias: "RIALZISTA" | "RIBASSISTA" | "NEUTRO"
    allowed_direction: "BUY" | "SELL" | "BOTH" | "NONE"
    h1_compatibility: 0.3..1.0
    h1_reason: human-readable
    ambiguous: True when algo is uncertain (can be fed to BrainBias later)
    rsi_h4_override: True when RSI H4 extreme triggered an override
    """
    bias: str
    allowed_direction: str
    h1_compatibility: float
    h1_reason: str
    ambiguous: bool = False
    rsi_h4_override: bool = False


def _neutro(reason: str) -> BiasData:
    return BiasData(
        bias="NEUTRO",
        allowed_direction="NONE",
        h1_compatibility=0.5,
        h1_reason=reason,
        ambiguous=False,
    )


def compute_algo_bias(df4: pd.DataFrame) -> BiasData:
    """
    Algorithmic H4 bias from EMA(20/50) + structural HH/HL on last 8 bars.

    V15-parity overrides (priority over EMA/struct):
      RSI H4 > 80 -> RIBASSISTA, allowed SELL (overbought MR)
      RSI H4 < 20 -> RIALZISTA, allowed BUY (oversold MR)

    Returns:
        BiasData. ambiguous=True when EMA and structure disagree
        (a downstream BrainBias may resolve).
    """
    if df4 is None or len(df4) < 20:
        return _neutro("insufficient_h4_bars")

    ema20 = df4["close"].ewm(span=20).mean()
    ema50 = df4["close"].ewm(span=50).mean()

    last_close = float(df4["close"].iloc[-1])
    last_ema20 = float(ema20.iloc[-1])
    last_ema50 = float(ema50.iloc[-1])

    # RSI H4 extremes override everything (V15 parity)
    try:
        rsi_h4 = float(calc_rsi(df4).iloc[-1])
    except Exception:
        rsi_h4 = 50.0

    if rsi_h4 > 80:
        return BiasData(
            bias="RIBASSISTA",
            allowed_direction="SELL",
            h1_compatibility=0.85,
            h1_reason=f"OVERRIDE: RSI H4 {rsi_h4:.1f} > 80 (overbought extreme)",
            rsi_h4_override=True,
        )
    if rsi_h4 < 20:
        return BiasData(
            bias="RIALZISTA",
            allowed_direction="BUY",
            h1_compatibility=0.85,
            h1_reason=f"OVERRIDE: RSI H4 {rsi_h4:.1f} < 20 (oversold extreme)",
            rsi_h4_override=True,
        )

    ema_bullish = last_ema20 > last_ema50 and last_close > last_ema20
    ema_bearish = last_ema20 < last_ema50 and last_close < last_ema20

    highs = df4["high"].iloc[-8:].values
    lows = df4["low"].iloc[-8:].values
    hh = sum(1 for i in range(1, len(highs)) if highs[i] > highs[i - 1])
    hl = sum(1 for i in range(1, len(lows)) if lows[i] > lows[i - 1])
    lh = sum(1 for i in range(1, len(highs)) if highs[i] < highs[i - 1])
    ll = sum(1 for i in range(1, len(lows)) if lows[i] < lows[i - 1])

    struct_bullish = hh >= 4 and hl >= 4
    struct_bearish = lh >= 4 and ll >= 4

    if ema_bullish and struct_bullish:
        return BiasData(
            bias="RIALZISTA",
            allowed_direction="BUY",
            h1_compatibility=1.0,
            h1_reason=f"EMA20>EMA50 H4 + HH/HL ({hh} HH, {hl} HL)",
        )
    if ema_bearish and struct_bearish:
        return BiasData(
            bias="RIBASSISTA",
            allowed_direction="SELL",
            h1_compatibility=1.0,
            h1_reason=f"EMA20<EMA50 H4 + LH/LL ({lh} LH, {ll} LL)",
        )
    if ema_bullish and not struct_bearish:
        return BiasData(
            bias="RIALZISTA",
            allowed_direction="BUY",
            h1_compatibility=0.7,
            h1_reason=f"EMA bullish, struct mixed ({hh} HH/{hl} HL vs {lh} LH/{ll} LL)",
        )
    if ema_bearish and not struct_bullish:
        return BiasData(
            bias="RIBASSISTA",
            allowed_direction="SELL",
            h1_compatibility=0.7,
            h1_reason=f"EMA bearish, struct mixed ({lh} LH/{ll} LL vs {hh} HH/{hl} HL)",
        )

    # EMA and structure clash -> ambiguous (Brain Bias / AI can resolve later)
    if (ema_bullish and struct_bearish) or (ema_bearish and struct_bullish):
        return BiasData(
            bias="NEUTRO",
            allowed_direction="NONE",
            h1_compatibility=0.5,
            h1_reason=f"EMA/struct conflict (ema_bull={ema_bullish}, struct_bear={struct_bearish})",
            ambiguous=True,
        )

    # Quasi-flat
    return BiasData(
        bias="NEUTRO",
        allowed_direction="NONE",
        h1_compatibility=0.4,
        h1_reason=f"Range/consolidation - EMA {last_ema20:.2f}/{last_ema50:.2f}, HH {hh} HL {hl}",
        ambiguous=True,
    )


# ============================================================
# AI BIAS OVERRIDE — port V15 bias_computer.ai_override_bias
# ============================================================
#
# V15 used temperature=0.7 here (interpretive bias, not a trade decision).
# V16 preserves V15 calibration explicitly via temperature=0.7.
# DA RIVALIDARE IN CALIBRAZIONE V16.

AI_BIAS_PROMPT = """Sei un analista di mercato. Analizza questo contesto H4 e determina il bias.

SYMBOL: {symbol}
H4 Close recenti: {h4_closes}
H4 EMA20: {ema20:.4f}  EMA50: {ema50:.4f}
H4 RSI: {rsi_h4:.1f}
ATR H4: {atr_h4:.4f}

Il calcolo algoritmico ha rilevato ambiguità ({algo_reason}).
Decidi il bias finale. Restituisci SOLO JSON valido, zero testo fuori dal JSON:

{{
    "bias": "RIALZISTA" | "RIBASSISTA" | "NEUTRO",
    "allowed_direction": "BUY" | "SELL" | "NONE",
    "h1_compatibility": 0.3-1.0,
    "reason": "max 15 words"
}}"""


async def compute_ai_bias(
    *,
    ai_client,
    symbol: str,
    df4: pd.DataFrame,
    algo_result: BiasData,
) -> BiasData:
    """
    AI override on ambiguous algo bias. Pure function (no state).

    Silent fallback to algo_result on:
      - df4 None or shorter than 10 bars
      - feature-extraction exception
      - AIResponse.text is None (any error_kind: credit / overload / invalid / unknown)
      - JSON parse failure
      - missing 'bias' field in AI response
      - bias not in {RIALZISTA, RIBASSISTA, NEUTRO} (V16 stricter than V15
        which silently coerced unknown bias to NEUTRO; V16 prefers algo)

    Validation rules (V15-parity, see V15 bias_computer.py:254-262):
      - allowed_direction whitelist {BUY, SELL, NONE}; anything else
        (incl. "BOTH") -> coerced to NONE
      - bias=NEUTRO  ->  allowed_direction forced to NONE
        (V14 coherence rule preserved by V15)
      - h1_compatibility clamped to [0.3, 1.0]
    """
    if df4 is None or len(df4) < 10:
        return algo_result

    try:
        ema20_v = float(df4["close"].ewm(span=20).mean().iloc[-1])
        ema50_v = float(df4["close"].ewm(span=50).mean().iloc[-1])
        rsi_h4_v = float(calc_rsi(df4).iloc[-1])
        atr_h4_v = float(calc_atr(df4).iloc[-1])
        h4_closes = [round(float(c), 4) for c in df4["close"].iloc[-8:].tolist()]
    except Exception:
        return algo_result

    prompt = AI_BIAS_PROMPT.format(
        symbol=symbol,
        h4_closes=h4_closes,
        ema20=ema20_v, ema50=ema50_v,
        rsi_h4=rsi_h4_v, atr_h4=atr_h4_v,
        algo_reason=algo_result.h1_reason or "ambiguous",
    )

    try:
        resp = await ai_client.ask(prompt, temperature=0.7)
    except Exception:
        return algo_result

    if resp.text is None:
        return algo_result

    try:
        ai_data = json.loads(extract_json_from_response(resp.text))
    except (json.JSONDecodeError, ValueError):
        return algo_result

    if not isinstance(ai_data, dict) or "bias" not in ai_data:
        return algo_result

    bias = ai_data.get("bias")
    if bias not in ("RIALZISTA", "RIBASSISTA", "NEUTRO"):
        return algo_result

    allowed = ai_data.get("allowed_direction", "NONE")
    if allowed not in ("BUY", "SELL", "NONE"):
        allowed = "NONE"
    if bias == "NEUTRO":
        allowed = "NONE"

    try:
        h1_comp = float(ai_data.get("h1_compatibility", 0.5))
    except (TypeError, ValueError):
        h1_comp = 0.5
    h1_comp = max(0.3, min(1.0, h1_comp))

    reason = str(ai_data.get("reason", "override"))[:200]

    return BiasData(
        bias=bias,
        allowed_direction=allowed,
        h1_compatibility=h1_comp,
        h1_reason=f"AI: {reason}",
        ambiguous=False,
        rsi_h4_override=False,
    )


# ============================================================
# BIAS RESOLVER — algo + AI dispatch with state-backed cache
# ============================================================

def _bias_data_to_entry(bd: BiasData) -> BiasEntry:
    """
    Persist subset of BiasData. ambiguous and rsi_h4_override are
    process flags, not output: intentionally NOT persisted.
    """
    confidence = max(30, min(100, int(round(bd.h1_compatibility * 100))))
    return BiasEntry(
        direction=bd.allowed_direction,
        confidence=confidence,
        rationale=(bd.h1_reason or "")[:200],
        computed_at=utc_now().isoformat(),
    )


def _entry_to_bias_data(be: BiasEntry) -> BiasData:
    """
    Reconstruct a BiasData from a persisted BiasEntry. The bias label
    is deterministically derived from allowed_direction (V15 mapping is
    1:1 in compute_algo_bias and ai-override). Process flags reset.
    """
    bias_for_dir = {
        "BUY": "RIALZISTA",
        "SELL": "RIBASSISTA",
    }.get(be.direction, "NEUTRO")
    h1_comp = max(0.3, min(1.0, be.confidence / 100.0))
    return BiasData(
        bias=bias_for_dir,
        allowed_direction=be.direction,
        h1_compatibility=h1_comp,
        h1_reason=be.rationale,
        ambiguous=False,
        rsi_h4_override=False,
    )


class BiasResolver:
    """
    H4 bias dispatcher with AI override + per-symbol cache.

    Coordinates:
      1. compute_algo_bias  (deterministic algo)
      2. compute_ai_bias    (AI override on ambiguous; silent fallback)
      3. SessionState.bias_cache (TTL persistence for cold start)

    Cache semantics (V15 parity, TTL=3600s):
      - resolve(symbol, df4) returns cached BiasData if BiasEntry.computed_at
        is within ttl_seconds; otherwise recomputes.
      - Cache writes happen for BOTH algo-only and AI-resolved decisions, so
        the next cold start finds them all (state v2 BiasCache).
      - resolve() does NOT save SessionState; the orchestrator owns the
        save after each tick (V15-BUG-9 discipline preserved).

    AI failure policy (silent fallback):
      - AIResponse.text is None / parse error / schema violation -> use algo.
      - state.brain.bias_calls_count is incremented when an AI call is
        ATTEMPTED (algo-ambiguous path), regardless of success. Failure
        rate is greppable from system.log via "[BIAS AI] {symbol} fallback".
        A dedicated bias_ai_failures_count is on BACKLOG.
    """

    DEFAULT_TTL_SECONDS = 3600   # V15 parity: BiasComputer.CACHE_TTL_SECONDS

    def __init__(
        self,
        *,
        ai_client,
        state,
        logger=None,
        ttl_seconds: int = DEFAULT_TTL_SECONDS,
    ) -> None:
        self.ai_client = ai_client
        self.state = state
        self.logger = logger
        self.ttl_seconds = ttl_seconds

    async def resolve(self, symbol: str, df4: Optional[pd.DataFrame]) -> BiasData:
        cached = self._cache_get(symbol)
        if cached is not None:
            return cached

        # V15 router parity (APEX_PREDATOR_V15.get_bias_data:949): when no H4
        # data is available we return a permissive default (BOTH) at the
        # router level, NOT the algo's strict NONE. The Brain's own gates
        # decide whether to act. We still cache (TTL full) so cold start
        # remains stable.
        if df4 is None or len(df4) < 20:
            bd = BiasData(
                bias="NEUTRO",
                allowed_direction="BOTH",
                h1_compatibility=0.5,
                h1_reason="no_h4_data",
                ambiguous=False,
            )
            self._cache_put(symbol, bd)
            return bd

        algo = compute_algo_bias(df4)

        if algo.ambiguous:
            self.state.brain.bias_calls_count += 1
            final = await compute_ai_bias(
                ai_client=self.ai_client,
                symbol=symbol,
                df4=df4,
                algo_result=algo,
            )
            if final is algo or final.h1_reason == algo.h1_reason:
                self._log_warning(
                    f"[BIAS AI] {symbol}: fallback algo (no override applied)"
                )
        else:
            final = algo

        self._cache_put(symbol, final)
        return final

    def invalidate(self, symbol: Optional[str] = None) -> None:
        """Drop cached BiasEntry for symbol, or clear the entire cache."""
        if symbol is None:
            self.state.bias_cache.entries.clear()
        else:
            self.state.bias_cache.entries.pop(symbol, None)

    def has_fresh_cache(self, symbol: str) -> bool:
        """
        True iff there is a non-expired BiasEntry for symbol.
        Used by the orchestrator to skip the H4 REST fetch when resolve()
        will return the cached value anyway (df4 is unused on cache hit).
        """
        return self._cache_get(symbol) is not None

    # ------------------------------------------------------------
    # internals
    # ------------------------------------------------------------

    def _cache_get(self, symbol: str) -> Optional[BiasData]:
        entry = self.state.bias_cache.entries.get(symbol)
        if entry is None:
            return None
        try:
            computed_at = datetime.fromisoformat(entry.computed_at)
        except (TypeError, ValueError):
            return None
        age = (utc_now() - computed_at).total_seconds()
        if age >= self.ttl_seconds:
            return None
        return _entry_to_bias_data(entry)

    def _cache_put(self, symbol: str, bd: BiasData) -> None:
        self.state.bias_cache.entries[symbol] = _bias_data_to_entry(bd)

    def _log_warning(self, msg: str) -> None:
        if self.logger is None:
            return
        try:
            self.logger.system.warning(msg)
        except Exception:
            pass
