# -*- coding: utf-8 -*-
"""
PMS 管理页面 · Web 入口 (FastAPI + 单页)
=========================================
设计 §3.3 四块: 参数设置 / 命令台 / 持仓与账本 / 提议确认。

工程原则:
  * 任何后端异常都不得让页面开不了 —— 全部 API 走 `ok(...)` 包装, 失败返回
    {"ok": false, "error": "..."} 且 HTTP 200, 由前端在顶部横幅提示。
  * 页面只读参数一律经 ParamStore (表值优先), 改参即持久化到 pms_runtime_param。
  * 手动运维按钮 (回放/对账/日终/日报) 与调度器调用同一份服务函数, 便于未接调度时先验证。
"""
from __future__ import annotations

import logging
import os
from datetime import datetime, timedelta

from fastapi import Body, FastAPI, Query
from fastapi.responses import FileResponse, JSONResponse

from config.settings import settings
from app.core import command_spec as cs
from app.core import tradedays as td
from app.db import session as dbs
from app.repo import downstream_repo, pms_repo
from app.services import command_service, industry, ledger_service, param_store, portfolio

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("pms.web")

VERSION = "0.2.0-dev"
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")

app = FastAPI(title="PMS 持仓管理系统", version=VERSION)

# 把 static 目录挂到 /static —— 内网隔离时的离线兜底:
# 页面默认从 unpkg 取 Vue3 / ElementPlus / axios, 浏览器上不了外网就会白屏 (更准确地说是
# 只剩一个光秃秃的头部, 因为 el-* 组件全渲染不出来)。把三个库放进 static/vendor/ 再把
# index.html 头部那五个 URL 换成 /static/vendor/xxx 即可离线运行, 无需构建步骤。
# 具体做法见 README「管理页面」一节。
try:
    from fastapi.staticfiles import StaticFiles
    if os.path.isdir(STATIC_DIR):
        app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
except Exception as _e:            # 挂不上不影响任何 API, 页面照常从 CDN 取
    logger.warning("静态目录挂载失败 (不影响接口): %s", _e)


def ok(fn, *args, **kw):
    """统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。"""
    try:
        data = fn(*args, **kw)
        if isinstance(data, dict) and "ok" in data:
            return data
        return {"ok": True, "data": data}
    except Exception as e:
        logger.exception("API 失败: %s", getattr(fn, "__name__", fn))
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}


def _oplog(op, *, ok_flag, reason=None, ts_code=None, params=None, ref=None, by="user"):
    """交易员操作日志: 每个写操作落一行 (OK/BLOCKED 都记, 含原因)。日志失败不影响操作本体 (但记 logger)。"""
    try:
        pms_repo.insert_op_log(op=op, by=by, ts_code=ts_code,
                               result=("OK" if ok_flag else "BLOCKED"),
                               reason=reason, params=params, ref=ref)
    except Exception as e:
        logger.error("[op_log] 写操作日志失败 op=%s: %s (操作本体不受影响)", op, e)


def ok_logged(op, fn, *args, ts_code=None, params=None, by="user", **kw):
    """跑 fn (经 ok 包装) 并按结果落一条操作日志。ok=False -> BLOCKED + error/errors 作原因。"""
    data = ok(fn, *args, **kw)
    okf = not (isinstance(data, dict) and data.get("ok") is False)
    reason = None
    if not okf:
        reason = "; ".join(data.get("errors") or []) or data.get("error") or "被约束挡下"
    ref = None
    if isinstance(data, dict):
        ref = (data.get("command_id") or data.get("instruction_id")
               or data.get("strategy_id") or data.get("proposal_id"))
    _oplog(op, ok_flag=okf, reason=reason, ts_code=ts_code, params=params, ref=ref, by=by)
    return data


# ================================================================ 基础
@app.get("/health")
def health():
    """容器健康检查 + 自证: 配置装载、库连通、参数来源、交易日历。"""
    db = dbs.ping("proxy")
    return {
        "status": "ok" if db["ok"] else "degraded",
        "version": VERSION, "service": "pms-web",
        "now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "trade_day": td.is_trade_day(), "calendar_degraded": td.calendar_degraded(),
        "db_proxy": db,
        "config_loaded": {
            "total_scale": param_store.get("PMS_TOTAL_SCALE"),
            "portfolio_cap": param_store.get("PMS_PORTFOLIO_CAP"),
            "stock_cap": param_store.get("PMS_STOCK_CAP"),
            "max_names": param_store.get("PMS_MAX_NAMES"),
            "autonomy": param_store.get("PMS_AUTONOMY"),
            "web_port": settings.PMS_WEB_PORT,
        },
        "sector": industry.status(),
        "dispatch": _dispatch_health(),
    }


def _dispatch_health() -> dict:
    """下发通道健康快照。通道读不到不能让 /health 挂 —— 容器健康检查靠它。"""
    try:
        from app.services import dispatcher
        d = dispatcher.describe()
        ch = d.get("channel") or {}
        return {"mode": d["mode"], "online": ch.get("online"),
                "conn_state": ch.get("conn_state"), "queued": (ch.get("queue") or {}).get("QUEUED", 0),
                "hint": d.get("hint")}
    except Exception as e:
        return {"error": f"{type(e).__name__}: {e}"}


@app.get("/")
def index():
    path = os.path.join(STATIC_DIR, "index.html")
    if os.path.exists(path):
        return FileResponse(path, media_type="text/html; charset=utf-8")
    return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"})


@app.get("/api/overview")
def api_overview():
    return ok(portfolio.overview)


@app.get("/api/names")
def api_names(codes: str = Query("")):
    """代码 → 中文名 (gp_code_all)。codes 逗号分隔点式代码。绝不抛错 —— 交易员视图靠它显示中文名。"""
    cs_list = [c.strip() for c in (codes or "").split(",") if c.strip()]
    return ok(lambda: {"ok": True, "names": downstream_repo.fetch_names(cs_list)})


# ================================================================ ① 参数设置
@app.get("/api/params")
def api_params():
    return ok(param_store.snapshot)


@app.post("/api/params")
def api_set_params(payload: dict = Body(...)):
    """单个 {key, value} 或批量 {items:[{key,value}...]}。逐项返回结果, 部分失败不整体回滚。"""
    items = payload.get("items") or [{"key": payload.get("key"), "value": payload.get("value")}]
    results = []
    for it in items:
        k = it.get("key")
        if not k:
            results.append({"ok": False, "error": "缺少 key"})
            continue
        results.append(param_store.set_param(k, it.get("value"),
                                             updated_by=payload.get("by") or "user"))
    _okf = all(r.get("ok") for r in results)
    _oplog("set_params", ok_flag=_okf,
           params={it.get("key"): it.get("value") for it in items},
           reason=(None if _okf else "; ".join((r.get("error") or "") for r in results if not r.get("ok"))),
           by=payload.get("by") or "user")
    return {"ok": _okf, "results": results}


# ================================================================ ② 命令台
@app.get("/api/commands/catalog")
def api_catalog():
    return ok(lambda: {"commands": cs.list_commands(sector_source_ready=industry.ready()),
                       "sector": industry.status()})


@app.get("/api/commands/active")
def api_commands_active():
    return ok(command_service.active_commands)


@app.get("/api/commands")
def api_commands(status: str = Query(None), limit: int = Query(100)):
    statuses = [s for s in (status or "").split(",") if s] or None
    return ok(pms_repo.list_commands, statuses=statuses, limit=limit)


@app.get("/api/commands/{command_id}")
def api_command_detail(command_id: str):
    def _detail():
        c = pms_repo.get_command(command_id)
        if not c:
            return {"ok": False, "error": f"命令 {command_id} 不存在"}
        return {"ok": True, "command": c,
                "plans": pms_repo.list_plans(command_id=command_id, limit=500)}
    return ok(_detail)


@app.post("/api/commands")
def api_issue(payload: dict = Body(...)):
    return ok_logged("issue_command:" + str(payload.get("cmd_type")),
                     command_service.issue, payload.get("cmd_type"), payload.get("params") or {},
                     note=payload.get("note"), issued_by=payload.get("by") or "user",
                     force_conflict=bool(payload.get("force")),
                     ts_code=(payload.get("params") or {}).get("ts_code"),
                     params=payload, by=payload.get("by") or "user")


@app.post("/api/commands/{command_id}/cancel")
def api_cancel(command_id: str):
    return ok_logged("cancel_command", command_service.cancel, command_id,
                     params={"command_id": command_id})


@app.post("/api/commands/{command_id}/replan")
def api_replan(command_id: str):
    """重新生成方案 (窗口内行情变化后可重算; 旧方案作废)。"""
    def _replan():
        c = pms_repo.get_command(command_id)
        if not c:
            return {"ok": False, "error": "命令不存在"}
        if c["status"] not in cs.ACTIVE_TASK_STATES:
            return {"ok": False, "error": f"命令处于 {c['status']}, 不可重规划"}
        pms_repo.cancel_plans_of_command(command_id)
        return command_service.plan_command(c)
    return ok_logged("replan_command", _replan, params={"command_id": command_id})


@app.get("/api/plans")
def api_plans(command_id: str = Query(None), status: str = Query(None), limit: int = 300):
    statuses = [s for s in (status or "").split(",") if s] or None
    return ok(pms_repo.list_plans, command_id=command_id, statuses=statuses, limit=limit)


# ================================================================ ③ 持仓与账本
@app.get("/api/positions")
def api_positions():
    return ok(lambda: {"ok": True, **portfolio.positions_view(),
                       "stock_params": command_service.effective_stock_params()})


@app.get("/api/positions/{ts_code}/lots")
def api_lots(ts_code: str, status: str = Query("OPEN")):
    return ok(pms_repo.list_lots, ts_code, status=(status or None))


@app.get("/api/instructions")
def api_instructions(status: str = Query(None), limit: int = 200):
    statuses = [s for s in (status or "").split(",") if s] or None
    return ok(pms_repo.list_instructions, statuses=statuses, limit=limit)


@app.get("/api/ledger")
def api_ledger(ts_code: str = Query(None), limit: int = 100):
    return ok(pms_repo.list_ledger, ts_code=ts_code, limit=limit)


@app.get("/api/report")
def api_report(ymd: int = Query(None)):
    return ok(lambda: (pms_repo.get_report(ymd) if ymd else pms_repo.latest_report())
              or {"ymd": None, "report": {}})


# ================================================================ ④ 提议确认
@app.get("/api/proposals")
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100):
    statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
    return ok(pms_repo.list_proposals, statuses=statuses, limit=limit)


@app.post("/api/proposals/{proposal_id}/decide")
def api_decide(proposal_id: str, payload: dict = Body(default={})):
    """采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。"""
    decision = str(payload.get("decision") or "").upper()
    if decision not in ("ACCEPTED", "DECLINED"):
        return {"ok": False, "error": "decision 必须是 ACCEPTED 或 DECLINED"}

    def _decide():
        p = pms_repo.get_proposal(proposal_id)
        if not p:
            return {"ok": False, "error": "提议不存在"}
        if p["status"] != "WAIT_USER":
            return {"ok": False, "error": f"提议已处于 {p['status']}"}
        if not pms_repo.decide_proposal(proposal_id, decision):
            return {"ok": False, "error": "提议状态已变更, 请刷新"}
        hn = p.get("hard_numbers") or {}
        pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
                               verdict="PASS" if decision == "ACCEPTED" else "REJECT",
                               price_at=float(hn.get("price") or 0), hard_numbers=hn,
                               ref_id=proposal_id, reason=payload.get("reason") or "页面人工裁决")
        instruction_id = None
        if decision == "ACCEPTED":
            # 策略(confirm 档)提议: 两腿同 action、side 无法由 action 反推, 交策略层按腿谱发指令
            if str((hn or {}).get("kind")) == "strategy":
                from app.services import strategy_runner
                rr = strategy_runner.emit_from_spec(hn)
                if not rr.get("ok"):
                    return {"ok": False, "error": rr.get("error") or "策略提议发指令失败"}
                return {"ok": True, "decision": decision,
                        "instruction_id": rr.get("instruction_id"), "strategy": True}
            instruction_id = cs.make_instruction_id(td.ymd(), p["ts_code"], p["action"], 1)
            side = "sell" if p["action"] in ("TRIM", "EXIT") else "buy"
            _win = param_store.get_int("PMS_EXEC_WINDOW_TDAYS", 3)
            pms_repo.insert_instruction(
                instruction_id=instruction_id, origin_type="proposal", origin_id=proposal_id,
                ts_code=p["ts_code"], action=p["action"], side=side, qty=int(p["qty"] or 0),
                limit_price=hn.get("price"),
                window_tdays=_win,
                status="PROPOSED",
                progress={"from_proposal": proposal_id, "is_command": False, "children": [],
                          "deadline": str(td.window_deadline(None, _win))})
            # 与自主执行同一口径: 采纳即算「做过一次」, 计数器要跟着走
            # (否则页面采纳的那条动作绕开了 §6 的一次性约束)
            from app.services import proposal_service
            g = proposal_service.bump_once_guards(p["ts_code"], p["action"], hn) or {}
            if not g.get("ok"):
                # 指令已落表, 不回滚; 但要让页面看见"这条一次性纪律本轮没锁上"
                return {"ok": True, "decision": decision, "instruction_id": instruction_id,
                        "warning": f"一次性守卫计数器未写入 ({g.get('error')}) —— "
                                   f"{p['ts_code']} 的 {p['action']}「只做一次」本轮失效"}
        return {"ok": True, "decision": decision, "instruction_id": instruction_id}
    return ok_logged("decide_proposal", _decide,
                     params={"proposal_id": proposal_id, "decision": decision},
                     by=payload.get("by") or "user")


@app.post("/api/proposals")
def api_create_proposal(payload: dict = Body(...)):
    """人工补录一条待确认提议 (影子运行期造数与联调用)。"""
    def _create():
        pid = payload.get("proposal_id") or f"PRP_{td.ymd()}_{int(datetime.now().timestamp())}"
        ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
        pms_repo.insert_proposal(
            proposal_id=pid, ts_code=cs.normalize_code(payload.get("ts_code") or ""),
            action=payload.get("action") or "ADD", qty=int(payload.get("qty") or 0),
            hard_numbers=payload.get("hard_numbers") or {},
            expire_at=datetime.now() + timedelta(hours=ttl))
        return {"ok": True, "proposal_id": pid}
    return ok(_create)


# ================================================================ 运维操作 (与调度器同一实现)
@app.post("/api/ops/replay")
def api_replay(limit: int = Query(500)):
    return ok(ledger_service.replay_fills, limit=limit)


@app.get("/api/ops/rebuild-preflight")
def api_rebuild_preflight():
    """账本重建的只读预检 (README 待办 #4)。不写任何东西, 随时可点。

    成本价能不能用是这一步的成败所在 —— 详见 app/core/rebuild_check.py 的模块说明。
    """
    def _pf():
        from app.services import ledger_service
        return ledger_service.rebuild_preflight()
    return ok(_pf)


@app.get("/api/ops/rebuild-accept")
def api_rebuild_accept():
    """重建之后的只读判收: 安全垫分布 / 批次账 / 行业集中度。"""
    def _ac():
        from app.services import ledger_service
        return ledger_service.rebuild_accept()
    return ok(_ac)


@app.post("/api/ops/reconcile")
def api_reconcile(apply_fix: bool = Query(True), force: bool = Query(False)):
    """force=true 绕过「差异面过大不自动改账」的限制, 仅在人工确认下游读数无误后使用。"""
    return ok(ledger_service.reconcile, apply_fix=apply_fix, force=force)


@app.post("/api/ops/premarket")
def api_premarket():
    return ok(ledger_service.premarket)


@app.post("/api/ops/daily-settle")
def api_daily_settle():
    return ok(ledger_service.daily_settle)


@app.post("/api/ops/report")
def api_build_report():
    return ok(ledger_service.build_daily_report)


@app.post("/api/ops/plan-pending")
def api_plan_pending():
    return ok(command_service.plan_pending)


@app.post("/api/ops/materialize")
def api_materialize():
    """方案 → 指令 (先记账后动作)。"""
    from app.services import executor
    return ok(executor.materialize_plans)


@app.post("/api/ops/exec-tick")
def api_exec_tick(dry_run: bool = Query(False)):
    """择时出手一跳。dry_run=true 只试算不下发, 用来在盘中先看「现在会怎么动」。"""
    from app.services import executor
    return ok(executor.run_tick, dry_run=dry_run)


@app.post("/api/ops/sweep-windows")
def api_sweep_windows():
    from app.services import executor
    return ok(executor.sweep_windows)


@app.post("/api/instructions/{instruction_id}/cancel")
def api_cancel_instruction(instruction_id: str, payload: dict = Body(default={})):
    from app.services import executor
    return ok_logged("cancel_instruction", executor.cancel_instruction, instruction_id,
                     payload.get("reason") or "页面人工撤销",
                     params={"instruction_id": instruction_id})


@app.post("/api/ops/scan-proposals")
def api_scan_proposals(dry_run: bool = Query(False)):
    """自主提议扫描一轮 (动作引擎 → 规则闸 → 研判闸 → 按档位分流)。"""
    from app.services import proposal_service
    return ok(proposal_service.scan_and_route, dry_run=dry_run)


@app.post("/api/ops/digest-signals")
def api_digest_signals(dry_run: bool = Query(False)):
    """消化一批决策系统盘中信号。dry_run=true 只解析判定, 不落表也不 ACK。"""
    from app.services import signal_service
    return ok(signal_service.consume, dry_run=dry_run)


@app.get("/api/signal-status")
def api_signal_status():
    from app.services import signal_service
    return ok(signal_service.status)


@app.get("/api/dispatch-mode")
def api_dispatch_mode():
    from app.services import dispatcher, judge
    return ok(lambda: {"ok": True, **dispatcher.describe(), "judge": judge.status()})


@app.get("/api/ws-channel")
def api_ws_channel(limit: int = Query(50)):
    """ws 通道运维视图: 连接状态 / seq 水位 / 出口队列 / 最近上行消息。

    这四样凑一起才看得出通道到底"通没通" —— 心跳说明进程在、conn_state 说明连接在、
    水位说明消息没断层、队列深度说明指令有没有卡住。少看一样都可能误判。
    """
    from app.repo import qmt_repo
    from app.services import dispatcher

    def _view():
        out = {"ok": True, "mode": dispatcher.mode(),
               "channel": dispatcher.channel_status()}
        try:
            out["orders"] = qmt_repo.list_orders(limit=limit)
            out["inbox"] = qmt_repo.inbox_list(limit=limit)
        except Exception as e:
            out["error"] = f"{type(e).__name__}: {e}"
        return out
    return ok(_view)


@app.post("/api/ws-channel/clear-resync")
def api_clear_resync():
    """人工确认全量对账已做完后清 resync 标记 (协议 §6.2)。

    这个标记只能人来清 —— 它的含义是「对端补发不全, 中间那段消息我们永远拿不到了」,
    自动清等于假装没发生过。
    """
    from app.repo import qmt_repo

    def _clear():
        st = qmt_repo.get_state()
        qmt_repo.set_conn(st.get("conn_state") or "OFFLINE", resync=False, beat=False)
        return {"ok": True, "message": "已清除 resync 标记 —— 请确认全量对账确实已完成"}
    return ok(_clear)


# ================================================================ 上游选股计划
@app.get("/api/open-scan")
def api_open_scan():
    """候选池处置快照 (只读): 每只候选今天为什么下单 / 没下单。

    复用每分钟那条自主扫描的 scan_open 口径, 但止步于规则闸之前、不送研判、不落任何表 ——
    补上「名额/资金/上限/不足一手/在途/已持有」这些算完即弃、评审账本里查不到的原因。
    「已下单/已生成提议」由前端拿它已在手的 instructions/proposals 按股票对齐, 这里不重复查。
    """
    from app.services import proposal_service
    return ok(proposal_service.disposition_snapshot)


@app.get("/api/upstream-signals")
def api_upstream_signals():
    """上游信号只读快照 (三系统盘中信号: 决策系统买卖 / 择时层 BUY / 双向告警 / 资金异动 / 实况分)。
    只展示、不进任何下单或决策逻辑; 内部一律 XREVRANGE/ZREVRANGE, 不建消费组、不 ACK、不写。"""
    from app.services import upstream_signals
    return ok(upstream_signals.snapshot)


# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md)
@app.get("/api/macro/status")
def api_macro_status():
    """宏观择时面板快照: 各信号当前值/区域/近 20 日序列/当日建议/最近命令/冷却 + 闸状态。"""
    from app.services import macro_service
    return ok(macro_service.status)


@app.post("/api/ops/macro-scan")
def api_macro_scan(dry_run: bool = Query(False)):
    """手动宏观扫描 (= 09:35 调度位)。dry_run=true 只算不落表不下达, 供判收与盘中复核。"""
    from app.services import macro_service
    return ok_logged("macro_scan", macro_service.scan, dry_run=dry_run,
                     params={"dry_run": dry_run})


@app.post("/api/macro/adopt")
def api_macro_adopt(payload: dict = Body(default={})):
    """采纳当日宏观建议 → 以用户名义下命令 (幂等, 建议隔日作废)。"""
    from app.services import macro_service
    key = (payload or {}).get("signal_key") or "hedge_fx"
    return ok_logged("macro_adopt", macro_service.adopt, key, params={"signal_key": key})


@app.get("/api/upstream/plan")
def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
                      bucket: str = Query("main")):
    """上游 /plan 预览。绝不抛错 —— 接口不通时 status.ok=false + hint, 页面照常渲染。

    candidates_raw 是**不扣持仓/黑名单**的原始筛选结果 (纯看参数选出来什么),
    真正下命令时的候选池还会再剔除已持有、黑名单与取不到价的票。
    """
    def _plan():
        from app.services import plan_feed
        st = plan_feed.status()
        out = {"ok": True, "status": st, "rows": [], "candidates_raw": None}
        if not st.get("ok"):
            return out
        try:
            plan = plan_feed.get_plan(date=(date or None))
            rows = plan.get("observe" if bucket == "observe" else "main") or []
            out["rows"] = rows[:max(1, int(limit or 30))]
            out["candidates_raw"] = plan_feed.candidates()
        except Exception as e:
            out["status"] = {**st, "ok": False, "hint": f"{type(e).__name__}: {e}"}
        return out
    return ok(_plan)


@app.post("/api/ops/plan-refresh")
def api_plan_refresh(date: str = Query(None)):
    """强刷上游计划, 并把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)。"""
    def _refresh():
        from app.services import plan_feed
        plan_feed.invalidate()
        plan = plan_feed.get_plan(force=True, date=(date or None))
        return {"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"),
                "returned": plan["returned"], "theme_sync": plan.get("theme_sync"),
                "snapshot": plan.get("snapshot"), "industry": industry.status()}
    return ok(_refresh)


@app.get("/api/upstream/plan-changes")
def api_plan_changes(limit: int = Query(50)):
    """榜单变化 (PMS 自算, 上游 `changes` 字段恒为 null —— UPSTREAM_PLAN_API.md §9)。

    绝不抛错: 变化提示是锦上添花, 它坏了不该让「上游计划」抽屉打不开。
    `log.revised` 里某个 plan_date 出现多版, 就是 §7.3 那个"同一 date 会变"。

    子状态**嵌在 changes 里**而不是顶层 —— 顶层 `ok` 一为假, 页面会挂全局红条。
    「快照关着」「还没有快照」都不是接口故障, 不该长成那副样子 (同 status 的处理)。
    """
    def _changes():
        from app.services import plan_feed
        return {"ok": True, "changes": plan_feed.changes(),
                "log": plan_feed.snapshot_log(limit=max(1, int(limit or 50)))}
    return ok(_changes)


@app.get("/api/ops/downstream-schema")
def api_downstream_schema():
    """导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""
    def _schema():
        out = {}
        for t in ("trading_position", "trading_order", "trading_buy_plan",
                  "gp_stock_category"):
            try:
                out[t] = downstream_repo.describe(t)
            except Exception as e:
                out[t] = {"error": f"{type(e).__name__}: {e}"}
        try:
            out["_position_probe"] = downstream_repo.fetch_positions()["columns"]
        except Exception as e:
            out["_position_probe"] = {"error": str(e)}
        try:
            out["_category_probe"] = downstream_repo.category_columns(force=True)
        except Exception as e:
            out["_category_probe"] = {"error": str(e)}
        return out
    return ok(_schema)


# ================================================================ 行业映射
@app.get("/api/industry")
def api_industry(limit: int = 2000):
    return ok(lambda: {"ok": True, "status": industry.status(),
                       "rows": pms_repo.list_industry(limit=limit)})


@app.post("/api/industry/import")
def api_industry_import(payload: dict = Body(...)):
    """导入行业映射。支持 {rows:[{ts_code,industry}]} 或 {text:"600000.SH,银行\\n..."}。"""
    def _imp():
        rows = payload.get("rows")
        if not rows and payload.get("text"):
            rows = []
            for line in str(payload["text"]).splitlines():
                parts = [x.strip() for x in line.replace("\t", ",").split(",") if x.strip()]
                if len(parts) >= 2:
                    rows.append({"ts_code": cs.normalize_code(parts[0]), "industry": parts[1]})
        rows = [r for r in (rows or []) if r.get("ts_code") and r.get("industry")]
        n = pms_repo.upsert_industry(rows)
        industry.invalidate()
        return {"ok": True, "imported": len(rows), "affected": n, "status": industry.status()}
    return ok(_imp)



# ================================================================ 个股交易方案 (策略) + 操作日志
@app.get("/api/strategies")
def api_strategies(status: str = Query(None)):
    statuses = [s for s in (status or "").split(",") if s] or None
    from app.services import strategy_service

    def _load():
        rows = pms_repo.list_strategies(statuses=statuses, limit=300)
        try:
            bp = strategy_service.buypause_map()
        except Exception:
            bp = {}
        for r in rows:
            r["buy_paused"] = bp.get(r.get("ts_code")) or None
        return {"ok": True, "strategies": rows,
                "enabled": param_store.get_bool("PMS_STRATEGY_ENABLED", False)}
    return ok(_load)


@app.post("/api/strategies/validate")
def api_strategy_validate(payload: dict = Body(...)):
    """挂载前约束校验 (不写库): 返回 {ok, reasons}。违反仓位/存量/上限就给中文原因, 页面红字提示、不落库。"""
    from app.services import strategy_service
    return ok(lambda: strategy_service.validate(payload))


@app.post("/api/strategies")
def api_strategy_attach(payload: dict = Body(...)):
    """挂载一条策略 (先校验再落库)。校验不过返回 {ok:false, errors}, 并落一条 BLOCKED 操作日志。"""
    from app.services import strategy_service
    return ok_logged("attach_strategy:" + str(payload.get("type")),
                     strategy_service.attach, payload,
                     ts_code=payload.get("ts_code"), params=payload,
                     by=payload.get("by") or "user")


@app.post("/api/strategies/{strategy_id}/status")
def api_strategy_status(strategy_id: str, payload: dict = Body(...)):
    """暂停(PAUSED)/恢复(ACTIVE)/撤下(CANCELLED): payload {status}。"""
    from app.services import strategy_service
    return ok_logged("set_strategy_status:" + str(payload.get("status")),
                     strategy_service.set_status, strategy_id,
                     str(payload.get("status") or "").upper(),
                     params={"strategy_id": strategy_id, "status": payload.get("status")},
                     by=payload.get("by") or "user")


@app.get("/api/op-log")
def api_op_log(limit: int = Query(200)):
    """交易员操作日志 (每个页面写操作一行, 含 OK/BLOCKED 与原因)。"""
    return ok(lambda: {"ok": True, "rows": pms_repo.list_op_log(limit=limit)})


@app.post("/api/strategies/{strategy_id}/resume-buy")
def api_strategy_resume_buy(strategy_id: str, payload: dict = Body(default={})):
    """恢复该策略的买入 (决策系统风控预警触发的暂停由你手动解除; 只恢复买入, 不影响卖出/平回)。"""
    from app.services import strategy_service
    return ok_logged("resume_strategy_buy", strategy_service.resume_buy, strategy_id,
                     params={"strategy_id": strategy_id}, by=payload.get("by") or "user")
