# -*- coding: utf-8 -*-
"""上游信号只读快照 (2026-08-12: 只展示、不进任何下单/决策逻辑)。

把三系统盘中信号取最近若干条给页面看:
  · 决策系统(bionic) 买卖广播 + 风控卖出动作          —— 208 db2 intraday_signals / db3 llm_sell_actions
  · 盘中择时层(intraday_timing) BUY 入场 + 双向风控告警 —— 208 db2 intraday_signals / intraday_alerts
  · mtf 资金异动 + 实况分关注/回避榜                    —— 208 db2 stream:metrics / 214 db0 mr:board

**严格只读**: 一律 XREVRANGE / ZREVRANGE 取最近, 不建消费组、不 ACK、不写 —— 碰不到上游的消费与
投递, 也不和 PMS 现有信号消化(signal_service 的消费组)抢消息。连不通的源单独降级 (ok=False + error),
不影响其余与整页。取数带进程内缓存 (默认 8s), 不高频打上游。
`intraday_signals` 一条流两家都写、格式不同: 按 producer_id / entry_score 字段特征分成
「决策系统」与「择时层」两类展示。
"""
from __future__ import annotations

import json
import logging
import time
from datetime import datetime

from config.settings import settings
from app.services import param_store

logger = logging.getLogger("pms.upstream")

_clients = {}
_cache = {"at": 0.0, "data": None}


def _to() -> int:
    return max(1, param_store.get_int("PMS_UPSTREAM_TIMEOUT_SEC", 3))


def _limit() -> int:
    return max(1, param_store.get_int("PMS_UPSTREAM_LIMIT", 40))


def _alert_scan() -> int:
    # 告警流扫更大窗口, 防高频源(如 price_notice)把风控告警挤出"最新N"。
    return max(_limit(), param_store.get_int("PMS_UPSTREAM_ALERT_SCAN", 300))


def _alert_per_cat() -> int:
    # 每个类型最多留最新 N 条, 高频源只占它自己那一档, 不挤别的类。
    return max(1, param_store.get_int("PMS_UPSTREAM_ALERT_PER_CAT", 20))


def _alert_max_age_sec() -> int:
    # 告警时间窗(秒): 0=不限龄。只用于给每条打"陈旧"标记(超龄置灰), **不删条目** ——
    # 用户拍板"置灰标注"而非隐藏, 风控面板不丢信息。与每类上限正交: 先按龄标 stale, 再每类限量。
    return max(0, param_store.get_int("PMS_UPSTREAM_ALERT_MAX_AGE_MIN", 60)) * 60


def _c208(db: int):
    """208(与 SIGNAL_REDIS 同实例)的只读客户端, 按库缓存。"""
    key = ("208", db)
    if key in _clients:
        return _clients[key]
    import redis
    kw = dict(host=settings.SIGNAL_REDIS_HOST, port=settings.SIGNAL_REDIS_PORT,
              password=settings.SIGNAL_REDIS_PASSWORD or None, db=db, decode_responses=True,
              socket_timeout=_to(), socket_connect_timeout=_to())
    try:
        c = redis.Redis(protocol=2, **kw)     # RESP2: 服务端 <6.0 不认 HELLO (与行情库同一个坑)
    except TypeError:
        c = redis.Redis(**kw)
    _clients[key] = c
    return c


def _c214():
    """214(mtf 实况分)的只读客户端, 走 .env 里的 UP_MR_REDIS_URL。"""
    if "214" in _clients:
        return _clients["214"]
    import redis
    url = (getattr(settings, "UP_MR_REDIS_URL", "") or "").strip()
    if not url:
        raise RuntimeError("UP_MR_REDIS_URL 未配置 (请在 .env 加, 见 config/settings.py 注释)")
    try:
        c = redis.Redis.from_url(url, protocol=2, decode_responses=True,
                                 socket_timeout=_to(), socket_connect_timeout=_to())
    except TypeError:
        c = redis.Redis.from_url(url, decode_responses=True,
                                 socket_timeout=_to(), socket_connect_timeout=_to())
    _clients["214"] = c
    return c


def _hm(ms):
    """epoch 毫秒 → HH:MM (本地时区)。拿不到就空串。"""
    try:
        return time.strftime("%H:%M", time.localtime(int(ms) // 1000))
    except Exception:
        return ""


def _num(v):
    try:
        return round(float(v), 4)
    except (TypeError, ValueError):
        return v


# ================================================================ 各源解析 (只读)
def _read_intraday(c, ymd):
    """一条 intraday_signals 流两家都写: 有 producer_id=intraday_timing / entry_score 的归「择时层 BUY」,
    其余归「决策系统买卖」(bionic 广播 ENTRY/EXIT · BUY/SELL)。"""
    key = "intraday_signals:%s" % ymd
    decision, timing = [], []
    for _id, f in c.xrevrange(key, count=_limit()):
        producer = str(f.get("producer_id") or "")
        is_itd = ("intraday_timing" in producer) or (f.get("entry_score") is not None)
        row = {"ts_code": f.get("ts_code"), "action": f.get("action"),
               "signal_type": f.get("signal_type"), "price": _num(f.get("suggested_price")),
               "target": _num(f.get("target_price")), "confidence": _num(f.get("confidence")),
               "entry_score": _num(f.get("entry_score")), "time": _hm(f.get("trigger_time"))}
        (timing if is_itd else decision).append(row)
    return {"key": key, "decision": decision, "timing": timing}


# 告警源 → (方向, 类型key, 类型标签)。权威依据: intraday_timing/alerts/orchestrator.py 的
# DIRECTION_DOWN_SOURCES / DIRECTION_UP_SOURCES (2026-06-15 现行) 与《盘中择时层_信号输出说明》§5。
#   · intraday_buy_emitted 是「每发一条 BUY 镜像一条」的事件, **无涨跌方向** (原来按子串误判成看涨)。
#   · multi_source 的方向看 metadata.direction。
#   · volume_capitulation/breakout 已退役(被 capital_distribution/accumulation 取代), 历史键仍可能在流里, 一并登记。
_ALERT_META = {
    "daily_qrs_symmetric_down": ("看跌", "qrs", "日QRS对称"),
    "money_flow_out_intensity": ("看跌", "money_flow", "资金流强度"),
    "capital_distribution":     ("看跌", "capital", "资金分布"),
    "volume_capitulation":      ("看跌", "volume", "放量异动"),
    "daily_qrs_symmetric_up":   ("看涨", "qrs", "日QRS对称"),
    "money_flow_in_intensity":  ("看涨", "money_flow", "资金流强度"),
    "capital_accumulation":     ("看涨", "capital", "资金分布"),
    "volume_breakout":          ("看涨", "volume", "放量异动"),
    # 股价异动通知: 是"价格大幅变动"事件, 不是情绪信号 → 方向用 涨/跌(区别于看涨/看跌), 单独一类。
    "price_notice_up":          ("涨",   "price_notice", "股价异动"),
    "price_notice_down":        ("跌",   "price_notice", "股价异动"),
    "multi_source":             (None,  "multi", "多源升级"),
    "intraday_buy_emitted":     ("—",   "buy_emitted", "买入镜像"),
}


def _read_alerts(c, ymd):
    key = "intraday_alerts:%s" % ymd
    per_cat = _alert_per_cat()
    max_age = _alert_max_age_sec()   # 秒; 0=不限龄。超龄不删, 只标 stale 供页面置灰。
    now = time.time()
    counts = {}          # cat -> 已收数量; 每类只留最新 per_cat 条(仍按 newest-first)
    out = []
    for _id, f in c.xrevrange(key, count=_alert_scan()):
        src = str(f.get("source") or "")
        meta = f.get("metadata")
        if not isinstance(meta, dict):
            try:
                meta = json.loads(meta)
            except Exception:
                meta = {}
        dir_fixed, cat, cat_label = _ALERT_META.get(src, (None, "other", "其他"))
        if counts.get(cat, 0) >= per_cat:     # 该类已满 → 跳过, 高频源不挤掉别的类
            continue
        if dir_fixed is None:
            # multi_source 或未登记源: 方向以 metadata.direction 为准, 拿不到就 —
            mdir = str((meta or {}).get("direction") or "").lower()
            d = "看跌" if mdir in ("down", "short", "bear") else ("看涨" if mdir in ("up", "long", "bull") else "—")
        else:
            d = dir_fixed
        # 分钟龄 + 陈旧标记: 时间窗只做"置灰标注", 不删条目(超龄由页面按 stale 置灰)。
        # trigger_time 是 epoch 毫秒; 缺失/非法 → age_min=None 且不判陈旧(风控从宽, 不误灰)。
        try:
            age_sec = now - float(f.get("trigger_time")) / 1000.0
            age_min = int(age_sec // 60)
        except (TypeError, ValueError):
            age_sec, age_min = None, None
        stale = bool(max_age) and age_sec is not None and age_sec > max_age
        counts[cat] = counts.get(cat, 0) + 1
        out.append({"ts_code": f.get("ts_code"), "source": src, "direction": d,
                    "cat": cat, "cat_label": cat_label,
                    "level": f.get("level"), "value": _num(f.get("value")),
                    "time": _hm(f.get("trigger_time")), "age_min": age_min, "stale": stale})
    return {"key": key, "items": out}


def _read_metrics(c):
    """mtf 资金异动: 真实载荷打包在 stream 的 data(JSON字符串)里, 顶层只有 ts_code ——
    之前直接取顶层 z_dd/direction 全落空(页面显示为空)。这里拆 data 取真实字段, 顶层作兜底。
    mtf 侧字段: direction(inflow/outflow), z_dd(横截面异动z), window_net(净额), window_ret(区间涨跌); 无原生 level。"""
    key = "mtf:intraday:stream:metrics"
    out = []
    for _id, f in c.xrevrange(key, count=_limit()):
        code = f.get("ts_code") or f.get("code")
        payload = f.get("data")
        if isinstance(payload, str):
            try:
                payload = json.loads(payload)
            except Exception:
                payload = {}
        if not isinstance(payload, dict):
            payload = {}
        g = lambda k, d=None: payload.get(k, f.get(k, d))
        out.append({"ts_code": code, "direction": g("direction"),
                    "z_dd": _num(g("z_dd")), "window_net": _num(g("window_net")),
                    "window_ret": _num(g("window_ret")), "level": g("level"),
                    "value": _num(g("value") if g("value") is not None else g("net")),
                    "raw": payload})
    return {"key": key, "items": out}


def _read_sell_actions(c):
    """风控卖出流: 真实载荷包在 data(JSON 字符串)里, 顶层只有 data 一个字段 ——
    之前直接取顶层 ts_code/confidence/reason 全落空, 页面每行只剩硬编码的 SELL(2026-08-18 修)。
    与消化端 signal_rules.parse_risk_sell 同口径拆 data; 字段名对齐 bionic 写入端
    (tasks_risk/tasks_intraday): 理由是 llm_reason, 时间是 timestamp(epoch 毫秒),
    dominant_signal 区分风控止损与止盈(take_profit)。顶层字段留作兜底, 兼容未来可能的扁平化。"""
    key = "bionic:signals:llm_sell_actions"
    out = []
    for _id, f in c.xrevrange(key, count=_limit()):
        raw = f.get("data")
        d = f
        if raw:
            try:
                d = json.loads(raw) if isinstance(raw, str) else dict(raw)
            except Exception:
                d = {}
        out.append({"ts_code": d.get("ts_code") or f.get("ts_code"),
                    "action": d.get("action") or f.get("action"),
                    "confidence": _num(d.get("confidence")),
                    "dominant_signal": d.get("dominant_signal"),
                    "reason": d.get("llm_reason") or d.get("reason") or f.get("reason"),
                    "time": _hm(d.get("timestamp") or d.get("trigger_time") or f.get("ts"))})
    return {"key": key, "items": out}


def _read_mr(c):
    """mtf 实况分 关注/回避榜 (zset, score=实况分 0~1): 高分=关注、低分=回避, 各取前 10。"""
    key = "mtf:mr:intraday:board"
    top = c.zrevrange(key, 0, 9, withscores=True)
    bottom = c.zrange(key, 0, 9, withscores=True)
    fmt = lambda pairs: [{"ts_code": k, "score": round(float(v), 3)} for k, v in pairs]
    return {"key": key, "watch": fmt(top), "avoid": fmt(bottom)}


# ================================================================ 快照
def snapshot(force: bool = False) -> dict:
    ttl = max(2, param_store.get_int("PMS_UPSTREAM_CACHE_SEC", 8))
    now = time.time()
    if not force and _cache["data"] is not None and (now - _cache["at"]) < ttl:
        return _cache["data"]
    ymd = datetime.now().strftime("%Y-%m-%d")
    out = {"ok": True, "at": datetime.now().isoformat(timespec="seconds"), "sources": {}}

    def _try(name, fn):
        try:
            out["sources"][name] = {"ok": True, **fn()}
        except Exception as e:
            logger.warning("[上游信号] %s 读取失败: %s", name, e)
            out["sources"][name] = {"ok": False, "error": f"{type(e).__name__}: {e}"}

    dbi, dba = settings.SIGNAL_REDIS_DB_INTRADAY, settings.SIGNAL_REDIS_DB_ACTIONS
    _try("intraday", lambda: _read_intraday(_c208(dbi), ymd))
    _try("alerts", lambda: _read_alerts(_c208(dbi), ymd))
    _try("metrics", lambda: _read_metrics(_c208(dbi)))
    _try("sell_actions", lambda: _read_sell_actions(_c208(dba)))
    _try("mr", lambda: _read_mr(_c214()))

    _cache["at"], _cache["data"] = now, out
    return out
