"""Deterministic orderflow research gate.

The LLM may propose a trade, but this gate checks the proposal against the
local orderflow playbook before live execution. It is intentionally conservative
and read-only.
"""
from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any

from .models import MarketRegime, OrderflowSnapshot
from .policy import TradingPolicy, alignment_summary, has_side_reversal, true_side_triggers


@dataclass(frozen=True)
class ResearchGateResult:
    allowed: bool
    reasons: list[str]

    @property
    def reason(self) -> str:
        return "; ".join(self.reasons)


def evaluate_research_gate(
    decision: Any,
    coin_data: dict[str, Any],
    *,
    ws_book_max_age_s: float = 10.0,
    ws_tape_max_age_s: float = 20.0,
    policy: TradingPolicy | None = None,
) -> ResearchGateResult:
    """Return allowed/reject for a proposed LLM trade."""
    if getattr(decision, "action", "") != "enter":
        return ResearchGateResult(True, [])

    policy = policy or TradingPolicy()
    of: OrderflowSnapshot = coin_data["of"]
    regime: MarketRegime = coin_data["regime"]
    side = str(getattr(decision, "side", "") or "").lower()
    playbook_match = getattr(decision, "playbook_match", None) or {}
    trigger_state = coin_data.get("trigger_state") or {}
    reasons: list[str] = []

    if side not in ("long", "short"):
        return ResearchGateResult(False, ["invalid side"])

    strong_live_override = _strong_live_trigger_override(side, coin_data, policy)
    reasons.extend(_fresh_ws_reasons(coin_data, side, ws_book_max_age_s, ws_tape_max_age_s, policy, strong_live_override))
    reasons.extend(_side_alignment_reasons(side, of, regime, coin_data, policy, strong_live_override))

    reasons.extend(_structured_playbook_reasons(decision, side, coin_data, policy))
    reasons.extend(_noise_margin_reasons(decision, coin_data, policy))
    reasons.extend(_trend_continuation_quality_reasons(decision, side, coin_data, policy))
    reasons.extend(_sweep_reclaim_quality_reasons(decision, side, coin_data, policy))
    reasons.extend(_breakout_quality_reasons(decision, side, coin_data, policy))

    side_triggers = true_side_triggers(side, trigger_state)
    if trigger_state and not side_triggers:
        reasons.append(f"playbook blocked: no true {side} trigger_state")

    if side == "short" and trigger_state.get("breakout_acceptance_long") and not has_side_reversal("short", trigger_state):
        reasons.append("short blocked: trigger_state shows long breakout acceptance, not failed-push rejection")
    if side == "long" and trigger_state.get("breakdown_acceptance_short") and not has_side_reversal("long", trigger_state):
        reasons.append("long blocked: trigger_state shows short breakdown acceptance, not sweep-reclaim")

    # Research rule: do not chase already-exhausted moves. The LLM repeatedly
    # tried to override this by saying "trend strong" despite the WAIT rule.
    if side == "long" and of.rsi_14 > 70:
        reasons.append(f"long blocked: RSI {of.rsi_14:.1f} is overbought")
    if side == "short" and of.rsi_14 < 30:
        reasons.append(f"short blocked: RSI {of.rsi_14:.1f} is oversold")

    # Absorption contradiction from the research notes.
    if side == "long" and of.ask_absorption >= policy.absorption_hard_veto_threshold:
        reasons.append(f"long blocked: ask absorption {of.ask_absorption:.2f} exceeds hard veto {policy.absorption_hard_veto_threshold:.2f}")
    if side == "short" and of.bid_absorption >= policy.absorption_hard_veto_threshold:
        reasons.append(f"short blocked: bid absorption {of.bid_absorption:.2f} exceeds hard veto {policy.absorption_hard_veto_threshold:.2f}")

    # CVD divergence against trade direction.
    if of.cvd_divergence:
        if side == "long" and of.cvd < 0:
            reasons.append("long blocked: CVD divergence/pressure is bearish")
        if side == "short" and of.cvd > 0:
            reasons.append("short blocked: CVD divergence/pressure is bullish")

    playbook_name = str(playbook_match.get("playbook") or getattr(decision, "setup_type", "") or "").lower()
    if "trend_pullback_continuation" in playbook_name and _is_countertrend(side, regime):
        reasons.append(
            f"trend continuation blocked: side fights regime 15m={regime.trend_15m} 1h={regime.trend_1h}"
        )

    if _is_countertrend(side, regime) or "absorption_reversal" in playbook_name:
        absorption_ok = of.bid_absorption >= policy.reversal_absorption_threshold if side == "long" else of.ask_absorption >= policy.reversal_absorption_threshold
        structure_ok = bool(true_side_triggers(side, trigger_state))
        if not absorption_ok and not structure_ok:
            reasons.append("countertrend/reversal blocked: needs real absorption or sweep/reclaim confirmation")

    if _side_price_response_conflicts(side, coin_data, policy):
        reasons.append("entry blocked: latest 15m candle does not confirm tape direction")

    return ResearchGateResult(not reasons, reasons)


def _fresh_ws_reasons(
    coin_data: dict[str, Any],
    side: str,
    book_max_age_s: float,
    tape_max_age_s: float,
    policy: TradingPolicy,
    strong_live_override: bool = False,
) -> list[str]:
    """Hard gate for live orderflow entries.

    Hyperliquid orderflow setups need fresh L2 book and fresh tape. A fresh
    funding/OI context alone must not make the websocket state look tradable.
    """
    ws = coin_data.get("ws_summary") or {}
    if not ws:
        return ["ws blocked: missing live websocket summary"]

    reasons: list[str] = []
    book_stale_s = float(ws.get("book_stale_s") or 9999.0)
    tape_stale_s = float(ws.get("tape_stale_s") or 9999.0)
    if ws.get("available") is not True:
        reasons.append(
            f"ws blocked: fresh book+tape unavailable (book_stale={book_stale_s:.1f}s tape_stale={tape_stale_s:.1f}s)"
        )
    if ws.get("book_available") is not True:
        reasons.append("ws blocked: no live l2Book snapshot")
    if ws.get("tape_available") is not True:
        reasons.append("ws blocked: no live trades tape")
    if book_stale_s > book_max_age_s:
        reasons.append(f"ws blocked: l2Book stale {book_stale_s:.1f}s > {book_max_age_s:.1f}s")
    if tape_stale_s > tape_max_age_s:
        reasons.append(f"ws blocked: trades tape stale {tape_stale_s:.1f}s > {tape_max_age_s:.1f}s")

    windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
    w30 = windows.get("30s") if isinstance(windows.get("30s"), dict) else {}
    w60 = windows.get("1m") if isinstance(windows.get("1m"), dict) else {}
    w120 = windows.get("2m") if isinstance(windows.get("2m"), dict) else {}
    if float(w120.get("total_usd") or ws.get("total_usd_120s") or 0.0) <= 0.0:
        reasons.append("ws blocked: 2m tape has zero traded notional")

    for label, window in (("30s", w30), ("1m", w60)):
        delta = float(window.get("delta_ratio") or 0.0)
        total = float(window.get("total_usd") or 0.0)
        if total <= 0:
            reasons.append(f"ws blocked: {label} tape window is empty")
            continue
        if label != "30s" and strong_live_override:
            continue
        if side == "long" and delta < -policy.trigger_response_delta_threshold:
            reasons.append(f"long blocked: {label} tape delta {delta:.2f} is sell-side")
        if side == "short" and delta > policy.trigger_response_delta_threshold:
            reasons.append(f"short blocked: {label} tape delta {delta:.2f} is buy-side")

    return reasons


def _side_alignment_reasons(
    side: str,
    of: OrderflowSnapshot,
    regime: MarketRegime,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
    strong_live_override: bool = False,
) -> list[str]:
    """Recompute the basic 5/7 side alignment instead of trusting LLM prose."""
    reasons: list[str] = []
    trigger_state = coin_data.get("trigger_state") or {}
    has_long_reversal = has_side_reversal("long", trigger_state)
    has_short_reversal = has_side_reversal("short", trigger_state)
    side_triggers = true_side_triggers(side, trigger_state)
    summary = alignment_summary(of, side, policy)
    if summary["passing"] < policy.min_alignment_score:
        reasons.append(
            f"{side} blocked: alignment {summary['score']} below required {policy.min_alignment_score}/7"
        )

    if side == "long":
        if of.book_imbalance < -policy.book_imbalance_threshold:
            reasons.append(f"long blocked: book imbalance {of.book_imbalance:.2f} is ask-heavy")
        if of.tape_delta_pct < -policy.tape_delta_threshold and not strong_live_override:
            reasons.append(f"long blocked: REST tape delta {of.tape_delta_pct:.2f} is sell-side")
        if of.macd_histogram < 0 and not has_long_reversal and not side_triggers:
            reasons.append(f"long blocked: MACD histogram {of.macd_histogram:.6g} is bearish without reversal trigger")
        if regime.trend_15m == "down" and regime.trend_1h == "down" and not has_long_reversal:
            reasons.append("long blocked: 15m and 1h regime both down without sweep/reclaim reversal")
    else:
        if of.book_imbalance > policy.book_imbalance_threshold:
            reasons.append(f"short blocked: book imbalance {of.book_imbalance:.2f} is bid-heavy")
        if of.tape_delta_pct > policy.tape_delta_threshold and not strong_live_override:
            reasons.append(f"short blocked: REST tape delta {of.tape_delta_pct:.2f} is buy-side")
        if of.macd_histogram > 0 and not has_short_reversal and not side_triggers:
            reasons.append(f"short blocked: MACD histogram {of.macd_histogram:.6g} is bullish without reversal trigger")
        if regime.trend_15m == "up" and regime.trend_1h == "up" and not has_short_reversal:
            reasons.append("short blocked: 15m and 1h regime both up without failed-push reversal")

    return reasons


def _is_countertrend(side: str, regime: MarketRegime) -> bool:
    if side == "long":
        return regime.trend_15m == "down" or regime.trend_1h == "down"
    return regime.trend_15m == "up" or regime.trend_1h == "up"


def _strong_live_trigger_override(side: str, coin_data: dict[str, Any], policy: TradingPolicy) -> bool:
    """Allow fresh 30s live tape to outrank slower REST/1m tape in full trend moves."""
    regime: MarketRegime = coin_data["regime"]
    trigger_state = coin_data.get("trigger_state") or {}
    if not true_side_triggers(side, trigger_state):
        return False
    if side == "long":
        full_trend = regime.trend_15m == "up" and regime.trend_1h == "up" and regime.trend_4h == "up"
    else:
        full_trend = regime.trend_15m == "down" and regime.trend_1h == "down" and regime.trend_4h == "down"
    if not full_trend:
        return False
    ws = coin_data.get("ws_summary") or {}
    windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
    w30 = windows.get("30s") if isinstance(windows.get("30s"), dict) else {}
    if float(w30.get("total_usd") or 0.0) <= 0:
        return False
    delta = float(w30.get("delta_ratio") or ws.get("delta_ratio_30s") or 0.0)
    if side == "long":
        return delta >= max(0.50, policy.trigger_response_delta_threshold)
    return delta <= -max(0.50, policy.trigger_response_delta_threshold)


def _structured_playbook_reasons(
    decision: Any,
    side: str,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
) -> list[str]:
    playbook = getattr(decision, "playbook_match", None) or {}
    if not isinstance(playbook, dict):
        return ["playbook blocked: missing structured playbook_match"]

    allowed = {
        "trend_pullback_continuation",
        "liquidity_sweep_reclaim",
        "absorption_reversal",
        "vwap_poc_reversion",
        "breakout_acceptance",
        "breakout_acceptance_long",
        "breakdown_acceptance_short",
    }
    name = str(playbook.get("playbook") or "").strip().lower()
    reasons: list[str] = []
    if name not in allowed:
        reasons.append("playbook blocked: invalid playbook field")

    trigger_present = playbook.get("trigger_present")
    if isinstance(trigger_present, str):
        trigger_present = trigger_present.strip().lower() == "true"
    if trigger_present is not True:
        reasons.append("playbook blocked: missing confirmed trigger_present=true")

    required_fields = ("trigger_level", "price_response", "anti_chase_check", "self_reject_if")
    for field in required_fields:
        if len(str(playbook.get(field) or "").strip()) < 12:
            reasons.append(f"playbook blocked: missing concrete {field}")

    trigger_state = coin_data.get("trigger_state") or {}
    if trigger_state and not true_side_triggers(side, trigger_state):
        reasons.append(f"playbook blocked: no side-matching trigger_state for {side}")

    anti_chase = str(playbook.get("anti_chase_check") or "").lower()
    if "current mid" in anti_chase and not any(
        key in anti_chase for key in ("not", "after", "because", "retest", "reclaim", "failed", "sweep", "acceptance", "hold")
    ):
        reasons.append("playbook blocked: anti_chase_check does not explain why entry is not a chase")

    of: OrderflowSnapshot = coin_data["of"]
    if "absorption_reversal" in name:
        needed = of.bid_absorption if side == "long" else of.ask_absorption
        if needed < policy.reversal_absorption_threshold and not has_side_reversal(side, trigger_state):
            reasons.append(
                f"playbook blocked: absorption_reversal lacks {side} absorption >= {policy.reversal_absorption_threshold:.2f}"
            )

    return reasons


def _noise_margin_reasons(
    decision: Any,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
) -> list[str]:
    """Reject stops that are only barely outside recent OHLC noise.

    Recent losing trades repeatedly had stop/floor around 0.97x-1.07x. They
    were valid-looking on R:R, but normal 1m/5m movement tagged them before the
    thesis had room to work.
    """
    if getattr(decision, "action", "") != "enter":
        return []
    entry = float(getattr(decision, "entry_price", 0.0) or 0.0)
    stop = float(getattr(decision, "stop_price", 0.0) or 0.0)
    noise = coin_data.get("noise_state") or {}
    floor = float(noise.get("dynamic_stop_floor") or 0.0)
    if entry <= 0 or stop <= 0 or floor <= 0:
        return []
    stop_distance = abs(entry - stop)
    required = floor * policy.min_stop_noise_multiple
    if stop_distance < required:
        return [
            "stop blocked by OHLC noise lesson: "
            f"stop_distance={stop_distance:.8g} < required={required:.8g} "
            f"({policy.min_stop_noise_multiple:.2f}x dynamicStopFloor={floor:.8g})"
        ]
    return []


def _trend_continuation_quality_reasons(
    decision: Any,
    side: str,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
) -> list[str]:
    """Reject fake trend-pullback entries in chop/mixed HTF.

    Recent OHLC showed SOL/WLD/ETH losses came from labeling a local reclaim as
    trend continuation while the 15m or higher-timeframe context did not really
    support continuation. NEAR worked because 15m was aligned and CVD/flow was
    unusually strong.
    """
    playbook = getattr(decision, "playbook_match", None) or {}
    name = str(playbook.get("playbook") or getattr(decision, "setup_type", "") or "").lower()
    if "trend_pullback_continuation" not in name:
        return []

    of: OrderflowSnapshot = coin_data["of"]
    regime: MarketRegime = coin_data["regime"]
    trigger_state = coin_data.get("trigger_state") or {}
    reasons: list[str] = []

    if side == "long":
        tf15_ok = regime.trend_15m == "up"
        htf_ok = regime.trend_1h == "up" or regime.trend_4h == "up"
        flow_override = (
            (of.cvd_divergence and of.cvd > 0)
            or of.bid_absorption >= policy.reversal_absorption_threshold
            or trigger_state.get("cvd_divergence_long") is True
            or trigger_state.get("absorption_reversal_long") is True
        )
    else:
        tf15_ok = regime.trend_15m == "down"
        htf_ok = regime.trend_1h == "down" or regime.trend_4h == "down"
        flow_override = (
            (of.cvd_divergence and of.cvd < 0)
            or of.ask_absorption >= policy.reversal_absorption_threshold
            or trigger_state.get("cvd_divergence_short") is True
            or trigger_state.get("absorption_reversal_short") is True
        )

    if not tf15_ok:
        reasons.append(
            f"trend continuation blocked: 15m trend is {regime.trend_15m}, not {side}"
        )
    if not htf_ok and not flow_override:
        reasons.append(
            "trend continuation blocked: no 1h/4h trend alignment and no CVD/absorption override"
        )

    return reasons


def _sweep_reclaim_quality_reasons(
    decision: Any,
    side: str,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
) -> list[str]:
    """Extra quality checks for liquidity-sweep entries.

    Sweep/reclaim has been the most error-prone playbook. The orderflow notes
    and recent OHLC both point to the same rule: tape must create result at a
    level. A sweep against 4h value/trend with weak VWAP/MACD context is a trap
    unless there is clear absorption or acceptance.
    """
    playbook = getattr(decision, "playbook_match", None) or {}
    name = str(playbook.get("playbook") or getattr(decision, "setup_type", "") or "").lower()
    if "liquidity_sweep_reclaim" not in name:
        return []

    of: OrderflowSnapshot = coin_data["of"]
    regime: MarketRegime = coin_data["regime"]
    trigger_meta = coin_data.get("trigger_meta") or {}
    reasons: list[str] = []

    if side == "short":
        if not _fresh_tape_agrees(side, coin_data, policy, require_all=True):
            reasons.append("sweep short blocked: fresh websocket tape does not confirm rejection")
        if trigger_meta and trigger_meta.get("resistance_response") is not True:
            reasons.append("sweep short blocked: no computed resistance_response/rejection at the swept level")
        if regime.trend_4h == "up" and of.macd_histogram > 0:
            reasons.append("sweep short blocked: 4h trend is up and MACD is still bullish")
        if (
            regime.trend_4h == "up"
            and of.price_vs_vwap > -policy.vwap_near_pct
            and of.ask_absorption < policy.reversal_absorption_threshold
        ):
            reasons.append(
                "sweep short blocked: 4h up and price has not accepted below VWAP/value "
                "without strong ask absorption"
            )
    else:
        if not _fresh_tape_agrees(side, coin_data, policy, require_all=True):
            reasons.append("sweep long blocked: fresh websocket tape does not confirm reclaim")
        if trigger_meta and trigger_meta.get("support_response") is not True:
            reasons.append("sweep long blocked: no computed support_response/reclaim at the swept level")
        if regime.trend_4h == "down" and of.macd_histogram < 0:
            reasons.append("sweep long blocked: 4h trend is down and MACD is still bearish")
        if (
            regime.trend_4h == "down"
            and of.price_vs_vwap < policy.vwap_near_pct
            and of.bid_absorption < policy.reversal_absorption_threshold
        ):
            reasons.append(
                "sweep long blocked: 4h down and price has not accepted above VWAP/value "
                "without strong bid absorption"
            )

    return reasons


def _breakout_quality_reasons(
    decision: Any,
    side: str,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
) -> list[str]:
    """Reject weak breakout-acceptance entries that are really tiny countertrend scalps.

    Local orderflow research says breakouts need acceptance/retest and structural
    target room. A first push through a local level is not enough, especially
    when the higher timeframe and VWAP context disagree.
    """
    playbook = getattr(decision, "playbook_match", None) or {}
    name = str(playbook.get("playbook") or getattr(decision, "setup_type", "") or "").lower()
    if "breakout_acceptance" not in name and "breakdown_acceptance" not in name:
        return []

    of: OrderflowSnapshot = coin_data["of"]
    regime: MarketRegime = coin_data["regime"]
    trigger_meta = coin_data.get("trigger_meta") or {}
    trigger_state = coin_data.get("trigger_state") or {}
    reasons: list[str] = []

    if side == "long":
        if regime.trend_4h == "down" and of.price_vs_vwap < 0 and of.bid_absorption < policy.reversal_absorption_threshold:
            reasons.append(
                "breakout long blocked: 4h trend down and price still below VWAP/value without strong bid absorption"
            )
        if trigger_meta and trigger_meta.get("support_response") is not True and trigger_state.get("breakout_acceptance_long") is not True:
            reasons.append("breakout long blocked: no computed hold/retest response after breakout level")
    else:
        if regime.trend_4h == "up" and of.price_vs_vwap > 0 and of.ask_absorption < policy.reversal_absorption_threshold:
            reasons.append(
                "breakout short blocked: 4h trend up and price still above VWAP/value without strong ask absorption"
            )
        if trigger_meta and trigger_meta.get("resistance_response") is not True and trigger_state.get("breakdown_acceptance_short") is not True:
            reasons.append("breakout short blocked: no computed hold/retest response after breakdown level")

    return reasons


def _side_price_response_conflicts(side: str, coin_data: dict[str, Any], policy: TradingPolicy) -> bool:
    trigger_state = coin_data.get("trigger_state") or {}
    if true_side_triggers(side, trigger_state) and _fresh_tape_agrees(side, coin_data, policy):
        return False

    candles = coin_data.get("candles_15m") or []
    if len(candles) < 2:
        return False
    prev = candles[-2].close
    last = candles[-1].close
    if prev <= 0:
        return False
    change = (last - prev) / prev
    if side == "long":
        return change < -0.001
    return change > 0.001


def _fresh_tape_agrees(
    side: str,
    coin_data: dict[str, Any],
    policy: TradingPolicy,
    *,
    require_all: bool = False,
) -> bool:
    ws = coin_data.get("ws_summary") or {}
    windows = ws.get("tape_windows") if isinstance(ws.get("tape_windows"), dict) else {}
    values = []
    for key in ("30s", "1m"):
        window = windows.get(key) if isinstance(windows.get(key), dict) else {}
        total = float(window.get("total_usd") or 0.0)
        if total <= 0:
            continue
        values.append(float(window.get("delta_ratio") or 0.0))
    if not values:
        values.append(float(ws.get("delta_ratio_30s") or 0.0))

    threshold = float(policy.trigger_response_delta_threshold)
    if side == "long":
        checks = [value >= threshold for value in values]
    else:
        checks = [value <= -threshold for value in values]
    return all(checks) if require_all else any(checks)


def _entry_text(decision: Any) -> str:
    parts = [
        str(getattr(decision, "setup_type", "") or ""),
        str(getattr(decision, "reason", "") or ""),
    ]
    parts.extend(str(x) for x in (getattr(decision, "evidence", None) or []))
    level_reasoning = getattr(decision, "level_reasoning", None) or {}
    if isinstance(level_reasoning, dict):
        parts.append(str(level_reasoning.get("entry") or ""))
    trade_plan = getattr(decision, "trade_plan", None) or {}
    if isinstance(trade_plan, dict):
        parts.append(str(trade_plan.get("thesis") or ""))
        parts.append(str(trade_plan.get("entry_trigger") or ""))
    playbook = getattr(decision, "playbook_match", None) or {}
    if isinstance(playbook, dict):
        parts.append(json.dumps(playbook, sort_keys=True))
    return " ".join(parts).lower()


def _all_text(decision: Any) -> str:
    fields = [
        getattr(decision, "setup_type", ""),
        getattr(decision, "reason", ""),
        getattr(decision, "regime_note", ""),
        getattr(decision, "risk_assessment", ""),
    ]
    fields.extend(getattr(decision, "evidence", None) or [])
    fields.append(json.dumps(getattr(decision, "level_reasoning", None) or {}, sort_keys=True))
    fields.append(json.dumps(getattr(decision, "exit_plan", None) or {}, sort_keys=True))
    fields.append(json.dumps(getattr(decision, "trade_plan", None) or {}, sort_keys=True))
    fields.append(json.dumps(getattr(decision, "playbook_match", None) or {}, sort_keys=True))
    return " ".join(str(x) for x in fields).lower()


def _has_any(text: str, needles: tuple[str, ...]) -> bool:
    return any(needle in text for needle in needles)
