"""Signal combination pipeline for hz.run().

Provides signal extractors and a combiner that feeds into sizing/quoting.

Usage:
    hz.run(
        pipeline=[
            hz.signal_combiner([
                hz.price_signal("btc", weight=0.4),
                hz.imbalance_signal("book", levels=5, weight=0.3),
                hz.momentum_signal("btc", lookback=20, weight=0.2),
                hz.spread_signal("book", weight=0.1),
            ], method="weighted_avg", smoothing=10),
            hz.kelly_sizer(fraction=0.25, bankroll=1000),
            quoter,
        ],
    )
"""

from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from typing import Callable

from horizon._horizon import combine_signals, ema
from horizon.context import Context


@dataclass
class Signal:
    """A named signal with a weight for combination."""

    name: str
    fn: Callable[[Context], float]
    weight: float = 1.0


def signal_combiner(
    signals: list[Signal],
    method: str = "weighted_avg",
    smoothing: int = 0,
    clip: tuple[float, float] = (0.0, 1.0),
) -> Callable[[Context], float]:
    """Create a pipeline function that combines multiple signals.

    Args:
        signals: List of Signal objects to combine.
        method: "weighted_avg", "rank", or "zscore".
        smoothing: EMA smoothing span (0 = disabled).
        clip: (min, max) range to clamp the output.

    Returns:
        Pipeline function: (Context) -> float
    """
    histories: dict[str, deque[float]] = {}
    max_len = max(smoothing, 1)

    def _combiner(ctx: Context) -> float:
        pairs = []
        for sig in signals:
            try:
                val = sig.fn(ctx)
                if isinstance(val, (int, float)) and not (
                    val != val  # NaN check
                ):
                    pairs.append((float(val), sig.weight))
            except Exception:
                pass

        if not pairs:
            return 0.0

        result = combine_signals(pairs, method)

        # Optional EMA smoothing (per-market to avoid cross-contamination)
        if smoothing > 0:
            market_id = getattr(ctx.market, "id", "__default__") if ctx.market else "__default__"
            if market_id not in histories:
                histories[market_id] = deque(maxlen=max_len)
            history = histories[market_id]
            history.append(result)
            if len(history) >= 2:
                result = ema(list(history), smoothing)

        # Clip to range
        lo, hi = clip
        return max(lo, min(hi, result))

    _combiner.__name__ = "signal_combiner"
    return _combiner


# ---------------------------------------------------------------------------
# Built-in signal extractors
# ---------------------------------------------------------------------------


def price_signal(feed_name: str, weight: float = 1.0) -> Signal:
    """Extract mid price from a feed as a signal (0-1 for prediction markets).

    Args:
        feed_name: Name of the feed to read from.
        weight: Signal weight for combination.
    """

    def _extract(ctx: Context) -> float:
        fd = ctx.feeds.get(feed_name)
        if fd is None:
            return 0.0
        if fd.bid > 0 and fd.ask > 0:
            return (fd.bid + fd.ask) / 2.0
        return fd.price if fd.price > 0 else 0.0

    _extract.__name__ = f"price_signal({feed_name})"
    return Signal(name=f"price:{feed_name}", fn=_extract, weight=weight)


def imbalance_signal(
    feed_name: str, levels: int = 5, weight: float = 1.0
) -> Signal:
    """Extract orderbook imbalance as a signal (0-1, >0.5 = buy pressure).

    Args:
        feed_name: Name of the orderbook feed.
        levels: Number of levels to compute imbalance over.
        weight: Signal weight.
    """

    def _extract(ctx: Context) -> float:
        # Try to get orderbook from params (engine can inject this)
        orderbooks = ctx.params.get("orderbooks", {})
        ob = orderbooks.get(feed_name)
        if ob is not None:
            try:
                imb = ob.imbalance(levels)
                # imbalance is in [-1, 1], normalize to [0, 1]
                return (imb + 1.0) / 2.0
            except Exception:
                pass
        # Fallback: use bid/ask from feed data
        fd = ctx.feeds.get(feed_name)
        if fd is not None and fd.bid > 0 and fd.ask > 0:
            spread = fd.ask - fd.bid
            mid = (fd.bid + fd.ask) / 2.0
            if mid > 0:
                return 0.5 + (fd.bid / mid - 0.5) * 0.5
        return 0.5

    _extract.__name__ = f"imbalance_signal({feed_name})"
    return Signal(name=f"imbalance:{feed_name}", fn=_extract, weight=weight)


def spread_signal(feed_name: str, weight: float = 1.0) -> Signal:
    """Extract bid-ask spread as a signal (0-1, tighter = higher).

    Args:
        feed_name: Name of the feed.
        weight: Signal weight.
    """

    def _extract(ctx: Context) -> float:
        fd = ctx.feeds.get(feed_name)
        if fd is None or fd.bid <= 0 or fd.ask <= 0:
            return 0.0
        spread = fd.ask - fd.bid
        # Normalize: 0 spread → 1.0, 0.1+ spread → 0.0
        return max(0.0, min(1.0, 1.0 - spread * 10.0))

    _extract.__name__ = f"spread_signal({feed_name})"
    return Signal(name=f"spread:{feed_name}", fn=_extract, weight=weight)


def momentum_signal(
    feed_name: str, lookback: int = 20, weight: float = 1.0
) -> Signal:
    """Extract rolling return as a signal (0-1, >0.5 = uptrend).

    Args:
        feed_name: Name of the feed.
        lookback: Number of ticks for rolling window.
        weight: Signal weight.
    """
    price_histories: dict[str, deque[float]] = {}

    def _extract(ctx: Context) -> float:
        fd = ctx.feeds.get(feed_name)
        if fd is None:
            return 0.5
        price = fd.price if fd.price > 0 else (fd.bid + fd.ask) / 2.0
        if price <= 0:
            return 0.5
        market_id = getattr(ctx.market, "id", "__default__") if ctx.market else "__default__"
        if market_id not in price_histories:
            price_histories[market_id] = deque(maxlen=lookback)
        price_history = price_histories[market_id]
        price_history.append(price)
        if len(price_history) < 2:
            return 0.5
        # Rolling return normalized to [0, 1]
        ret = (price_history[-1] - price_history[0]) / price_history[0]
        # Sigmoid-like normalization: map [-0.1, 0.1] to [0, 1]
        return max(0.0, min(1.0, 0.5 + ret * 5.0))

    _extract.__name__ = f"momentum_signal({feed_name})"
    return Signal(name=f"momentum:{feed_name}", fn=_extract, weight=weight)


def flow_signal(
    feed_name: str, window: int = 50, weight: float = 1.0
) -> Signal:
    """Extract volume flow direction as a signal (0-1, >0.5 = buy flow).

    Tracks price changes to infer flow direction (tick rule).

    Args:
        feed_name: Name of the feed.
        window: Number of ticks for volume flow window.
        weight: Signal weight.
    """
    tick_histories: dict[str, deque[float]] = {}

    flow_histories: dict[str, deque[float]] = {}

    def _extract(ctx: Context) -> float:
        fd = ctx.feeds.get(feed_name)
        if fd is None:
            return 0.5
        market_id = getattr(ctx.market, "id", "__default__") if ctx.market else "__default__"

        # Prefer real trade data (volume-weighted flow) when available
        if fd.last_trade_size > 0:
            if market_id not in flow_histories:
                flow_histories[market_id] = deque(maxlen=window)
            flow_hist = flow_histories[market_id]
            flow = fd.last_trade_size if fd.last_trade_is_buy else -fd.last_trade_size
            flow_hist.append(flow)
            if len(flow_hist) < 2:
                return 0.5
            total_flow = sum(flow_hist)
            abs_flow = sum(abs(f) for f in flow_hist)
            if abs_flow <= 0:
                return 0.5
            # Normalize to [0, 1]: -1 → 0, +1 → 1
            return max(0.0, min(1.0, 0.5 + (total_flow / abs_flow) * 0.5))

        # Fallback: tick-rule heuristic
        price = fd.price if fd.price > 0 else (fd.bid + fd.ask) / 2.0
        if price <= 0:
            return 0.5
        if market_id not in tick_histories:
            tick_histories[market_id] = deque(maxlen=window)
        ticks = tick_histories[market_id]
        ticks.append(price)
        if len(ticks) < 2:
            return 0.5
        # Count up-ticks vs down-ticks
        up = 0
        down = 0
        for i in range(1, len(ticks)):
            if ticks[i] > ticks[i - 1]:
                up += 1
            elif ticks[i] < ticks[i - 1]:
                down += 1
        total = up + down
        if total == 0:
            return 0.5
        return up / total

    _extract.__name__ = f"flow_signal({feed_name})"
    return Signal(name=f"flow:{feed_name}", fn=_extract, weight=weight)
