# -*- coding: utf-8 -*-
"""
上游榜单的版本比对 (纯逻辑, 零外部依赖)
========================================
上游 `/plan` 应答里有个 `changes` 字段, 本该回答「跟上一份比, 谁新进榜、谁掉榜」。
实测它**恒为 `null`** (UPSTREAM_PLAN_API.md §4 Q4: 已问, 未给非空示例)。等它补, 就是把
一件 PMS 自己能算的事挂在外部依赖上 —— 于是改成 PMS 每次拉计划落一份名册快照, 差异自己算。

顺带补上 §7.3 那个洞: `/plan` 是 `plan.collect()` **实时算**的, 同一个 `date` 可以对应
多个版本 (07-31 实测: 相隔一小时的两次请求, 打分池 972/81 → 423/502, 榜首换人), 而应答里
没有 `generated_at` 或版本戳 —— **下游此前没有任何办法判断"手上这份是哪一版"**。落了快照,
这个问题就地解决: 同一 `plan_date` 底下有几行, 就是上游那天重算过几版。

三种比对语义, 混在一起看会得出错误结论, 所以显式分开 (`kind`):

  * `first`               —— 没有上一份快照。**一条变化都不报** (否则首次全表 300 条"新进榜")
  * `cross_day`           —— 计划日不同。这是 Q4 说的「每日几十只」的正常升降档
  * `same_date_revision`  —— **同一个计划日的不同版本**。这不是市场变化, 是上游重算;
                             它意味着 PMS 一小时前拿到的候选池与现在不是一回事, 要显眼报出来

--------------------------------------------------------------------------------
掉榜为什么不能直接算「上一版有、这一版没有」
--------------------------------------------------------------------------------
我们向上游要 `top=300`。一只票从 rank 250 掉到 rank 310, 它会从名册里"消失"—— 但这不是
上游观点变化, 只是排到了我们要的条数之外。**吃满 top 的那一版, 尾部的进出全是截断噪音。**
不作区分的话, 每天会多出几十上百条假掉榜, 提示就没人看了 (同 `_capped` 治的那个假警报,
是同一类错误的两个面)。

新进榜是**对称**的同一个问题: 上一版吃满了 top, 那么这一版排在末尾的"新面孔"很可能上一版
就在榜上, 只是排在了 300 名开外看不见。所以两道闸各看各的那一版:

    掉榜 —— 看 **这一版** 吃没吃满 (它截断了, 掉出去的可能只是被截掉)
    新进 —— 看 **上一版** 吃没吃满 (它截断了, 新面孔可能上一版就在, 只是没露面)

`tail_guard` 是那道闸的位置: 排名进入榜单前 `tail_guard` 比例 (默认一半) 才算数, 其余计进
`tail_churn` 只报个数、不列名。没吃满 top 的那一版不设闸 —— 那种情况下的进出是真的。

**持仓票不受这道闸约束。** 闸是拿"少报几条噪音"换"提示还有人看", 这笔账在持仓票上不成立:
非持仓票误报一条, 代价是白看一眼; **持仓票漏报一条, 代价是一个该减没减的仓位**。所以持仓票
一律照报, 并在 `tail_churn.held_exempt` 里记下有几条是靠这条豁免出来的。
"""
from __future__ import annotations

import hashlib

# 传导档位的强弱次序 (上游 2026-07-31 答复 Q7: 强传导 > 弱传导 > 无传导)。
# 不在表里的档位 **不参与升降判定** —— 上游哪天加了新档位, 宁可不报, 不能报反。
TIER_ORDER = {"强传导": 3, "弱传导": 2, "无传导": 1}

BUCKET_MAIN, BUCKET_OBSERVE = "main", "observe"

KIND_FIRST, KIND_CROSS_DAY, KIND_REVISION = "first", "cross_day", "same_date_revision"

DEFAULT_RANK_JUMP = 50      # 名次跳变多少名才值得报
DEFAULT_TAIL_GUARD = 0.5    # 吃满 top 时, 上一版排名在前多少比例内的掉榜才算数

# 名册里每条存哪些字段。键名取短的 —— 一份名册 300~400 条, 要落进 MEDIUMTEXT。
_FIELDS = ("c", "n", "r", "s", "t", "h", "b")


def tier_rank(tier):
    """档位 → 强弱序数; 未知档位返回 None (判不了升降就不判)。"""
    if tier is None:
        return None
    return TIER_ORDER.get(str(tier).strip())


# ================================================================ 名册
def roster_of(plan: dict) -> dict:
    """`plan_feed.parse_plan()` 的结果 → 名册 {ts_code: 精简行}。

    主榜在前, 同一只票若两档都出现以主榜为准 (与 parse_plan 里 themes 的口径一致)。
    """
    out = {}
    for bucket in (BUCKET_MAIN, BUCKET_OBSERVE):
        for r in (plan.get(bucket) or []):
            code = r.get("ts_code")
            if not code or code in out:
                continue
            out[code] = {"c": code, "n": r.get("name"), "r": r.get("rank"),
                         "s": r.get("score"), "t": r.get("tier"),
                         "h": r.get("theme"), "b": r.get("bucket") or bucket}
    return out


def roster_rows(roster: dict) -> list:
    """名册 → 落库用的 list (代码序, 保证同一份名册序列化结果稳定)。"""
    return [{k: roster[c].get(k) for k in _FIELDS} for c in sorted(roster)]


def roster_from_rows(rows) -> dict:
    """落库 list → 名册 dict。坏行跳过, 不让一条脏数据废掉整份快照。"""
    out = {}
    for r in (rows or []):
        if not isinstance(r, dict):
            continue
        code = r.get("c") or r.get("ts_code")
        if not code:
            continue
        out[code] = {"c": code, "n": r.get("n"), "r": r.get("r"), "s": r.get("s"),
                     "t": r.get("t"), "h": r.get("h"), "b": r.get("b")}
    return out


def digest_of(roster: dict) -> str:
    """名册指纹 —— 同一份榜重复拉不重复落。

    **不含 score。** `/plan` 是实时算的, score 末位天天抖; 把它算进指纹, 每次缓存过期重拉
    都会多落一行快照, 表白涨而信息量为零。含 rank 就够了: 分数抖到改变了次序才算真的变了,
    没改变次序的抖动对候选池没有任何影响 (候选就是按 score 降序切前 N)。
    """
    h = hashlib.sha1()
    for c in sorted(roster):
        r = roster[c]
        h.update(("%s|%s|%s|%s|%s\n" % (c, r.get("r"), r.get("t") or "",
                                        r.get("h") or "", r.get("b") or "")).encode("utf-8"))
    return h.hexdigest()


# ================================================================ 比对
def _row(r: dict, **extra) -> dict:
    out = {"ts_code": r.get("c"), "name": r.get("n"), "rank": r.get("r"),
           "score": r.get("s"), "tier": r.get("t"), "theme": r.get("h"),
           "bucket": r.get("b")}
    out.update(extra)
    return out


def _bucket_len(roster: dict, bucket: str) -> int:
    return sum(1 for r in (roster or {}).values() if (r.get("b") or BUCKET_MAIN) == bucket)


def _cutoff(roster: dict, capped, ratio: float) -> dict:
    """尾部闸的位置 {bucket: 名次上限 or None}。

    只有那一版**吃满了 top** 才设闸 —— 没吃满说明上游能给的都给了, 榜尾的进出是真的。
    `capped` 按档给 (`{"main": bool, "observe": bool}`): 两档是两个独立的请求参数
    (`top` / `obs_top`), 主榜吃满**完全不意味着**观察档也吃满了。早先只传主榜那一个标志,
    效果是拿主榜的截断去解释观察档的进出 —— 观察档一次真实的摘牌会被记成"截断噪音"。
    该档一条都没有时也不设闸: 整档消失/整档出现本身就是要报的大事, 不该被闸吞掉。
    """
    caps = capped if isinstance(capped, dict) else {BUCKET_MAIN: bool(capped),
                                                    BUCKET_OBSERVE: bool(capped)}
    out = {}
    for b in (BUCKET_MAIN, BUCKET_OBSERVE):
        n = _bucket_len(roster, b)
        out[b] = (n * ratio) if (caps.get(b) and n > 0) else None
    return out


def _tail_dropped(row: dict, rank, cut: dict) -> bool:
    """这条进/出是不是只是榜尾截断的噪音。

    rank 缺失时返回 False = **照报**。全模块的口径是"判不了就不判"—— 在别处那意味着不报
    变化, 在这里却要反过来: 这个函数的"是"代表**吞掉一条变化**, 所以拿不准时必须放行。
    """
    lim = cut.get(row.get("b") or BUCKET_MAIN)
    return lim is not None and isinstance(rank, int) and rank > lim


def diff(prev_roster, curr_roster, *, prev_date=None, curr_date=None, held=(),
         rank_jump: int = DEFAULT_RANK_JUMP, tail_guard: float = DEFAULT_TAIL_GUARD,
         prev_capped=False, curr_capped=False, prev_broken: bool = False) -> dict:
    """两份名册的差异。**不抛错** —— 页面提示不该有能力搞崩取数。

    held: 当前持仓代码 (点式, 调用方负责归一)。持仓票的变化单独拎出来, 且**不受尾部闸约束**
    —— 上游把一只持仓票摘出榜或降了档, 是"该不该继续拿着"的直接信号, 漏报的代价比误报大
    得多 (见模块头部)。

    prev_capped / curr_capped: 那一版吃没吃满请求的条数。可以给 bool, 也可以按档给
    `{"main": ..., "observe": ...}` —— 两档是两个独立的请求参数, 主榜吃满不代表观察档也满。
    掉榜看 `curr_capped`、新进看 `prev_capped`, 各看各的那一版, 理由见模块头部。

    prev_broken: 上一版**存在但读不出来** (名册落库时坏了)。这跟"没有上一版"是两回事:
    后者是正常的首次, 前者是故障 —— 必须说成故障, 否则一条真实变化都不报还显示得很正常。
    """
    held = {c for c in (held or []) if c}
    curr_roster = curr_roster or {}
    if not prev_roster:
        note = ("上一版名册读不出来 (快照损坏?) —— **这次比不了**, 下面的空白不代表没有变化"
                if prev_broken else
                "首次落快照, 无可比对的上一版 —— 变化提示从下一次拉取开始")
        return _empty(KIND_FIRST, prev_date, curr_date, curr_roster, held, note=note,
                      rank_jump=rank_jump, tail_guard=tail_guard, prev_broken=prev_broken)

    kind = (KIND_REVISION if (prev_date and curr_date and prev_date == curr_date)
            else KIND_CROSS_DAY)

    entered, exited = [], []
    tail_churn = {"entered": 0, "exited": 0, "held_exempt": 0}
    tier_up, tier_down, bucket_moved, jumps = [], [], [], []

    # 两道尾部闸各看各的那一版 —— 见模块头部
    ratio = max(0.0, min(1.0, float(tail_guard if tail_guard is not None else DEFAULT_TAIL_GUARD)))
    exit_cut = _cutoff(curr_roster, curr_capped, ratio)    # 掉榜: 这一版截没截
    enter_cut = _cutoff(prev_roster, prev_capped, ratio)   # 新进: 上一版截没截

    for code, p in (prev_roster or {}).items():
        if curr_roster.get(code) is not None:
            continue
        h = code in held
        if _tail_dropped(p, p.get("r"), exit_cut):
            if not h:
                tail_churn["exited"] += 1   # 本来就在榜尾 —— 大概率只是被这一版的 top 截掉
                continue
            tail_churn["held_exempt"] += 1  # 持仓票不适用这道闸: 漏报一条就是一个仓位
        exited.append(_row(p, held=h))

    for code, c in curr_roster.items():
        p = (prev_roster or {}).get(code)
        if p is None:
            h = code in held
            if _tail_dropped(c, c.get("r"), enter_cut):
                if not h:
                    tail_churn["entered"] += 1   # 上一版就吃满了, 这张脸当时可能只是没露面
                    continue
                tail_churn["held_exempt"] += 1
            entered.append(_row(c, held=h))
            continue
        h = code in held
        pb, cb = p.get("b") or BUCKET_MAIN, c.get("b") or BUCKET_MAIN
        if pb != cb:
            bucket_moved.append(_row(c, held=h, moved_from=pb, moved_to=cb))
        pt, ct = tier_rank(p.get("t")), tier_rank(c.get("t"))
        if pt is not None and ct is not None and pt != ct:
            (tier_up if ct > pt else tier_down).append(
                _row(c, held=h, tier_from=p.get("t"), tier_to=c.get("t")))
        pr, cr = p.get("r"), c.get("r")
        if pb == cb and isinstance(pr, int) and isinstance(cr, int):
            d = cr - pr
            if abs(d) >= max(1, int(rank_jump or DEFAULT_RANK_JUMP)):
                jumps.append(_row(c, held=h, rank_from=pr, rank_to=cr, rank_delta=d))

    entered.sort(key=lambda x: (x["rank"] if isinstance(x["rank"], int) else 10 ** 9))
    exited.sort(key=lambda x: (x["rank"] if isinstance(x["rank"], int) else 10 ** 9))
    jumps.sort(key=lambda x: abs(x.get("rank_delta") or 0), reverse=True)
    for lst in (tier_up, tier_down, bucket_moved):
        lst.sort(key=lambda x: (x["rank"] if isinstance(x["rank"], int) else 10 ** 9))

    out = {"kind": kind, "prev_date": prev_date, "curr_date": curr_date,
           "entered": entered, "exited": exited, "tier_up": tier_up, "tier_down": tier_down,
           "bucket_moved": bucket_moved, "rank_jump": jumps, "tail_churn": tail_churn,
           "tail_guarded": {"entered": _guard_view(enter_cut), "exited": _guard_view(exit_cut)},
           "roster_size": {"prev": len(prev_roster or {}), "curr": len(curr_roster)},
           "prev_broken": False, "date_regressed": _regressed(prev_date, curr_date),
           "params": {"rank_jump": int(rank_jump or DEFAULT_RANK_JUMP), "tail_guard": ratio}}
    out["counts"] = {k: len(out[k]) for k in
                     ("entered", "exited", "tier_up", "tier_down", "bucket_moved", "rank_jump")}
    out["held"] = _held_view(out, held)
    out["note"] = _note(out)
    return out


def _guard_view(cut: dict) -> dict:
    """哪几档真的设了闸, 闸位在第几名 —— 让"为什么这条没报"可查, 而不是只能猜。"""
    return {b: (None if cut.get(b) is None else round(cut[b], 1))
            for b in (BUCKET_MAIN, BUCKET_OBSERVE)}


def _regressed(prev_date, curr_date) -> bool:
    """这一版的计划日比上一版还早。

    正常流程不会这样 —— 快照按落库次序排, 而计划日是往前走的。会出现只有一种情况:
    有人拿 `--date` 显式拉了一份旧计划。那时"新进/掉榜"的方向是反的, 得说明白。
    """
    return bool(prev_date and curr_date and str(curr_date) < str(prev_date))


def _empty(kind, prev_date, curr_date, curr_roster, held, note="",
           rank_jump=DEFAULT_RANK_JUMP, tail_guard=DEFAULT_TAIL_GUARD,
           prev_broken=False) -> dict:
    ratio = max(0.0, min(1.0, float(tail_guard if tail_guard is not None else DEFAULT_TAIL_GUARD)))
    out = {"kind": kind, "prev_date": prev_date, "curr_date": curr_date,
           "entered": [], "exited": [], "tier_up": [], "tier_down": [],
           "bucket_moved": [], "rank_jump": [],
           "tail_churn": {"entered": 0, "exited": 0, "held_exempt": 0},
           "tail_guarded": {"entered": _guard_view({}), "exited": _guard_view({})},
           "roster_size": {"prev": 0, "curr": len(curr_roster or {})},
           "prev_broken": bool(prev_broken), "date_regressed": _regressed(prev_date, curr_date),
           # 回显**调用方实际给的**参数, 不是默认值 —— 页面拿它显示当前口径, 写死会骗人
           "params": {"rank_jump": int(rank_jump or DEFAULT_RANK_JUMP), "tail_guard": ratio}}
    out["counts"] = {k: 0 for k in
                     ("entered", "exited", "tier_up", "tier_down", "bucket_moved", "rank_jump")}
    out["held"] = _held_view(out, held)
    out["note"] = note
    return out


def _held_view(d: dict, held) -> dict:
    """持仓票命中的变化。**掉榜与降档排在最前** —— 那是要不要继续持有的信号。

    `n_watch` 是"要盯的"那几条, 页面红条与探活脚本都拿它当判据。三类算进去:

      掉榜 · 降档 · **主榜→观察档**

    第三类容易漏。观察档的定义是"无券商覆盖", 所以掉进观察档的含义是**估值锚没了**, 与
    降档是同一量级的信号; 而观察档的行没有 `tier`, 于是它既不算 `exited` 也不算
    `tier_down` —— 不显式算进来的话, 一只持仓票丢了券商覆盖, 页面红条不亮、探活脚本还会
    打一句"持仓票没被摘也没降档"。
    """
    pick = lambda k: [x for x in d.get(k) or [] if x.get("held")]   # noqa: E731
    out = {k: pick(k) for k in ("exited", "tier_down", "bucket_moved",
                                "tier_up", "entered", "rank_jump")}
    out["coverage_lost"] = [x for x in out["bucket_moved"] if x.get("moved_to") == BUCKET_OBSERVE]
    out["n_watch"] = len(out["exited"]) + len(out["tier_down"]) + len(out["coverage_lost"])
    out["n_total"] = sum(len(v) for k, v in out.items()
                         if isinstance(v, list) and k != "coverage_lost")   # 别重复计 bucket_moved
    return out


def _note(d: dict) -> str:
    """一句人话结论 —— 页面横幅与日志直接用这句, 不必各写一份措辞。"""
    c, h = d["counts"], d["held"]
    if d["kind"] == KIND_REVISION:
        head = (f"同一计划日 {d['curr_date']} 的**新版本**: 上游重算过 —— "
                f"此前拿到的候选池与现在不是同一份")
    else:
        head = f"计划日 {d['prev_date']} → {d['curr_date']}"
    body = (f"新进 {c['entered']} · 掉榜 {c['exited']} · 升档 {c['tier_up']} · "
            f"降档 {c['tier_down']} · 覆盖翻转 {c['bucket_moved']}")
    tail = ""
    if h["n_watch"]:
        tail = f" · **持仓票 {h['n_watch']} 只掉榜/降档/丢了券商覆盖**"
    elif h["n_total"]:
        tail = f" · 持仓票命中 {h['n_total']} 条"
    churn = d.get("tail_churn") or {}
    n_churn = int(churn.get("entered") or 0) + int(churn.get("exited") or 0)
    if n_churn:
        tail += f" · 榜尾进出 {n_churn} 只未计入 (那一版吃满 top, 尾部不可信)"
    if churn.get("held_exempt"):
        tail += f" · 其中 {churn['held_exempt']} 只因是持仓票照报"
    if d.get("date_regressed"):
        tail += " · **注意计划日是往回走的** (拉了一份旧计划?), 新进/掉榜的方向是反的"
    return f"{head}: {body}{tail}"
