"""Meta-Labeling module (Lopez de Prado, AFML Chapter 3.6).

Implements the triple-barrier meta-labeling method. A primary model provides
directional signals (+1 long, -1 short). The meta-labeling layer decides
whether to *act* on each signal (1) or *abstain* (0), using a triple barrier
framework: profit-taking (PT), stop-loss (SL), and a vertical (time) barrier.

Usage:
    from horizon.meta_label import compute_meta_labels, meta_label_pipeline

    labels = compute_meta_labels(
        prices=[100, 101, 102, 99, 98, 103, 105],
        timestamps=[0, 1, 2, 3, 4, 5, 6],
        primary_signals=[(0, 1), (3, -1)],
        pt_sl=(1.0, 1.0),
        max_holding=5,
        vol_span=20,
    )

    # Inside hz.run() pipeline:
    pipeline = [my_primary_model, meta_label_pipeline(), quoter]
"""

from __future__ import annotations

import math
from collections import deque
from dataclasses import dataclass
from typing import Any, Callable


@dataclass
class MetaLabel:
    """Result of meta-labeling a single primary signal event.

    Attributes:
        event_idx: Index into the price series where the primary signal fired.
        primary_side: Direction of the primary signal (+1 long, -1 short).
        meta_label: 1 if the signal was profitable (act), 0 if not (abstain).
        ret: Realized return from the primary signal's perspective.
        confidence: Confidence score in [0, 1] based on return magnitude
            relative to volatility.
    """

    event_idx: int
    primary_side: int
    meta_label: int
    ret: float
    confidence: float


# ---------------------------------------------------------------------------
# Core computation
# ---------------------------------------------------------------------------


def _ewm_std(values: list[float], span: int) -> list[float]:
    """Exponentially weighted moving standard deviation.

    Uses the standard EWM formula with adjust=True semantics:
    alpha = 2 / (span + 1).

    Returns a list the same length as *values*. The first element is 0.0
    (insufficient data).
    """
    n = len(values)
    if n == 0:
        return []

    alpha = 2.0 / (span + 1)
    result = [0.0] * n

    if n < 2:
        return result

    # Running EWM mean and variance (online Welford-like for EWM)
    ewm_mean = values[0]
    ewm_var = 0.0

    for i in range(1, n):
        delta = values[i] - ewm_mean
        ewm_mean = alpha * values[i] + (1.0 - alpha) * ewm_mean
        ewm_var = (1.0 - alpha) * (ewm_var + alpha * delta * delta)
        result[i] = math.sqrt(max(ewm_var, 0.0))

    return result


def compute_meta_labels(
    prices: list[float],
    timestamps: list[float],
    primary_signals: list[tuple[int, int]],
    pt_sl: tuple[float, float] = (1.0, 1.0),
    max_holding: int = 100,
    vol_span: int = 20,
) -> list[MetaLabel]:
    """Compute meta-labels from primary model signals using triple barriers.

    For each primary signal, scans forward from the signal index and applies
    three barriers:

    - **Profit-taking (PT)**: Return exceeds ``vol * pt_sl[0]`` in the
      direction of the primary signal. Meta-label = 1 (act).
    - **Stop-loss (SL)**: Return exceeds ``vol * pt_sl[1]`` *against* the
      primary signal. Meta-label = 0 (don't act).
    - **Vertical barrier**: ``max_holding`` bars elapse. Meta-label = 1 if
      cumulative return > 0, else 0.

    Args:
        prices: Price series (length T).
        timestamps: Timestamp series (length T, monotonically increasing).
        primary_signals: List of ``(event_index, side)`` where side is +1
            (long) or -1 (short). Each ``event_index`` must be a valid
            index into *prices*.
        pt_sl: Multipliers for profit-taking and stop-loss barriers,
            applied to local volatility. ``(1.0, 1.0)`` means symmetric
            barriers at 1x volatility.
        max_holding: Maximum bars to hold before the vertical barrier fires.
        vol_span: Span for the EWM standard deviation of log returns used
            to set barrier widths.

    Returns:
        List of :class:`MetaLabel` results, one per primary signal.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    n = len(prices)
    if n == 0 or not primary_signals:
        return []

    if len(timestamps) != n:
        raise ValueError("prices and timestamps must have the same length")

    # Compute log returns
    log_returns: list[float] = [0.0]
    for i in range(1, n):
        if prices[i - 1] > 0 and prices[i] > 0:
            log_returns.append(math.log(prices[i] / prices[i - 1]))
        else:
            log_returns.append(0.0)

    # Compute rolling volatility (EWM std of log returns)
    vol_series = _ewm_std(log_returns, vol_span)

    pt_mult, sl_mult = pt_sl
    results: list[MetaLabel] = []

    for event_idx, side in primary_signals:
        if event_idx < 0 or event_idx >= n:
            continue

        entry_price = prices[event_idx]
        if entry_price <= 0:
            continue

        # Local volatility at the event
        local_vol = vol_series[event_idx] if event_idx < len(vol_series) else 0.0
        if local_vol < 1e-15:
            # Fallback: use a small default volatility
            local_vol = 0.01

        pt_barrier = local_vol * pt_mult
        sl_barrier = local_vol * sl_mult

        # Scan forward
        end_idx = min(event_idx + max_holding, n - 1)
        final_ret = 0.0
        label = 0
        barrier_hit = False

        for j in range(event_idx + 1, end_idx + 1):
            # Return from primary side's perspective
            raw_ret = (prices[j] - entry_price) / entry_price
            directed_ret = raw_ret * side

            if directed_ret >= pt_barrier:
                # Profit-taking barrier hit -- act on signal
                final_ret = directed_ret
                label = 1
                barrier_hit = True
                break

            if directed_ret <= -sl_barrier:
                # Stop-loss barrier hit -- don't act
                final_ret = directed_ret
                label = 0
                barrier_hit = True
                break

        if not barrier_hit:
            # Vertical barrier: use return at end of holding period
            if end_idx > event_idx and entry_price > 0:
                raw_ret = (prices[end_idx] - entry_price) / entry_price
                final_ret = raw_ret * side
            else:
                final_ret = 0.0
            label = 1 if final_ret > 0 else 0

        # Confidence: |return| / (vol * max(pt, sl)) clamped to [0, 1]
        barrier_scale = local_vol * max(pt_mult, sl_mult)
        if barrier_scale > 1e-15:
            confidence = min(1.0, max(0.0, abs(final_ret) / barrier_scale))
        else:
            confidence = 0.0

        results.append(
            MetaLabel(
                event_idx=event_idx,
                primary_side=side,
                meta_label=label,
                ret=final_ret,
                confidence=confidence,
            )
        )

    return results


# ---------------------------------------------------------------------------
# Pipeline function for hz.run()
# ---------------------------------------------------------------------------


def meta_label_pipeline(
    primary_signal_key: str = "primary_signal",
    window: int = 50,
) -> Callable:
    """Create a meta-labeling pipeline function for ``hz.run()``.

    Reads the primary model's signal from ``ctx.params[primary_signal_key]``
    (expected to be +1, -1, or 0). Maintains a rolling buffer of recent
    price observations and meta-label outcomes. Injects the following into
    ``ctx.params``:

    - ``"meta_label"``: 0 (abstain) or 1 (act)
    - ``"meta_confidence"``: float in [0, 1]

    If the primary signal is 0 or missing, passes through with meta_label=0.

    Args:
        primary_signal_key: Key in ``ctx.params`` containing the primary
            model's directional signal (+1/-1/0).
        window: Number of recent observations to keep for meta-label
            computation.

    Returns:
        Pipeline function compatible with ``hz.run()``.
    """
    _prices: deque[float] = deque(maxlen=window)
    _timestamps: deque[float] = deque(maxlen=window)
    _recent_labels: deque[MetaLabel] = deque(maxlen=window)
    _last_meta_label: int = 0
    _last_confidence: float = 0.0

    def _meta_label(ctx: Any, signal: Any = None) -> Any:
        nonlocal _last_meta_label, _last_confidence

        # Extract current price from feed
        feed = None
        if hasattr(ctx, "feeds") and ctx.feeds:
            if "default" in ctx.feeds:
                feed = ctx.feeds["default"]
            else:
                feed = next(iter(ctx.feeds.values()))
        elif hasattr(ctx, "feed"):
            feed = ctx.feed

        price = 0.0
        timestamp = 0.0
        if feed is not None:
            price = getattr(feed, "price", 0.0)
            timestamp = getattr(feed, "timestamp", 0.0)

        if price > 0:
            _prices.append(price)
            _timestamps.append(timestamp)

        # Read primary signal
        params = getattr(ctx, "params", {})
        primary_signal = params.get(primary_signal_key, 0)

        if primary_signal == 0 or len(_prices) < 3:
            # No signal or insufficient data -- abstain
            params["meta_label"] = 0
            params["meta_confidence"] = 0.0
            return signal

        # Compute meta-labels on the buffered price history
        prices_list = list(_prices)
        timestamps_list = list(_timestamps)

        # Create a signal at the most recent complete observation
        # (second-to-last, so we have at least one forward bar)
        signal_idx = len(prices_list) - 2
        if signal_idx < 0:
            params["meta_label"] = 0
            params["meta_confidence"] = 0.0
            return signal

        labels = compute_meta_labels(
            prices=prices_list,
            timestamps=timestamps_list,
            primary_signals=[(signal_idx, int(primary_signal))],
            pt_sl=(1.0, 1.0),
            max_holding=min(10, len(prices_list) - signal_idx),
            vol_span=min(20, max(3, len(prices_list))),
        )

        if labels:
            latest = labels[-1]
            _recent_labels.append(latest)
            _last_meta_label = latest.meta_label
            _last_confidence = latest.confidence

            # Blend with recent hit rate for smoother confidence
            if len(_recent_labels) >= 5:
                recent_hit_rate = sum(
                    1 for lb in _recent_labels if lb.meta_label == 1
                ) / len(_recent_labels)
                _last_confidence = 0.7 * latest.confidence + 0.3 * recent_hit_rate
                _last_confidence = min(1.0, max(0.0, _last_confidence))
        else:
            _last_meta_label = 0
            _last_confidence = 0.0

        params["meta_label"] = _last_meta_label
        params["meta_confidence"] = _last_confidence

        return signal

    _meta_label.__name__ = "meta_label_pipeline"
    return _meta_label
