# -*- coding: utf-8 -*-
"""
命令目录 · 参数校验 · 命令状态机 (纯逻辑, 无外部依赖, 可单测)
================================================================
对应设计 POSITION_MGMT_DESIGN.md §3.1 (命令场景目录, 已定「不分期、全部实现」)
与 §11 (pms_command)、§13 (命令冲突由用户裁决)。

三类命令 (cls):
  param  参数命令 —— 改变长期约束, 立即生效并持久化, 状态 EFFECTIVE/SUPERSEDED。
         全局参数落 pms_runtime_param (param_key); 个股参数以 pms_command 最新
         EFFECTIVE 记录为事实源 (设计 §11 原话), 同时投影到 pms_position 便于规则闸速读。
  task   任务命令 —— 有生命周期的一次性使命, 状态 PENDING→PLANNING→EXECUTING→DONE/PARTIAL/CANCELLED。
  task(instant=True)  即时任务 —— 有真实动作 (撤在途买入/改窗口/撤命令) 但无执行窗口,
         规划完即 DONE。设计把「全局暂停买入」等归在任务命令表下, 故保留 task 归类。

本模块只做「目录 + 校验 + 状态迁移 + 冲突识别」, 不碰数据库、不生成方案 (方案见 planner.py)。
"""
from __future__ import annotations

CLS_PARAM = "param"
CLS_TASK = "task"

# ---- 字段类型 ----
F_MONEY = "money"   # 金额 (元), > 0
F_PCT = "pct"       # 比例, 0~1; 接受 "10%" 字符串或 0.1 浮点
F_INT = "int"
F_FLOAT = "float"
F_PRICE = "price"   # 价格 (元), > 0
F_CODE = "code"     # Tushare 点式 600000.SH
F_ENUM = "enum"
F_STR = "str"

# ---- 状态 ----
ST_PENDING = "PENDING"
ST_EFFECTIVE = "EFFECTIVE"
ST_SUPERSEDED = "SUPERSEDED"
ST_PLANNING = "PLANNING"
ST_EXECUTING = "EXECUTING"
ST_PARTIAL = "PARTIAL"
ST_DONE = "DONE"
ST_CANCELLED = "CANCELLED"

# 状态迁移表 (设计 §2: 待处理→规划中→执行中→完成/撤销)
TRANSITIONS = {
    (CLS_PARAM, ST_PENDING):    {ST_EFFECTIVE, ST_CANCELLED},
    (CLS_PARAM, ST_EFFECTIVE):  {ST_SUPERSEDED},
    (CLS_PARAM, ST_SUPERSEDED): set(),
    (CLS_PARAM, ST_CANCELLED):  set(),
    (CLS_TASK, ST_PENDING):     {ST_PLANNING, ST_CANCELLED},
    (CLS_TASK, ST_PLANNING):    {ST_EXECUTING, ST_DONE, ST_CANCELLED},
    (CLS_TASK, ST_EXECUTING):   {ST_PARTIAL, ST_DONE, ST_CANCELLED},
    (CLS_TASK, ST_PARTIAL):     {ST_EXECUTING, ST_DONE, ST_CANCELLED},  # 顺延一日 → 回执行中
    (CLS_TASK, ST_DONE):        set(),
    (CLS_TASK, ST_CANCELLED):   set(),
}

TERMINAL = {ST_SUPERSEDED, ST_DONE, ST_CANCELLED}
ACTIVE_TASK_STATES = {ST_PENDING, ST_PLANNING, ST_EXECUTING, ST_PARTIAL}


def _f(ftype, required=True, **kw):
    d = {"type": ftype, "required": required}
    d.update(kw)
    return d


# =====================================================================
# 命令目录 (设计 §3.1 A/B/C 三组全量)
# =====================================================================
SPECS = {
    # ---------------- A. 资金与总体参数 (参数命令, 全局) ----------------
    "SET_SCALE": {
        "cls": CLS_PARAM, "label": "设定总规模", "group": "A", "scope": "global",
        "fields": {"scale": _f(F_MONEY, min=0)},
        "param_key": "PMS_TOTAL_SCALE", "value_field": "scale",
        "note": "注资/抽资即改此值; 抽资导致超限由降仓提议消化, 不自动强平",
    },
    "SET_PORTFOLIO_CAP": {
        "cls": CLS_PARAM, "label": "设定总仓上限", "group": "A", "scope": "global",
        "fields": {"cap": _f(F_PCT, min=0, max=1)},
        "param_key": "PMS_PORTFOLIO_CAP", "value_field": "cap",
        "note": "调低后超限 → 生成降仓提议供确认",
    },
    "SET_STOCK_CAP": {
        "cls": CLS_PARAM, "label": "设定单股上限", "group": "A", "scope": "global",
        "fields": {"cap": _f(F_PCT, min=0, max=1)},
        "param_key": "PMS_STOCK_CAP", "value_field": "cap",
    },
    "SET_STOCK_TARGET": {
        "cls": CLS_PARAM, "label": "设定默认单股目标仓位", "group": "A", "scope": "global",
        "fields": {"target": _f(F_PCT, min=0, max=1)},
        "param_key": "PMS_STOCK_TARGET_DEFAULT", "value_field": "target",
    },
    "SET_MAX_NAMES": {
        "cls": CLS_PARAM, "label": "设定最大持仓数", "group": "A", "scope": "global",
        "fields": {"n": _f(F_INT, min=1, max=200)},
        "param_key": "PMS_MAX_NAMES", "value_field": "n",
    },
    "SET_AUTONOMY": {
        "cls": CLS_PARAM, "label": "设定自主档位", "group": "A", "scope": "global",
        "fields": {"mode": _f(F_ENUM, choices=["full", "propose_only", "off"])},
        "param_key": "PMS_AUTONOMY", "value_field": "mode",
    },
    "SET_CASH_RESERVE": {
        "cls": CLS_PARAM, "label": "设定预留现金比例", "group": "A", "scope": "global",
        "fields": {"ratio": _f(F_PCT, min=0, max=1)},
        "param_key": "PMS_CASH_RESERVE", "value_field": "ratio",
        "note": "与总仓上限双重约束",
    },

    # ---------------- B. 组合级动作 (任务命令) ----------------
    "REDUCE_EXPOSURE": {
        "cls": CLS_TASK, "label": "降仓", "group": "B", "scope": "global",
        "fields": {"pct": _f(F_PCT, min=0, max=1),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "reduce_exposure",
        "note": "释放金额 = 规模×pct; 优先级 停新买→清弱票→收利润→等比微减",
    },
    "INCREASE_EXPOSURE": {
        "cls": CLS_TASK, "label": "升仓", "group": "B", "scope": "global",
        "fields": {"pct": _f(F_PCT, min=0, max=1),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "increase_exposure",
        "note": "来源: 既有持仓垫厚票补到目标 + 上游候选池新票建仓",
    },
    "HALT_BUY": {
        "cls": CLS_TASK, "label": "全局暂停买入", "group": "B", "scope": "global",
        "fields": {}, "planner": "halt_buy", "instant": True,
        "switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": True,
        "note": "停止一切新增买入并撤销在途买入指令; 卖出与止损不受影响",
    },
    "RESUME_BUY": {
        "cls": CLS_TASK, "label": "恢复买入", "group": "B", "scope": "global",
        "fields": {}, "planner": "noop", "instant": True,
        "switch_key": "PMS_GLOBAL_BUY_HALT", "switch_value": False,
    },
    "HALT_ALL": {
        "cls": CLS_TASK, "label": "全局暂停执行(休假模式)", "group": "B", "scope": "global",
        "fields": {}, "planner": "halt_all", "instant": True,
        "switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": True,
        "note": "所有自动动作暂停, 仅保留账本对账与日报",
    },
    "RESUME_ALL": {
        "cls": CLS_TASK, "label": "恢复执行", "group": "B", "scope": "global",
        "fields": {}, "planner": "noop", "instant": True,
        "switch_key": "PMS_GLOBAL_EXEC_HALT", "switch_value": False,
    },
    "LIQUIDATE_ALL": {
        "cls": CLS_TASK, "label": "一键清仓(紧急)", "group": "B", "scope": "global",
        "fields": {"window_tdays": _f(F_INT, required=False, min=1, max=5, default=1),
                   "confirm": _f(F_ENUM, required=True, choices=["YES"])},
        "planner": "liquidate_all", "danger": True,
        "note": "全部持仓按最快节奏卖出, 不做择时优化; 页面二次确认 (confirm=YES)",
    },
    "SECTOR_EXIT": {
        "cls": CLS_TASK, "label": "清仓某行业", "group": "B", "scope": "sector",
        "fields": {"sector": _f(F_STR),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "sector_exit", "needs_sector_source": True,
    },
    "SECTOR_CAP": {
        "cls": CLS_TASK, "label": "限制某行业上限", "group": "B", "scope": "sector",
        "fields": {"sector": _f(F_STR), "cap": _f(F_PCT, min=0, max=1),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "sector_cap", "instant": False, "needs_sector_source": True,
        "note": "写入行业上限参数; 当前已超限则同时生成减仓方案",
    },

    # ---------------- C. 个股级动作 ----------------
    "OPEN_TARGET": {
        "cls": CLS_TASK, "label": "建仓某股至目标%", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "open_target",
        "note": "分批投放 50/25/25, 含一手合并; 上限与行业硬拦截在规划期即校验",
    },
    "EXIT_STOCK": {
        "cls": CLS_TASK, "label": "清仓某股", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "exit_stock",
    },
    "REDUCE_STOCK": {
        "cls": CLS_TASK, "label": "减至 X%", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE), "target_pct": _f(F_PCT, min=0, max=1),
                   "window_tdays": _f(F_INT, required=False, min=1, max=20, default=3)},
        "planner": "reduce_stock",
    },
    "FREEZE_STOCK": {
        "cls": CLS_PARAM, "label": "冻结某股", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
        "projection": {"frozen_reason": "COMMAND_HALT"},
        "note": "停止该股一切增持(在途买入撤销), 卖出不受影响",
    },
    "UNFREEZE_STOCK": {
        "cls": CLS_PARAM, "label": "解冻某股", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
        "projection": {"frozen_reason": "NONE"},
    },
    "T0_ENABLE": {
        "cls": CLS_PARAM, "label": "做T授权", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE), "t_ratio": _f(F_PCT, min=0, max=0.333)},
        "projection": {"t0_enabled": 1},
        "note": "T 仓 = 持仓 × t_ratio, 硬上限 1/3",
    },
    "T0_DISABLE": {
        "cls": CLS_PARAM, "label": "取消做T授权", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
        "projection": {"t0_enabled": 0},
    },
    "SET_STOP_PRICE": {
        "cls": CLS_PARAM, "label": "设定某股止损价", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
        "projection": {"stop_ref": "@price", "ref_source": "user"},
        "note": "覆盖系统参考位; 触发即生成卖出方案",
    },
    "SET_TARGET_PRICE": {
        "cls": CLS_PARAM, "label": "设定某股目标价", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE), "price": _f(F_PRICE, min=0)},
        "note": "触发即生成止盈提议 (事实源为本命令最新 EFFECTIVE 记录)",
    },
    "BLACKLIST_ADD": {
        "cls": CLS_PARAM, "label": "加入黑名单(永不买入)", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
    },
    "BLACKLIST_REMOVE": {
        "cls": CLS_PARAM, "label": "移出黑名单", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
    },
    "WHITELIST_ADD": {
        "cls": CLS_PARAM, "label": "加入白名单(优先规划)", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
    },
    "WHITELIST_REMOVE": {
        "cls": CLS_PARAM, "label": "移出白名单", "group": "C", "scope": "stock",
        "fields": {"ts_code": _f(F_CODE)},
    },

    # ---------------- C'. 对在途命令的操作 ----------------
    "ADJUST_WINDOW": {
        "cls": CLS_TASK, "label": "调整命令窗口", "group": "C", "scope": "command",
        "fields": {"target_command_id": _f(F_STR), "window_tdays": _f(F_INT, min=1, max=20)},
        "planner": "adjust_window", "instant": True,
    },
    "CANCEL_COMMAND": {
        "cls": CLS_TASK, "label": "撤销命令", "group": "C", "scope": "command",
        "fields": {"target_command_id": _f(F_STR)},
        "planner": "cancel_command", "instant": True,
    },
}

# 互斥/冲突规则: (A 类型集合, B 类型集合, 作用域, 说明)
#   同一作用域内 A 与 B 同时在途 → 冲突, 由用户在页面裁决 (设计 §13, 系统不代替仲裁)
_CONFLICT_PAIRS = [
    ({"REDUCE_EXPOSURE"}, {"INCREASE_EXPOSURE"}, "global", "组合降仓与升仓方向相反"),
    ({"LIQUIDATE_ALL"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "一键清仓在途, 不应再建仓"),
    ({"HALT_BUY"}, {"INCREASE_EXPOSURE", "OPEN_TARGET"}, "global", "全局暂停买入生效中"),
    ({"HALT_ALL"}, {"REDUCE_EXPOSURE", "INCREASE_EXPOSURE", "OPEN_TARGET",
                    "EXIT_STOCK", "REDUCE_STOCK", "LIQUIDATE_ALL"}, "global", "休假模式生效中"),
    ({"OPEN_TARGET"}, {"EXIT_STOCK", "REDUCE_STOCK"}, "stock", "同股建仓与减持方向相反"),
    ({"EXIT_STOCK"}, {"OPEN_TARGET", "REDUCE_STOCK"}, "stock", "同股清仓在途"),
    ({"FREEZE_STOCK"}, {"OPEN_TARGET"}, "stock", "该股已被命令冻结(禁增持)"),
]


def list_commands(group=None, sector_source_ready=True):
    """页面命令台目录。sector_source_ready=False 时行业类命令置灰 (设计 §5)。"""
    out = []
    for t, s in SPECS.items():
        if group and s["group"] != group:
            continue
        item = {
            "cmd_type": t, "cls": s["cls"], "label": s["label"], "group": s["group"],
            "scope": s["scope"], "fields": s["fields"], "note": s.get("note", ""),
            "danger": bool(s.get("danger")), "instant": bool(s.get("instant")),
            "disabled": bool(s.get("needs_sector_source")) and not sector_source_ready,
        }
        if item["disabled"]:
            item["disabled_reason"] = "行业划分数据源未配置 (PMS_SECTOR_SOURCE 为空)"
        out.append(item)
    return out


# =====================================================================
# 参数校验与归一
# =====================================================================
def _norm_pct(v):
    if isinstance(v, str):
        s = v.strip()
        if s.endswith("%"):
            return float(s[:-1]) / 100.0
        return float(s)
    return float(v)


def normalize_code(code: str) -> str:
    """归一为 Tushare 点式。接受 600000.SH / SH600000 / 600000 (按交易所规则补后缀)。"""
    c = (code or "").strip().upper()
    if not c:
        return ""
    if "." in c:
        num, mkt = c.split(".", 1)
        return f"{num}.{mkt}"
    if c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
        return f"{c[2:]}.{c[:2]}"
    if c.isdigit() and len(c) == 6:
        if c[0] == "6":
            return f"{c}.SH"
        if c[0] in "03":
            return f"{c}.SZ"
        if c[0] in "48":
            return f"{c}.BJ"
    return c


# A 股股票代码的前缀 × 交易所对照。只认股票, 不含 ETF/基金/B 股 —— PMS 管的是个股。
# 用途见 ledger_service.reconcile: 下游是券商(或联调期的模拟)持仓表, 会混进测试垃圾。
# 2026-07-29 下游就只剩一只 `999999.SH` 2000 股 —— 我们自己联调时用的假代码 ——
# 差点被对账当成真持仓补进账本。
STOCK_PREFIXES = {
    "SH": ("600", "601", "603", "605", "688", "689"),          # 沪主板 + 科创板
    "SZ": ("000", "001", "002", "003", "300", "301"),          # 深主板 + 创业板
    "BJ": ("430", "830", "831", "832", "833", "834", "835",    # 北交所
           "836", "837", "838", "839", "870", "871", "872", "873", "874", "920"),
}


def is_stock_code(code: str) -> bool:
    """点式代码是不是一只真实 A 股个股。

    宁可保守: 将来新代码段出现时这里会误杀, 但调用方都会带着原始代码告警, 补一行前缀
    即可 —— 比把垃圾放进账本便宜得多。
    """
    c = normalize_code(code)
    if "." not in c:
        return False
    num, mkt = c.split(".", 1)
    if not (num.isdigit() and len(num) == 6):
        return False
    return num[:3] in STOCK_PREFIXES.get(mkt, ())


def validate(cmd_type: str, params: dict) -> tuple:
    """校验并归一命令参数。返回 (normalized_params, errors)。errors 非空即拒绝下达。"""
    spec = SPECS.get(cmd_type)
    if not spec:
        return {}, [f"UNKNOWN_CMD: 未知命令类型 {cmd_type}"]
    params = params or {}
    out, errs = {}, []

    for name, f in spec["fields"].items():
        raw = params.get(name)
        if raw is None or (isinstance(raw, str) and raw.strip() == ""):
            if f.get("required", True):
                errs.append(f"MISSING: 缺少参数 {name}")
            elif "default" in f:
                out[name] = f["default"]
            continue
        t = f["type"]
        try:
            if t == F_PCT:
                v = _norm_pct(raw)
            elif t in (F_MONEY, F_PRICE, F_FLOAT):
                v = float(raw)
            elif t == F_INT:
                v = int(float(raw))
            elif t == F_CODE:
                v = normalize_code(str(raw))
                if not v or "." not in v:
                    errs.append(f"BAD_CODE: 股票代码无法归一为点式 ({raw})")
                    continue
            elif t == F_ENUM:
                v = str(raw).strip()
                if v not in f["choices"]:
                    errs.append(f"BAD_ENUM: {name} 应为 {f['choices']} 之一, 收到 {raw}")
                    continue
            else:
                v = str(raw).strip()
        except (TypeError, ValueError):
            errs.append(f"BAD_TYPE: {name} 无法解析为 {t} ({raw})")
            continue

        if isinstance(v, (int, float)) and not isinstance(v, bool):
            if "min" in f and v < f["min"]:
                errs.append(f"OUT_OF_RANGE: {name}={v} 小于下限 {f['min']}")
            if "max" in f and v > f["max"]:
                errs.append(f"OUT_OF_RANGE: {name}={v} 大于上限 {f['max']}")
            if t in (F_MONEY, F_PRICE) and v <= 0:
                errs.append(f"OUT_OF_RANGE: {name} 必须为正数")
            if t == F_PCT and cmd_type in ("REDUCE_EXPOSURE", "INCREASE_EXPOSURE") and v <= 0:
                errs.append(f"OUT_OF_RANGE: {name} 必须大于 0")
        out[name] = v

    unknown = set(params) - set(spec["fields"])
    if unknown:
        errs.append(f"UNKNOWN_FIELD: 多余参数 {sorted(unknown)}")
    return out, errs


# =====================================================================
# 状态机
# =====================================================================
def can_transition(cls: str, frm: str, to: str) -> bool:
    return to in TRANSITIONS.get((cls, frm), set())


def transition(cls: str, frm: str, to: str) -> str:
    """迁移状态; 非法迁移抛 ValueError (调用方须捕获并落 ERROR 日志)。"""
    if not can_transition(cls, frm, to):
        raise ValueError(f"非法状态迁移: {cls} {frm} → {to}")
    return to


def initial_status(cls: str) -> str:
    return ST_PENDING


def settle_task_status(target_amount: float, done_amount: float, window_over: bool,
                       eps: float = 1e-6) -> str:
    """任务命令进度结算 (设计 §3.2 第4步): 达标 DONE; 窗口末仍未完成 PARTIAL; 否则继续 EXECUTING。"""
    if target_amount is None or target_amount <= 0:
        return ST_DONE
    if done_amount + eps >= target_amount:
        return ST_DONE
    return ST_PARTIAL if window_over else ST_EXECUTING


# =====================================================================
# 冲突检测 (系统只识别与提示, 裁决权在用户 —— 设计 §13)
# =====================================================================
def detect_conflicts(new_cmd: dict, active_cmds: list) -> list:
    """new_cmd: {cmd_type, ts_code?, params?}; active_cmds: 在途/生效中的命令列表
    (task 取 PENDING/PLANNING/EXECUTING/PARTIAL, param 取 EFFECTIVE)。
    返回 [{"with_command_id","with_cmd_type","scope","reason"}...], 空 = 无冲突。"""
    t = new_cmd.get("cmd_type")
    code = normalize_code(new_cmd.get("ts_code") or "")
    out = []
    for a in active_cmds or []:
        at = a.get("cmd_type")
        acode = normalize_code(a.get("ts_code") or "")
        for set_a, set_b, scope, why in _CONFLICT_PAIRS:
            hit = (t in set_a and at in set_b) or (t in set_b and at in set_a)
            if not hit:
                continue
            if scope == "stock" and (not code or code != acode):
                continue
            out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
                        "scope": scope, "reason": why})
            break
        else:
            # 同类型同标的重复下达 (参数命令不算冲突, 属于覆盖)
            if t == at and SPECS.get(t, {}).get("cls") == CLS_TASK and code == acode:
                out.append({"with_command_id": a.get("command_id"), "with_cmd_type": at,
                            "scope": SPECS[t]["scope"], "reason": "同类型命令已在途, 重复下达"})
    return out


def make_command_id(ymd: int, seq: int) -> str:
    return f"CMD_{ymd}_{seq:04d}"


def make_plan_id(command_id: str, seq: int) -> str:
    return f"PLAN_{command_id}_{seq:03d}"


def make_instruction_id(ymd: int, ts_code: str, action: str, seq: int) -> str:
    return f"INS_{ymd}_{ts_code.replace('.', '')}_{action}_{seq:03d}"
