# -*- coding: utf-8 -*-
"""
研判闸客户端 · 二级关口 (设计 §7)
==================================
「PMS 备齐硬数字与账本流水作为 context → 请求决策系统研判 (其 process_intraday_audit
  新增 PMS 请求类 direction, 复用既有仲裁哲学: 代码算数、AI 裁定性、越界收口)
  → 结构化答复 通过/驳回+理由。**研判服务不可用 → 自动降级 propose_only
  (人工确认替位) + ERROR 告警**。命令驱动动作不过研判闸 (不用 AI 审用户)。」

当前状态: 决策系统侧的 PMS 请求 direction 尚未开发 (bionic 仓库的配套改造)。
因此本模块默认 `PMS_JUDGE_API_BASE` 为空 = 未接通, 一律返回 UNAVAILABLE, 调用方按设计
降级为「人工确认替位」。接通后只需在页面把 base 填上, 无需改代码。

返回结构固定: {"verdict": PASS|REJECT|UNAVAILABLE, "reason", "degraded", "raw"}
"""
from __future__ import annotations

import logging

from app.services import param_store

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

PASS, REJECT, UNAVAILABLE = "PASS", "REJECT", "UNAVAILABLE"

# 新建仓送研判时, hard_numbers 里**只送这几个键**。
#
# 决策系统本来就不管仓位 —— 它是一套聚合了大量信号的判断系统, 「买多少」从来不是它的活。
# 而它收到 hard_numbers 之后是逐键渲染成「【硬数字 (PMS 已算好, 勿重算)】」贴进提示词的,
# 所以把名额、可投金额、批次比例、组合占比原样送过去, 等于当面请一个不该管仓位的系统去看
# 仓位 —— 提示词后面那句「仓位纪律不归你管」压不住已经摆在它眼前的一串数字。
#
# 这里只送「这只票凭什么被选出来」: 现价 + 候选池给的定性材料, 那正是要它裁的问题。
# 账本不受影响 —— pms_action_ledger.hard_numbers_json 照旧存全量, 判分锚一个字不少。
#
# **只对 OPEN 生效。** FILL/ADD/DCA 的硬数字 (安全垫、评估档位、底仓量) 本身就是它们的判据,
# 一个都不能删: 那几类动作是在**已有持仓**上做加减, 谈的就是这个仓位, 与新建仓不是一回事。
OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat",
                   "plan_rank", "plan_bucket", "plan_src", "sector",
                   # 决策系统今天盘中判没判过这只票转多。这是它自己的结论, 不是仓位数字,
                   # 送回去等于当面提醒它「你今天判过」, 让它拿自己的结论对照一次。
                   "intraday_buy_signal", "intraday_buy_reason")


def _judge_hard_numbers(action: str, hard: dict) -> dict:
    """按动作裁剪送出去的硬数字。非 OPEN 一律原样送 (行为与 2026-08-06 之前一字不差)。"""
    if str(action or "").upper() != "OPEN":
        return dict(hard or {})
    return {k: v for k, v in (hard or {}).items() if k in OPEN_JUDGE_KEYS}


def jsonable(v):
    """把请求体里 `requests` 的 `json=` 序列化不了的类型换掉。递归处理字典与列表。

    2026-08-06 实机撞上的一条: 研判闸第一次真正接通并发出请求, 三条新建仓提议全部失败于
        TypeError: Object of type Decimal is not JSON serializable
    源头是 `proposal_service._recent_ledger()` —— 它把评审账本流水塞进 context, 其中
    `price` 取自 `pms_action_ledger.price_at`, 那是数据库的 DECIMAL 列, 读出来是 Decimal。

    **这条的危害是渐进的, 所以特别值得写下来**: 只要一只票在评审账本里有过任何一行,
    它的研判请求就必然失败; 而账本只会越积越多, 于是过几天几乎所有票的研判都会失败,
    全部降级成人工确认 —— 「不用每天靠人」这件事会一点点失效, 而每一条看起来都只是
    「研判不可用, 降级人工确认」这种系统里天天都有的正常降级, 没人会觉得不对。
    它今天才第一次有机会暴露, 是因为在此之前 PMS_JUDGE_API_BASE 一直是空的:
    `available()` 为假就直接返回不可用, 请求体根本没构造过。

    修在这里而不是修 `_recent_ledger` 一处, 是因为请求体的任何一层将来都可能再冒出
    数据库类型 —— 一次拦住比每加一个字段就想一次靠谱。`_recent_ledger` 那边也顺手把
    `price` 转成了 float, 让日志与留痕里的数字也是干净的。

    择时那条路 (`exec_advisor._advice`) 目前不受影响: 它的 refs 与 position 都取自
    `portfolio.positions_view()`, 那里每个数值都过了 float()/round()。哪天它也报同样的
    TypeError, 照这里的写法在它的 `_post` 前包一层即可。
    """
    from datetime import date, datetime
    from decimal import Decimal
    if isinstance(v, Decimal):
        return float(v)
    if isinstance(v, (datetime, date)):
        return str(v)
    if isinstance(v, dict):
        return {k: jsonable(x) for k, x in v.items()}
    if isinstance(v, (list, tuple, set)):
        return [jsonable(x) for x in v]
    return v


def enabled() -> bool:
    return param_store.get_bool("PMS_JUDGE_ENABLED", True)


def base_url() -> str:
    return (param_store.get("PMS_JUDGE_API_BASE", "") or "").strip().rstrip("/")


def available() -> bool:
    return bool(enabled() and base_url())


def judged_actions() -> set:
    return set(param_store.get_list("PMS_JUDGE_ACTIONS", ["FILL", "ADD", "DCA", "SWITCH"]))


def status() -> dict:
    if not enabled():
        return {"available": False, "reason": "研判闸已关闭 (PMS_JUDGE_ENABLED=False), "
                                              "自主提议一律走人工确认"}
    if not base_url():
        return {"available": False, "reason": "决策系统 PMS 研判接口未配置 "
                                              "(PMS_JUDGE_API_BASE 为空) —— 待 bionic 侧配套改造"
                                              "完成后填入, 当前自动降级为人工确认"}
    return {"available": True, "base": base_url(), "actions": sorted(judged_actions())}


def request(candidate: dict, context: dict = None, *, timeout: int = None) -> dict:
    """请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。"""
    action = str(candidate.get("action") or "").upper()
    if action not in judged_actions():
        return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可",
                "degraded": False, "raw": None}
    if not available():
        st = status()
        logger.error("[研判闸] 不可用, 降级人工确认: %s", st["reason"])
        return {"verdict": UNAVAILABLE, "reason": st["reason"], "degraded": True, "raw": None}

    payload = {
        "direction": "PMS_JUDGE", "action": action, "ts_code": candidate.get("ts_code"),
        "qty": candidate.get("qty"), "reason": candidate.get("reason"),
        # 硬数字按动作裁剪: 新建仓只送定性材料, 不送仓位数字 (见 OPEN_JUDGE_KEYS 的说明)
        "hard_numbers": _judge_hard_numbers(action, candidate.get("hard_numbers")),
        "context": context or {},
        # 设计要求: 补仓类研判必须回答「下跌是杀逻辑还是杀情绪」。
        # 新建仓的必答 (「把这只票挑出来的驱动到今天还成不成立」) **只写在决策系统的判据里**,
        # 这边不传 —— 「建仓该问什么」属于仲裁哲学, 是它的知识, PMS 不需要懂。
        "must_answer": (["下跌是杀逻辑还是杀情绪"] if action == "DCA" else []),
    }
    to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90))
    url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "")
    payload = jsonable(payload)      # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable
    try:
        import requests
        r = requests.post(url, json=payload, timeout=to)
        r.raise_for_status()
        data = r.json() or {}
    except Exception as e:
        logger.error("[研判闸] 请求失败, 降级人工确认: %s: %s", type(e).__name__, e)
        return {"verdict": UNAVAILABLE, "reason": f"研判请求失败: {type(e).__name__}: {e}",
                "degraded": True, "raw": None}

    verdict = str(data.get("verdict") or data.get("decision") or "").upper()
    if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"):
        v = PASS
    elif verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"):
        v = REJECT
    else:
        logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data)
        return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}",
                "degraded": True, "raw": data}
    return {"verdict": v, "reason": data.get("reason") or data.get("rationale") or "",
            "degraded": False, "raw": data}
