"""
Orderflow analysis engine — L2 book, tape reading, CVD tracking,
absorption detection, volume profile, stacked imbalances.

Implements all concepts from the orderflow wiki documents.
"""
from __future__ import annotations
import json
import time
import math
import logging
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from .models import (
    BookSnapshot, BookLevel, TradeTick, OrderflowSnapshot,
    Candle, MarketRegime,
)

log = logging.getLogger(__name__)

# === Constants ===
IMBALANCE_THRESHOLD = 0.20
STACKED_DOMINANCE = 1.8
ABSORPTION_MIN_SCORE = 0.05
LARGE_TRADE_MULT = 8.0
LARGE_TRADE_MIN_USD = 5000
CVD_DIVERGENCE_WINDOW = 10  # candles


def parse_book(raw: dict, mid: Optional[float] = None) -> BookSnapshot:
    """Parse Hyperliquid l2Book response into BookSnapshot."""
    ts = int(time.time() * 1000)
    levels = raw.get("levels", [])
    bids_raw = levels[0] if len(levels) > 0 else raw.get("bids", [])
    asks_raw = levels[1] if len(levels) > 1 else raw.get("asks", [])

    bids = [BookLevel(price=float(l["px"]), size=float(l["sz"])) for l in bids_raw]
    asks = [BookLevel(price=float(l["px"]), size=float(l["sz"])) for l in asks_raw]

    if not mid:
        if bids and asks:
            mid = (bids[0].price + asks[0].price) / 2
        elif bids:
            mid = bids[0].price
        elif asks:
            mid = asks[0].price
        else:
            mid = 0.0

    return BookSnapshot(ts=ts, bids=bids, asks=asks, mid=mid)


def parse_tape(raw_trades: list[dict]) -> list[TradeTick]:
    """Parse recentTrades response."""
    ticks = []
    for t in raw_trades:
        ticks.append(TradeTick(
            ts=int(t.get("time", t.get("timestamp", time.time() * 1000))),
            price=float(t["px"]),
            size=float(t["sz"]),
            side=t.get("side", "buy"),
        ))
    return ticks


# === Book Analysis ===

def analyze_book(book: BookSnapshot, depth_levels: int = 10) -> dict:
    """
    Full L2 book analysis: imbalance, walls, stacked imbalances,
    proximity-weighted depth skew (MDS).
    """
    bids = book.bids[:depth_levels]
    asks = book.asks[:depth_levels]

    bid_depth = sum(b.usd for b in bids)
    ask_depth = sum(a.usd for a in asks)
    total_depth = bid_depth + ask_depth

    # Book imbalance
    imbalance = (bid_depth - ask_depth) / total_depth if total_depth > 0 else 0

    # Largest walls
    largest_bid = max((b.usd for b in bids), default=0)
    largest_ask = max((a.usd for a in asks), default=0)
    wall_skew = 0.0
    if largest_bid and largest_ask:
        wall_skew = (largest_bid - largest_ask) / (largest_bid + largest_ask)

    # Stacked imbalances — consecutive levels where one side dominates
    stacked_bid = _count_stacked_imbalances(bids, asks, "bid")
    stacked_ask = _count_stacked_imbalances(bids, asks, "ask")

    # MDS — Market Depth Skew with proximity weighting (exponential decay)
    mds = _compute_mds(bids, asks, book.mid)

    return {
        "bid_depth_usd": bid_depth,
        "ask_depth_usd": ask_depth,
        "book_imbalance": imbalance,
        "wall_skew": wall_skew,
        "stacked_bid_count": stacked_bid,
        "stacked_ask_count": stacked_ask,
        "largest_bid_wall": largest_bid,
        "largest_ask_wall": largest_ask,
        "mds": mds,
        "spread_bps": book.spread_bps,
    }


def _count_stacked_imbalances(bids: list[BookLevel], asks: list[BookLevel], side: str) -> int:
    """Count consecutive levels where one side dominates by STACKED_DOMINANCE factor."""
    count = 0
    n = min(len(bids), len(asks))
    for i in range(n):
        if side == "bid" and bids[i].usd >= asks[i].usd * STACKED_DOMINANCE:
            count += 1
        elif side == "ask" and asks[i].usd >= bids[i].usd * STACKED_DOMINANCE:
            count += 1
        else:
            break
    return count


def _compute_mds(bids: list[BookLevel], asks: list[BookLevel], mid: float) -> float:
    """
    Proximity-weighted depth skew.
    S = (W_bid - W_ask) / (W_bid + W_ask)
    Orders closer to mid get exponentially more weight.
    """
    if mid == 0:
        return 0.0
    w_bid = 0.0
    w_ask = 0.0
    for b in bids:
        dist = abs(b.price - mid) / mid
        weight = math.exp(-dist * 20)  # exponential decay
        w_bid += b.usd * weight
    for a in asks:
        dist = abs(a.price - mid) / mid
        weight = math.exp(-dist * 20)
        w_ask += a.usd * weight
    total = w_bid + w_ask
    if total == 0:
        return 0.0
    return (w_bid - w_ask) / total


# === Tape Analysis ===

def analyze_tape(ticks: list[TradeTick], window_sec: float = 60.0) -> dict:
    """
    Tape analysis: delta, large prints, trades-per-second.
    """
    if not ticks:
        return {
            "tape_delta_usd": 0, "tape_delta_pct": 0,
            "large_prints": 0, "trades_per_sec": 0,
            "buy_usd": 0, "sell_usd": 0,
        }

    now_ms = max(t.ts for t in ticks)
    cutoff = now_ms - window_sec * 1000
    recent = [t for t in ticks if t.ts >= cutoff]

    buy_usd = sum(t.usd for t in recent if t.side == "buy" or t.side == "B")
    sell_usd = sum(t.usd for t in recent if t.side == "sell" or t.side == "A")
    delta = buy_usd - sell_usd
    total = buy_usd + sell_usd
    delta_pct = delta / total if total > 0 else 0

    # Large print detection
    if recent:
        sizes = [t.usd for t in recent]
        median_size = sorted(sizes)[len(sizes) // 2] if sizes else 0
        threshold = max(LARGE_TRADE_MIN_USD, median_size * LARGE_TRADE_MULT)
        large = sum(1 for s in sizes if s >= threshold)
    else:
        large = 0

    elapsed = max(1, (now_ms - cutoff) / 1000)
    tps = len(recent) / elapsed

    return {
        "tape_delta_usd": delta,
        "tape_delta_pct": delta_pct,
        "large_prints": large,
        "trades_per_sec": tps,
        "buy_usd": buy_usd,
        "sell_usd": sell_usd,
    }


# === CVD Tracking ===

class CVDTracker:
    """
    Cumulative Volume Delta tracker per coin.
    Tracks running CVD and detects divergences vs price.
    """

    def __init__(
        self,
        history_size: int = 100,
        state_path: str | None = None,
        reset_hour_utc: int = 0,
    ):
        self.history_size = history_size
        self.state_path = Path(state_path) if state_path else None
        self.reset_hour_utc = int(reset_hour_utc) % 24
        self._session_key = self._current_session_key()
        # coin -> list of (ts, cvd_value, price)
        self._data: dict[str, list[tuple[int, float, float]]] = defaultdict(list)
        self._cvd: dict[str, float] = defaultdict(float)
        self._load_state()

    def update(self, coin: str, ticks: list[TradeTick], current_price: float):
        """Feed new ticks, update CVD."""
        self._maybe_reset_session()
        for t in ticks:
            if t.side in ("buy", "B"):
                self._cvd[coin] += t.usd
            else:
                self._cvd[coin] -= t.usd

        # Store snapshot
        self._data[coin].append((
            int(time.time() * 1000),
            self._cvd[coin],
            current_price,
        ))
        # Trim
        if len(self._data[coin]) > self.history_size:
            self._data[coin] = self._data[coin][-self.history_size:]
        self._save_state()

    def get_cvd(self, coin: str) -> float:
        return self._cvd.get(coin, 0.0)

    def detect_divergence(self, coin: str) -> Optional[str]:
        """
        Detect CVD divergence vs price.
        Returns: 'bullish_divergence', 'bearish_divergence', or None.

        Bearish: price higher-high, CVD lower-high (buying exhaustion)
        Bullish: price lower-low, CVD higher-low (selling exhaustion)
        """
        data = self._data.get(coin, [])
        if len(data) < CVD_DIVERGENCE_WINDOW:
            return None

        recent = data[-CVD_DIVERGENCE_WINDOW:]
        older = data[-CVD_DIVERGENCE_WINDOW * 2:-CVD_DIVERGENCE_WINDOW] if len(data) >= CVD_DIVERGENCE_WINDOW * 2 else data[:CVD_DIVERGENCE_WINDOW]

        if not older:
            return None

        # Price trend
        price_recent_max = max(d[2] for d in recent)
        price_older_max = max(d[2] for d in older)
        price_recent_min = min(d[2] for d in recent)
        price_older_min = min(d[2] for d in older)

        # CVD trend
        cvd_recent_max = max(d[1] for d in recent)
        cvd_older_max = max(d[1] for d in older)
        cvd_recent_min = min(d[1] for d in recent)
        cvd_older_min = min(d[1] for d in older)

        # Bearish divergence: price makes HH, CVD makes LH
        if price_recent_max > price_older_max and cvd_recent_max < cvd_older_max:
            return "bearish_divergence"

        # Bullish divergence: price makes LL, CVD makes HL
        if price_recent_min < price_older_min and cvd_recent_min > cvd_older_min:
            return "bullish_divergence"

        return None

    def reset(self, coin: str):
        self._cvd[coin] = 0.0
        self._data[coin] = []
        self._save_state()

    def _current_session_key(self) -> str:
        now = datetime.now(timezone.utc)
        if now.hour < self.reset_hour_utc:
            now = datetime.fromtimestamp(now.timestamp() - 86400, tz=timezone.utc)
        return now.date().isoformat()

    def _maybe_reset_session(self):
        key = self._current_session_key()
        if key == self._session_key:
            return
        log.info("CVD session reset: %s -> %s", self._session_key, key)
        self._session_key = key
        self._data.clear()
        self._cvd.clear()
        self._save_state()

    def _load_state(self):
        if not self.state_path or not self.state_path.exists():
            return
        try:
            raw = json.loads(self.state_path.read_text())
            if raw.get("session_key") != self._session_key:
                return
            self._cvd.update({str(k): float(v) for k, v in (raw.get("cvd") or {}).items()})
            for coin, rows in (raw.get("data") or {}).items():
                parsed = []
                for row in rows[-self.history_size:]:
                    if isinstance(row, (list, tuple)) and len(row) == 3:
                        parsed.append((int(row[0]), float(row[1]), float(row[2])))
                self._data[str(coin)] = parsed
        except Exception as exc:
            log.warning("CVD state load failed: %s", exc)

    def _save_state(self):
        if not self.state_path:
            return
        try:
            self.state_path.parent.mkdir(parents=True, exist_ok=True)
            data = {coin: rows[-self.history_size:] for coin, rows in self._data.items()}
            payload = {
                "session_key": self._session_key,
                "reset_hour_utc": self.reset_hour_utc,
                "cvd": dict(self._cvd),
                "data": data,
                "updated_ms": int(time.time() * 1000),
            }
            self.state_path.write_text(json.dumps(payload, sort_keys=True))
        except Exception as exc:
            log.debug("CVD state save failed: %s", exc)


# === Absorption Detection ===

def detect_absorption(
    book_snapshots: list[BookSnapshot],
    tape: dict,
    price_change_bps: float,
) -> dict:
    """
    Detect absorption: high aggressive volume but price stalls.

    Bid absorption: selling into bids but price holds → buyers absorbing
    Ask absorption: buying into asks but price stalls → sellers absorbing
    """
    bid_abs = 0.0
    ask_abs = 0.0

    if not book_snapshots:
        return {"bid_absorption": bid_abs, "ask_absorption": ask_abs}

    # Check for repeated price levels that don't deplete
    bid_prices = defaultdict(int)
    ask_prices = defaultdict(int)
    for snap in book_snapshots:
        for b in snap.bids[:5]:
            bid_prices[b.price] += 1
        for a in snap.asks[:5]:
            ask_prices[a.price] += 1

    n = len(book_snapshots)

    # Bid absorption: same bid price persists across snapshots (being absorbed, not fleeing)
    if n > 1:
        persistent_bids = sum(1 for c in bid_prices.values() if c >= n * 0.7)
        bid_abs = min(1.0, persistent_bids / max(1, len(bid_prices)) * 2)

        persistent_asks = sum(1 for c in ask_prices.values() if c >= n * 0.7)
        ask_abs = min(1.0, persistent_asks / max(1, len(ask_prices)) * 2)

    # Tape confirmation: sell delta must be SIGNIFICANT and price truly stalls
    sell_usd = tape.get("sell_usd", 0)
    buy_usd = tape.get("buy_usd", 0)
    total_usd = sell_usd + buy_usd

    if total_usd > 0:
        # Bid absorption: heavy selling (sell > 60% of tape) but price didn't drop
        if sell_usd / total_usd > 0.6 and price_change_bps > -0.5:
            bid_abs = min(1.0, bid_abs + 0.3)

        # Ask absorption: heavy buying (buy > 60% of tape) but price didn't rise
        if buy_usd / total_usd > 0.6 and price_change_bps < 0.5:
            ask_abs = min(1.0, ask_abs + 0.3)

    return {"bid_absorption": bid_abs, "ask_absorption": ask_abs}


# === Volume Profile ===

def compute_volume_profile(candles: list[Candle], bins: int = 50) -> dict:
    """
    Compute Volume Point of Control (VPOC) and Value Area from candles.
    Distributes each candle's volume across price bins.
    """
    if not candles:
        return {"vpoc": 0, "vah": 0, "val": 0}

    all_highs = max(c.high for c in candles)
    all_lows = min(c.low for c in candles)
    if all_highs == all_lows:
        return {"vpoc": all_highs, "vah": all_highs, "val": all_lows}

    bin_size = (all_highs - all_lows) / bins
    vol_at_price = defaultdict(float)

    for c in candles:
        # Distribute volume across the candle's range
        if c.range == 0:
            vol_at_price[c.close] += c.volume
            continue
        lo_bin = int((c.low - all_lows) / bin_size)
        hi_bin = int((c.high - all_lows) / bin_size)
        n_bins = max(1, hi_bin - lo_bin + 1)
        vol_per_bin = c.volume / n_bins
        for i in range(lo_bin, hi_bin + 1):
            price = all_lows + (i + 0.5) * bin_size
            vol_at_price[price] += vol_per_bin

    # VPOC: price level with highest volume
    vpoc = max(vol_at_price, key=lambda p: vol_at_price[p])

    # Value Area: 70% of total volume around VPOC
    total_vol = sum(vol_at_price.values())
    target_vol = total_vol * 0.70

    sorted_prices = sorted(vol_at_price.keys())
    vpoc_idx = sorted_prices.index(vpoc)

    accumulated = vol_at_price[vpoc]
    lo_idx = vpoc_idx
    hi_idx = vpoc_idx

    while accumulated < target_vol:
        expand_lo = vol_at_price.get(sorted_prices[lo_idx - 1], 0) if lo_idx > 0 else 0
        expand_hi = vol_at_price.get(sorted_prices[hi_idx + 1], 0) if hi_idx < len(sorted_prices) - 1 else 0

        if expand_lo >= expand_hi and lo_idx > 0:
            lo_idx -= 1
            accumulated += expand_lo
        elif hi_idx < len(sorted_prices) - 1:
            hi_idx += 1
            accumulated += expand_hi
        else:
            break

    val = sorted_prices[lo_idx]
    vah = sorted_prices[hi_idx]

    return {"vpoc": vpoc, "vah": vah, "val": val}


# === Technical Indicators (lightweight) ===

def compute_rsi(closes: list[float], period: int = 14) -> float:
    if len(closes) < period + 1:
        return 50.0
    deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
    gains = [d if d > 0 else 0 for d in deltas[-period:]]
    losses = [-d if d < 0 else 0 for d in deltas[-period:]]
    avg_gain = sum(gains) / period
    avg_loss = sum(losses) / period
    if avg_loss == 0:
        return 100.0
    rs = avg_gain / avg_loss
    return 100 - (100 / (1 + rs))


def compute_macd(closes: list[float], fast: int = 12, slow: int = 26, signal: int = 9) -> float:
    """Returns MACD histogram value."""
    if len(closes) < slow + signal:
        return 0.0
    ema_fast = _ema(closes, fast)
    ema_slow = _ema(closes, slow)
    macd_line = ema_fast - ema_slow
    # Approximate signal line
    macd_series = []
    for i in range(slow, len(closes) + 1):
        ef = _ema(closes[:i], fast)
        es = _ema(closes[:i], slow)
        macd_series.append(ef - es)
    if len(macd_series) >= signal:
        sig = _ema(macd_series, signal)
        return macd_line - sig
    return macd_line


def compute_atr(candles: list[Candle], period: int = 14) -> float:
    """Average True Range."""
    if len(candles) < period + 1:
        return 0.0
    trs = []
    for i in range(1, len(candles)):
        tr = max(
            candles[i].high - candles[i].low,
            abs(candles[i].high - candles[i - 1].close),
            abs(candles[i].low - candles[i - 1].close),
        )
        trs.append(tr)
    if not trs:
        return 0.0
    return sum(trs[-period:]) / min(period, len(trs))


def compute_vwap(candles: list[Candle]) -> float:
    """Session VWAP from candles."""
    if not candles:
        return 0.0
    cum_vol = 0.0
    cum_tp_vol = 0.0
    for c in candles:
        tp = (c.high + c.low + c.close) / 3
        cum_tp_vol += tp * c.volume
        cum_vol += c.volume
    if cum_vol == 0:
        return candles[-1].close
    return cum_tp_vol / cum_vol


def _ema(data: list[float], period: int) -> float:
    if not data:
        return 0.0
    mult = 2 / (period + 1)
    ema = data[0]
    for val in data[1:]:
        ema = (val - ema) * mult + ema
    return ema


# === Regime Classification ===

def classify_regime(candles_15m: list[Candle], candles_1h: list[Candle], candles_4h: list[Candle]) -> MarketRegime:
    """Classify market regime from multi-timeframe candles."""

    def trend(candles: list[Candle]) -> str:
        if len(candles) < 5:
            return "chop"
        closes = [c.close for c in candles]
        sma = sum(closes[-10:]) / min(10, len(closes[-10:]))
        last = closes[-1]
        rsi = compute_rsi(closes)
        if last > sma * 1.002 and rsi > 55:
            return "up"
        elif last < sma * 0.998 and rsi < 45:
            return "down"
        return "chop"

    def vol_regime(candles: list[Candle]) -> str:
        if len(candles) < 20:
            return "normal"
        ranges = [c.range / c.close for c in candles if c.close > 0]
        recent = sum(ranges[-5:]) / 5
        older = sum(ranges[-20:-5]) / 15
        if older == 0:
            return "normal"
        ratio = recent / older
        if ratio > 1.5:
            return "expanding"
        elif ratio < 0.5:
            return "contracting"
        return "normal"

    t15 = trend(candles_15m)
    t1h = trend(candles_1h)
    t4h = trend(candles_4h)
    vr = vol_regime(candles_15m)

    # Overall
    trends = [t15, t1h, t4h]
    if all(t == "up" for t in trends):
        overall = "trending"
    elif all(t == "down" for t in trends):
        overall = "trending"
    elif vr == "expanding" and t15 == t1h and t15 != "chop":
        overall = "breakout"
    elif all(t == "chop" for t in trends):
        overall = "choppy"
    else:
        overall = "ranging"

    return MarketRegime(
        trend_15m=t15, trend_1h=t1h, trend_4h=t4h,
        vol_regime=vr, overall=overall,
    )


# === Full Snapshot Builder ===

def build_orderflow_snapshot(
    book: BookSnapshot,
    tape_ticks: list[TradeTick],
    candles: list[Candle],
    candles_1h: list[Candle],
    candles_4h: list[Candle],
    cvd_tracker: CVDTracker,
    coin: str,
    asset_ctx: Optional[dict] = None,
    book_snapshots: Optional[list[BookSnapshot]] = None,
) -> OrderflowSnapshot:
    """
    Build complete orderflow snapshot from all data sources.
    This is the main entry point for the strategy engine.
    """
    mid = book.mid
    book_analysis = analyze_book(book)
    tape_analysis = analyze_tape(tape_ticks)

    # Price change for absorption
    price_change_bps = 0.0
    if len(candles) >= 2:
        price_change_bps = (candles[-1].close - candles[-2].close) / candles[-2].close * 10000

    # Absorption
    all_book_snaps = book_snapshots or [book]
    absorption = detect_absorption(all_book_snaps, tape_analysis, price_change_bps)

    # CVD
    cvd_tracker.update(coin, tape_ticks, mid)
    cvd = cvd_tracker.get_cvd(coin)
    cvd_div = cvd_tracker.detect_divergence(coin) is not None

    # Technicals
    closes = [c.close for c in candles]
    rsi = compute_rsi(closes)
    macd = compute_macd(closes)
    atr = compute_atr(candles)
    vwap = compute_vwap(candles)

    # Volume profile from 1h candles
    vol_profile = compute_volume_profile(candles_1h if candles_1h else candles)

    # Funding / OI
    funding = 0.0
    oi = 0.0
    premium = 0.0
    if asset_ctx:
        funding = asset_ctx.get("funding", 0)
        oi = asset_ctx.get("open_interest", 0)
        premium = asset_ctx.get("premium_bps", 0)

    return OrderflowSnapshot(
        ts=int(time.time() * 1000),
        mid=mid,
        spread_bps=book_analysis["spread_bps"],
        book_imbalance=book_analysis["book_imbalance"],
        wall_skew=book_analysis["wall_skew"],
        stacked_bid_count=book_analysis["stacked_bid_count"],
        stacked_ask_count=book_analysis["stacked_ask_count"],
        bid_depth_usd=book_analysis["bid_depth_usd"],
        ask_depth_usd=book_analysis["ask_depth_usd"],
        tape_delta_usd=tape_analysis["tape_delta_usd"],
        tape_delta_pct=tape_analysis["tape_delta_pct"],
        cvd=cvd,
        cvd_divergence=cvd_div,
        large_prints=tape_analysis["large_prints"],
        trades_per_sec=tape_analysis["trades_per_sec"],
        bid_absorption=absorption["bid_absorption"],
        ask_absorption=absorption["ask_absorption"],
        vwap=vwap,
        price_vs_vwap=(mid - vwap) / vwap if vwap else 0,
        rsi_14=rsi,
        macd_histogram=macd,
        atr_14=atr,
        vpoc=vol_profile["vpoc"],
        vah=vol_profile["vah"],
        val=vol_profile["val"],
        funding_rate=funding,
        open_interest=oi,
        premium_bps=premium,
    )
