"""MacroLongScreener: generates synthetic ETF long candidates on broad market rallies.

Extracted from BacktestRunner._schedule_macro_long_candidates() so both the
backtester and paper trader can reuse the same logic.

Trigger conditions (example preset: idle_macro_breadth_smh_postalloc):
  - SMH reaction_day_return >= 1.8%
  - SMH close_location >= 0.64
  - SMH volume_ratio_20d >= 1.1
  - Breadth: QQQ, XLK, SMH — at least 2 pass the same checks
  - SMH leadership vs SPY >= +0.4%
"""
from __future__ import annotations

import datetime as dt
import math
from typing import Any

from libs.backtest.domain import Candidate, StrategyEngineConfig


def value_fails_bounds(
    value: float | None,
    minimum: float | None,
    maximum: float | None,
) -> bool:
    """Return True if value is out of [minimum, maximum] bounds."""
    if value is None:
        return minimum is not None or maximum is not None
    if minimum is not None and value < minimum:
        return True
    if maximum is not None and value > maximum:
        return True
    return False


def compute_market_features_from_bars(
    bars: dict[dt.date, dict[str, Any]],
    as_of_date: dt.date,
) -> dict[str, Any]:
    """Compute reaction_day_return, volume_ratio_20d, gap_size, close_location,
    event_close, atr_14, avg_dollar_volume_20d from daily bars dict.

    bars: {date -> {"open", "high", "low", "close", "volume"}}
    Returns dict with market features or {} if insufficient data.
    """
    sorted_dates = sorted(d for d in bars if d <= as_of_date)
    if not sorted_dates or sorted_dates[-1] != as_of_date:
        return {}

    today_bar = bars[as_of_date]
    today_close = float(today_bar.get("close") or 0.0)
    today_open = float(today_bar.get("open") or today_close)
    today_high = float(today_bar.get("high") or today_close)
    today_low = float(today_bar.get("low") or today_close)
    today_vol = float(today_bar.get("volume") or 0.0)

    if today_close <= 0:
        return {}

    prev_dates = [d for d in sorted_dates if d < as_of_date]
    if not prev_dates:
        return {}
    prev_close = float(bars[prev_dates[-1]].get("close") or today_close)

    # reaction_day_return: today's close vs prev close
    reaction_day_return = (today_close - prev_close) / prev_close if prev_close > 0 else 0.0

    # gap_size: open vs prev close
    gap_size = (today_open - prev_close) / prev_close if prev_close > 0 else 0.0

    # close_location: (close - low) / (high - low)
    hl = today_high - today_low
    close_location = (today_close - today_low) / hl if hl > 0 else 0.5

    # volume_ratio_20d and avg_dollar_volume_20d from last 20 bars
    lookback = sorted_dates[-21:-1]  # up to 20 previous bars
    if lookback:
        recent_vols = [float(bars[d].get("volume") or 0) for d in lookback]
        recent_advs = [
            float(bars[d].get("volume") or 0) * float(bars[d].get("close") or 0)
            for d in lookback
        ]
        avg_vol = sum(recent_vols) / len(recent_vols) if recent_vols else 0.0
        volume_ratio_20d = today_vol / avg_vol if avg_vol > 0 else 1.0
        avg_dollar_volume = sum(recent_advs) / len(recent_advs) if recent_advs else today_close * today_vol
    else:
        volume_ratio_20d = 1.0
        avg_dollar_volume = today_close * today_vol

    # ATR-14 (simplified: average of |high - low| over last 14 bars)
    atr_bars = [bars[d] for d in sorted_dates[-15:] if d <= as_of_date]
    if atr_bars:
        true_ranges = []
        for i, b in enumerate(atr_bars):
            h = float(b.get("high") or 0)
            lo = float(b.get("low") or 0)
            true_ranges.append(max(h - lo, 0.001))
        atr_14 = sum(true_ranges) / len(true_ranges)
    else:
        atr_14 = today_close * 0.02

    return {
        "reaction_day_return": reaction_day_return,
        "volume_ratio_20d": volume_ratio_20d,
        "gap_size": gap_size,
        "close_location": close_location,
        "event_close": today_close,
        "atr_14": atr_14,
        "avg_dollar_volume_20d": avg_dollar_volume,
    }


class MacroLongScreener:
    """Check macro_long engine trigger conditions and generate synthetic Candidate.

    Extracted from BacktestRunner._schedule_macro_long_candidates() (run.py:5509).
    Both backtester and paper trader use this to avoid code duplication.
    """

    def screen(
        self,
        *,
        signal_date: dt.date,
        execution_date: dt.date,
        engine: StrategyEngineConfig,
        macro_vix: float | None,
        market_features: dict[str, Any],
        breadth_features: dict[str, dict[str, Any]],
        open_symbols: set[str],
        event_breadth_count: int = 0,
        event_breadth_unique_sectors: int = 0,
        execution_bar: dict[str, Any] | None = None,
    ) -> Candidate | None:
        """Return a synthetic Candidate if macro_long trigger fires, else None.

        Args:
            signal_date: The date on which the trigger is evaluated (reaction date).
            execution_date: The date on which the trade would be entered (next open).
            engine: StrategyEngineConfig with macro_long_* fields set.
            macro_vix: Current VIX value (or None if unavailable).
            market_features: Features for the trigger symbol (e.g. SMH).
            breadth_features: {symbol -> features} for breadth symbols (QQQ, XLK, SMH).
            open_symbols: Symbols already in open positions (skip if already held).
            event_breadth_count: Number of qualifying PEAD candidates today.
            event_breadth_unique_sectors: Number of unique sectors in today's PEAD candidates.
            execution_bar: Bar data for execution_date (used for entry price). If None,
                uses event_close from market_features.
        """
        trigger_symbol = str(engine.macro_long_symbol or "").upper()
        if not trigger_symbol:
            return None

        # VIX gate
        if value_fails_bounds(
            float(macro_vix) if macro_vix is not None else None,
            getattr(engine, "macro_vix_min", None),
            engine.macro_vix_max,
        ):
            return None

        reaction_return = market_features.get("reaction_day_return")
        volume_ratio = market_features.get("volume_ratio_20d")
        gap_size = market_features.get("gap_size")
        close_location = market_features.get("close_location")
        event_close = market_features.get("event_close")
        atr_14 = market_features.get("atr_14")
        avg_dollar_volume = market_features.get("avg_dollar_volume_20d")

        if not market_features:
            return None

        # Trigger symbol bounds checks
        for val, mn, mx in [
            (reaction_return, engine.macro_long_reaction_day_return_min, engine.macro_long_reaction_day_return_max),
            (volume_ratio, engine.macro_long_volume_ratio_min, engine.macro_long_volume_ratio_max),
            (gap_size, engine.macro_long_gap_size_min, engine.macro_long_gap_size_max),
            (close_location, engine.macro_long_close_location_min, engine.macro_long_close_location_max),
        ]:
            if value_fails_bounds(float(val) if val is not None else None, mn, mx):
                return None

        # Breadth check
        breadth_symbols = [
            str(s).upper() for s in (engine.macro_long_breadth_symbols or []) if str(s).strip()
        ]
        breadth_match_symbols: list[str] = []
        breadth_count = event_breadth_count
        breadth_unique_sectors = event_breadth_unique_sectors
        breadth_feature_map: dict[str, dict[str, Any]] = {}

        if breadth_symbols:
            for bs in dict.fromkeys(breadth_symbols):
                bfeat = breadth_features.get(bs, {})
                if not bfeat:
                    continue
                breadth_feature_map[bs] = bfeat
                if value_fails_bounds(
                    float(bfeat.get("reaction_day_return")) if bfeat.get("reaction_day_return") is not None else None,
                    engine.macro_long_breadth_reaction_day_return_min,
                    engine.macro_long_breadth_reaction_day_return_max,
                ):
                    continue
                if value_fails_bounds(
                    float(bfeat.get("volume_ratio_20d")) if bfeat.get("volume_ratio_20d") is not None else None,
                    engine.macro_long_breadth_volume_ratio_min,
                    engine.macro_long_breadth_volume_ratio_max,
                ):
                    continue
                if value_fails_bounds(
                    float(bfeat.get("gap_size")) if bfeat.get("gap_size") is not None else None,
                    engine.macro_long_breadth_gap_size_min,
                    engine.macro_long_breadth_gap_size_max,
                ):
                    continue
                if value_fails_bounds(
                    float(bfeat.get("close_location")) if bfeat.get("close_location") is not None else None,
                    engine.macro_long_breadth_close_location_min,
                    engine.macro_long_breadth_close_location_max,
                ):
                    continue
                breadth_match_symbols.append(bs)

            breadth_count = len(breadth_match_symbols)
            breadth_unique_sectors = breadth_count
            if (
                engine.macro_long_min_breadth_count is not None
                and breadth_count < engine.macro_long_min_breadth_count
            ):
                return None
        else:
            if (
                engine.macro_long_min_daily_candidate_count is not None
                and breadth_count < engine.macro_long_min_daily_candidate_count
            ):
                return None
            if (
                engine.macro_long_min_unique_sector_count is not None
                and breadth_unique_sectors < engine.macro_long_min_unique_sector_count
            ):
                return None

        # Leadership vs SPY
        leadership_vs_spy: float | None = None
        if engine.macro_long_leadership_vs_spy_min is not None:
            spy_feat = breadth_features.get("SPY", {})
            spy_ret = spy_feat.get("reaction_day_return")
            if reaction_return is None or spy_ret is None:
                return None
            leadership_vs_spy = float(reaction_return) - float(spy_ret)
            if leadership_vs_spy < engine.macro_long_leadership_vs_spy_min:
                return None

        # Trade symbol selection
        trade_symbol = trigger_symbol
        trade_symbol_mode = str(engine.macro_long_trade_symbol_mode or "fixed").lower()
        if trade_symbol_mode == "leader" and breadth_match_symbols:
            def _leader_key(sym: str) -> tuple[float, float, float]:
                f = breadth_feature_map.get(sym, {})
                return (
                    float(f.get("reaction_day_return") or 0.0),
                    float(f.get("close_location") or 0.0),
                    float(f.get("volume_ratio_20d") or 0.0),
                )
            trade_symbol = max(breadth_match_symbols, key=_leader_key)
            tf = breadth_feature_map.get(trade_symbol, market_features)
            reaction_return = tf.get("reaction_day_return", reaction_return)
            volume_ratio = tf.get("volume_ratio_20d", volume_ratio)
            gap_size = tf.get("gap_size", gap_size)
            close_location = tf.get("close_location", close_location)
            event_close = tf.get("event_close", event_close)
            atr_14 = tf.get("atr_14", atr_14)
            avg_dollar_volume = tf.get("avg_dollar_volume_20d", avg_dollar_volume)

        if trade_symbol.upper() in open_symbols:
            return None

        # Score computation
        reaction_scale = abs(engine.macro_long_reaction_day_return_min or 0.015) or 0.015
        reaction_quality = min(1.0, max(0.0, float(reaction_return or 0.0)) / reaction_scale)

        if engine.macro_long_volume_ratio_min:
            volume_quality = min(1.0, float(volume_ratio or 0.0) / engine.macro_long_volume_ratio_min)
        else:
            volume_quality = 0.5 if volume_ratio is None else min(1.0, float(volume_ratio) / 2.0)

        close_quality = 0.5 if close_location is None else max(0.0, min(1.0, float(close_location)))

        breadth_components: list[float] = []
        if breadth_symbols and engine.macro_long_min_breadth_count:
            breadth_components.append(min(1.0, breadth_count / float(engine.macro_long_min_breadth_count)))
        elif not breadth_symbols:
            if engine.macro_long_min_daily_candidate_count:
                breadth_components.append(min(1.0, breadth_count / float(engine.macro_long_min_daily_candidate_count)))
            if engine.macro_long_min_unique_sector_count:
                breadth_components.append(min(1.0, breadth_unique_sectors / float(engine.macro_long_min_unique_sector_count)))
        breadth_quality = sum(breadth_components) / len(breadth_components) if breadth_components else 0.5

        score = min(0.99, 0.35 + 0.30 * reaction_quality + 0.15 * volume_quality + 0.10 * close_quality + 0.10 * breadth_quality)
        score_bucket = "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium"

        close_value = float(event_close) if event_close is not None else 0.0
        if close_value <= 0:
            if execution_bar:
                close_value = float(execution_bar.get("close") or 0.0)
        if close_value <= 0:
            return None

        atr_value = float(atr_14) if atr_14 is not None and float(atr_14) > 0 else close_value * 0.02
        adv_value = float(avg_dollar_volume) if avg_dollar_volume is not None and float(avg_dollar_volume) > 0 else 1e9

        return Candidate(
            event_id=f"synth_macro_long_{trade_symbol.lower()}_{signal_date.isoformat()}",
            symbol=trade_symbol,
            source_symbol=trigger_symbol,
            score=score,
            sector="MACRO",
            event_type="macro_bullish_event",
            event_timestamp=dt.datetime.combine(signal_date, dt.time(16, 0), tzinfo=dt.timezone.utc),
            event_date=signal_date,
            filing_time_bucket="after_close",
            reaction_date=signal_date,
            execution_date=execution_date,
            entry_price_est=close_value,
            avg_dollar_volume=adv_value,
            atr_14=atr_value,
            score_bucket=score_bucket,
            engine_id=engine.engine_id,
            entry_timing_policy="next_open",
            trade_direction="long",
            engine_max_holding_days=engine.max_holding_days,
            engine_risk_budget_pct=engine.engine_risk_budget_pct,
            engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
            engine_target_1_r=engine.target_1_r_override,
            engine_target_1_fraction=engine.target_1_fraction_override,
            engine_trailing_model=engine.trailing_model_override,
            engine_trailing_warmup_days=engine.trailing_warmup_days_override,
            engine_stop_atr_multiplier=engine.stop_atr_multiplier_override,
            engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
            engine_use_reaction_day_low_stop=False,
            engine_early_failure_close_below_entry_and_reaction_close=False,
            engine_early_failure_no_progress_days=999,
            shadow_only=engine.shadow_only,
            features={
                "macro_long_symbol": trigger_symbol,
                "macro_long_trade_symbol": trade_symbol,
                "macro_long_trade_symbol_mode": trade_symbol_mode,
                "macro_long_reaction_day_return": reaction_return,
                "macro_long_volume_ratio_20d": volume_ratio,
                "macro_long_gap_size": gap_size,
                "macro_long_close_location": close_location,
                "macro_long_breadth_symbols": breadth_match_symbols,
                "macro_long_breadth_count": breadth_count,
                "macro_long_leadership_vs_spy": leadership_vs_spy,
                "macro_long_daily_candidate_count": breadth_count,
                "macro_long_daily_unique_sector_count": breadth_unique_sectors,
                "macro_long_event_candidate_count": event_breadth_count,
                "macro_long_event_unique_sector_count": event_breadth_unique_sectors,
                "macro_long_vix": macro_vix,
                "macro_long_event_close": close_value,
            },
        )
