"""Opt-in stop research utilities; never imported by the production strategy."""
from __future__ import annotations

import numpy as np
import pandas as pd

from strategies.strategy_activation_scores import COMMISSION_RATE


def replay_stop(frame: pd.DataFrame, opportunities: dict, *, start: str, end: str,
                widths: np.ndarray | None = None) -> tuple[pd.DataFrame, list[dict]]:
    """Canonical execution with an optional causal, ratcheted width series.

    None disables the experiment (retains the canonical stop, including zero).
    Widths are observable at each close; entry uses t, holding decisions t-1.
    Candidate threshold risk is capped at the baseline entry loss percentage.
    """
    n = len(frame)
    close, high = frame.close.to_numpy(float), frame.high.to_numpy(float)
    if not np.isfinite(close).all() or not np.isfinite(high).all() or (close <= 0).any():
        raise ValueError('Prices must be finite and closes positive')
    trail = float(opportunities['trail'])
    if not np.isfinite(trail) or not 0 <= trail < 1:
        raise ValueError('Invalid baseline stop')
    if widths is not None:
        widths = np.asarray(widths, dtype=float)
        if widths.shape != (n,):
            raise ValueError('width length must match frame')
        if trail <= 0:
            raise ValueError('Candidate requires a positive baseline maximum loss bound')
    allowed = ((frame.time >= pd.Timestamp(start)) &
               (frame.time < pd.Timestamp(end)+pd.Timedelta(days=1))).to_numpy() & opportunities['time_ok']
    positions, entered, exited = np.zeros(n, int), np.zeros(n, bool), np.zeros(n, bool)
    stops, used_width, peaks = np.full(n, np.nan), np.full(n, np.nan), np.full(n, np.nan)
    entry_ids = np.full(n, -1, int)
    events = []
    active, use_raw = False, False
    entry_bar, peak, stop = -1, 0., np.nan

    def width_at(t: int) -> float:
        value = trail if widths is None else float(widths[t])
        if not np.isfinite(value) or not 0 < value < 1:
            raise ValueError(f'Invalid active width at bar {t}')
        return value

    for t in range(n):
        if active:
            if t != entry_bar+1:
                peak = max(peak, high[t])
            if trail > 0:
                width = width_at(t-1)
                proposed = peak*(1-width)
                stop = proposed if widths is None else max(stop, proposed, close[entry_bar]*(1-trail))
                stops[t], used_width[t] = stop, width
            stop_hit = trail > 0 and close[t] <= stop
            score_exit = bool(opportunities['fallback'][t] if use_raw else opportunities['exit'][t])
            peaks[t], entry_ids[t] = peak, entry_bar
            if allowed[t] and (stop_hit or score_exit):
                events.append({'bar': t, 'time': str(frame.time.iloc[t]), 'entry_bar': entry_bar,
                               'age_bars': t-entry_bar, 'action': 'exit',
                               'reason': 'trailing_stop' if stop_hit else 'score_exit',
                               'score_exit': score_exit, 'stop_hit': bool(stop_hit),
                               'entry_price': close[entry_bar], 'exit_price': close[t],
                               'trade_high': peak, 'stop_price': stop if trail > 0 else None,
                               'threshold_overshoot_pct': 100*max(0., (stop-close[t])/close[entry_bar]) if stop_hit else 0.,
                               'entry_bound_breach_pct': 100*max(0., 1-trail-close[t]/close[entry_bar]) if trail > 0 else 0.})
                active, use_raw = False, False
                exited[t] = True
        elif allowed[t] and opportunities['entry'][t]:
            active, entry_bar, peak = True, t, close[t]
            entered[t] = True
            use_raw = opportunities['dead_zone'] and opportunities['score_exit'][t] < opportunities['exit_threshold']
            if trail > 0:
                width = width_at(t)
                stop = peak*(1-width)
                if widths is not None:
                    stop = max(stop, peak*(1-trail))
                stops[t], used_width[t] = stop, width
            peaks[t], entry_ids[t] = peak, t
        positions[t] = active
    result = frame.copy()
    result['position'], result['execute_entry'], result['execute_exit'] = positions, entered, exited
    result['stop_price'], result['requested_width'], result['trade_high'] = stops, used_width, peaks
    result['entry_bar'] = entry_ids
    result['strategy_return'] = (result.position.shift(1, fill_value=0)*result.close.pct_change().fillna(0)
                                - COMMISSION_RATE*(result.execute_entry.shift(1, fill_value=False).astype(float)
                                                   + result.execute_exit.astype(float)))
    return result, events


def volatility(frame: pd.DataFrame) -> pd.DataFrame:
    """Wilder ATR(14), SMA seed; realized log-return std(20), ddof=1.

    Values on t use only OHLC through t. Replay uses t-1 after entry.
    """
    prices = frame[['open', 'high', 'low', 'close']].to_numpy(float)
    if not np.isfinite(prices).all() or (prices <= 0).any():
        raise ValueError('OHLC must be finite and positive')
    if (frame.high < frame[['open', 'close', 'low']].max(axis=1)).any() or (
            frame.low > frame[['open', 'close']].min(axis=1)).any():
        raise ValueError('Invalid OHLC ordering')
    if not frame.time.is_monotonic_increasing or frame.time.duplicated().any():
        raise ValueError('Times must be strictly increasing')
    previous = frame.close.shift(1)
    tr = pd.concat([frame.high-frame.low, (frame.high-previous).abs(),
                    (frame.low-previous).abs()], axis=1).max(axis=1).to_numpy()
    atr = np.full(len(frame), np.nan)
    if len(frame) >= 14:
        atr[13] = tr[:14].mean()
        for t in range(14, len(frame)):
            atr[t] = (13*atr[t-1]+tr[t])/14
    return pd.DataFrame({'atr_price': atr/frame.close.to_numpy(),
                         'rv20': np.log(frame.close).diff().rolling(20).std()}, index=frame.index)


def event_study(frame: pd.DataFrame, decisions: list[dict], vol: pd.DataFrame,
                end: str, horizons: tuple[int, ...] = (6, 24, 42)) -> pd.DataFrame:
    """Descriptive exit continuations, not additive strategy returns.

    Greedily retain disjoint horizons across BOTH exit types, separately per
    horizon. Incomplete/window-crossing/gapped horizons stay as censored rows.
    """
    rows = []
    boundary = pd.Timestamp(end)+pd.Timedelta(days=1)
    last_retained = {h: -1 for h in horizons}
    times = frame.time.reset_index(drop=True)
    close = frame.close.to_numpy()
    for decision in decisions:
        if decision.get('action') != 'exit':
            continue
        t, entry = decision['bar'], decision['entry_bar']
        for horizon in horizons:
            endpoint = t+horizon
            complete = endpoint < len(frame) and times.iloc[endpoint] < boundary
            gap = complete and bool((times.iloc[t: endpoint+1].diff().dropna() != pd.Timedelta(hours=4)).any())
            valid = complete and not gap
            retained = valid and t > last_retained[horizon]
            if retained:
                last_retained[horizon] = endpoint
            row = {'bar': t, 'entry_bar': entry, 'time': str(times.iloc[t]),
                   'reason': decision['reason'], 'score_exit_also': decision['score_exit'],
                   'horizon_bars': horizon, 'valid': valid, 'gap': gap, 'nonoverlap': retained,
                   'label_end': str(times.iloc[endpoint]) if complete else None,
                   'holding_hours': (times.iloc[t]-times.iloc[entry]).total_seconds()/3600,
                   'trade_price_return_pct': 100*(close[t]/close[entry]-1),
                   'entry_atr_pct': 100*vol.atr_price.iloc[entry],
                   'exit_atr_pct': 100*vol.atr_price.iloc[t],
                   'entry_rv_pct': 100*vol.rv20.iloc[entry],
                   'exit_rv_pct': 100*vol.rv20.iloc[t]}
            if valid:
                path = close[t+1:endpoint+1]/close[t]-1
                # Entry fee is sunk. Match the engine's additive exit fee on
                # the final return bar, rather than applying a wealth haircut.
                future_returns = close[t+1:endpoint+1]/close[t:endpoint]-1
                future_returns[-1] -= COMMISSION_RATE
                advantage = np.prod(1+future_returns)-(1-COMMISSION_RATE)
                row.update(terminal_return_pct=100*path[-1],
                           hold_advantage_pct=100*advantage,
                           missed_recovery_pct=100*max(0., path.max()),
                           subsequent_decline_pct=100*max(0., -path.min()),
                           terminal_loss_avoided_pct=100*max(0., -advantage),
                           recovered_entry=bool((close[t+1:endpoint+1] >= close[entry]).any()))
            rows.append(row)
    return pd.DataFrame(rows)
