"""
exit_market_sensor.py — Multi-dimensional market perception for exit decisions.

Reads live bars + regime data every cycle and produces a rich market context
that the exit optimizer uses to adapt its behavior per market phase.

Dimensions (7 total):
  D1: Volatility state   — ATR ratio vs 20-bar baseline, GARCH vol_ratio
  D2: Trend strength     — ADX × EMA alignment × DI direction × MACD momentum
  D3: Trend quality      — Structure integrity, pullback depth, bar consecutiveness
  D4: Volume energy      — Tick volume expansion/contraction vs baseline
  D5: Key level proximity — Distance to S/R levels in ATR units
  D6: Market phase       — ACCUMULATION / MARKUP / DISTRIBUTION / MARKDOWN / CHOP
  D7: Anomaly flags      — Flash spike, liquidity vacuum, gap, news event

Output: MarketContext dataclass consumed by exit_optimizer evaluate_exit().
"""
from __future__ import annotations

import time
from dataclasses import dataclass, field
from typing import Any


# ═══════════════════════════════════════════════════════════════
# Data structures
# ═══════════════════════════════════════════════════════════════

@dataclass
class MarketContext:
    """Rich market perception snapshot for exit decisions."""

    # ── D1: Volatility ──
    atr: float = 3.0
    atr_ratio: float = 1.0         # current ATR / 20-bar avg ATR (>1=expanding, <1=contracting)
    vol_regime: str = "NORMAL"      # LOW / NORMAL / HIGH / EXTREME
    vol_percentile: float = 0.5     # 0-1 where current vol sits in recent distribution

    # ── D2: Trend strength ──
    adx: float = 25.0
    adx_slope: float = 0.0          # ADX change over last 6 bars (±)
    di_diff: float = 0.0            # +DI − -DI (positive = bullish momentum)
    trend_strength: str = "NEUTRAL"  # STRONG_BULL / BULL / NEUTRAL / BEAR / STRONG_BEAR
    trend_score: float = 0.0        # -1.0 to +1.0 composite trend score

    # ── D3: Trend quality ──
    ema_aligned: bool = False       # EMA9>21>50 (bull) or reverse (bear)
    pullback_depth_atr: float = 0.0  # how far from peak in ATR units
    structure_intact: bool = True   # whether higher-highs/higher-lows pattern holds
    consecutive_bars: int = 0       # consecutive same-direction bars (stealth trend)

    # ── D4: Volume energy ──
    vol_expanding: bool = False     # recent vol > 1.3× baseline
    vol_contracting: bool = False   # recent vol < 0.7× baseline
    vol_ratio: float = 1.0          # recent_vol / baseline_vol

    # ── D5: Key levels ──
    nearest_support: float = 0.0
    nearest_resistance: float = 0.0
    dist_to_support_atr: float = 999.0
    dist_to_resistance_atr: float = 999.0
    inside_range: bool = False      # price is within a detected trading range

    # ── D6: Market phase ──
    phase: str = "UNKNOWN"          # ACCUMULATION / MARKUP / DISTRIBUTION / MARKDOWN / CHOP
    phase_confidence: float = 0.0

    # ── D7: Anomaly ──
    flash_spike: bool = False       # sudden >3× ATR bar
    liquidity_vacuum: bool = False  # spread > 3× normal
    gap_detected: bool = False      # price gap between bars
    anomaly_score: float = 0.0      # 0-1 composite anomaly score

    # ── Meta ──
    timestamp_ms: int = 0
    data_valid: bool = False


# ═══════════════════════════════════════════════════════════════
# Core sensor
# ═══════════════════════════════════════════════════════════════

class ExitMarketSensor:
    """Multi-dimensional market sensor for exit timing optimization.

    Reads bars (OHLCV) + current price/spread and produces a MarketContext
    that the exit optimizer uses to adapt exit thresholds, trailing distances,
    partial-close ratios, and emergency triggers.

    Usage::

        sensor = ExitMarketSensor()
        ctx = sensor.sense(bars, bid, ask, spread, regime_label)
        # ctx consumed by exit_optimizer.evaluate_exit()
    """

    def __init__(self):
        self._prev_ctx: MarketContext | None = None

    # ── Public API ──────────────────────────────────────────────

    def sense(
        self,
        bars: list[dict],
        bid: float = 0.0,
        ask: float = 0.0,
        spread: float = 0.0,
        regime_label: str = "UNKNOWN",
        vol_ratio: float = 1.0,
    ) -> MarketContext:
        """Produce a full MarketContext from raw market data."""
        ctx = MarketContext(timestamp_ms=int(time.time() * 1000))

        if not bars or len(bars) < 20:
            return ctx  # data_valid = False

        closes = [float(b.get("close", 0)) for b in bars]
        highs = [float(b.get("high", 0)) for b in bars]
        lows = [float(b.get("low", 0)) for b in bars]
        volumes = [float(b.get("tick_volume", 0)) for b in bars]
        current_price = (bid + ask) / 2.0 if bid and ask else closes[-1]
        spread_val = spread if spread > 0 else (ask - bid)

        if not any(closes) or current_price <= 0:
            return ctx

        # ── Compute all indicator series once ──
        atr_val = self._compute_atr(highs, lows, closes)
        ctx.atr = atr_val
        ctx.atr_ratio = self._atr_ratio(atr_val, highs, lows, closes)
        ctx.vol_percentile = self._vol_percentile(atr_val, highs, lows, closes)

        # D2: Trend strength
        adx_val, adx_series, plus_di, minus_di = self._compute_adx(bars)
        ctx.adx = adx_val
        ctx.adx_slope = self._compute_adx_slope(adx_series)
        ctx.di_diff = plus_di - minus_di
        ctx.trend_strength = self._classify_trend_strength(adx_val, plus_di, minus_di)
        ctx.trend_score = self._trend_composite_score(
            adx_val, plus_di, minus_di, closes, ctx.adx_slope
        )

        # D3: Trend quality
        ema9 = self._ema(closes, 9)
        ema21 = self._ema(closes, 21)
        ema50 = self._ema(closes, 50)
        ctx.ema_aligned = (
            ema9 > ema21 > ema50 if ema9 and ema21 and ema50
            else ema9 < ema21 < ema50 if ema9 and ema21 and ema50
            else False
        )
        ctx.pullback_depth_atr = self._pullback_depth(bars, current_price, atr_val)
        ctx.structure_intact = self._check_structure(bars)
        ctx.consecutive_bars = self._count_consecutive_bars(closes)

        # D4: Volume energy
        if len(volumes) >= 20:
            avg_vol = sum(volumes[-20:]) / 20
            recent_vol = sum(volumes[-5:]) / 5
            ctx.vol_ratio = recent_vol / max(0.01, avg_vol)
            ctx.vol_expanding = ctx.vol_ratio > 1.3
            ctx.vol_contracting = ctx.vol_ratio < 0.7
        else:
            ctx.vol_ratio = vol_ratio

        # D5: Key levels
        support, resistance = self._find_key_levels(highs, lows, closes)
        ctx.nearest_support = support
        ctx.nearest_resistance = resistance
        ctx.dist_to_support_atr = (current_price - support) / max(0.01, atr_val)
        ctx.dist_to_resistance_atr = (resistance - current_price) / max(0.01, atr_val)
        ctx.inside_range = self._detect_trading_range(highs, lows, closes)

        # D6: Market phase
        ctx.phase, ctx.phase_confidence = self._classify_phase(
            bars, adx_val, ctx.vol_ratio, ctx.ema_aligned, regime_label
        )

        # D7: Anomaly
        ctx.flash_spike = self._detect_flash_spike(bars, atr_val)
        ctx.liquidity_vacuum = spread_val > 3.0 * max(1.5, self._avg_spread(bars))
        ctx.gap_detected = self._detect_gap(bars)
        ctx.anomaly_score = self._compute_anomaly_score(ctx)

        # Vol regime
        ctx.vol_regime = self._vol_regime_label(ctx.atr_ratio, ctx.flash_spike)

        ctx.data_valid = True
        self._prev_ctx = ctx
        return ctx

    # ═══════════════════════════════════════════════════════════
    # D1: Volatility
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _compute_atr(highs, lows, closes, period=14):
        trs = []
        for i in range(1, len(highs)):
            trs.append(max(
                highs[i] - lows[i],
                abs(highs[i] - closes[i - 1]),
                abs(lows[i] - closes[i - 1]),
            ))
        if len(trs) < period:
            return sum(trs) / max(1, len(trs))
        return sum(trs[-period:]) / period

    @staticmethod
    def _atr_ratio(current_atr, highs, lows, closes):
        """Current ATR / rolling 20-bar avg ATR."""
        if len(highs) < 40:
            return 1.0
        atr_samples = []
        for window_start in range(max(0, len(highs) - 40), len(highs) - 13):
            w_highs = highs[window_start:window_start + 14]
            w_lows = lows[window_start:window_start + 14]
            w_closes = closes[window_start:window_start + 14]
            sample = ExitMarketSensor._compute_atr(w_highs, w_lows, w_closes, 14)
            if sample > 0:
                atr_samples.append(sample)
        if not atr_samples:
            return 1.0
        return current_atr / (sum(atr_samples) / len(atr_samples))

    @staticmethod
    def _vol_percentile(current_atr, highs, lows, closes):
        """Where current ATR sits in the recent distribution (0-1)."""
        if len(highs) < 40:
            return 0.5
        atr_samples = []
        for ws in range(max(0, len(highs) - 40), len(highs) - 13):
            s = ExitMarketSensor._compute_atr(
                highs[ws:ws + 14], lows[ws:ws + 14], closes[ws:ws + 14], 14
            )
            if s > 0:
                atr_samples.append(s)
        if not atr_samples:
            return 0.5
        return sum(1 for s in atr_samples if s <= current_atr) / len(atr_samples)

    @staticmethod
    def _vol_regime_label(atr_ratio, flash_spike):
        if flash_spike:
            return "EXTREME"
        if atr_ratio > 1.8:
            return "HIGH"
        if atr_ratio > 1.3:
            return "ELEVATED"
        if atr_ratio < 0.5:
            return "LOW"
        return "NORMAL"

    # ═══════════════════════════════════════════════════════════
    # D2: Trend strength
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _compute_adx(bars, period=14):
        n = len(bars)
        if n < period + 1:
            return 25.0, [25.0], 25.0, 25.0
        tr_list, plus_dm, minus_dm = [], [], []
        for i in range(1, n):
            b, pb = bars[i], bars[i - 1]
            hl = b["high"] - pb["high"]
            lh = pb["low"] - b["low"]
            plus_dm.append(max(hl, 0) if hl > lh else 0)
            minus_dm.append(max(lh, 0) if lh > hl else 0)
            tr_list.append(max(
                b["high"] - b["low"],
                abs(b["high"] - pb["close"]),
                abs(b["low"] - pb["close"]),
            ))

        def _ema(data, p):
            if len(data) < p:
                return [sum(data) / max(1, len(data))] * len(data) if data else [0]
            k = 2.0 / (p + 1)
            result = [sum(data[:p]) / p]
            for v in data[p:]:
                result.append(v * k + result[-1] * (1 - k))
            return [result[0]] * (len(data) - len(result)) + result

        atr_s = _ema(tr_list, period)
        ep = _ema(plus_dm, period)
        em = _ema(minus_dm, period)
        di_range = min(len(atr_s), len(ep), len(em))
        plus_di_vals, minus_di_vals, dx_vals = [], [], []
        for j in range(di_range):
            dp = (100 * ep[j] / atr_s[j]) if atr_s[j] > 0 else 0
            dm = (100 * em[j] / atr_s[j]) if atr_s[j] > 0 else 0
            plus_di_vals.append(dp)
            minus_di_vals.append(dm)
            dx_vals.append(100 * abs(dp - dm) / (dp + dm) if (dp + dm) > 0 else 0)
        adx_ema = _ema(dx_vals, period)
        return (
            adx_ema[-1] if adx_ema else 25.0,
            adx_ema,
            plus_di_vals[-1] if plus_di_vals else 25.0,
            minus_di_vals[-1] if minus_di_vals else 25.0,
        )

    @staticmethod
    def _compute_adx_slope(adx_series):
        if len(adx_series) < 6:
            return 0.0
        return adx_series[-1] - adx_series[-6]

    @staticmethod
    def _classify_trend_strength(adx, plus_di, minus_di):
        if adx < 18:
            return "NEUTRAL"
        if plus_di > minus_di:
            return "STRONG_BULL" if adx >= 28 else "BULL"
        return "STRONG_BEAR" if adx >= 28 else "BEAR"

    @staticmethod
    def _trend_composite_score(adx, plus_di, minus_di, closes, adx_slope):
        """Composite trend score: -1.0 (strong bear) to +1.0 (strong bull)."""
        di_score = (plus_di - minus_di) / max(1.0, plus_di + minus_di)
        adx_weight = min(1.0, max(0.1, adx / 30.0))
        momentum = (closes[-1] - closes[-10]) / max(0.01, closes[-10]) * 100 if len(closes) >= 10 else 0
        momentum_score = max(-1.0, min(1.0, momentum / 5.0))
        slope_score = max(-1.0, min(1.0, adx_slope / 5.0))
        return round(di_score * 0.5 + momentum_score * 0.3 + slope_score * 0.2, 4)

    # ═══════════════════════════════════════════════════════════
    # D3: Trend quality
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _ema(data, period):
        if len(data) < period:
            return None
        k = 2.0 / (period + 1)
        result = sum(data[:period]) / period
        for v in data[period:]:
            result = v * k + result * (1 - k)
        return result

    @staticmethod
    def _pullback_depth(bars, current_price, atr):
        """How far price has retraced from its recent extreme (ATR units)."""
        if len(bars) < 20:
            return 0.0
        recent_high = max(b["high"] for b in bars[-20:])
        recent_low = min(b["low"] for b in bars[-20:])
        if current_price >= recent_high * 0.99:
            return 0.0  # at the high — no pullback
        return (recent_high - current_price) / max(0.01, atr)

    @staticmethod
    def _check_structure(bars):
        """Check if higher-highs/higher-lows (uptrend) or lower-highs/lower-lows
        (downtrend) pattern remains intact."""
        if len(bars) < 20:
            return True
        mid = len(bars) // 2
        first_highs = [b["high"] for b in bars[:mid]]
        second_highs = [b["high"] for b in bars[mid:]]
        first_lows = [b["low"] for b in bars[:mid]]
        second_lows = [b["low"] for b in bars[mid:]]
        avg_fh = sum(first_highs) / len(first_highs)
        avg_sh = sum(second_highs) / len(second_highs)
        avg_fl = sum(first_lows) / len(first_lows)
        avg_sl = sum(second_lows) / len(second_lows)
        # Structure breaking: both highs and lows moving opposite to trend
        return not (avg_sh < avg_fh and avg_sl < avg_fl)  # bull structure break

    @staticmethod
    def _count_consecutive_bars(closes):
        """Count consecutive same-direction bars from the end."""
        if len(closes) < 3:
            return 0
        count = 0
        direction = 1 if closes[-1] > closes[-2] else -1
        for i in range(len(closes) - 2, max(0, len(closes) - 15), -1):
            if (closes[i + 1] - closes[i]) * direction > 0:
                count += 1
            else:
                break
        return count

    # ═══════════════════════════════════════════════════════════
    # D5: Key levels
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _find_key_levels(highs, lows, closes):
        """Find nearest significant support/resistance from pivot points."""
        if len(highs) < 20:
            return lows[-1], highs[-1]
        # Use 20-bar pivot highs and lows
        pivot_highs = []
        pivot_lows = []
        for i in range(2, len(highs) - 2):
            if highs[i] > highs[i - 1] and highs[i] > highs[i - 2] and \
               highs[i] > highs[i + 1] and highs[i] > highs[i + 2]:
                pivot_highs.append(highs[i])
            if lows[i] < lows[i - 1] and lows[i] < lows[i - 2] and \
               lows[i] < lows[i + 1] and lows[i] < lows[i + 2]:
                pivot_lows.append(lows[i])
        current = closes[-1]
        # Nearest resistance above current price
        above = [h for h in pivot_highs if h > current]
        resistance = min(above) if above else highs[-1]
        # Nearest support below current price
        below = [l for l in pivot_lows if l < current]
        support = max(below) if below else lows[-1]
        return support, resistance

    @staticmethod
    def _detect_trading_range(highs, lows, closes):
        """Check if price is inside a detectable trading range."""
        if len(highs) < 60:
            return False
        recent = highs[-60:]
        tr_high = max(recent)
        tr_low = min(lows[-60:])
        tr_range = tr_high - tr_low
        atr = ExitMarketSensor._compute_atr(highs, lows, closes)
        if atr <= 0:
            return False
        # A real range is 2-5 ATR wide (not too tight, not too wide)
        tr_width_atr = tr_range / atr
        if not (2.0 <= tr_width_atr <= 8.0):
            return False
        # Current price should be inside the range (not breaking out)
        current = closes[-1]
        return tr_low + 0.1 * tr_range <= current <= tr_high - 0.1 * tr_range

    # ═══════════════════════════════════════════════════════════
    # D6: Market phase (Wyckoff-inspired)
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _classify_phase(bars, adx, vol_ratio, ema_aligned, regime_label):
        """Classify market into ACCUMULATION / MARKUP / DISTRIBUTION / MARKDOWN / CHOP."""
        # Use regime_router's label as primary signal
        reg = str(regime_label or "").upper()
        if reg in ("STRONG_TREND",):
            return "MARKUP" if ema_aligned else "MARKDOWN", 0.80
        if reg in ("WEAK_TREND", "STEALTH_TREND"):
            return "MARKUP" if ema_aligned else "MARKDOWN", 0.55
        if reg in ("RANGING",):
            # Distinguish accumulation vs distribution by volume
            if vol_ratio > 1.1:
                return "ACCUMULATION", 0.60
            if vol_ratio < 0.85:
                return "DISTRIBUTION", 0.60
            return "CHOP", 0.50
        if reg in ("FAKE_TREND",):
            return "DISTRIBUTION", 0.70  # false breakout = distribution
        if reg in ("VOLATILITY_SPIKE", "EXTREME"):
            return "CHOP", 0.80  # chaos
        return "CHOP", 0.30

    # ═══════════════════════════════════════════════════════════
    # D7: Anomaly detection
    # ═══════════════════════════════════════════════════════════

    @staticmethod
    def _detect_flash_spike(bars, atr):
        """A bar with body > 3× ATR is a flash spike."""
        if len(bars) < 3:
            return False
        latest = bars[-1]
        body = abs(latest["close"] - latest["open"])
        return body > 3.0 * atr and atr > 0

    @staticmethod
    def _avg_spread(bars):
        """Estimate average spread from bar high-low ranges."""
        if len(bars) < 20:
            return 3.0
        ranges = [b["high"] - b["low"] for b in bars[-20:]]
        return sum(ranges) / len(ranges) * 0.15  # spread ~15% of bar range

    @staticmethod
    def _detect_gap(bars):
        """Price gap between consecutive bars."""
        if len(bars) < 3:
            return False
        prev_close = bars[-2]["close"]
        curr_open = bars[-1]["open"]
        if prev_close <= 0 or curr_open <= 0:
            return False
        gap_ratio = abs(curr_open - prev_close) / max(0.01, prev_close)
        return gap_ratio > 0.003  # 0.3% gap = ~$13 on gold

    @staticmethod
    def _compute_anomaly_score(ctx: MarketContext) -> float:
        """Composite anomaly score 0-1."""
        score = 0.0
        if ctx.flash_spike:
            score += 0.4
        if ctx.liquidity_vacuum:
            score += 0.3
        if ctx.gap_detected:
            score += 0.2
        if ctx.vol_regime == "EXTREME":
            score += 0.3
        elif ctx.vol_regime == "HIGH":
            score += 0.15
        if not ctx.structure_intact:
            score += 0.1
        return min(1.0, score)


# ═══════════════════════════════════════════════════════════════
# Singleton
# ═══════════════════════════════════════════════════════════════

_DEFAULT_SENSOR: ExitMarketSensor | None = None


def get_exit_market_sensor() -> ExitMarketSensor:
    global _DEFAULT_SENSOR
    if _DEFAULT_SENSOR is None:
        _DEFAULT_SENSOR = ExitMarketSensor()
    return _DEFAULT_SENSOR
