# -*- coding: utf-8 -*-
"""
ws 通道联调工具 (协议 QMT_WS_PROTOCOL.md §9 的 S1/S2/S3)
=========================================================
运行:
  docker compose run --rm pms-web python scripts/ws_smoke.py status        # 通道状态一览
  docker compose run --rm pms-web python scripts/ws_smoke.py watch         # 持续盯 (Ctrl-C 退出)
  docker compose run --rm pms-web python scripts/ws_smoke.py place --yes \
      --code 600000.SH --side sell --qty 100 --price 9.99                  # 手工发一张测试委托
  docker compose run --rm pms-web python scripts/ws_smoke.py cancel --id INS-20260729-xxxx --yes
  docker compose run --rm pms-web python scripts/ws_smoke.py inbox         # 最近上行消息

为什么要有这个工具, 而不是直接切 PMS_DISPATCH_MODE=ws
-----------------------------------------------------
协议 §9 的「S2 影子模式」指的是 **QMT 侧只回报不下单**; PMS 的 `PMS_DISPATCH_MODE=shadow`
指的是 **PMS 侧压根不发**。两个"影子"是不同的东西, 别混。要跑 S2, PMS 必须真发 —— 但如果
为此把 PMS_DISPATCH_MODE 切成 ws, 整条真实指令流 (命令方案、自主提议、信号消化) 就一起
上了通道, 你没法控制"这一刻只发这一张"。

本工具直接往 pms_qmt_order 出口表塞一张委托, **绕开 dispatch_mode 开关**, 但完整走
ws_codec 的参数校验和 pms-ws 的签名下发路径 —— 消息格式、签名、幂等、回报入账全都是真的,
只有"发什么"由你说了算。联调完删掉这张单即可, 生产开关自始至终没动过。

**这张单会真的报到 QMT。** 对方若已在实盘模式, 就会真成交。所以:
  * 必须显式 --yes
  * 默认 100 股, 且限价故意给远离市价的值 (卖单挂高/买单挂低), 挂不上才是预期
  * 组合当前超总仓上限时规则闸会挡买入 —— 但本工具**绕过规则闸**, 请自己把住
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timedelta

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

BAR = "=" * 70


def _fmt_age(dt):
    if not dt:
        return "从未"
    return f"{(datetime.now() - dt).total_seconds():.0f}s 前"


def cmd_status(args):
    from app.repo import qmt_repo
    from app.services import dispatcher
    from config.settings import settings

    st = qmt_repo.get_state()
    ch = dispatcher.channel_status()
    print(BAR)
    print(f"ws 通道状态  ·  {datetime.now():%Y-%m-%d %H:%M:%S}")
    print(BAR)
    print(f"  端点           {settings.PMS_QMT_WS_URL}")
    print(f"  下发模式       PMS_DISPATCH_MODE = {dispatcher.mode()}"
          f"   (联调用本工具, 不必切到 ws)")
    print(f"  pms-ws 进程    {'在线' if ch['process_alive'] else '**不在线**'}"
          f"  (心跳 {_fmt_age(st.get('heartbeat_at'))})")
    # connected_at 是"最后一次连上"的时刻, 不是"从这时起断的"。OFFLINE 时得换个说法,
    # 否则"OFFLINE 自 11:21"读起来像已经断了十几分钟, 跟事实正好相反。
    ca = st.get("connected_at")
    when = (f"   自 {ca}" if ch["conn_state"] == "ONLINE" else f"   上次连上 {ca}") if ca else ""
    print(f"  连接           {ch['conn_state']}{when}")
    srv = int(st.get("server_seq") or 0)
    print(f"  seq 水位       已落库 {ch['last_seq']} / 已确认 {ch['acked_seq']}"
          f" / 对端自报 {srv} (握手那一刻的快照, 会话中水位超过它是正常推进)")
    print(f"  待入账上行     {ch['inbox_pending']} 条"
          + (f"  ·  挂起待人工 {ch['orphan_held']} 笔" if ch.get("orphan_held") else ""))
    print(f"  出口队列       {ch['queue'] or '空'}")
    # 快照是对账的事实源 (协议 §6.2), 拿不到就意味着账本建不起来 —— 必须一眼能看见
    try:
        snap = qmt_repo.latest_snapshot("positions")
    except Exception as e:
        snap = None
        print(f"  持仓快照       读取失败: {type(e).__name__}: {e}")
    if snap:
        items = (snap["payload"] or {}).get("items")
        print(f"  持仓快照       {len(items) if isinstance(items, list) else '?'} 个条目 · "
              f"{snap['age_sec']:.0f}s 前 (seq={snap['seq']})")
    elif snap is None:
        print(f"  持仓快照       **从未收到** (对账没有事实源, 账本建不起来)")
    s = st.get("stat") or {}
    if s:
        print(f"  收发计数       收 {s.get('rx')} 发 {s.get('tx')} · 成交 {s.get('trades')}"
              f" · 拒绝 {s.get('rejects')} · 重连 {s.get('reconnects')}"
              + (f" · **丢弃 {s['dropped']}**" if s.get("dropped") else ""))
    skew, lo, hi = s.get("peer_skew_ms"), s.get("peer_skew_min"), s.get("peer_skew_max")
    if skew is not None:
        rng = f"  本次会话 min {int(lo):+d} / max {int(hi):+d}" if lo is not None else ""
        print(f"  对端时延偏差   最近 {int(skew):+d} ms{rng}")
        print(f"                 min 逼近真实时钟差 (QMT 侧 ±30 秒时间窗看的是它);"
              f" max-min 是最严重的一次投递卡顿")
    if st.get("last_error"):
        print(f"  最后错误       {st['last_error']}")
    if s.get("last_reject"):
        print(f"  最后一条拒绝   {s['last_reject']}")
    if s.get("last_close"):
        print(f"  最后断开原因   {s['last_close']}")

    # ---- 异常自检。这几条光看数字不容易反应过来, 直接点破
    bad = []
    # 倒挂只能拿"同一时刻"的两个数比。实时水位 vs 握手时的 server_seq 快照会误报 ——
    # 会话跑起来水位本来就会超过快照, 那是正常推进。runner 在握手当下存了这对快照。
    hs_srv, hs_last = int(s.get("hs_server_seq") or 0), int(s.get("hs_last_seq") or 0)
    if hs_srv and hs_srv < hs_last:
        bad.append(f"序号倒挂: 握手时对端自报 {hs_srv}, 本端水位已到 {hs_last}。对端此后发的"
                   f"每一条(含成交)都会因「不高于水位」被静默丢弃。**不要自行下调水位**: "
                   f"会把 {hs_srv}..{hs_last} 重复入账, 摊薄成本和安全垫跟着全错。"
                   f"须先与 QMT 侧对齐 seq 语义")
    if int(s.get("dropped") or 0):
        bad.append(f"已丢弃 {s['dropped']} 条上行 (seq 不高于水位)。补发时命中是正常的, "
                   f"持续增长则说明序号语义对不上")
    if int(s.get("reconnects") or 0) >= 5:
        bad.append(f"重连 {s['reconnects']} 次: 连接不稳, 见上面「最后断开原因」")
    # 判时钟用 min (排队延迟最小时约等于 0), 不用最后一次采样 —— 后者会把一次投递卡顿
    # 误报成时钟漂移, 那是两个完全不同的排查方向
    if lo is not None and abs(int(lo)) > 20000:
        bad.append(f"对端时钟差约 {int(lo):+d} ms, 已逼近 QMT 侧 ±30 秒时间窗。再漂下去连 "
                   f"place_order 都会被 TS_SKEW 拒, 现象是「下单没反应」。两台机器都对一下 NTP")
    if lo is not None and hi is not None and int(hi) - int(lo) > 5000:
        bad.append(f"投递卡顿: 本次会话最慢一条上行比最快的晚了 {(int(hi)-int(lo))/1000:.1f} 秒。"
                   f"时钟没问题 (min {int(lo):+d} ms), 是对端消息在它那边排了队 —— "
                   f"多半是事件循环被同步 I/O 堵住。配合「重连」计数一起看")
    if s.get("ack_seq_degraded"):
        bad.append("ack_seq 已降级停发 (对端不认这个类型, 协议 §4.5)。水位照常落库、重连"
                   "补发靠 hello.last_seq, **不丢成交**; 代价是 QMT 侧 Redis 清不掉。"
                   "需对方按 §4.5 补上 ack_seq, 补好后重连自动恢复")
    if int(s.get("proto_rejects") or 0):
        bad.append(f"协议级 reject {s['proto_rejects']} 条 (不带 instruction_id, 拒的不是委托"
                   f"而是我们发的协议消息)。若 rx≈tx 且两者同步增长, 基本可以断定是"
                   f"「我们 ack → 对端拒 → 拒绝本身带 seq → 我们又要 ack」的自激循环: "
                   f"通道显示 ONLINE, 实际什么也没在做。整条 payload 见 "
                   f"docker compose logs pms-ws | grep 协议级")
    elif int(s.get("rejects") or 0) and not qmt_repo.list_orders(limit=1):
        bad.append(f"出口表一张委托都没有, 却收到 {s['rejects']} 条 reject —— 对端在拒绝我们"
                   f"的非委托消息 (hello/ping/ack?), 见上面「最后一条拒绝」")
    if int(ch.get("orphan_held") or 0):
        bad.append(f"{ch['orphan_held']} 笔成交挂起未入账 (processed=3): 出口表查不到对应委托, "
                   f"多为上下游停机后 inbox 里的遗留行。**这是有意不入账**, 不是漏跑 —— "
                   f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 "
                   f"processed 改回 0")
    if ch.get("resync_required"):
        bad.append("resync_flag=1: 对端补发不全, 须走全量对账后经页面清除")
    if bad:
        print()
        for b in bad:
            print(f"  ⚠ {b}")
    print()
    rows = qmt_repo.list_orders(limit=int(args.limit))
    if rows:
        print(f"最近 {len(rows)} 张委托:")
        for o in rows:
            print(f"  {o['instruction_id']:<28} {o['ts_code']:<11} {o['side']:<4} "
                  f"{o['qty']:>6}股 @{float(o['limit_price']):<8.2f} "
                  f"{o['status']:<12} 成交 {o['cum_qty']:>6}"
                  + (f"  撤单 {o['cancel_state']}" if o['cancel_state'] != "NONE" else "")
                  + (f"  {o['reject_code']}" if o.get("reject_code") else ""))
    else:
        print("出口表暂无委托")
    return 0


def cmd_watch(args):
    print("持续盯通道状态, Ctrl-C 退出\n")
    try:
        while True:
            os.system("clear" if os.name != "nt" else "cls")
            cmd_status(args)
            time.sleep(int(args.interval))
    except KeyboardInterrupt:
        print("\n已退出")
    return 0


def cmd_place(args):
    from app.core import ws_codec as wsc
    from app.repo import qmt_repo
    from app.services import dispatcher

    ch = dispatcher.channel_status()
    if not ch["process_alive"]:
        print("FAIL  pms-ws 进程不在线 —— 先起进程再联调:")
        print("      docker compose --profile ws up -d pms-ws")
        return 1
    if ch["conn_state"] != "ONLINE":
        print(f"WARN  连接当前 {ch['conn_state']} (未 ONLINE)。委托会排在队列里, "
              f"连上之后立即发出 —— 确认这是你想要的。")

    iid = args.id or wsc.new_instruction_id(int(datetime.now().strftime("%Y%m%d")))
    valid_until = int((datetime.now() + timedelta(minutes=int(args.ttl))).timestamp() * 1000)
    try:
        payload = wsc.place_order_payload(
            instruction_id=iid, ts_code=args.code, side=args.side, qty=int(args.qty),
            limit_price=float(args.price), valid_until=valid_until,
            intent=args.intent, note=args.note or "ws 联调测试单")
    except wsc.CodecError as e:
        print(f"FAIL  参数不合协议 ({e.code}): {e.message}")
        return 1

    print(BAR)
    print("即将向 QMT 发出一张**真实委托**")
    print(BAR)
    for k in ("instruction_id", "ts_code", "side", "qty", "limit_price", "intent", "note"):
        print(f"  {k:<16} {payload[k]}")
    print(f"  {'valid_until':<16} {datetime.fromtimestamp(valid_until / 1000):%H:%M:%S}"
          f"  ({args.ttl} 分钟后由 QMT 自动撤)")
    print(f"\n  对端若已在实盘模式, 这张单会真成交。S2 阶段请先与 QMT 侧确认"
          f"「只回报不下单」。")
    if not args.yes:
        print("\n[演练] 未发出。确认后加 --yes 重跑。")
        return 0

    qmt_repo.enqueue_order(
        instruction_id=iid, parent_id=f"SMOKE_{iid}", ts_code=payload["ts_code"],
        side=payload["side"], qty=payload["qty"], limit_price=payload["limit_price"],
        valid_until=valid_until, intent=payload["intent"], note=payload["note"])
    print(f"\nOK  已入出口队列: {iid}")
    print(f"    pms-ws 会在 {0.5} 秒内取走并签名下发。跟踪:")
    print(f"      python scripts/ws_smoke.py status")
    print(f"      docker compose logs -f pms-ws")
    print(f"    撤单:")
    print(f"      python scripts/ws_smoke.py cancel --id SMOKE_{iid} --yes")
    return 0


def cmd_cancel(args):
    from app.core import ws_codec as wsc
    from app.repo import qmt_repo
    if not args.id:
        print("FAIL  需要 --id (父指令 id; 联调单是 SMOKE_INS-...)")
        return 1
    if not args.yes:
        print(f"[演练] 将把 {args.id} 名下所有在途子单标为待撤。确认后加 --yes。")
        return 0
    cid = wsc.new_cancel_id(int(datetime.now().strftime("%Y%m%d")))
    n = qmt_repo.request_cancel(parent_id=args.id, cancel_id=cid)
    print(f"OK  已标记 {n} 张待撤 (cancel_id={cid})" if n
          else "该指令名下没有在途子单 (可能已终态)")
    return 0


def cmd_rewind(args):
    """人为把 seq 水位往回退, 逼对端按 §6.1 补发 —— 联调唯一没法自然造出来的场景。

    为什么要专门做个命令, 而不是手写两条 SQL: 手改会被两条独立机制悄悄撤销, 两个都踩过。
      1. pms-ws 优雅退出时 _shutdown 会 save_watermark(内存水位) 刷回库, 盖掉你的改动。
         所以必须**先停进程**再改, 而不是改完 restart。
      2. 就算先停了, _boot 还会用 inbox_recover_watermark 从落库水位往后走 inbox 的连续段,
         把水位原样走回去。所以水位和 inbox 行**必须一起降**。
    两次都不报错, 只是测试静悄悄地没跑起来 —— 这种失败最费时间。
    """
    from app.repo import qmt_repo
    from app.services import dispatcher

    ch = dispatcher.channel_status()
    if ch["process_alive"]:
        print("FAIL  pms-ws 还在运行 —— 现在改水位会被它退出时的刷库盖掉 (踩过)。先停:")
        print("      docker compose stop pms-ws")
        print("      改完再起: docker compose --profile ws up -d pms-ws")
        return 1

    st = qmt_repo.get_state()
    cur, cur_ack = int(st.get("last_seq") or 0), int(st.get("acked_seq") or 0)
    target = int(args.to) if args.to else cur - int(args.by)
    if target < 0 or target >= cur:
        print(f"FAIL  目标水位 {target} 不合法 (当前 {cur}, 必须 0 ≤ 目标 < 当前)")
        return 1

    rows = qmt_repo.inbox_stats_above(target)
    print(BAR)
    print(f"seq 水位回退  ·  {cur} → {target}  (退 {cur - target} 格)")
    print(BAR)
    if not rows:
        print(f"  {target} 之上没有 inbox 行 —— 只改水位即可")
    else:
        print(f"  将删除 {target} 之上的 inbox 行:")
        mark = {0: "待入账", 1: "已入账", 2: "已消化", 3: "挂起"}
        for r in rows:
            print(f"    {r['msg_type']:<15} {mark.get(int(r['processed']), '?'):<7}"
                  f" {int(r['n']):>5} 条   seq {r['lo']}..{r['hi']}")

    # 已入账的成交绝不能回退: 删掉 inbox 行等于连 seq 和 trade_no 两层去重一起抹掉,
    # 对端补发时会被当成全新成交再入账一次 —— 持仓和摊薄成本直接算错。
    booked = [r for r in rows if r["msg_type"] == "trade" and int(r["processed"]) == 1]
    if booked and not args.force:
        print()
        print("FAIL  区间里有**已入账的成交**, 拒绝回退:")
        for r in booked:
            print(f"        seq {r['lo']}..{r['hi']} 共 {int(r['n'])} 笔")
        print("      删掉它们的 inbox 行会同时抹掉 seq 与 trade_no 两层去重, 对端补发时")
        print("      会被当成新成交再入一次账 —— 摊薄成本和安全垫跟着错。")
        print("      改退到这些成交之下, 或确认无误后加 --force。")
        return 1

    if not args.yes:
        print("\n[演练] 未执行。确认后加 --yes 重跑。")
        return 0

    n = qmt_repo.inbox_delete_above(target)
    qmt_repo.force_watermark(target, min(cur_ack, target))
    print(f"\nOK  已删 inbox {n} 行, 水位置为 {target}")
    print("    起进程, 然后看对端补不补:")
    print("      docker compose --profile ws up -d pms-ws && sleep 20")
    print("      docker compose logs pms-ws --tail 40")
    print(f"    日志里 `握手完成 ... last_seq={target}` 才算回退生效;")
    print("      出现 `水位由 inbox 重算` 说明没删干净, 白做。")
    print("    然后三选一:")
    print(f"      水位从 {target + 1} 起一条条爬回来  → 补发正常, §6.1 通过")
    print("      直接跳到当前值, 中间不补            → 对端没实现补发")
    print("      resync 触发                        → 对端 ack 后就删了消息,")
    print("                                           没守 §6.1「已确认也留 7 天」")
    return 0


def cmd_inbox(args):
    from app.repo import qmt_repo
    # pong 每 5 秒一条, 不按类型过滤的话 30 行里 30 行都是它, 成交根本翻不到
    rows = qmt_repo.inbox_list(limit=int(args.limit), msg_type=args.type)
    if not rows:
        print(f"没有{'类型为 ' + args.type + ' 的' if args.type else ''}上行消息")
        return 0
    print(f"最近 {len(rows)} 条上行消息"
          + (f" (type={args.type})" if args.type else "") + " (新→旧):")
    for r in rows:
        mark = {0: "待入账", 1: "已入账", 2: "已消化", 3: "挂起"}.get(int(r.get("processed") or 0), "?")
        body = json.dumps(r.get("payload") or {}, ensure_ascii=False)
        # 默认 110 字够看 trade/order_update; snapshot 的 items 一截就把 cost_price 切掉了,
        # 而那正是接管持仓时最要紧的一个字段 —— 用 --width 放宽
        print(f"  seq={r['seq']:<8} {r['msg_type']:<15} {mark}  {body[:int(args.width)]}")
    return 0


def main():
    ap = argparse.ArgumentParser(description="ws 通道联调工具 (协议 §9)")
    sub = ap.add_subparsers(dest="cmd")

    p = sub.add_parser("status", help="通道状态与最近委托")
    p.add_argument("--limit", default=10)
    p.set_defaults(fn=cmd_status)

    p = sub.add_parser("watch", help="持续盯状态")
    p.add_argument("--limit", default=10)
    p.add_argument("--interval", default=3)
    p.set_defaults(fn=cmd_watch)

    p = sub.add_parser("place", help="手工发一张测试委托 (绕开 dispatch_mode)")
    p.add_argument("--code", required=True, help="点式代码, 如 600000.SH")
    p.add_argument("--side", required=True, choices=["buy", "sell"])
    p.add_argument("--qty", default=100, help="默认 100 股")
    p.add_argument("--price", required=True, help="限价, 2 位小数")
    p.add_argument("--ttl", default=5, help="有效期 (分钟), 到点 QMT 自动撤")
    p.add_argument("--intent", default="TRIM", choices=["OPEN", "FILL", "ADD", "DCA",
                                                        "TRIM", "EXIT", "T0"])
    p.add_argument("--id", default=None, help="自定 instruction_id (默认自动生成)")
    p.add_argument("--note", default=None)
    p.add_argument("--yes", action="store_true", help="确认发出 (缺省只演练)")
    p.set_defaults(fn=cmd_place)

    p = sub.add_parser("cancel", help="请求撤单")
    p.add_argument("--id", required=True, help="父指令 id")
    p.add_argument("--yes", action="store_true")
    p.set_defaults(fn=cmd_cancel)

    p = sub.add_parser("inbox", help="最近上行消息")
    p.add_argument("--limit", default=20)
    p.add_argument("--type", default=None,
                   help="只看某一类, 如 trade / ack / order_update / reject / snapshot "
                        "(不填会被 pong 刷屏)")
    p.add_argument("--width", default=110, help="payload 打印宽度 (看 snapshot 建议 600)")
    p.set_defaults(fn=cmd_inbox)

    p = sub.add_parser("rewind", help="回退 seq 水位, 逼对端补发 (协议 §6.1; 须先停 pms-ws)")
    p.add_argument("--by", default=60, help="往回退几格 (默认 60)")
    p.add_argument("--to", default=None, help="直接指定目标水位, 优先于 --by")
    p.add_argument("--yes", action="store_true", help="确认执行 (缺省只演练)")
    p.add_argument("--force", action="store_true",
                   help="区间内有已入账成交时仍然执行 —— 会导致重复入账, 基本别用")
    p.set_defaults(fn=cmd_rewind)

    args = ap.parse_args()
    if not getattr(args, "fn", None):
        ap.print_help()
        return 2
    return args.fn(args)


if __name__ == "__main__":
    sys.exit(main())
