"""Screener 服务(§6.3)。

性能优化:
  - enriched parquet 仅存 14 列基础数据, 指标和信号即时计算
  - preset 策略: 从内存缓存或即时计算获取完整指标, ~10-50ms
  - custom SQL: DuckDB (用户传 SQL WHERE 字符串), ~10-50ms
"""
from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from datetime import date, timedelta

import polars as pl

from app.tickflow.repository import KlineRepository

logger = logging.getLogger(__name__)

# ── 进程级历史数据缓存 (避免 run_all 每次重新扫描 parquet + 计算指标) ──
_history_cache: dict[tuple[date, int], tuple[float, pl.DataFrame]] = {}
_HISTORY_CACHE_TTL = 120.0  # 秒


# 内置预设策略 — Polars 表达式方式
PRESET_STRATEGIES: dict[str, dict] = {
    "trend_breakout": {
        "name": "趋势突破",
        "description": "MA60 上方 + 60 日新高 + 量能 ≥ 2 倍均量",
        "filter": (
            (pl.col("close") > pl.col("ma60"))
            & pl.col("signal_n_day_high").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 2.0)
        ),
        "order_by": "momentum_60d",
        "descending": True,
        "limit": 100,
    },
    "ma_golden_cross": {
        "name": "MA 金叉",
        "description": "MA5 上穿 MA20 当日触发,量能配合",
        "filter": (
            pl.col("signal_ma_golden_5_20").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 1.2)
            & (pl.col("close") > pl.col("ma60"))
        ),
        "order_by": "momentum_20d",
        "descending": True,
        "limit": 100,
    },
    "macd_golden": {
        "name": "MACD 金叉放量",
        "description": "MACD 金叉当日 + 量能放大",
        "filter": (
            pl.col("signal_macd_golden").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 1.5)
        ),
        "order_by": "momentum_60d",
        "descending": True,
        "limit": 100,
    },
    "volume_price_surge": {
        "name": "量价齐升",
        "description": "突破 MA20 + 放量 + 收阳",
        "filter": (
            pl.col("signal_ma20_breakout").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 2.0)
            & (pl.col("close") > pl.col("open"))
        ),
        "order_by": "vol_ratio_5d",
        "descending": True,
        "limit": 100,
    },
    "low_volatility_leader": {
        "name": "低波动龙头",
        "description": "20 日动量为正 + 年化波动 < 30% + MA20 上方",
        "filter": (
            (pl.col("momentum_20d") > 0)
            & (pl.col("annual_vol_20d") < 0.30)
            & (pl.col("close") > pl.col("ma20"))
        ),
        "order_by": "momentum_60d",
        "descending": True,
        "limit": 100,
    },
    "broken_board_recovery": {
        "name": "断板反包",
        "description": "连板 ≥2 后断板 1-2 天，出现放量反包信号",
        "filter": (
            pl.col("signal_limit_up").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 1.5)
            & (pl.col("change_pct") > 0.03)
        ),
        "order_by": "change_pct",
        "descending": True,
        "limit": 100,
    },
    "oversold_bounce": {
        "name": "超跌反弹",
        "description": "RSI14 < 30 超卖区 + 当日收阳 + 放量，抄底信号",
        "filter": (
            (pl.col("rsi_14") < 30)
            & (pl.col("close") > pl.col("open"))
            & (pl.col("vol_ratio_5d") >= 1.2)
        ),
        "order_by": "rsi_14",
        "descending": False,
        "limit": 100,
    },
    "boll_breakout": {
        "name": "布林突破",
        "description": "突破布林上轨 + 放量，强势加速信号",
        "filter": (
            pl.col("signal_boll_breakout_upper").fill_null(False)
            & (pl.col("vol_ratio_5d") >= 1.5)
        ),
        "order_by": "vol_ratio_5d",
        "descending": True,
        "limit": 100,
    },
    "bullish_alignment": {
        "name": "均线多头",
        "description": "MA5 > MA10 > MA20 > MA60 多头排列 + 短期动量为正",
        "filter": (
            (pl.col("ma5") > pl.col("ma10"))
            & (pl.col("ma10") > pl.col("ma20"))
            & (pl.col("ma20") > pl.col("ma60"))
            & (pl.col("momentum_20d") > 0)
        ),
        "order_by": "momentum_60d",
        "descending": True,
        "limit": 100,
    },
    "consecutive_limit_ups": {
        "name": "连板股",
        "description": "当日涨停且连续涨停 ≥ 2 天，强势追涨",
        "filter": (
            pl.col("signal_limit_up").fill_null(False)
            & (pl.col("consecutive_limit_ups") >= 2)
        ),
        "order_by": "consecutive_limit_ups",
        "descending": True,
        "limit": 100,
    },
    "pullback_to_support": {
        "name": "缩量回踩",
        "description": "回踩 MA20 附近 + 缩量 + 中期趋势向上",
        "filter": (
            (pl.col("close") > pl.col("ma20") * 0.98)
            & (pl.col("close") < pl.col("ma20") * 1.02)
            & (pl.col("vol_ratio_5d") < 0.8)
            & (pl.col("close") > pl.col("ma60"))
            & (pl.col("momentum_20d") > 0)
        ),
        "order_by": "momentum_60d",
        "descending": True,
        "limit": 100,
    },
    "n_day_low_reversal": {
        "name": "新低反转",
        "description": "触及 60 日新低后当日收阳放量，反转信号",
        "filter": (
            pl.col("signal_n_day_low").fill_null(False)
            & (pl.col("close") > pl.col("open"))
            & (pl.col("vol_ratio_5d") >= 1.5)
        ),
        "order_by": "change_pct",
        "descending": True,
        "limit": 100,
    },
}


@dataclass
class ScreenerResult:
    as_of: date
    strategy: str | None
    rows: list[dict] = field(default_factory=list)
    total: int = 0
    elapsed_ms: float = 0.0


class ScreenerService:
    def __init__(self, repo: KlineRepository) -> None:
        self.repo = repo

    @staticmethod
    def clear_history_cache() -> None:
        """清空进程级 _history_cache (TTL 缓存)。

        清除数据后调用, 避免内存里的旧历史窗口残留导致策略/看板仍命中旧数据。
        """
        _history_cache.clear()

    def _load_enriched_for_date(self, target_date: date) -> pl.DataFrame:
        """从 enriched parquet 读取指定日期的基础数据并即时计算完整指标+信号。

        enriched parquet 仅存 14 列。读取后需要即时计算 ma/ema/macd/kdj/rsi/boll/momentum/signal 等列。
        对于最新日, 优先使用内存缓存 (已包含完整指标)。
        """
        # 优先使用 repo 最新日缓存
        cache, cache_date = self.repo.get_enriched_latest()
        if cache is not None and not cache.is_empty() and cache_date == target_date:
            df = cache
            # JOIN instruments
            df_i = self.repo.get_instruments()
            if not df_i.is_empty():
                inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
                if "name" not in df.columns:
                    df = df.join(df_i.select(inst_cols), on="symbol", how="left")
            return df

        # 尝试从 repo 级预计算历史缓存中提取目标日期
        cached_hist = self.repo.get_enriched_history(target_date, 1)
        if cached_hist is not None and not cached_hist.is_empty() and "date" in cached_hist.columns:
            df = cached_hist.filter(pl.col("date") == target_date)
            if not df.is_empty():
                logger.debug("_load_enriched_for_date: repo history cache for %s", target_date)
                # JOIN instruments
                df_i = self.repo.get_instruments()
                if not df_i.is_empty():
                    inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in df_i.columns]
                    if "name" not in df.columns:
                        df = df.join(df_i.select(inst_cols), on="symbol", how="left")
                return df

        # 历史日期: 从 parquet 读取 14 列, 即时计算指标 (慢路径)
        enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
        ds = target_date.isoformat()
        target_parquet = enriched_dir / f"date={ds}" / "part.parquet"

        if not target_parquet.exists():
            return pl.DataFrame()

        try:
            df = pl.read_parquet(target_parquet)
        except Exception as e:  # noqa: BLE001
            logger.warning("load_enriched_for_date failed: %s", e)
            return pl.DataFrame()

        if df.is_empty():
            return df

        # 即时计算指标: 需要加载历史窗口作 warmup
        df_full = self._compute_enriched_full(df, target_date)
        return df_full

    def _compute_enriched_full(self, df_target: pl.DataFrame, target_date: date) -> pl.DataFrame:
        """从 14 列基础数据即时计算完整 enriched (含全部指标和信号)。

        读取历史数据作为指标计算的 warmup, 计算完成后只返回目标日期的行。
        """
        from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals

        # 加载 warmup 历史 (目标日期前 ~120 天)
        enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
        start = target_date - timedelta(days=150)
        read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
                     "amount", "raw_close", "raw_high", "raw_low"]

        try:
            lf = (
                pl.scan_parquet(str(enriched_dir / "**" / "*.parquet"))
                .filter(
                    (pl.col("date") >= start)
                    & (pl.col("date") <= target_date)
                )
                .sort(["symbol", "date"])
            )
            available = [c for c in read_cols if c in lf.schema]
            df_hist = lf.select(available).collect()
        except Exception as e:  # noqa: BLE001
            logger.warning("warmup history load failed: %s", e)
            df_hist = df_target

        if df_hist.is_empty():
            df_hist = df_target

        # 计算指标
        df_full = compute_indicators(df_hist)
        df_full = compute_signals(df_full)

        # 计算涨跌停信号 (需要 instruments)
        instruments = self.repo.get_instruments()
        if instruments is not None and not instruments.is_empty():
            df_full = compute_limit_signals(df_full, instruments)

        # 只保留目标日期
        df_result = df_full.filter(pl.col("date") == target_date)

        # JOIN instruments (name, total_shares, float_shares)
        if not instruments.is_empty():
            inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
            if "name" not in df_result.columns:
                df_result = df_result.join(instruments.select(inst_cols), on="symbol", how="left")

        return df_result

    def _load_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame:
        """读取目标日期之前的基础行情数据, 供历史窗口策略使用。

        优先从 repo 内存缓存获取 (启动时已预计算), 命中时 0ms。
        缓存 miss 时走 scan_parquet + compute_indicators 慢路径。
        """
        # 优先级 1: repo 级预计算缓存 (启动时 _refresh_enriched 已计算完整历史)
        t0 = time.perf_counter()
        cached = self.repo.get_enriched_history(target_date, lookback_days)
        if cached is not None and not cached.is_empty():
            # JOIN instruments (repo 缓存不含 name 等列)
            instruments = self.repo.get_instruments()
            if instruments is not None and not instruments.is_empty() and "name" not in cached.columns:
                inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"]
                             if c in instruments.columns]
                cached = cached.join(instruments.select(inst_cols), on="symbol", how="left")
            elapsed = (time.perf_counter() - t0) * 1000
            logger.info("_load_enriched_history(%s, %d): repo cache hit, %.1fms, %d rows",
                        target_date, lookback_days, elapsed, len(cached))
            return cached

        # 优先级 2: 进程级 history_cache (之前的 TTL 缓存)
        cache_key = (target_date, lookback_days)
        now = time.monotonic()
        ttl_cached = _history_cache.get(cache_key)
        if ttl_cached is not None:
            ts, cached_df = ttl_cached
            if now - ts < _HISTORY_CACHE_TTL:
                logger.debug("history TTL cache hit: %s lookback=%d", target_date, lookback_days)
                return cached_df
            del _history_cache[cache_key]

        # 优先级 3: scan_parquet + compute_indicators (慢路径, ~5s)
        logger.warning("_load_enriched_history cache miss, computing indicators (%s, %d)...",
                       target_date, lookback_days)
        from app.indicators.pipeline import compute_indicators, compute_signals, compute_limit_signals

        warmup = 60
        start = target_date - timedelta(days=min((lookback_days + warmup) * 2, 180))

        enriched_dir = self.repo.store.data_dir / "kline_daily_enriched"
        read_cols = ["symbol", "date", "open", "high", "low", "close", "volume",
                     "amount", "raw_close", "raw_high", "raw_low"]

        try:
            lf = (
                pl.scan_parquet(str(enriched_dir / "**" / "*.parquet"))
                .filter((pl.col("date") >= start) & (pl.col("date") <= target_date))
                .sort(["symbol", "date"])
            )
            available = [c for c in read_cols if c in lf.collect_schema().names()]
            df_hist = lf.select(available).collect()
        except Exception as e:  # noqa: BLE001
            logger.warning("load_enriched_history failed: %s", e)
            return pl.DataFrame()

        if df_hist.is_empty():
            return pl.DataFrame()

        df_full = compute_indicators(df_hist)
        df_full = compute_signals(df_full)

        instruments = self.repo.get_instruments()
        if instruments is not None and not instruments.is_empty():
            df_full = compute_limit_signals(df_full, instruments)

        if instruments is not None and not instruments.is_empty():
            inst_cols = [c for c in ["symbol", "name", "total_shares", "float_shares"] if c in instruments.columns]
            if "name" not in df_full.columns:
                df_full = df_full.join(instruments.select(inst_cols), on="symbol", how="left")

        # 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销)
        lookback_start = target_date - timedelta(days=lookback_days)
        if "date" in df_full.columns:
            df_full = df_full.filter(pl.col("date") >= lookback_start)

        df_full = df_full.sort(["symbol", "date"])

        elapsed = (time.perf_counter() - t0) * 1000
        logger.info("_load_enriched_history(%s, %d): computed in %.1fms, %d rows",
                    target_date, lookback_days, elapsed, len(df_full))

        _history_cache[cache_key] = (now, df_full)
        if len(_history_cache) > 10:
            expired = [k for k, (ts, _) in _history_cache.items() if now - ts > _HISTORY_CACHE_TTL]
            for k in expired:
                del _history_cache[k]

        return df_full

    def run(
        self,
        as_of: date,
        conditions: list[str],
        order_by: str | None = None,
        limit: int = 30,
        pool: list[str] | None = None,
    ) -> ScreenerResult:
        """自定义 SQL 条件选股。

        先通过 Polars 即时计算完整指标, 再用 DuckDB 做 SQL WHERE 过滤。
        kline_enriched DuckDB 视图只有 14 列, 不能直接用于指标过滤。
        """
        t0 = time.perf_counter()

        if not conditions:
            return ScreenerResult(as_of=as_of, strategy=None)

        # 从即时计算获取完整 enriched 数据
        df = self._load_enriched_for_date(as_of)
        if df.is_empty():
            return ScreenerResult(as_of=as_of, strategy=None)

        # Pool 过滤
        if pool:
            df = df.filter(pl.col("symbol").is_in(pool))

        # 用 DuckDB 做 SQL 过滤 (注册临时视图)
        try:
            import duckdb
            con = duckdb.connect(database=":memory:")
            con.register("enriched", df.to_arrow())
            where = " AND ".join(f"({c})" for c in conditions)
            sql = f"SELECT * FROM enriched WHERE {where}"
            if order_by:
                sql += f" ORDER BY {order_by}"
            if limit:
                sql += f" LIMIT {limit}"
            df_result = con.execute(sql).pl()
            con.close()
        except Exception as e:  # noqa: BLE001
            logger.warning("screener SQL query failed: %s", e)
            df_result = pl.DataFrame()

        rows = df_result.to_dicts() if not df_result.is_empty() else []
        elapsed = (time.perf_counter() - t0) * 1000

        return ScreenerResult(
            as_of=as_of,
            strategy=None,
            rows=rows,
            total=len(rows),
            elapsed_ms=elapsed,
        )

    def run_preset(
        self,
        strategy_id: str,
        as_of: date,
        pool: list[str] | None = None,
        precomputed: pl.DataFrame | None = None,
        basic_filter: dict | None = None,
        display_limit: int | None = None,
    ) -> ScreenerResult:
        """预设策略选股 — 从 enriched 读取预计算好的指标列后过滤。

        - precomputed 不为空: 直接复用（run_all 场景）
        - precomputed 为空: 从 enriched 读目标日期
        - basic_filter: 用户保存的基础参数过滤（boards、价格等）
        """
        t0 = time.perf_counter()

        strat = PRESET_STRATEGIES.get(strategy_id)
        if not strat:
            raise ValueError(f"unknown strategy: {strategy_id}")

        if precomputed is not None and not precomputed.is_empty():
            df = precomputed
        else:
            df = self._load_enriched_for_date(as_of)
            if df.is_empty():
                return ScreenerResult(as_of=as_of, strategy=strategy_id)

        # 应用用户基础参数过滤（boards、价格区间等）
        if basic_filter and basic_filter.get("enabled", True):
            df = self._apply_basic_filter(df, basic_filter)

        # 应用策略过滤
        df = df.filter(strat["filter"])

        # 应用 pool
        if pool:
            df = df.filter(pl.col("symbol").is_in(pool))

        # 排序 + 限制
        order_col = strat["order_by"]
        if order_col in df.columns:
            df = df.sort(order_col, descending=strat.get("descending", True))

        # display_limit: None=不限制, 0=全部, N=前N个
        if display_limit == 0:
            limit = None  # 不限制
        elif display_limit is not None:
            limit = display_limit
        else:
            limit = None  # 未配置时默认不限制
        if limit is not None and limit > 0:
            df = df.head(limit)

        # 基于排序列生成 0-100 评分 (与 StrategyEngine 统一)
        if order_col in df.columns and not df.is_empty():
            col_vals = df[order_col].cast(pl.Float64)
            col_min = col_vals.min()
            col_max = col_vals.max()
            col_range = col_max - col_min
            if col_range and col_range > 0:
                normalized = (col_vals - col_min) / col_range
            else:
                normalized = pl.Series("norm", [0.5] * len(df))
            if not strat.get("descending", True):
                normalized = 1.0 - normalized
            df = df.with_columns((normalized * 100).alias("score"))

        rows = df.to_dicts()
        elapsed = (time.perf_counter() - t0) * 1000

        # sanitize
        for r in rows:
            for k, v in list(r.items()):
                if isinstance(v, float) and (v != v or abs(v) == float("inf")):
                    r[k] = None

        return ScreenerResult(
            as_of=as_of,
            strategy=strategy_id,
            rows=rows,
            total=len(rows),
            elapsed_ms=elapsed,
        )

    @staticmethod
    def _apply_basic_filter(df: pl.DataFrame, bf: dict) -> pl.DataFrame:
        """应用用户基础参数过滤（boards、价格区间、市值等）"""
        exprs: list[pl.Expr] = []
        if bf.get("price_min") is not None:
            exprs.append(pl.col("close") >= bf["price_min"])
        if bf.get("price_max") is not None:
            exprs.append(pl.col("close") <= bf["price_max"])
        if bf.get("float_cap_min") is not None and "float_shares" in df.columns:
            exprs.append(pl.col("close") * pl.col("float_shares") >= bf["float_cap_min"])
        if bf.get("float_cap_max") is not None and "float_shares" in df.columns:
            exprs.append(pl.col("close") * pl.col("float_shares") <= bf["float_cap_max"])
        if bf.get("amount_min") is not None:
            exprs.append(pl.col("amount") >= bf["amount_min"])
        if bf.get("amount_max") is not None:
            exprs.append(pl.col("amount") <= bf["amount_max"])
        if bf.get("turnover_min") is not None and "turnover_rate" in df.columns:
            exprs.append(pl.col("turnover_rate") >= bf["turnover_min"])
        if bf.get("turnover_max") is not None and "turnover_rate" in df.columns:
            exprs.append(pl.col("turnover_rate") <= bf["turnover_max"])
        if bf.get("exclude_st") and "name" in df.columns:
            exprs.append(~pl.col("name").str.contains("(?i)ST|\\*ST|退"))
        # 板块过滤
        boards = bf.get("boards")
        if boards and isinstance(boards, list) and len(boards) > 0:
            board_exprs: list[pl.Expr] = []
            for b in boards:
                if b == "沪主板":
                    board_exprs.append(pl.col("symbol").str.starts_with("60"))
                elif b == "深主板":
                    board_exprs.append(
                        pl.col("symbol").str.starts_with("00")
                        | pl.col("symbol").str.starts_with("001")
                    )
                elif b == "创业板":
                    board_exprs.append(
                        pl.col("symbol").str.starts_with("300")
                        | pl.col("symbol").str.starts_with("301")
                    )
                elif b == "科创板":
                    board_exprs.append(pl.col("symbol").str.starts_with("688"))
                elif b == "北交所":
                    board_exprs.append(pl.col("symbol").str.contains(r"\.BJ$"))
            if board_exprs:
                exprs.append(pl.any_horizontal(board_exprs))
        if exprs:
            return df.filter(pl.all_horizontal(exprs))
        return df

    def latest_date(self) -> date | None:
        d = self.repo.enriched_latest_date()
        if d:
            return d
        # 回退 DuckDB
        try:
            res = self.repo.execute_one(
                "SELECT max(date) FROM kline_enriched",
            )
            if res and res[0]:
                d = res[0]
                return d if isinstance(d, date) else date.fromisoformat(str(d))
        except Exception:  # noqa: BLE001
            return None
        return None
