"""Utilities for reconstructing daily top-gainer style signals from bars.

This module is intentionally generic. A Yahoo-style probe can reuse it by
ranking candidates on signal-day return after applying liquidity/quality gates.
"""
from __future__ import annotations

import datetime as dt
import statistics
from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class DailyGainerCriteria:
    min_change: float = 0.08
    max_change: float | None = 0.25
    min_volume_ratio: float = 1.5
    min_close_location: float = 0.60
    min_avg_dollar_volume: float = 10_000_000.0
    max_gap: float | None = None
    top_n: int = 3


@dataclass(frozen=True)
class DailyGainerSignal:
    symbol: str
    signal_date: dt.date
    rank: int
    signal_return: float
    volume_ratio_20d: float
    close_location: float
    gap_size: float
    avg_dollar_volume_20d: float
    signal_close: float


@dataclass(frozen=True)
class DailyGainerTrade:
    symbol: str
    signal_date: dt.date
    rank: int
    signal_return: float
    volume_ratio_20d: float
    close_location: float
    gap_size: float
    avg_dollar_volume_20d: float
    signal_close: float
    entry_date: dt.date
    entry_price: float
    entry_gap_pct: float
    hold_1d_return: float | None = None
    hold_3d_return: float | None = None
    hold_5d_return: float | None = None


def collect_daily_gainer_signals(
    *,
    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]],
    start_date: dt.date,
    end_date: dt.date,
    criteria: DailyGainerCriteria,
) -> dict[dt.date, list[DailyGainerSignal]]:
    """Return top-N daily gainer signals per date from historical bars."""
    candidates_by_date: dict[dt.date, list[DailyGainerSignal]] = {}

    for raw_symbol, bars in bars_by_symbol.items():
        symbol = str(raw_symbol or "").upper()
        if not symbol or len(bars) < 21:
            continue

        sorted_dates = sorted(bars)
        for idx in range(20, len(sorted_dates)):
            signal_date = sorted_dates[idx]
            if signal_date < start_date or signal_date > end_date:
                continue

            prev_date = sorted_dates[idx - 1]
            prev_bar = bars[prev_date]
            signal_bar = bars[signal_date]

            prev_close = float(prev_bar.get("close") or 0.0)
            signal_open = float(signal_bar.get("open") or 0.0)
            signal_high = float(signal_bar.get("high") or 0.0)
            signal_low = float(signal_bar.get("low") or 0.0)
            signal_close = float(signal_bar.get("close") or 0.0)
            signal_volume = float(signal_bar.get("volume") or 0.0)

            if prev_close <= 0 or signal_open <= 0 or signal_close <= 0:
                continue

            lookback_dates = sorted_dates[idx - 20 : idx]
            avg_volume = statistics.fmean(
                float(bars[d].get("volume") or 0.0)
                for d in lookback_dates
            )
            avg_dollar_volume = statistics.fmean(
                float(bars[d].get("close") or 0.0) * float(bars[d].get("volume") or 0.0)
                for d in lookback_dates
            )
            volume_ratio = signal_volume / avg_volume if avg_volume > 0 else 0.0
            signal_return = signal_close / prev_close - 1.0
            gap_size = signal_open / prev_close - 1.0
            intraday_range = signal_high - signal_low
            close_location = (
                (signal_close - signal_low) / intraday_range
                if intraday_range > 0
                else 0.5
            )

            if signal_return < criteria.min_change:
                continue
            if criteria.max_change is not None and signal_return > criteria.max_change:
                continue
            if volume_ratio < criteria.min_volume_ratio:
                continue
            if close_location < criteria.min_close_location:
                continue
            if avg_dollar_volume < criteria.min_avg_dollar_volume:
                continue
            if criteria.max_gap is not None and gap_size > criteria.max_gap:
                continue

            candidates_by_date.setdefault(signal_date, []).append(
                DailyGainerSignal(
                    symbol=symbol,
                    signal_date=signal_date,
                    rank=0,
                    signal_return=signal_return,
                    volume_ratio_20d=volume_ratio,
                    close_location=close_location,
                    gap_size=gap_size,
                    avg_dollar_volume_20d=avg_dollar_volume,
                    signal_close=signal_close,
                )
            )

    ranked_by_date: dict[dt.date, list[DailyGainerSignal]] = {}
    for signal_date, signals in candidates_by_date.items():
        ranked = sorted(
            signals,
            key=lambda item: (
                -item.signal_return,
                -item.volume_ratio_20d,
                -item.avg_dollar_volume_20d,
                item.symbol,
            ),
        )
        trimmed: list[DailyGainerSignal] = []
        for rank, signal in enumerate(ranked[: criteria.top_n], start=1):
            trimmed.append(
                DailyGainerSignal(
                    symbol=signal.symbol,
                    signal_date=signal.signal_date,
                    rank=rank,
                    signal_return=signal.signal_return,
                    volume_ratio_20d=signal.volume_ratio_20d,
                    close_location=signal.close_location,
                    gap_size=signal.gap_size,
                    avg_dollar_volume_20d=signal.avg_dollar_volume_20d,
                    signal_close=signal.signal_close,
                )
            )
        if trimmed:
            ranked_by_date[signal_date] = trimmed

    return ranked_by_date


def evaluate_daily_gainer_signals(
    *,
    signals_by_date: dict[dt.date, list[DailyGainerSignal]],
    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]],
    horizons: tuple[int, ...] = (1, 3, 5),
) -> list[DailyGainerTrade]:
    """Evaluate next-open continuation returns for reconstructed gainer signals."""
    sorted_dates_cache: dict[str, list[dt.date]] = {}
    date_index_cache: dict[str, dict[dt.date, int]] = {}
    trades: list[DailyGainerTrade] = []

    for signal_date in sorted(signals_by_date):
        for signal in signals_by_date[signal_date]:
            symbol = signal.symbol.upper()
            bars = bars_by_symbol.get(symbol) or {}
            if len(bars) < 2:
                continue

            sorted_dates = sorted_dates_cache.get(symbol)
            if sorted_dates is None:
                sorted_dates = sorted(bars)
                sorted_dates_cache[symbol] = sorted_dates
                date_index_cache[symbol] = {d: idx for idx, d in enumerate(sorted_dates)}

            date_index = date_index_cache[symbol]
            signal_idx = date_index.get(signal.signal_date)
            if signal_idx is None or signal_idx + 1 >= len(sorted_dates):
                continue

            entry_date = sorted_dates[signal_idx + 1]
            entry_bar = bars.get(entry_date) or {}
            entry_price = float(entry_bar.get("open") or 0.0)
            if entry_price <= 0:
                continue

            returns: dict[int, float | None] = {h: None for h in horizons}
            for horizon in horizons:
                exit_idx = signal_idx + horizon
                if exit_idx >= len(sorted_dates):
                    continue
                exit_date = sorted_dates[exit_idx]
                exit_bar = bars.get(exit_date) or {}
                exit_close = float(exit_bar.get("close") or 0.0)
                if exit_close <= 0:
                    continue
                returns[horizon] = exit_close / entry_price - 1.0

            trades.append(
                DailyGainerTrade(
                    symbol=symbol,
                    signal_date=signal.signal_date,
                    rank=signal.rank,
                    signal_return=signal.signal_return,
                    volume_ratio_20d=signal.volume_ratio_20d,
                    close_location=signal.close_location,
                    gap_size=signal.gap_size,
                    avg_dollar_volume_20d=signal.avg_dollar_volume_20d,
                    signal_close=signal.signal_close,
                    entry_date=entry_date,
                    entry_price=entry_price,
                    entry_gap_pct=entry_price / signal.signal_close - 1.0,
                    hold_1d_return=returns.get(1),
                    hold_3d_return=returns.get(3),
                    hold_5d_return=returns.get(5),
                )
            )

    return trades


def summarize_metric(values: list[float]) -> dict[str, float | int | None]:
    """Return compact summary stats for a metric series."""
    if not values:
        return {"count": 0, "mean": None, "median": None, "win_rate": None}
    return {
        "count": len(values),
        "mean": statistics.fmean(values),
        "median": statistics.median(values),
        "win_rate": sum(1 for value in values if value > 0) / len(values),
    }

