"""美团 CPS API Playground —— 本地调参/试 API 的可视化工具。

浏览器填参数 → 本地后端用 meituan.py 的同款签名打美团 → 返回结果,
页面渲染「卡片列表 + 可折叠 JSON 树」。AppSecret 只留在后端,浏览器不接触。

跑法:
    /Users/pure/miniconda3/envs/price/bin/python tools/meituan_playground.py
然后开 http://127.0.0.1:8799

只读工具(只调 query 类接口),复用本仓库 .env 里的 MT_CPS 凭证。
"""
from __future__ import annotations

import asyncio
import json
import os
import sys
import time

# 让 `from app...` 可导入 + pydantic-settings 从仓库根读 .env
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
os.chdir(ROOT)
sys.path.insert(0, ROOT)

import httpx  # noqa: E402
from fastapi import FastAPI, Request  # noqa: E402
from fastapi.responses import HTMLResponse, JSONResponse  # noqa: E402

from app.core.config import settings  # noqa: E402
from app.integrations.meituan import _content_md5, _sign  # noqa: E402

app = FastAPI(title="Meituan CPS Playground")

DEFAULT_PATH = "/cps_open/common/api/v1/query_coupon"


def call_raw(path: str, body_obj: dict) -> dict:
    """用 meituan.py 同款 S-Ca 签名打美团,原样返回(不管 code 是否为 0,方便看错误体)。"""
    body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
    md5 = _content_md5(body)
    ts = str(int(time.time() * 1000))
    signed = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
    sig = _sign(settings.MT_CPS_APP_SECRET, "POST", md5, path, signed)
    headers = {
        "Content-Type": "application/json",
        "Content-MD5": md5,
        "S-Ca-App": settings.MT_CPS_APP_KEY,
        "S-Ca-Timestamp": ts,
        "S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
        "S-Ca-Signature": sig,
    }
    url = f"{settings.MT_CPS_HOST}{path}"
    t0 = time.time()
    # trust_env=False: 美团是国内域名,强制直连绕开本机代理(代理会掐断 TLS 握手,报 SSL EOF)
    resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC, trust_env=False)
    ms = int((time.time() - t0) * 1000)
    try:
        j = resp.json()
    except Exception:
        j = {"_raw_text": resp.text[:2000]}
    return {"http_status": resp.status_code, "elapsed_ms": ms, "json": j}


def _is_rate_limited(j: dict) -> bool:
    code = str(j.get("code"))
    msg = str(j.get("message") or j.get("msg") or "")
    return code == "402" or "频繁" in msg


@app.post("/api/query")
async def api_query(req: Request):
    try:
        payload = await req.json()
    except Exception as e:
        return JSONResponse({"ok": False, "error": f"请求体不是合法 JSON: {e}"})
    path = (payload.get("path") or DEFAULT_PATH).strip()
    body = payload.get("body")
    if not isinstance(body, dict):
        return JSONResponse({"ok": False, "error": "body 必须是 JSON 对象"})
    if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
        return JSONResponse({"ok": False, "error": "MT_CPS_APP_KEY / SECRET 未配置(.env)"})

    # 美团这接口很容易 402「调用频繁」,交互式工具自动退避重试 3 次,UX 顺一点
    last = None
    for a in range(3):
        try:
            out = call_raw(path, body)
        except Exception as e:
            return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"})
        if _is_rate_limited(out.get("json") or {}) and a < 2:
            last = out
            await asyncio.sleep(1.5 * (a + 1))
            continue
        return JSONResponse({"ok": True, **out, "retries": a})
    return JSONResponse({"ok": True, **(last or {}), "retries": 2})


@app.get("/", response_class=HTMLResponse)
def index():
    return HTML


HTML = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>美团 CPS Playground</title>
<style>
  :root{ --bd:#e3e6ea; --mut:#6b7280; --bg:#f6f7f9; --pri:#ffb000; --pri2:#ff8a00; }
  *{box-sizing:border-box}
  body{margin:0;font:13px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;color:#1f2328;background:var(--bg)}
  header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#fff;border-bottom:1px solid var(--bd);position:sticky;top:0;z-index:5}
  header b{font-size:15px}
  header .path{flex:1;min-width:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:6px}
  #status{font-size:12px;color:var(--mut);white-space:nowrap}
  #status .ok{color:#0a7d28;font-weight:600}
  #status .err{color:#c0392b;font-weight:600}
  main{display:flex;gap:0;height:calc(100vh - 49px)}
  section{height:100%;overflow:auto;padding:12px}
  #params{width:380px;min-width:340px;border-right:1px solid var(--bd);background:#fff}
  #cards{flex:1;min-width:300px;border-right:1px solid var(--bd)}
  #jsonpane{flex:1;min-width:300px;background:#fff}
  h3{margin:2px 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
  #cards h3{display:flex;align-items:center;gap:8px}
  .presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
  .presets button{font-size:12px;padding:4px 8px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
  .presets button:hover{border-color:var(--pri2);color:var(--pri2)}
  .cgrp{font-size:12px;font-weight:700;color:#374151;margin:9px 0 4px;padding-left:2px;border-left:3px solid var(--pri);padding-left:7px}
  .chtag{display:inline-block;min-width:14px;text-align:center;font-size:10px;font-weight:800;padding:0 3px;border-radius:3px;margin-right:4px}
  .ch-r{background:#fff1f0;color:#cf1322}
  .ch-b{background:#eef3ff;color:#1d39c4}
  .ch-a{background:#fff7e6;color:#d46b08}
  .grid{display:grid;grid-template-columns:auto 1fr;gap:6px 8px;align-items:center;margin-bottom:10px}
  .grid label{color:var(--mut);font-size:12px;text-align:right}
  .grid input,.grid select{width:100%;padding:5px 7px;border:1px solid var(--bd);border-radius:6px;font-size:12px;font-family:inherit}
  textarea{width:100%;height:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:8px;border:1px solid var(--bd);border-radius:6px;resize:vertical;white-space:pre}
  .btns{display:flex;gap:8px;margin:8px 0}
  button.act{padding:6px 10px;border:1px solid var(--bd);background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
  button.send{flex:1;background:linear-gradient(180deg,var(--pri),var(--pri2));border:none;color:#3a2600;font-weight:700;padding:9px;border-radius:7px;cursor:pointer;font-size:13px}
  button.send:active{transform:translateY(1px)}
  .hint{font-size:11px;color:var(--mut);margin:6px 0}
  #pageBtn{margin-left:auto;font-size:12px;padding:4px 12px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
  #pageBtn:hover:not(:disabled){border-color:var(--pri2);color:var(--pri2)}
  #pageBtn:disabled{opacity:.4;cursor:not-allowed}
  /* cards */
  .cardgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
  .pagediv{grid-column:1/-1;text-align:center;color:#9aa0a6;font-size:12px;border-top:1px dashed var(--bd);padding:9px 0 3px;margin-top:6px}
  .card{border:1px solid var(--bd);border-radius:10px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
  .card .thumb{width:100%;height:118px;object-fit:cover;background:#f0f1f3}
  .card .body{padding:8px 9px;display:flex;flex-direction:column;gap:5px}
  .card .brand{display:flex;align-items:center;gap:5px;color:var(--mut);font-size:11px}
  .card .brand img{width:15px;height:15px;border-radius:3px;object-fit:cover}
  .card .nm{font-weight:600;font-size:13px;line-height:1.3;max-height:2.6em;overflow:hidden}
  .card .pr{display:flex;align-items:baseline;gap:6px}
  .card .pr .sell{color:#e8420f;font-weight:800;font-size:17px}
  .card .pr .sell:before{content:"¥";font-size:12px;font-weight:600}
  .card .pr .ori{color:#9aa0a6;text-decoration:line-through;font-size:11px}
  .badges{display:flex;flex-wrap:wrap;gap:4px}
  .b{font-size:10px;padding:1px 6px;border-radius:8px;background:#f1f3f5;color:#445}
  .b.sale{background:#fff2e8;color:#d4380d}
  .b.comm{background:#e6fffb;color:#08979c}
  .b.poi{background:#f0f5ff;color:#2f54eb}
  .b.rank{background:#fff7e6;color:#d46b08}
  .b.dist{background:#eaf7ee;color:#0a7d28;font-weight:600}
  .pvs{font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#888;display:flex;align-items:center;gap:5px;border-top:1px dashed var(--bd);padding-top:5px;margin-top:2px}
  .pvs code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .pvs button{font-size:10px;border:1px solid var(--bd);background:#fff;border-radius:4px;cursor:pointer;padding:1px 5px}
  .empty{color:var(--mut);padding:20px;text-align:center}
  /* json tree */
  .jtools{display:flex;gap:6px;margin-bottom:8px}
  .jtools button{font-size:11px;border:1px solid var(--bd);background:#fff;border-radius:5px;cursor:pointer;padding:3px 7px}
  #tree{font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.65}
  .jrow{padding-left:14px}
  .jhead{cursor:pointer;user-select:none}
  .jtog{display:inline-block;width:12px;color:#9aa0a6}
  .jkey{color:#8250df}
  .jsum{color:#9aa0a6}
  .jval.str{color:#0a7d28}
  .jval.num{color:#0550ae}
  .jval.bool,.jval.null{color:#cf222e}
  .jkids{border-left:1px dotted #e3e6ea;margin-left:5px}
</style></head>
<body>
<header>
  <b>🍔 美团 CPS Playground</b>
  <input id="path" class="path" value="/cps_open/common/api/v1/query_coupon">
  <span id="status">就绪</span>
</header>
<main>
  <section id="params">
    <h3>快捷模板</h3>
    <div class="hint" style="margin:0 0 4px">渠道:<span class="chtag ch-r">榜</span>榜单&nbsp;&nbsp;<span class="chtag ch-b">搜</span>搜索词&nbsp;&nbsp;<span class="chtag ch-a">供</span>供给</div>
    <div class="cgrp">到店 · 团购</div>
    <div class="presets" id="presets-dd"></div>
    <div class="cgrp">到家 · 外卖</div>
    <div class="presets" id="presets-dj"></div>
    <h3>测试坐标(点一下填经纬度)</h3>
    <div class="presets" id="coords"></div>
    <h3>参数(改完点「用表单生成 body」)</h3>
    <div class="grid">
      <label>召回方式</label>
      <select id="channel">
        <option value="topic">榜单 listTopiId</option>
        <option value="search">搜索 searchText</option>
        <option value="supply">多业务供给 multipleSupplyList</option>
        <option value="ids">按 ID productViewSignList</option>
      </select>
      <label>platform</label>
      <select id="platform"><option value="1">1 到家/外卖</option><option value="2" selected>2 到店</option></select>
      <label>bizLine</label>
      <input id="bizLine" value="1" placeholder="到店:1到餐2到综3酒店4门票;外卖填1">
      <label>listTopiId</label>
      <input id="listTopiId" value="5" placeholder="到店:2必推3热销5实时;到家:1精选2必推3热销">
      <label>searchText</label>
      <input id="searchText" placeholder="搜索关键词(选搜索时填)">
      <label>productViewSignList</label>
      <input id="ids" placeholder="逗号分隔的商品ID(按ID查时填)">
      <label>sortField</label>
      <select id="sortField">
        <option value="">(默认/不传)</option>
        <option value="1">1 售价</option><option value="2">2 销量</option>
        <option value="3">3 佣金</option><option value="6">6 离我最近</option>
      </select>
      <label>cityId</label>
      <input id="cityId" value="WKV2HMXUEK634WP64CUCUQGM64" placeholder="城市编码(默认北京)">
      <label>经度</label>
      <input id="lon" value="116.404" placeholder="十进制,自动×100万">
      <label>纬度</label>
      <input id="lat" value="39.928" placeholder="十进制,自动×100万">
      <label>pageSize</label>
      <input id="pageSize" value="20">
      <label>pageNo</label>
      <input id="pageNo" value="" placeholder="留空=默认1">
      <label>searchId</label>
      <input id="searchId" value="" placeholder="翻页用,一般不手填">
    </div>
    <div class="btns"><button class="act" id="genBtn">⟳ 用表单生成 body</button></div>
    <h3>请求 body(可直接手改,发送以这里为准)</h3>
    <textarea id="body"></textarea>
    <div class="btns"><button class="send" id="sendBtn">▶ 发送请求(第 1 页)</button></div>
    <div class="hint">经纬度填十进制(116.404)自动 ×100万;翻页按钮在中间卡片栏右上角(供给/搜索有下一页,榜单没有)。</div>
  </section>

  <section id="cards">
    <h3>卡片列表 <span id="cardCount" style="color:#9aa0a6"></span><button id="pageBtn" disabled>下一页 ›</button></h3>
    <div id="cardlist"><div class="empty">点「发送请求」后,这里渲染商品卡片</div></div>
  </section>

  <section id="jsonpane">
    <h3>原始 JSON(点三角折叠/展开)</h3>
    <div class="jtools">
      <button id="expandAll">全部展开</button>
      <button id="collapseAll">全部折叠</button>
    </div>
    <div id="tree"><div class="empty">原始响应在这里</div></div>
  </section>
</main>

<script>
const $ = id => document.getElementById(id);
const esc = s => String(s==null?"":s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]));

// 翻页状态
let lastJson = null;   // 最近一页响应
let pageNum = 0;       // 已展示页数
let totalShown = 0;    // 累计卡片数

// ---------- 快捷模板(按 到店/到家 分组 + 渠道标识) ----------
const LON = 116404000, LAT = 39928000, BJ = "WKV2HMXUEK634WP64CUCUQGM64";
const ZLON = 116316000, ZLAT = 39984000;   // 中关村
// ch: 榜=榜单(红) 搜=搜索词(蓝) 供=供给(琥珀)
const PRESETS = [
  // 到店 · 团购
  {g:"dd", ch:"榜", label:"实时热销",          body:{platform:2, bizLine:1, listTopiId:5, longitude:LON, latitude:LAT, pageSize:20}},
  {g:"dd", ch:"榜", label:"同城热销",          body:{platform:2, bizLine:1, listTopiId:3, longitude:LON, latitude:LAT, pageSize:20}},
  {g:"dd", ch:"供", label:"到餐·按销量(翻页)",   body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:2, pageSize:20}},
  {g:"dd", ch:"供", label:"到餐·按距离(中关村)",  body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
  // 到家 · 外卖
  {g:"dj", ch:"榜", label:"同城热销·按销量",     body:{platform:1, listTopiId:3, longitude:ZLON, latitude:ZLAT, pageSize:20}},
  {g:"dj", ch:"搜", label:"按距离(中关村)",      body:{searchText:"美食", sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
  {g:"dj", ch:"搜", label:"价格升序",            body:{searchText:"美食", sortField:2, longitude:ZLON, latitude:ZLAT, pageSize:20}},
  {g:"dj", ch:"搜", label:"综合(供给深)",        body:{searchText:"美食", sortField:1, longitude:ZLON, latitude:ZLAT, pageSize:20}},
];
const CH_CLS = {"榜":"ch-r", "搜":"ch-b", "供":"ch-a"};
PRESETS.forEach(p=>{
  const box = $(p.g==="dd" ? "presets-dd" : "presets-dj");
  const b = document.createElement("button");
  b.innerHTML = `<span class="chtag ${CH_CLS[p.ch]}">${p.ch}</span>${p.label}`;
  b.onclick = ()=>{ $("body").value = JSON.stringify(p.body, null, 2); };
  box.appendChild(b);
});

// ---------- 测试坐标 ----------
const COORDS = {
  "天安门":[116.397,39.909], "王府井":[116.418,39.914], "中关村":[116.316,39.984],
  "国贸":[116.461,39.909], "三里屯":[116.455,39.937], "望京":[116.470,39.997],
};
const cbox = $("coords");
Object.entries(COORDS).forEach(([nm,[lo,la]])=>{
  const b=document.createElement("button"); b.textContent=nm;
  b.onclick=()=>applyCoords(lo,la); cbox.appendChild(b);
});
function applyCoords(lo, la){
  $("lon").value = lo; $("lat").value = la;
  // 若当前 body 里已有经纬度,顺手原地替换,立即生效(供给查询用 cityId 无经纬度则不动)
  try{
    const o = JSON.parse($("body").value);
    if(o && typeof o==="object" && ("longitude" in o || "latitude" in o)){
      o.longitude = Math.round(lo*1e6); o.latitude = Math.round(la*1e6);
      $("body").value = JSON.stringify(o, null, 2);
    }
  }catch(e){}
}

// ---------- 表单 → body ----------
function num(id){ const v=$(id).value.trim(); return v===""? null : Number(v); }
function str(id){ const v=$(id).value.trim(); return v===""? null : v; }
function buildBody(){
  const ch = $("channel").value;
  const b = {};
  const ps = num("pageSize"); if(ps!=null) b.pageSize = ps;
  const pn = num("pageNo");   if(pn!=null) b.pageNo = pn;
  const lon = num("lon"), lat = num("lat");
  if(lon!=null) b.longitude = Math.round(lon*1e6);
  if(lat!=null) b.latitude  = Math.round(lat*1e6);
  const sf = str("sortField"); if(sf!=null) b.sortField = Number(sf);
  const city = str("cityId");  if(city!=null) b.cityId = city;
  const sid = str("searchId"); if(sid!=null) b.searchId = sid;
  const plat = Number($("platform").value);
  const bl = num("bizLine");

  if(ch==="supply"){
    b.multipleSupplyList = [{platform:plat, bizLineParamList:[{bizLine: bl!=null? bl : 1}]}];
    if(b.sortField==null) b.sortField = 2;   // 多供给查询 sortField 必填
  } else if(ch==="ids"){
    const ids = str("ids");
    b.productViewSignList = ids? ids.split(",").map(s=>s.trim()).filter(Boolean) : [];
  } else {
    b.platform = plat;
    if(bl!=null) b.bizLine = bl;
    if(ch==="topic"){ const t=num("listTopiId"); if(t!=null) b.listTopiId=t; }
    else if(ch==="search"){ const s=str("searchText"); if(s!=null) b.searchText=s; }
  }
  return b;
}
$("genBtn").onclick = ()=>{ $("body").value = JSON.stringify(buildBody(), null, 2); };

// ---------- 发送 / 翻页 ----------
function setStatus(t, cls){ const s=$("status"); s.innerHTML = cls? `<span class="${cls}">${t}</span>` : t; }

async function doQuery(body, append){
  setStatus("请求中…", "");
  $("pageBtn").disabled = true;
  let res;
  try{
    res = await fetch("/api/query", {method:"POST", headers:{"Content-Type":"application/json"},
      body: JSON.stringify({path: $("path").value.trim(), body})}).then(r=>r.json());
  }catch(e){ setStatus("本地请求失败: "+e.message, "err"); updatePageBtn(); return; }
  if(!res.ok){ setStatus("❌ "+res.error, "err"); renderTree({error:res.error}); if(!append) renderCards({}, false); updatePageBtn(); return; }
  const j = res.json || {};
  lastJson = j;
  const code = j.code, n = Array.isArray(j.data)? j.data.length : 0;
  const cls = code===0? "ok":"err";
  const rt = res.retries? ` · 退避${res.retries}次`:"";
  setStatus(`HTTP ${res.http_status} · code=${code} · 本页${n}条 · hasNext=${j.hasNext} · ${res.elapsed_ms}ms${rt}`, cls);
  renderCards(j, append);
  renderTree(j);
  updatePageBtn();
}

async function send(){
  let body;
  try{ body = JSON.parse($("body").value); }
  catch(e){ setStatus("body 不是合法 JSON: "+e.message, "err"); return; }
  pageNum = 1;
  await doQuery(body, false);
}
async function nextPage(){
  if(!lastJson) return;
  let body;
  try{ body = JSON.parse($("body").value); }
  catch(e){ setStatus("body 不是合法 JSON,无法翻页: "+e.message, "err"); return; }
  if(lastJson.searchId){ body.searchId = lastJson.searchId; }           // 供给/搜索:带令牌
  else if(lastJson.hasNext){ body.pageNo = (Number(body.pageNo)||1)+1; } // pageNo 翻页
  else { setStatus("没有下一页(searchId 为空且 hasNext=false)", "err"); return; }
  $("body").value = JSON.stringify(body, null, 2);  // 反映当前翻页状态
  pageNum += 1;
  await doQuery(body, true);
}
function updatePageBtn(){
  const b = $("pageBtn");
  const canNext = !!(lastJson && (lastJson.searchId || lastJson.hasNext===true));
  b.disabled = !canNext;
  b.textContent = canNext? "下一页 ›" : "没有下一页";
}
$("sendBtn").onclick = send;
$("pageBtn").onclick = nextPage;

// ---------- 卡片渲染 ----------
function clean(u){ return u? String(u).split("@")[0] : ""; }
function fmtDist(d){ if(d==null||d==="") return null; const v=Number(d); if(!isFinite(v)) return null; return v>=1000? (v/1000).toFixed(1)+"km" : Math.round(v)+"m"; }
function cardGrid(){
  let g = document.querySelector("#cardlist .cardgrid");
  if(!g){ $("cardlist").innerHTML=""; g=document.createElement("div"); g.className="cardgrid"; $("cardlist").appendChild(g); }
  return g;
}
function makeCardHtml(it){
  const cpd = it.couponPackDetail || {};
  const br  = it.brandInfo || {};
  const ci  = it.commissionInfo || {};
  const poi = it.availablePoiInfo || {};
  const dp  = it.deliverablePoiInfo || {};
  const lab = it.productLabel || {};
  const pp  = lab.pricePowerLabel || {};
  const pvs = cpd.productViewSign || cpd.skuViewId || "";
  const commPct = ci.commissionPercent!=null ? (Number(ci.commissionPercent)/100)+"%"
                : (ci.commission!=null? "¥"+ci.commission : null);
  const dist = fmtDist(dp.deliveryDistance);
  const badges = [];
  if(dist) badges.push(`<span class="b dist" title="${esc((dp.poiName||"")+" · "+(dp.deliveryDistance||"")+"米")}">📍 ${dist}</span>`);
  if(cpd.saleVolume) badges.push(`<span class="b sale">${esc(cpd.saleVolume)}</span>`);
  if(commPct) badges.push(`<span class="b comm">佣金 ${esc(commPct)}</span>`);
  if(poi.availablePoiNum) badges.push(`<span class="b poi">门店 ${esc(poi.availablePoiNum)}</span>`);
  if(dp.deliveryDistance) badges.push(`<span class="b">${esc(dp.deliveryDistance)}</span>`);
  if(pp.beatMTLabel) badges.push(`<span class="b rank">${esc(pp.beatMTLabel)}</span>`);
  else if(pp.historyPriceLabel) badges.push(`<span class="b rank">${esc(pp.historyPriceLabel)}</span>`);
  if(lab.productRankLabel) badges.push(`<span class="b rank">${esc(lab.productRankLabel)}</span>`);
  return `<div class="card">
    ${cpd.headUrl? `<img class="thumb" src="${esc(clean(cpd.headUrl))}" loading="lazy" onerror="this.style.visibility='hidden'">` : ``}
    <div class="body">
      <div class="brand">${br.brandLogoUrl? `<img src="${esc(clean(br.brandLogoUrl))}">`:``}${esc(br.brandName||dp.poiName||"—")}</div>
      <div class="nm">${esc(cpd.name||"(无名称)")}</div>
      <div class="pr"><span class="sell">${esc(cpd.sellPrice??"?")}</span>${cpd.originalPrice!=null?`<span class="ori">¥${esc(cpd.originalPrice)}</span>`:``}</div>
      <div class="badges">${badges.join("")}</div>
      ${pvs? `<div class="pvs"><code title="${esc(pvs)}">${esc(pvs)}</code><button onclick="navigator.clipboard.writeText('${esc(pvs)}')">复制ID</button></div>`:``}
    </div></div>`;
}
function renderCards(json, append){
  const data = json && Array.isArray(json.data)? json.data : [];
  if(!append){ $("cardlist").innerHTML=""; totalShown=0; }
  const g = cardGrid();
  if(!data.length){
    if(!append) $("cardlist").innerHTML = `<div class="empty">无 data 数组可渲染(看右侧 JSON)</div>`;
    else g.insertAdjacentHTML("beforeend", `<div class="pagediv">— 第 ${pageNum} 页 · 0 条 —</div>`);
  } else {
    const divider = append? `<div class="pagediv">— 第 ${pageNum} 页 · ${data.length} 条 —</div>` : "";
    g.insertAdjacentHTML("beforeend", divider + data.map(makeCardHtml).join(""));
    totalShown += data.length;
  }
  $("cardCount").textContent = totalShown? `(累计 ${totalShown} 条 / ${pageNum} 页)` : "";
}

// ---------- JSON 树(可折叠) ----------
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
function fmt(v){ return v===null? "null" : typeof v==="string"? `"${v}"` : String(v); }
function typeCls(v){ return v===null?"null":typeof v==="string"?"str":typeof v==="number"?"num":"bool"; }
function buildNode(key, val, depth){
  const wrap = el("div","jrow");
  const isObj = val && typeof val==="object";
  if(isObj){
    const entries = Array.isArray(val)? val.map((v,i)=>[i,v]) : Object.entries(val);
    const head = el("div","jhead");
    const open = depth < 2;
    const tog = el("span","jtog", entries.length? (open?"▼":"▶") : "·");
    const k = el("span","jkey", key!==null? key+": " : "");
    const sum = el("span","jsum", Array.isArray(val)? `[${entries.length}]` : `{${entries.length}}`);
    head.append(tog,k,sum);
    const kids = el("div","jkids");
    entries.forEach(([ck,cv])=> kids.append(buildNode(ck,cv,depth+1)));
    kids.style.display = open? "block":"none";
    head.onclick = ()=>{ const o=kids.style.display!=="none"; kids.style.display=o?"none":"block"; if(entries.length) tog.textContent=o?"▶":"▼"; };
    wrap.append(head,kids);
  } else {
    wrap.append(el("span","jkey", key+": "), el("span","jval "+typeCls(val), fmt(val)));
  }
  return wrap;
}
function renderTree(obj){ const t=$("tree"); t.innerHTML=""; t.append(buildNode(null, obj, 0)); }
$("expandAll").onclick = ()=>{ document.querySelectorAll("#tree .jkids").forEach(k=>k.style.display="block");
  document.querySelectorAll("#tree .jtog").forEach(x=>{ if(x.textContent==="▶")x.textContent="▼"; }); };
$("collapseAll").onclick = ()=>{ document.querySelectorAll("#tree .jrow .jkids").forEach((k,i)=>{ if(i>0) k.style.display="none"; });
  document.querySelectorAll("#tree .jtog").forEach((x,i)=>{ if(i>0 && x.textContent==="▼")x.textContent="▶"; }); };

// 初始填一个默认 body
$("body").value = JSON.stringify(PRESETS[0].body, null, 2);
</script>
</body></html>"""


if __name__ == "__main__":
    import uvicorn

    port = int(os.environ.get("MT_PLAYGROUND_PORT", "8799"))
    print(f"\n  美团 CPS Playground → http://127.0.0.1:{port}\n  (Ctrl-C 退出)\n")
    uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
