# -*- coding: utf-8 -*-
"""
成交回放与对账 (纯逻辑, 无外部依赖, 可单测)
============================================
对应设计 POSITION_MGMT_DESIGN.md §4「成交回放与对账（生命线）」与 §13 降级表。

四件事:
  1. 回放: 下游 trading_order 的已成交单 → 认领到 PMS 指令 → 转成批次入账动作;
     认领不上的即「外部成交」, 买入并入 BASE 并告警留痕 (设计原话)。
  2. 对账: 账本 total_qty vs 下游 trading_position 数量, **以下游为准**修正,
     修正走 RECON 批次留痕。
  3. 除权检测: 数量与价格反比突变 → 按比例调整批次; 比例不吻合 → ERROR 待人工。
  4. T+1 可用量: 日初重置 = 全部持仓可卖; 当日买入不增可卖, 当日卖出扣减可卖。

认领口径说明 (重要):
  trading_order 目前**没有** PMS 指令来源列 —— 该列已列入 QMT_INTERFACE_REQUIREMENTS A2
  («来源标识», 待下游答复)。在拿到来源标识前, 本模块按 (代码, 方向, 时间, 数量) 做
  FIFO 贪心认领: 同股同向、下发时间早于成交时间的在途指令按下发顺序吃单。
  下游一旦提供来源标识, 只需给 fills 带上 instruction_id, 认领即退化为精确匹配 (见 claim_fills)。
"""
from __future__ import annotations

from app.core.cushion import sell_allocation

# 指令动作 → 批次类型 (设计 §4: 批次 BASE/FILL/ADD/DCA/T0, RECON 为对账调整专用)
ACTION_TO_LOT = {
    "OPEN": "BASE", "FILL": "FILL", "ADD": "ADD", "DCA": "DCA", "T0_ROUND": "T0",
    "BASE": "BASE", "T0": "T0",
}

ALERT_EXTERNAL = "EXTERNAL_FILL"        # 对不上指令的外部成交
ALERT_OVERFILL = "OVER_FILL"            # 成交量超过指令数量
ALERT_SELL_NO_LOT = "SELL_WITHOUT_LOT"  # 卖出但账本无对应批次

SEV_OK, SEV_WARN, SEV_ERROR = "OK", "WARN", "ERROR"


def _side(v) -> str:
    s = str(v or "").strip().lower()
    if s in ("buy", "b", "1", "买入", "买"):
        return "buy"
    if s in ("sell", "s", "2", "卖出", "卖"):
        return "sell"
    return s


def fill_qty_price(f: dict) -> tuple:
    """从一条 trading_order 记录取 (成交数量, 成交价)。

    下游 filled_amount 不可靠 (bionic entry_gate 既有结论), 故价格口径:
    filled_price/exec_avg_price > order_price; 数量口径: filled_qty > order_quantity。
    """
    qty = f.get("filled_qty")
    if qty in (None, 0, "0"):
        qty = f.get("order_quantity")
    price = f.get("filled_price") or f.get("exec_avg_price") or f.get("order_price")
    try:
        qty = int(float(qty or 0))
    except (TypeError, ValueError):
        qty = 0
    try:
        price = float(price or 0)
    except (TypeError, ValueError):
        price = 0.0
    return qty, price


# ================================================================
# 1. 回放: 认领 + 转账本动作
# ================================================================
def claim_fills(fills: list, open_instructions: list) -> list:
    """把成交单认领到 PMS 在途指令上 (FIFO 贪心)。

    fills: [{order_id, ts_code, side, qty, price, done_time, instruction_id(可选)}] 时间升序
    open_instructions: [{instruction_id, ts_code, side, qty, exec_qty, action, dispatched_at}]
    返回 [{fill, claims:[{instruction_id, qty, action}], unclaimed_qty}]
    """
    remain = {}
    for i in open_instructions or []:
        remain[i["instruction_id"]] = max(0, int(i.get("qty") or 0) - int(i.get("exec_qty") or 0))
    idx = {i["instruction_id"]: i for i in (open_instructions or [])}

    out = []
    for f in fills or []:
        qty = int(f.get("qty") or 0)
        claims, left = [], qty
        # (a) 下游已提供来源标识 → 精确认领
        iid = f.get("instruction_id")
        if iid and iid in remain:
            take = min(left, remain[iid])
            if take > 0:
                claims.append({"instruction_id": iid, "qty": take,
                               "action": idx[iid].get("action")})
                remain[iid] -= take
                left -= take
        # (b) 无来源标识 → 同股同向、下发早于成交、按下发时间 FIFO
        if left > 0:
            cands = [i for i in (open_instructions or [])
                     if i.get("ts_code") == f.get("ts_code")
                     and _side(i.get("side")) == _side(f.get("side"))
                     and remain.get(i["instruction_id"], 0) > 0
                     and _not_after(i.get("dispatched_at"), f.get("done_time"))]
            cands.sort(key=lambda i: (str(i.get("dispatched_at") or ""), i["instruction_id"]))
            for i in cands:
                if left <= 0:
                    break
                take = min(left, remain[i["instruction_id"]])
                if take <= 0:
                    continue
                claims.append({"instruction_id": i["instruction_id"], "qty": take,
                               "action": i.get("action")})
                remain[i["instruction_id"]] -= take
                left -= take
        out.append({"fill": f, "claims": claims, "unclaimed_qty": left})
    return out


def _not_after(dispatched_at, done_time) -> bool:
    """下发时间 ≤ 成交时间 (任一为空则不做时间约束, 交给数量口径兜底)。"""
    if not dispatched_at or not done_time:
        return True
    try:
        return str(dispatched_at) <= str(done_time)
    except Exception:
        return True


def map_fills_to_book(fills: list, open_instructions: list, known_order_ids=()) -> dict:
    """成交单 → 账本动作。幂等: known_order_ids 里的单直接跳过 (回放可重跑)。

    返回 {"actions":[...], "alerts":[...], "skipped":int}
    action = {kind: BUY|SELL, ts_code, qty, price, lot_type(买入), instruction_id, order_id, alerts}
    """
    known = set(known_order_ids or ())
    fresh = [f for f in (fills or []) if f.get("order_id") not in known]
    claimed = claim_fills(fresh, open_instructions)

    actions, alerts = [], []
    for c in claimed:
        f, left = c["fill"], c["unclaimed_qty"]
        side = _side(f.get("side"))
        price = float(f.get("price") or 0)
        for cl in c["claims"]:
            lot_type = ACTION_TO_LOT.get(str(cl.get("action") or "").upper(), "BASE")
            actions.append({"kind": "BUY" if side == "buy" else "SELL",
                            "ts_code": f.get("ts_code"), "qty": cl["qty"], "price": price,
                            "lot_type": lot_type if side == "buy" else None,
                            "instruction_id": cl["instruction_id"],
                            "order_id": f.get("order_id"), "alerts": []})
        if left > 0:
            a = [ALERT_EXTERNAL]
            msg = (f"外部成交: {f.get('ts_code')} {side} {left} 股 @ {price} "
                   f"(order_id={f.get('order_id')}) 认领不到 PMS 指令")
            if c["claims"]:
                a.append(ALERT_OVERFILL)
                msg += " —— 且成交量超过在途指令数量"
            alerts.append({"level": "WARN", "code": ALERT_EXTERNAL, "ts_code": f.get("ts_code"),
                           "message": msg, "order_id": f.get("order_id")})
            actions.append({"kind": "BUY" if side == "buy" else "SELL",
                            "ts_code": f.get("ts_code"), "qty": left, "price": price,
                            "lot_type": "BASE" if side == "buy" else None,   # 外部买入并入 BASE
                            "instruction_id": None, "order_id": f.get("order_id"), "alerts": a})
    return {"actions": actions, "alerts": alerts,
            "skipped": len(fills or []) - len(fresh)}


def parent_instruction_id(child_id: str) -> str:
    """子单 id → 父指令 id。子单形如 `{父指令}_D03` (executor._child_id)。"""
    s = str(child_id or "")
    tail = s.rsplit("_D", 1)
    return tail[0] if len(tail) == 2 and tail[1].isdigit() else s


def map_trades_to_book(trades: list, parent_actions=None) -> dict:
    """ws 逐笔成交 (协议 §5.5) → 账本动作 + 费用流水。

    **与 map_fills_to_book 的根本差别: trade 自带 instruction_id, 是精确认领而不是 FIFO 猜。**
    影子期只能拿下游 trading_order 的成交往在途指令上"凑" —— 同一只票同时挂着两条在途指令
    时凑错了根本看不出来, 批次类型一错, 卖出核销次序 (T0→ADD→DCA→FILL→BASE) 跟着错。
    ws 通了以后这层不确定性直接消失: 对端把 instruction_id 原样带回来了。

    parent_actions: {父指令id: action} —— 用来定买入的批次类型 (OPEN→BASE / FILL→FILL / …)。
                    查不到就并入 BASE 并告警, 与外部成交同一口径。

    **费用单独出一条流水, 不进 action** —— 协议 §5.5: fee 不摊进持仓成本, 只记现金流出。
    对端明说逐笔费用是按费率估的, 估算误差一旦进了摊薄成本就会污染安全垫。

    返回 {"actions", "fees", "alerts", "seqs"} —— seqs 是本批消费掉的 inbox 序号。
    """
    parent_actions = parent_actions or {}
    actions, fees, alerts, seqs = [], [], [], []
    for t in trades or []:
        pl, seq = t.get("payload") or {}, t.get("seq")
        seqs.append(seq)
        code, side = pl.get("ts_code"), _side(pl.get("side"))
        qty, price = int(pl.get("qty") or 0), float(pl.get("price") or 0)
        iid = pl.get("instruction_id") or ""
        parent = parent_instruction_id(iid)
        if qty <= 0 or price <= 0 or not code:
            alerts.append({"level": "WARN", "code": "BAD_TRADE", "ts_code": code,
                           "message": f"成交字段不完整, 已跳过: seq={seq} {pl}"[:300]})
            continue

        # amount 自洽性 (§5.5: 差异 > 0.01 元告警) —— 对不上不拦, 但要留痕
        amt = pl.get("amount")
        if amt is not None and abs(float(amt) - price * qty) > 0.01:
            alerts.append({"level": "WARN", "code": "AMOUNT_MISMATCH", "ts_code": code,
                           "message": f"成交金额不自洽: price×qty={price * qty:.2f} "
                                      f"≠ amount={float(amt):.2f} (trade_no={pl.get('trade_no')})"})

        action = parent_actions.get(parent)
        if not action:
            # 买卖**都要**告警。原先只告警买入, 卖出静默直接核销 —— 2026-07-29 联调时一笔
            # 认领不到的卖出就这么无声无息走完了全程; 标的当时若在持仓里, 批次已按一个
            # 荒谬的价格核销掉, 而日志里一个字都不会有。外部卖出同样得留痕才对得上账。
            how = ("按外部成交并入 BASE" if side == "buy"
                   else f"按外部卖出核销 {qty} 股 @{price} (次序仍是 T0→ADD→DCA→FILL→BASE)")
            alerts.append({"level": "WARN", "code": ALERT_EXTERNAL, "ts_code": code,
                           "message": f"成交带的指令 {iid} 在账本里找不到, {how} "
                                      f"(trade_no={pl.get('trade_no')})"})
        actions.append({"kind": "BUY" if side == "buy" else "SELL", "ts_code": code,
                        "qty": qty, "price": price,
                        "lot_type": (ACTION_TO_LOT.get(str(action or "").upper(), "BASE")
                                     if side == "buy" else None),
                        "instruction_id": parent if action else None,
                        "order_id": pl.get("trade_no"), "alerts": []})

        fee = pl.get("fee")
        if fee is not None and float(fee) != 0:
            fees.append({"ts_code": code, "amount": -abs(float(fee)),
                         "estimated": bool(pl.get("fee_estimated")),
                         "trade_no": pl.get("trade_no"), "instruction_id": parent,
                         "note": f"{side} {qty}股 @{price} 的手续费"
                                 + ("(对端标注为估算, 待日终校准)" if pl.get("fee_estimated") else "")})
    return {"actions": actions, "fees": fees, "alerts": alerts, "seqs": seqs}


def apply_sell_to_lots(lots: list, qty: int) -> dict:
    """卖出核销预演: 按 T0→ADD(新→旧)→DCA→FILL→BASE 分配。
    账本批次不足时按可核销量分配并告警 (以下游为准, 差额留给对账修正)。"""
    total = sum(int(l.get("qty") or 0) for l in (lots or []))
    if qty > total:
        alloc = sell_allocation(lots, total) if total > 0 else []
        return {"alloc": alloc, "short": qty - total,
                "alerts": [{"level": "WARN", "code": ALERT_SELL_NO_LOT,
                            "message": f"卖出 {qty} 股 > 账本批次 {total} 股, 差额 {qty - total} "
                                       f"股待对账修正 (以下游为准)"}]}
    return {"alloc": sell_allocation(lots, qty), "short": 0, "alerts": []}


def next_cursor(fills: list, cursor=None, key: str = "order_id"):
    """回放游标推进 (取本批最大 key, 空批保持原值)。"""
    vals = [f.get(key) for f in (fills or []) if f.get(key) is not None]
    if not vals:
        return cursor
    try:
        mx = max(int(v) for v in vals)
        return mx if cursor is None else max(int(cursor), mx)
    except (TypeError, ValueError):
        mx = max(str(v) for v in vals)
        return mx if cursor is None else max(str(cursor), mx)


# ================================================================
# 2. 对账: 账本 vs 下游 (以下游为准)
# ================================================================
def diff_positions(book_rows: list, ds_rows: list) -> list:
    """账本与下游持仓比对。返回差异列表 (无差异 = 空表)。

    book_rows: [{ts_code, total_qty}] ; ds_rows: [{ts_code, qty}] (下游 trading_position)
    kind: MISSING_IN_BOOK(下游有账本无) / EXTRA_IN_BOOK(账本有下游无) / QTY_MISMATCH
    delta = 下游 − 账本 (正数 = 账本要补, 负数 = 账本要减)
    """
    book = {r["ts_code"]: int(r.get("total_qty") or 0) for r in (book_rows or [])}
    ds = {r["ts_code"]: int(r.get("qty") or 0) for r in (ds_rows or [])}
    out = []
    for code in sorted(set(book) | set(ds)):
        b, d = book.get(code, 0), ds.get(code, 0)
        if b == d:
            continue
        if b == 0:
            kind = "MISSING_IN_BOOK"
        elif d == 0:
            kind = "EXTRA_IN_BOOK"
        else:
            kind = "QTY_MISMATCH"
        out.append({"ts_code": code, "book_qty": b, "ds_qty": d, "delta": d - b, "kind": kind})
    return out


def build_recon_fixes(diffs: list, price_map=None, lots_map=None, cost_map=None) -> list:
    """按差异生成修正动作 (以下游为准, 全部留痕)。

    delta > 0 → 新增 RECON 批次补足
    delta < 0 → 按核销次序冲销 |delta| 股 (用现价算已实现盈亏)

    **补仓位时开仓价必须优先取下游的成本价, 不能拿现价充数。** 这条踩过:
    2026-07-29 首次把下游 22 只已有持仓接进账本时, RECON 批次全按现价开仓, 于是每只票的
    摊薄成本都等于当天现价、安全垫齐刷刷是 0 —— 而安全垫是盈利加仓(≥3%)、保垫减仓(峰值≥6%)、
    补仓评估档(−8%/−15%) 共同的判断依据。一只真实成本 20、现价 10 的票 (实亏 50%) 会被记成
    "不赚不亏", 该评估的补仓不评估; 页面与日报上的浮动盈亏也全是 0。
    下游 `trading_position.cost_price` 本来就有这个数, 没有理由不用。
    现价只在下游给不出成本价时兜底, 并在 note 里注明来源 —— 账本上要看得出这笔成本是估的。
    """
    price_map = price_map or {}
    lots_map = lots_map or {}
    cost_map = cost_map or {}
    fixes = []
    for d in diffs or []:
        code, delta = d["ts_code"], d["delta"]
        px = float(price_map.get(code) or 0)
        if delta > 0:
            cost = float(cost_map.get(code) or 0)
            open_px, src = (cost, "下游成本价") if cost > 0 else (px, "现价兜底(下游无成本价)")
            fixes.append({"ts_code": code, "op": "ADD_RECON_LOT", "qty": delta,
                          "price": open_px, "need_price": open_px <= 0,
                          "price_source": src,
                          "note": f"对账修正(+{delta}股): 以下游为准补入 RECON 批次, "
                                  f"开仓价取{src}"})
        else:
            lots = lots_map.get(code) or []
            res = apply_sell_to_lots(lots, -delta)
            fixes.append({"ts_code": code, "op": "REDUCE_LOTS", "qty": -delta,
                          "alloc": res["alloc"], "short": res["short"], "price": px,
                          "note": f"对账修正({delta}股): 以下游为准冲销批次"})
    return fixes


def recon_severity(consecutive_days: int, alarm_days: int = 3) -> str:
    """连续不一致天数 → 告警级别 (设计 §13: 连续 3 日 → ERROR 待人工)。"""
    n = int(consecutive_days or 0)
    if n <= 0:
        return SEV_OK
    return SEV_ERROR if n >= int(alarm_days) else SEV_WARN


def advance_streak(prev_streak, prev_ymd, today_ymd, has_diff: bool) -> dict:
    """连续不一致的**按日**推进。返回 {streak, ymd, changed}。

    2026-07-31 修: 原来是每调一次 `reconcile()` 就 +1。而盘中轻对账**每分钟**调一次, 于是
    设计里「连续 3 日不一致 → ERROR 待人工」实际变成「连续 3 分钟」—— 一个持续存在的差异
    三分钟就升到 ERROR, 日报关注区还会写出「连续 175 日」这种数 (项目 07-14 才开工)。
    假警报天天响, 真告警就被埋掉了; 这跟 `_capped` 拿 counts 比返回条数、榜尾闸不区分截断
    噪音, 是同一类毛病的第三个面。

    两条规矩:
      1. **同一天内反复对账不重复计数** —— 判据是 `today_ymd != prev_ymd`。手工点五次
         「对账」也只算一天。
      2. **只有权威那一趟才推进** (调用方保证: `apply_fix=True` 的那次)。盘中轻对账是
         `apply_fix=False` 的只读观察, 不该写状态 —— 它照常读当前 streak 报 severity,
         那本来就是"截至上一次日结"的事实。

    差异消失时立即归零, 不必等到下一天。
    """
    prev_streak = int(prev_streak or 0)
    prev_ymd = int(prev_ymd or 0)
    today_ymd = int(today_ymd or 0)
    if not has_diff:
        return {"streak": 0, "ymd": today_ymd, "changed": prev_streak != 0}
    if today_ymd and today_ymd == prev_ymd:
        return {"streak": prev_streak, "ymd": prev_ymd, "changed": False}
    return {"streak": prev_streak + 1, "ymd": today_ymd, "changed": True}


def summarize_recon(diffs: list, consecutive_days: int = 0, alarm_days: int = 3) -> dict:
    kinds = {}
    for d in diffs or []:
        kinds[d["kind"]] = kinds.get(d["kind"], 0) + 1
    return {"diff_count": len(diffs or []), "by_kind": kinds,
            "consecutive_days": int(consecutive_days or 0),
            "severity": recon_severity(consecutive_days if diffs else 0, alarm_days)}


# ================================================================
# 3. 除权检测与批次调整
# ================================================================
def detect_ex_right(prev_qty: int, now_qty: int, prev_price: float, now_price: float,
                    tol: float = 0.02) -> dict:
    """数量与价格反比突变 = 除权 (送转股)。

    送股/转增: 数量 ×k, 价格 ÷k ⇒ 数量比 ≈ 价格反比。
    返回 None(无异动) / {"kind":"EX_RIGHT","ratio":k} / {"kind":"MISMATCH",...}(待人工)
    """
    prev_qty, now_qty = int(prev_qty or 0), int(now_qty or 0)
    prev_price, now_price = float(prev_price or 0), float(now_price or 0)
    if prev_qty <= 0 or now_qty <= prev_qty:
        return None
    qr = now_qty / prev_qty
    if prev_price <= 0 or now_price <= 0:
        return {"kind": "MISMATCH", "qty_ratio": round(qr, 4), "price_ratio": None,
                "reason": "缺少价格, 无法判定除权 (待人工)"}
    pr = prev_price / now_price
    rel = abs(qr - pr) / qr
    if rel <= float(tol):
        return {"kind": "EX_RIGHT", "ratio": round(qr, 6), "qty_ratio": round(qr, 4),
                "price_ratio": round(pr, 4), "rel_err": round(rel, 4)}
    return {"kind": "MISMATCH", "ratio": round(qr, 6), "qty_ratio": round(qr, 4),
            "price_ratio": round(pr, 4), "rel_err": round(rel, 4),
            "reason": f"数量比 {qr:.3f} 与价格反比 {pr:.3f} 不吻合 (偏差 {rel:.1%}), "
                      f"疑似非除权变动 → ERROR 待人工"}


def apply_ex_right(lots: list, ratio: float) -> list:
    """按除权比例调整批次: 数量 ×ratio, 成本价 ÷ratio (总成本不变)。"""
    r = float(ratio)
    out = []
    for l in lots or []:
        q = int(round(int(l.get("qty") or 0) * r))
        px = float(l.get("open_price") or 0) / r if r else 0.0
        n = dict(l)
        n["qty"], n["open_price"] = q, round(px, 3)
        n["note"] = f"除权调整 ×{r:g}"
        out.append(n)
    return out


# ================================================================
# 4. T+1 可用量
# ================================================================
def daily_avail_reset(total_qty: int) -> int:
    """日初重置: 昨日及以前的持仓全部可卖。"""
    return max(0, int(total_qty or 0))


def avail_after_fill(avail_qty: int, side: str, qty: int) -> int:
    """当日成交对可卖量的影响: 买入不增可卖 (T+1), 卖出扣减可卖。"""
    a, q = int(avail_qty or 0), int(qty or 0)
    return max(0, a - q) if _side(side) == "sell" else a


def sellable_today(avail_qty: int, want_qty: int) -> int:
    """本次可卖数量 = min(想卖, 可卖); 部分不可卖时由执行器顺延次日。"""
    return max(0, min(int(avail_qty or 0), int(want_qty or 0)))
