# -*- coding: utf-8 -*-
"""
组合快照 (账本 + 行情 + 行业 → 方案生成器/规则闸/页面的统一输入)
================================================================
一处组装, 三处复用: 命令方案生成、组合约束校验、管理页面「持仓与账本」。

安全垫连续为负天数 (清弱票判定所需) 的存放:
  DDL 未设该列, 故以单行运行参数 PMS_CUSHION_NEG_STREAK (JSON 映射) 承载,
  由日终结算 (ledger_service.daily_settle) 维护 —— 不改表结构, 数据可查可重算。
"""
from __future__ import annotations

import json
import logging

from app.core import cushion as cu
from app.core import ws_codec as wsc
from app.repo import pms_repo, qmt_repo
from app.services import industry, market, param_store

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

NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK"
CASH_WS, CASH_EST = "ws", "estimate"


def cash_view() -> dict:
    """真实可用资金 (ws 资金快照 §5.7 kind=funds)。取不到则回落估算并**标明是估的**。

    为什么必须有这一层
    ------------------
    原来全系统只有一个 `cash_est = PMS_TOTAL_SCALE − 持仓市值` —— 那是**从参数算出来的虚数**,
    与账户里真有多少钱毫无关系。`PMS_TOTAL_SCALE` 是用户命令参数 (「我打算投多少」),
    `available_cash` 是账户事实 (「现在有多少钱」), 两者是不同的约束, 不该混:
      * scale 管**仓位纪律** —— 单股不超几成、总仓不超几成、最多几只
      * 可用资金管**买不买得起** —— 这一单的钱够不够
    07-30 实测: scale=200 万, 而模拟账户 total_asset 只有 98.1 万、available_cash 97.1 万。
    少了第二个约束, 规划器会按 200 万排出账户根本执行不了的方案, 规则闸一路放行, 直到 QMT
    回 `INSUFFICIENT_CASH` 才被拒 —— 而 PMS 收到 reject 不自动重发, 择时下一跳又算又发又拒,
    页面看着一切正常, 实际一单也下不去。

    **`sell_return_today` 必须加进来**: 当日卖出回笼资金 T+0 可用 (协议 §5.7, A3 点名要的
    字段)。不加会把「刚卖掉一只、拿回笼的钱买另一只」这条最常见的换仓路径判成资金不足。
    """
    max_age = param_store.get_int("PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC", 900)
    out = {"source": CASH_EST, "cash_avail": None, "total_asset": None,
           "age_sec": None, "why": ""}
    try:
        snap = qmt_repo.latest_snapshot("funds")
    except Exception as e:
        out["why"] = f"读 ws 资金快照失败: {type(e).__name__}: {e}"
        return out
    if not snap:
        out["why"] = "ws 从未回过 funds 快照 (对端未实现 query_funds, 或 pms-ws 没在跑)"
        return out
    age = snap.get("age_sec")
    if age is not None and age > max_age:
        out.update({"why": f"ws 资金快照已过期 ({age:.0f}s > {max_age}s)", "age_sec": age})
        return out
    f = wsc.parse_funds_snapshot(snap["payload"])
    avail = f.get("available_cash")
    if avail is None:
        out["why"] = "ws 资金快照里认不出可用资金字段 (补进 ws_codec.parse_funds_snapshot)"
        return out
    out.update({"source": CASH_WS, "age_sec": age, "total_asset": f.get("total_asset"),
                "cash_avail": round(float(avail) + float(f.get("sell_return_today") or 0), 2)})
    return out


def neg_streak_map() -> dict:
    try:
        raw = pms_repo.get_param(NEG_STREAK_KEY)
        return json.loads(raw) if raw else {}
    except Exception:
        return {}


def save_neg_streak(m: dict) -> dict:
    """写「安全垫连续为负天数」。**必须返回成败, 调用方必须接。**

    这个计数是 `plan_reduce_exposure` 一档「清弱票」的唯一判据 (连负 N 日 → 优先清)。
    原来这里把异常吞成 warning 就返回 None, 上层 `_settle_cushion` 照样报
    {"updated": n, ...}、`daily_settle` 照样 ok=True —— 计数永远停在旧值, 降仓命令的
    第一档静默失灵, 改成卖别的票, 而没有任何一处报错。(2026-07-31 静默失败专项)
    """
    try:
        pms_repo.set_param(NEG_STREAK_KEY, json.dumps(m, ensure_ascii=False), "system")
        return {"ok": True}
    except Exception as e:
        logger.error("安全垫连负天数写入失败: %s —— 「清弱票」判据将停在旧值", e)
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}


def positions_view(*, with_price: bool = True) -> dict:
    """账本持仓 + 现价/安全垫/行业/占比。取不到现价的票用摊薄成本兜底并列入 price_missing。"""
    p = param_store.sizing_params()
    rows = pms_repo.list_positions()
    codes = [r["ts_code"] for r in rows]
    prices = market.get_prices(codes) if (with_price and codes) else {}
    sectors = industry.get_many(codes) if codes else {}
    streak = neg_streak_map()
    scale = float(p["scale"] or 0)

    out, missing = [], []
    for r in rows:
        code = r["ts_code"]
        avg_cost = float(r.get("avg_cost") or 0)
        px = prices.get(code)
        # 取不到现价时拿摊薄成本顶上, 是为了市值/占比还能算 (否则整张页面塌掉)。
        # **但安全垫绝不能跟着算出来**: px := avg_cost 会让 cushion 恰好等于 0.0, 而 0.0
        # 与"真的不赚不亏"完全同形。后果不是显示难看, 是**凭空产生卖单** —— 一只安全垫
        # 峰值 8% 的停牌票会被判成"回吐至 0%(过半)"而触发 TRIM 保垫减仓, 而保垫减仓
        # **不设确认门槛、直接落指令**; 反过来浮亏 −50% 的票补仓评估档一次都不会触发。
        # 停牌票在行情库里永远没有当日分钟线, 所以这是常规路径不是罕见路径。
        # 价格可以估, 安全垫必须留 None ——「拿不到 ≠ 是 0」。
        price_ok = bool(px and px > 0)
        if not price_ok:
            px = avg_cost or 0.0
            if int(r.get("total_qty") or 0) > 0:
                missing.append(code)
        qty = int(r.get("total_qty") or 0)
        mv = qty * px
        cp = (px / avg_cost - 1.0) if (price_ok and avg_cost > 0) else None
        out.append({
            "ts_code": code, "status": r.get("status"), "frozen_reason": r.get("frozen_reason"),
            # price_ok=False → 这一行的 price 是拿摊薄成本顶的, 不是行情。动作引擎据此整只
            # 跳过并留痕 (见 action_engine.scan) —— 不是靠 cushion_pct 恰好为 None 兜住。
            "price": round(px, 3), "price_ok": price_ok,
            "total_qty": qty, "avail_qty": int(r.get("avail_qty") or 0),
            "base_qty": int(r.get("base_qty") or 0), "fill_qty": int(r.get("fill_qty") or 0),
            "add_qty": int(r.get("add_qty") or 0), "dca_qty": int(r.get("dca_qty") or 0),
            "t0_qty": int(r.get("t0_qty") or 0), "avg_cost": round(avg_cost, 3) or None,
            "market_value": round(mv, 2),
            "cushion_pct": round(cp, 4) if cp is not None else None,
            "cushion_state": cu.cushion_state(cp, p["cushion_solid"]),
            "cushion_peak": float(r.get("cushion_peak") or 0),
            "neg_cushion_days": int(streak.get(code, 0)),
            "pct_of_scale": round(mv / scale, 4) if scale > 0 else None,
            "target_pct": float(r.get("target_pct") or 0) or None,
            "stop_ref": float(r.get("stop_ref") or 0) or None,
            "support_ref": float(r.get("support_ref") or 0) or None,
            "pressure_ref": float(r.get("pressure_ref") or 0) or None,
            "ref_source": r.get("ref_source"),
            "t0_enabled": int(r.get("t0_enabled") or 0), "t0_ratio": r.get("t0_ratio"),
            "realized_t_profit": float(r.get("realized_t_profit") or 0),
            "sector": sectors.get(code),
        })

    held = [x for x in out if x["total_qty"] > 0]
    port_mv = sum(x["market_value"] for x in held)
    sector_names, sector_mv = {}, {}
    for x in held:
        s = x.get("sector")
        if not s:
            continue
        sector_names[s] = sector_names.get(s, 0) + 1
        sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"]
    cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held)
    cash = cash_view()
    totals = {
        "scale": scale, "portfolio_mv": round(port_mv, 2),
        "portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None,
        # cash_est 是 scale−市值 的**估算**, 与账户里有多少钱无关;
        # cash_avail 才是账户事实 (ws 资金快照)。两个都留着并标明来源, 免得后来者
        # 又把估算值当真钱用 —— 见 cash_view 的说明。
        "names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None,
        "cash_avail": cash["cash_avail"], "cash_source": cash["source"],
        "cash_age_sec": cash["age_sec"], "cash_why": cash["why"],
        "total_asset": cash["total_asset"],
        "float_pnl": round(port_mv - cost_sum, 2) if cost_sum else 0.0,
        "float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None,
        "sector_names": sector_names, "sector_mv": sector_mv,
        "solid_names": len([x for x in held if x["cushion_state"] == "SOLID"]),
        "neg_names": len([x for x in held if (x["cushion_pct"] or 0) < 0]),
    }
    return {"positions": out, "held": held, "totals": totals, "params": p,
            "price_missing": missing, "sector_ready": industry.ready()}


def caps_ctx(view: dict, *, ts_code=None, is_new_name=False, sector=None) -> dict:
    """组装 check_all_caps / planner 所需的组合上下文 (加仓前快照)。"""
    p, t = view["params"], view["totals"]
    stock_mv = 0.0
    if ts_code:
        for x in view["positions"]:
            if x["ts_code"] == ts_code:
                stock_mv = x["market_value"]
                sector = sector or x.get("sector")
                is_new_name = x["total_qty"] <= 0
                break
        else:
            is_new_name = True
    ready = view.get("sector_ready", False)
    return {
        "scale": p["scale"], "portfolio_cap": p["portfolio_cap"], "stock_cap": p["stock_cap"],
        "max_names": p["max_names"], "portfolio_mv": t["portfolio_mv"],
        "names_count": t["names_count"], "stock_mv": stock_mv, "is_new_name": is_new_name,
        "sector": sector if ready else None,
        "sector_names": int(t["sector_names"].get(sector, 0)) if ready and sector else 0,
        "sector_mv": float(t["sector_mv"].get(sector, 0.0)) if ready and sector else 0.0,
        "sector_names_map": t["sector_names"] if ready else {},
        "sector_mv_map": t["sector_mv"] if ready else {},
        "sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"],
        "cash_reserve": p["cash_reserve"], "sector_source_ready": ready,
        # 真实可用资金随 caps 一起进规则闸 (executor 已经在传 caps, 不必再改它的取数)。
        # cash_source 一定要跟着传: 规则闸靠它区分「校验过了」和「拿不到数据只能降级」。
        "cash_avail": t.get("cash_avail"), "cash_source": t.get("cash_source", "estimate"),
    }


def overview() -> dict:
    """页面顶部仪表 (总览)。任何一环取不到都不该让页面开不了 —— 逐项 try。"""
    try:
        v = positions_view()
    except Exception as e:
        return {"ok": False, "error": f"{type(e).__name__}: {e}"}
    t, p = v["totals"], v["params"]
    brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
    return {
        "ok": True,
        "scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"],
        "portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"],
        "max_names": p["max_names"], "cash_est": t["cash_est"],
        # 真钱与来源一起给页面 —— 只显示 cash_est 的话, 那个数在 scale 与账户不一致时
        # 会让人以为钱多得是 (07-30: cash_est 199 万 vs 真实可用 97 万)
        "cash_avail": t["cash_avail"], "cash_source": t["cash_source"],
        "total_asset": t["total_asset"], "cash_why": t["cash_why"],
        "float_pnl": t["float_pnl"],
        "float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"],
        "neg_names": t["neg_names"], "autonomy": p["autonomy"],
        "buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"],
        "brake_until": brake_until, "sector_ready": v["sector_ready"],
        "price_missing": v["price_missing"],
        "cap_room": round(p["portfolio_cap"] * t["scale"] - t["portfolio_mv"], 2)
        if t["scale"] else None,
    }
