# -*- coding: utf-8 -*-
"""
指令下发通道 (设计 §9 权限移交的落点)
======================================
两个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**:

  shadow  影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED,
          等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。
          这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。
  ws      WebSocket 长连接直连 QMT 执行服务, 协议见 QMT_WS_PROTOCOL.md V1.0。

ws 模式的进程边界 (这一段是理解本模块的关键)
--------------------------------------------
ws 是**一条常驻长连接**, 而 executor 跑在 celery worker 这种短命任务进程里, 且协议 §1
明确「PMS 不开第二条连接」(避免指令乱序)。所以连接由独立的 `pms-ws` 进程持有
(app/ws/runner.py), 本模块**不碰 socket** —— `_ws()` 只做一件事:

    把这张委托写进 pms_qmt_order 置 QUEUED, 然后就返回。

出口队列落在业务表上而不是内存或 Redis, 是因为「先记账后动作」这条铁律在这里可以字面
成立: **落表就是记账**。ws 进程崩了重启队列还在; 页面查 pms_qmt_order 就能看到在途委托;
不引入任何新中间件。代价是亚秒级的轮询延迟 —— 择时本来就是分钟级节奏, 无感。

放不放行的判断 (对应协议 §6.3「故障即守成」)
-------------------------------------------
  ws 进程心跳陈旧 (进程没了)   → 一律拒发。队列里的单子谁也发不出去, 排进去只是假装干活。
  进程在、连接断 (非 ONLINE)   → **买入拒发, 卖出照常入队**, 重连后立即发出。
                                这是既有口径「冻结与刹车只挡增持不挡减持」的延续。
  进程在、连接通               → 放行。

无论哪种模式, 本模块只负责「往下游递一手」, 不改父指令状态 —— 状态由 executor 统一推进。

历史包袱清理 (2026-07-28)
-------------------------
原有 plan_x / channel_y 两个适配器已删除, 原因:

  plan_x     买入写 trading_buy_plan(is_active=6)。这个 6 本身就是错的
             (下游语义 0待审/1已激活/2择时监测/5盘中观察, 没有 6); 且该表唯一约束
             是 (stock_code, trading_time)、buy_amount 是金额不是股数, 单股分批
             必撞约束。架构定案后该路径整体作废。
  channel_y  写 pms_order_request 表由下游轮询。双方已改定 WebSocket 直连,
             表通道作废 (见 QMT_INTERFACE_REQUIREMENTS.md V2 的 B 部分)。
             `pms_order_request` 表保留未用, 不必删表。

留着作废路径比删掉更危险 —— 后来者会以为它可用。
"""
from __future__ import annotations

import logging
from datetime import datetime

from app.core import ws_codec as wsc
from app.repo import qmt_repo
from app.services import param_store

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

MODE_SHADOW, MODE_WS = "shadow", "ws"
MODES = (MODE_SHADOW, MODE_WS)

# ws 进程心跳超过这个时长没更新, 就认为进程已经没了 (它每 2 秒写一次)
DEFAULT_STALE_SEC = 15


def mode() -> str:
    m = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip()
    return m if m in MODES else MODE_SHADOW


# ================================================================ 通道健康
def channel_status() -> dict:
    """ws 通道当前能不能用。页面运维抽屉与下发前置校验共用同一份判断。"""
    stale = param_store.get_int("PMS_QMT_HEARTBEAT_STALE_SEC", DEFAULT_STALE_SEC)
    out = {"process_alive": False, "conn_state": "UNKNOWN", "online": False,
           "heartbeat_age_sec": None, "last_seq": 0, "acked_seq": 0,
           "resync_required": False, "queue": {}, "inbox_pending": 0,
           "orphan_held": 0, "error": None}
    try:
        st = qmt_repo.get_state()
    except Exception as e:
        out["error"] = f"通道状态读取失败: {type(e).__name__}: {e}"
        return out
    hb = st.get("heartbeat_at")
    if hb:
        age = (datetime.now() - hb).total_seconds()
        out["heartbeat_age_sec"] = round(age, 1)
        out["process_alive"] = age <= stale
    out.update({"conn_state": st.get("conn_state") or "INIT",
                "last_seq": int(st.get("last_seq") or 0),
                "acked_seq": int(st.get("acked_seq") or 0),
                "resync_required": bool(st.get("resync_flag")),
                "connected_at": str(st.get("connected_at") or ""),
                "last_error": st.get("last_error"), "stat": st.get("stat") or {}})
    out["online"] = out["process_alive"] and out["conn_state"] == "ONLINE"
    try:
        out["queue"] = qmt_repo.queue_depth()
        out["inbox_pending"] = qmt_repo.inbox_pending_count()
        # 挂起待人工的成交 (processed=3)。它不在 inbox_pending 里、调度也不会再碰,
        # 不在这里报个数就等于一笔无人知晓的漏账。
        out["orphan_held"] = qmt_repo.inbox_orphan_count()
    except Exception as e:                      # 统计失败不影响放行判断
        out["error"] = f"队列统计失败: {type(e).__name__}: {e}"
    return out


def describe() -> dict:
    m = mode()
    out = {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES)}
    if m == MODE_SHADOW:
        out["hint"] = ("影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, "
                       "成交由回放自动认领回账本")
        return out
    ch = channel_status()
    out["channel"] = ch
    if not ch["process_alive"]:
        out["hint"] = (f"WebSocket 直连: pms-ws 常驻进程未在线 (心跳 "
                       f"{ch['heartbeat_age_sec']}s 前), 指令一律拒发。"
                       f"启动: docker compose --profile ws up -d pms-ws")
    elif not ch["online"]:
        out["hint"] = (f"WebSocket 直连: 进程在线但连接 {ch['conn_state']} —— "
                       f"买入拒发, 卖出仍可入队待重连后发出 (协议 §6.3)")
    else:
        out["hint"] = (f"WebSocket 直连 QMT: 在线, 已确认水位 seq={ch['acked_seq']}, "
                       f"出口队列 {ch['queue'].get('QUEUED', 0)} 张待发")
    if ch.get("resync_required"):
        out["hint"] += " · 注意: 对端补发不全, 需走全量对账 (协议 §6.2)"
    return out


# ================================================================ 下发
def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None,
             valid_until=None, stock_name=None, intent=None, parent_id=None,
             note=None) -> dict:
    """递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。"""
    m = mode()
    try:
        if m == MODE_WS:
            return _ws(instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty,
                       limit_price=limit_price, valid_until=valid_until,
                       intent=intent, parent_id=parent_id, note=note)
        return _shadow(instruction_id, side)
    except Exception as e:
        logger.exception("下发失败 %s", instruction_id)
        return {"ok": False, "ref": None, "mode": m, "note": "",
                "error": f"{type(e).__name__}: {e}"}


def _shadow(instruction_id: str, side: str) -> dict:
    return {"ok": True, "ref": f"manual:{instruction_id}", "mode": MODE_SHADOW,
            "note": f"影子运行: 请在 QMT 侧人工{'买入' if side == 'buy' else '卖出'}, "
                    f"成交由回放认领", "error": None}


def _to_epoch_ms(valid_until) -> int:
    """valid_until 统一成 epoch 毫秒 (协议 §1: 时间字段一律整数毫秒)。

    executor 传的是 datetime, 页面或脚本可能直接传毫秒 —— 两种都收。
    给不出有效期时兜底为「当日 14:57」而不是无限期: 挂单不设终点等于把撤单责任又留回
    自己身上, 而 valid_until 存在的全部意义就是把它交给 QMT (协议 §4.2)。
    """
    if isinstance(valid_until, datetime):
        return int(valid_until.timestamp() * 1000)
    if isinstance(valid_until, (int, float)) and valid_until > 0:
        v = int(valid_until)
        return v if v > 10 ** 12 else v * 1000        # 传秒的也认
    fallback = datetime.now().replace(hour=14, minute=57, second=0, microsecond=0)
    return int(fallback.timestamp() * 1000)


def _parent_of(instruction_id: str, parent_id=None) -> str:
    """子单 id 形如 {父指令}_D01 (见 executor._child_id)。显式传入的父 id 优先。"""
    if parent_id:
        return parent_id
    iid = str(instruction_id or "")
    tail = iid.rsplit("_D", 1)
    return tail[0] if len(tail) == 2 and tail[1].isdigit() else iid


def _ws(*, instruction_id, ts_code, side, qty, limit_price, valid_until, intent=None,
        parent_id=None, note=None) -> dict:
    """ws 直连: 落出口队列即返回, 实际发送由 pms-ws 常驻进程完成。

    这里**不等** QMT 的 ack —— executor 一跳是分钟级节奏, 不该被一次网络往返卡住;
    受理结果由 ws 进程回写 pms_qmt_order.status, 页面与对账都看那里。
    """
    side = str(side or "").lower()
    ch = channel_status()
    if not ch["process_alive"]:
        return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
                "error": f"pms-ws 常驻进程未在线 (心跳 {ch['heartbeat_age_sec']}s 前), "
                         f"拒绝下发 —— 排进队列也发不出去"}
    if not ch["online"] and side != "sell":
        # 协议 §6.3: QMT 断连 → 停发一切增持指令, 仅保留减持路径
        return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
                "error": f"QMT 连接 {ch['conn_state']}, 按协议 §6.3 暂停买入下发 "
                         f"(卖出不受此限)"}

    try:
        payload = wsc.place_order_payload(
            instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty,
            limit_price=limit_price, valid_until=_to_epoch_ms(valid_until),
            intent=(intent or "OPEN"), note=(note or ""))
    except wsc.CodecError as e:
        # 参数不合规就在本地拦下 —— 换 QMT 一个 BAD_PARAM 是白跑一趟, 还污染对方日志
        return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
                "error": f"指令不合协议要求 ({e.code}): {e.message}"}

    qmt_repo.enqueue_order(
        instruction_id=payload["instruction_id"],
        parent_id=_parent_of(instruction_id, parent_id),
        ts_code=payload["ts_code"], side=payload["side"], qty=payload["qty"],
        limit_price=payload["limit_price"], valid_until=payload["valid_until"],
        intent=payload["intent"], note=payload["note"])
    logger.info("[ws] 入队 %s %s %s %s股 @%.2f 有效至 %s", payload["instruction_id"],
                payload["ts_code"], payload["side"], payload["qty"],
                payload["limit_price"],
                datetime.fromtimestamp(payload["valid_until"] / 1000).strftime("%H:%M:%S"))
    hint = "" if ch["online"] else " (连接未就绪, 重连后立即发出)"
    return {"ok": True, "ref": payload["instruction_id"], "mode": MODE_WS,
            "note": f"已入 ws 出口队列{hint}", "error": None}


# ================================================================ 撤单
def cancel(*, instruction_id: str, dispatch_ref=None) -> dict:
    """请求撤单。shadow 无下游撤单语义, 只回执由 executor 置本地状态。

    ws 模式下入参 instruction_id 是**父指令**, 名下可能有多张已发出的子单 —— 全部标记
    待撤, 由 ws 进程逐张发 cancel_order。已终态的子单不动 (协议 §4.3: 撤单前已全成会回
    reject{ALREADY_FINAL})。
    """
    m = mode()
    if m != MODE_WS:
        return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"}
    try:
        cancel_id = wsc.new_cancel_id(int(datetime.now().strftime("%Y%m%d")))
        n = qmt_repo.request_cancel(parent_id=instruction_id, cancel_id=cancel_id)
    except Exception as e:
        logger.exception("撤单请求落表失败 %s", instruction_id)
        return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"}
    if not n:
        return {"ok": True, "mode": m, "cancelled": 0,
                "note": "该指令名下没有在途子单, 仅本地置撤销"}
    note = f"已标记 {n} 张在途委托待撤 (cancel_id={cancel_id})"
    ch = channel_status()
    if not ch["online"]:
        note += f"; 连接当前 {ch['conn_state']}, 重连后发出"
    return {"ok": True, "mode": m, "cancelled": n, "cancel_id": cancel_id, "note": note}
