"""Signal ensemble: combine multiple signal sources with dynamic weighting."""

from __future__ import annotations

import logging
import math
import threading
import time
from collections import deque
from dataclasses import dataclass

from horizon._horizon import combine_signals, information_coefficient, signal_half_life

logger = logging.getLogger("horizon.fund.signal_ensemble")


# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Signal:
    """A single signal observation."""

    name: str
    value: float  # -1 to +1
    confidence: float  # 0 to 1
    timestamp: float
    source: str
    market_id: str


@dataclass(frozen=True)
class SignalQuality:
    """Quality metrics for a named signal."""

    name: str
    ic: float
    hit_rate: float
    decay_half_life: float
    n_observations: int


@dataclass(frozen=True)
class EnsembleOutput:
    """Result of combining multiple signals."""

    combined_signal: float
    combined_confidence: float
    weights_used: dict[str, float]
    redundancy_penalty: float
    n_signals: int


# ---------------------------------------------------------------------------
# Ensemble
# ---------------------------------------------------------------------------


class SignalEnsemble:
    """Combines multiple signal sources with dynamic weighting and redundancy detection.

    Maintains rolling histories for each signal, computes information
    coefficients against recorded outcomes, and applies a redundancy
    penalty when signals are highly correlated.

    Usage::

        ensemble = SignalEnsemble(stale_threshold_secs=300, min_signals=2)
        ensemble.submit(Signal(name="model_a", value=0.6, confidence=0.8,
                               timestamp=time.time(), source="ml",
                               market_id="btc-above-100k"))
        ensemble.submit(Signal(name="model_b", value=0.4, confidence=0.7,
                               timestamp=time.time(), source="stat",
                               market_id="btc-above-100k"))
        out = ensemble.combine("btc-above-100k")
    """

    def __init__(
        self,
        stale_threshold_secs: float = 300.0,
        min_signals: int = 2,
        lookback: int = 200,
    ) -> None:
        self._stale_threshold_secs = stale_threshold_secs
        self._min_signals = min_signals
        self._lookback = lookback

        self._signals: dict[str, Signal] = {}
        self._signal_history: dict[str, deque[float]] = {}
        self._outcomes: dict[str, deque[float]] = {}
        self._quality: dict[str, SignalQuality] = {}
        self._lock = threading.Lock()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def submit(self, signal: Signal) -> None:
        """Register or update a signal and append to its rolling history."""
        with self._lock:
            self._signals[signal.name] = signal

            if signal.name not in self._signal_history:
                self._signal_history[signal.name] = deque(maxlen=self._lookback)

            self._signal_history[signal.name].append(signal.value)

    def combine(self, market_id: str) -> EnsembleOutput | None:
        """Combine non-stale signals for *market_id* into a single output.

        Returns ``None`` if fewer than ``min_signals`` are available.
        """
        with self._lock:
            now = time.time()

            # Filter: correct market, not stale
            active: list[Signal] = [
                s
                for s in self._signals.values()
                if s.market_id == market_id
                and (now - s.timestamp) <= self._stale_threshold_secs
            ]

            if len(active) < self._min_signals:
                return None

            # --- Base weights from IC (quality) or equal ---
            raw_weights: dict[str, float] = {}
            for sig in active:
                q = self._quality.get(sig.name)
                if q is not None and q.n_observations >= 10:
                    # Use absolute IC so negative-IC signals still get weight
                    # proportional to their predictive power (direction is in
                    # the signal value itself).
                    raw_weights[sig.name] = max(abs(q.ic), 1e-6)
                else:
                    raw_weights[sig.name] = 1.0

            # --- Pairwise correlations & redundancy penalty ---
            names = [s.name for s in active]
            max_pairwise_corr = 0.0
            pair_corrs: dict[tuple[str, str], float] = {}

            for i in range(len(names)):
                for j in range(i + 1, len(names)):
                    a, b = names[i], names[j]
                    hist_a = self._signal_history.get(a)
                    hist_b = self._signal_history.get(b)
                    if hist_a is not None and hist_b is not None:
                        corr = self._pearson(list(hist_a), list(hist_b))
                        if corr is not None:
                            abs_corr = abs(corr)
                            pair_corrs[(a, b)] = abs_corr
                            if abs_corr > max_pairwise_corr:
                                max_pairwise_corr = abs_corr

            redundancy_penalty = max_pairwise_corr

            # Penalise redundant (highly correlated) signals: for each
            # signal, if its correlation with any higher-weighted signal
            # exceeds 0.7, scale its effective weight down.
            effective_weights: dict[str, float] = dict(raw_weights)
            sorted_names = sorted(names, key=lambda n: raw_weights[n], reverse=True)

            for idx, name in enumerate(sorted_names):
                higher = sorted_names[:idx]
                if not higher:
                    continue
                max_corr_with_higher = 0.0
                for h in higher:
                    key = (min(name, h), max(name, h))
                    c = pair_corrs.get(key, 0.0)
                    if c > max_corr_with_higher:
                        max_corr_with_higher = c
                if max_corr_with_higher > 0.7:
                    effective_weights[name] *= 1.0 - max_corr_with_higher

            # Normalise weights to sum to 1
            total_w = sum(effective_weights.values())
            if total_w < 1e-12:
                # Fallback: equal weight
                n = len(active)
                normalised: dict[str, float] = {s.name: 1.0 / n for s in active}
            else:
                normalised = {
                    n: w / total_w for n, w in effective_weights.items()
                }

            # --- Combine via Rust ---
            sig_weight_pairs = [
                (sig.value, normalised[sig.name]) for sig in active
            ]
            combined_value = combine_signals(sig_weight_pairs, "weighted_avg")

            # Combined confidence: weighted average of individual
            # confidences, reduced by redundancy.
            conf_sum = sum(
                sig.confidence * normalised[sig.name] for sig in active
            )
            combined_confidence = conf_sum * (1.0 - redundancy_penalty)

            return EnsembleOutput(
                combined_signal=combined_value,
                combined_confidence=combined_confidence,
                weights_used=normalised,
                redundancy_penalty=redundancy_penalty,
                n_signals=len(active),
            )

    def record_outcome(self, signal_name: str, outcome: float) -> None:
        """Record an actual outcome for IC tracking of *signal_name*."""
        with self._lock:
            if signal_name not in self._outcomes:
                self._outcomes[signal_name] = deque(maxlen=self._lookback)
            self._outcomes[signal_name].append(outcome)

    def update_quality(self) -> None:
        """Recompute quality metrics (IC, hit rate, half-life) for all signals."""
        with self._lock:
            for name, hist in self._signal_history.items():
                outcomes = self._outcomes.get(name)
                if outcomes is None or len(outcomes) < 5 or len(hist) < 5:
                    continue

                preds = list(hist)
                outs = list(outcomes)
                n = min(len(preds), len(outs))
                preds = preds[-n:]
                outs = outs[-n:]

                # IC via Rust
                try:
                    ic = information_coefficient(preds, outs)
                except Exception:
                    ic = 0.0

                # Hit rate: fraction with matching sign
                hits = sum(
                    1
                    for p, o in zip(preds, outs)
                    if (p >= 0 and o >= 0) or (p < 0 and o < 0)
                )
                hit_rate = hits / n if n > 0 else 0.0

                # Decay half-life via Rust
                try:
                    half_life = signal_half_life(preds)
                except Exception:
                    half_life = 0.0

                self._quality[name] = SignalQuality(
                    name=name,
                    ic=ic,
                    hit_rate=hit_rate,
                    decay_half_life=half_life,
                    n_observations=n,
                )

    def signal_quality(self, name: str) -> SignalQuality | None:
        """Return cached quality metrics for a signal, or ``None``."""
        with self._lock:
            return self._quality.get(name)

    def all_quality(self) -> list[SignalQuality]:
        """Return quality metrics for all tracked signals."""
        with self._lock:
            return list(self._quality.values())

    def active_signals(self, market_id: str) -> list[Signal]:
        """Return non-stale signals for *market_id*."""
        now = time.time()
        with self._lock:
            return [
                s
                for s in self._signals.values()
                if s.market_id == market_id
                and (now - s.timestamp) <= self._stale_threshold_secs
            ]

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _pearson(xs: list[float], ys: list[float]) -> float | None:
        """Pearson correlation coefficient.

        Returns ``None`` if fewer than 5 overlapping observations or if
        either series has near-zero standard deviation.
        """
        n = min(len(xs), len(ys))
        if n < 5:
            return None

        xs, ys = xs[-n:], ys[-n:]
        mx = sum(xs) / n
        my = sum(ys) / n

        cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
        sx = math.sqrt(sum((x - mx) ** 2 for x in xs))
        sy = math.sqrt(sum((y - my) ** 2 for y in ys))

        if sx < 1e-12 or sy < 1e-12:
            return None

        return cov / (sx * sy)
