"""Event-price coupling for synthetic scenario backtesting.

Controls the signal-to-noise ratio between event quality features and
subsequent price movements. This is the core mechanism for overfitting detection:

  signal_strength=0.0  → pure noise  → strategy should return ~0  (false positive check)
  signal_strength=0.35 → realistic SNR → strategy captures genuine alpha
  signal_strength=0.60 → strong signal → verify strategy responds to alpha

The coupling injects a drift into bars AFTER the execution date based on the
event's score and reaction features, while preserving OHLCV consistency.
"""
from __future__ import annotations

import datetime as dt
import math
from typing import Any

import numpy as np


def couple_events_to_prices(
    candidates: dict[dt.date, list[dict[str, Any]]],
    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]],
    trading_dates: list[dt.date],
    signal_strength: float,
    signal_decay_days: int = 10,
    false_positive_rate: float = 0.15,
    rng: np.random.Generator | None = None,
) -> None:
    """Inject signal-driven drift into bars after each event's execution date.

    Modifies bars_by_symbol in-place. Also updates each candidate's
    entry_price / entry_price_est / event_close to match the actual
    bar close on its reaction_date (so stop prices are consistent).

    Args:
        candidates: candidates_by_exec_date dict from generate_events().
        bars_by_symbol: OHLCV bars to modify in-place.
        trading_dates: Ordered list of NYSE trading dates.
        signal_strength: 0.0 = pure noise, 0.35 = realistic, 0.6 = strong alpha.
        signal_decay_days: Days over which signal drift decays to zero.
        false_positive_rate: Fraction of qualifying events that produce negative
                             returns (traps / false positives).
        rng: NumPy random generator.
    """
    if rng is None:
        rng = np.random.default_rng()

    date_to_idx = {d: i for i, d in enumerate(trading_dates)}

    for exec_date, rows in candidates.items():
        exec_idx = date_to_idx.get(exec_date)
        if exec_idx is None:
            continue

        # reaction_date is the day before execution
        react_idx = exec_idx - 1
        if react_idx < 0:
            continue
        reaction_date = trading_dates[react_idx]

        for row in rows:
            symbol = str(row.get("symbol", ""))
            sym_bars = bars_by_symbol.get(symbol)
            if sym_bars is None:
                continue

            # Sync entry_price with actual bar close on reaction_date
            react_bar = sym_bars.get(reaction_date)
            if react_bar and react_bar.get("close", 0) > 0:
                actual_close = float(react_bar["close"])
                row["entry_price"] = round(actual_close, 4)
                row["entry_price_est"] = round(actual_close, 4)
                row["event_close"] = round(actual_close, 4)
                # Recompute ATR based on actual price
                atr_pct = float(row.get("atr_14", actual_close * 0.022)) / max(float(row.get("entry_price", actual_close)), 1e-4)
                row["atr_14"] = round(actual_close * atr_pct, 4)

            # Skip coupling if signal_strength == 0 (pure noise scenario)
            if signal_strength <= 1e-9:
                continue

            # Compute expected drift from event features
            score = float(row.get("score", 0.5))
            reaction_return = float(row.get("reaction_day_return", 0.0))
            volume_ratio = float(row.get("volume_ratio_20d", 1.5))

            expected_drift_5d = signal_strength * (
                0.030 * (score - 0.5)
                + 0.020 * reaction_return
                + 0.008 * max(0.0, volume_ratio - 1.5)
            )

            # False positive: flip signal direction
            if rng.random() < false_positive_rate:
                expected_drift_5d = -expected_drift_5d * 0.7

            if abs(expected_drift_5d) < 1e-6:
                continue

            # Distribute drift over signal_decay_days using a decay schedule
            total_drift = expected_drift_5d
            decay = _compute_decay_schedule(total_drift, signal_decay_days)

            # Inject drift into bars starting at exec_date + 1 (first day we hold)
            apply_start_idx = exec_idx + 1
            for k, daily_adj in enumerate(decay):
                bar_idx = apply_start_idx + k
                if bar_idx >= len(trading_dates):
                    break
                bar_date = trading_dates[bar_idx]
                bar = sym_bars.get(bar_date)
                if bar is None:
                    continue

                _apply_drift_to_bar(bar, daily_adj)


def _compute_decay_schedule(total_drift: float, decay_days: int) -> list[float]:
    """Distribute total_drift over decay_days using exponential decay.

    Returns a list of per-day drift adjustments that sum to total_drift.
    """
    if decay_days <= 0:
        return [total_drift]

    # Exponential decay weights
    weights = [math.exp(-0.5 * k / max(decay_days, 1)) for k in range(decay_days)]
    total_weight = sum(weights)
    return [total_drift * w / total_weight for w in weights]


def _apply_drift_to_bar(bar: dict[str, Any], daily_drift: float) -> None:
    """Multiply all OHLCV price fields by (1 + daily_drift), preserving consistency.

    Applies uniform multiplicative adjustment so that OHLC relationships are
    maintained exactly. Volume is unchanged.
    """
    factor = 1.0 + daily_drift
    factor = max(0.5, min(2.0, factor))  # guard against extreme values

    for field in ("open", "high", "low", "close"):
        val = bar.get(field)
        if val is not None and float(val) > 0:
            bar[field] = round(float(val) * factor, 4)

    # Ensure OHLCV consistency after adjustment
    o = bar.get("open", 0)
    h = bar.get("high", 0)
    lo = bar.get("low", 0)
    c = bar.get("close", 0)

    if o and h and lo and c:
        bar["high"] = round(max(float(h), float(o), float(c)), 4)
        bar["low"] = round(max(0.01, min(float(lo), float(o), float(c))), 4)
