"""云超服 OPC 培训站 · FastAPI 子应用（已迁入 server-core 总后端）

由 server-core/dispatcher.py 统一对外（opc.pinesound.cn）：`/api/*` 路由到本子应用，
`/auth`、`/opc`、`/admin` 等由平台应用（app.main）服务。独立使用 data/opc.db 业务库。
"""
import asyncio
import json
import os
import time
from typing import Optional
from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from . import db, opc_engine, policy_engine, policy_data, survey_data
from .auth import hash_password, make_token, verify_token, send_sms_code, check_sms_code, PHONE_RE
from .opc_engine import SECTIONS, AXIS_NAMES, ADAPT_LABELS, PROFILES

from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(app):
    await asyncio.to_thread(db.init_db)
    yield


app = FastAPI(title="云超服 OPC 培训站后端", version="0.1", lifespan=lifespan)

ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))  # server-core 根

# ---- 上传目录 + 静态挂载（供活动封面图等） ----
UPLOAD_DIR = os.path.join(ROOT, "serverdata", "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")

# ---- 微信小程序配置（真实登录） ----
# 微信 AppID / AppSecret 由 server-core 环境变量或 server-core/.env 提供。
def _load_dotenv():
    """轻量 .env 加载（server-core/.env），不依赖 python-dotenv"""
    env_path = os.path.join(ROOT, ".env")
    if os.path.exists(env_path):
        with open(env_path, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                k, _, v = line.partition("=")
                os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))


_load_dotenv()
WX_APPID = os.environ.get("WX_APPID", "")
WX_SECRET = os.environ.get("WX_SECRET", "")
WX_HAS_CONFIG = bool(WX_APPID and WX_SECRET)

# 站点公开基础地址(用于把 /uploads 相对路径补全为完整 HTTPS 地址，供小程序/网页加载图片)
PUBLIC_BASE = os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/")


def abs_url(u):
    """把相对路径(如 /uploads/x.png)补全为绝对地址；已是完整 URL 则原样返回"""
    if not u:
        return u
    u = str(u).strip()
    if u.startswith("http://") or u.startswith("https://"):
        return u
    return PUBLIC_BASE + u

import httpx  # 微信 code2session 请求用

async def _wx_code2session(code: str):
    """用登录 code 换 openid/session_key（真实微信登录核心）"""
    url = (
        "https://api.weixin.qq.com/sns/jscode2session"
        f"?appid={WX_APPID}&secret={WX_SECRET}&js_code={code}&grant_type=authorization_code"
    )
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.get(url)
        return r.json()  # {openid, session_key} 或 {errcode, errmsg}

async def _wx_get_access_token():
    url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={WX_APPID}&secret={WX_SECRET}"
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.get(url)
        return r.json().get("access_token")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)



def now_iso():
    return db.now_iso()


def require_auth(authorization: str):
    token = (authorization or "").removeprefix("Bearer ").strip()
    payload = verify_token(token)
    if not payload:
        raise HTTPException(status_code=401, detail="未授权或登录已过期")
    return payload


# ================= 工具 =================
def read_answers(body: dict, key="answers"):
    a = body.get(key)
    return a if isinstance(a, dict) else {}


def _parse_topics(t):
    """accounts.topics 为 JSON 文本 → list，解析失败兜底 []"""
    if not t:
        return []
    if isinstance(t, list):
        return t
    try:
        v = json.loads(t)
        return v if isinstance(v, list) else []
    except Exception:
        return []


def _count_enrolled(event_id):
    """已报名人数 = 该活动全部报名数（含审核中）"""
    conn = db.get_conn()
    n = conn.execute("SELECT COUNT(*) AS c FROM bookings WHERE event_id=?", (event_id,)).fetchone()["c"]
    conn.close()
    return n


def _user_payload(acct):
    """统一构建 /me 与 /update-profile 返回的用户资料（含报名资料）"""
    return {
        "username": acct["username"],
        "name": acct.get("name") or acct["username"],
        "avatar": abs_url(acct.get("avatar")),
        "phone": acct.get("phone") or "",
        "phoneBound": bool(acct.get("phone_bound")),
        "status": acct.get("status_label") or "",
        "topics": _parse_topics(acct.get("topics")),
        "source": acct.get("source") or "",
    }


def jsonify(obj):
    # 处理 datetime 等 → dict
    return obj


# ================= 域名验证文件（微信/腾讯域名校验，须在根路径返回） =================
@app.get("/SpXvScDiDT.txt")
async def domain_verify():
    from fastapi.responses import Response
    return Response(content="d813c7bbab566807a833fbc3b0e20a9c", media_type="text/plain")


# ================= 认证 =================
@app.get("/api/health")
async def health():
    return {"ok": True, "service": "opc-fastapi"}


@app.post("/api/auth/register")
async def register(req: Request):
    b = await req.json()
    username = str(b.get("username", "")).strip()
    password = str(b.get("password", ""))
    name = str(b.get("name", "")).strip()
    contact = str(b.get("contact", "")).strip()
    if not username or len(username) < 2:
        raise HTTPException(400, "账号至少 2 个字符")
    if len(password) < 4:
        raise HTTPException(400, "密码至少 4 个字符")
    if await asyncio.to_thread(db.fetch_one, "accounts", username=username):
        raise HTTPException(400, "账号已存在，请直接登录")
    entry = {"id": db.gen_id("U"), "username": username, "password": hash_password(password),
             "name": name or username, "contact": contact, "identities": json.dumps(["opc_member|certified"]),
             "created_at": now_iso()}
    await asyncio.to_thread(db.insert, "accounts", entry)
    return {"ok": True, "token": make_token(username), "username": username, "name": name or username}


@app.post("/api/auth/login")
async def login(req: Request):
    b = await req.json()
    username = str(b.get("username", "")).strip()
    password = str(b.get("password", ""))
    if username == "pine":
        if password == "123456":
            return {"ok": True, "token": make_token("pine"), "username": "pine", "name": "Pine"}
        raise HTTPException(401, "密码错误，请重新输入")
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username)
    if not acct:
        raise HTTPException(404, "账号不存在，请先注册")
    if acct["password"] != hash_password(password):
        raise HTTPException(401, "密码错误，请重新输入")
    return {"ok": True, "token": make_token(username), "username": username, "name": acct.get("name") or username}


@app.post("/api/auth/send-code")
async def send_code(req: Request):
    b = await req.json()
    phone = str(b.get("phone", "")).strip()
    if not PHONE_RE.match(phone):
        raise HTTPException(400, "请输入正确的 11 位手机号")
    code = send_sms_code(phone)
    return {"ok": True, "sent": True, "debugCode": code}


def _same_phone_accounts(phone):
    """找出占用同一手机号的所有账号（username=phone 为主账号，或 phone=phone 的其它账号）"""
    conn = db.get_conn()
    rows = conn.execute("SELECT * FROM accounts WHERE username=? OR phone=?", (phone, phone)).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def _adopt_phone(acct, phone):
    """把同一手机号下其它账号的数据合并进 acct（保留 acct），最后只留一个账号、双向可命中。

    合并规则：若被并入账号带 wxid 而 acct 没有，则把 wxid 迁移到 acct（保证微信登录按 wxid 仍能命中）；
    再把重复账号的报名/测评/日志等归属数据挪到 acct，删除重复账号。最终同一手机号只对应一个账号。
    """
    phone = str(phone or "")
    conn = db.get_conn()
    keep_id = acct["id"]
    acct_username = acct["username"]
    conn.execute("UPDATE accounts SET phone=?, phone_bound=1 WHERE id=?", (phone, keep_id))
    dups = [dict(r) for r in conn.execute(
        "SELECT * FROM accounts WHERE (username=? OR phone=?) AND id != ?", (phone, phone, keep_id)
    ).fetchall()]
    for d in dups:
        # 迁移微信标识，避免 acct 属手机号账号时丢失 wxid 而微信登录失效
        if d.get("wxid") and not conn.execute("SELECT wxid FROM accounts WHERE id=?", (keep_id,)).fetchone()["wxid"]:
            conn.execute("UPDATE accounts SET wxid=? WHERE id=?", (d["wxid"], keep_id))
        # 迁移归属数据（报名 + 各日志）
        conn.execute("UPDATE bookings SET username=?, contact=? WHERE username=? OR contact=?",
                     (acct_username, phone, d["username"], d.get("phone") or ""))
        for t in ("tests", "policy_logs", "plan_logs", "survey_logs"):
            conn.execute(f"UPDATE {t} SET username=? WHERE username=?", (acct_username, d["username"]))
        conn.execute("DELETE FROM accounts WHERE id=?", (d["id"],))
    conn.commit()
    conn.close()
    acct = db.fetch_one("accounts", id=keep_id)
    return acct


def _resolve_phone_account(phone):
    """返回该手机号对应的唯一账号（优先按 username=phone，否则按 phone=phone），并合并重复账号。"""
    acct = db.fetch_one("accounts", username=phone)
    if not acct:
        for a in _same_phone_accounts(phone):
            if a.get("phone") == phone and a["id"]:
                acct = a
                break
    if not acct:
        return None
    return _adopt_phone(acct, phone)


def auth_by_phone(body: dict):
    phone = str(body.get("phone", "")).strip()
    code = str(body.get("code", "")).strip()
    if not PHONE_RE.match(phone):
        raise HTTPException(400, "请输入正确的 11 位手机号")
    if not check_sms_code(phone, code):
        raise HTTPException(401, "验证码错误或已过期，请重新获取")
    # 统一按手机号解析：命中现有账号（含已绑该号的微信账号）则合并复用，避免同一手机号多个账号
    acct = _resolve_phone_account(phone)
    is_new = False
    if not acct:
        acct = {"id": db.gen_id("U"), "username": phone, "phone": phone, "name": str(body.get("name", "")).strip() or phone,
                "phone_bound": 1, "identities": json.dumps(["opc_member|certified"]), "created_at": now_iso()}
        db.insert("accounts", acct)
        is_new = True
    return acct, is_new


@app.post("/api/auth/phone-login")
async def phone_login(req: Request):
    b = await req.json()
    acct, is_new = await asyncio.to_thread(auth_by_phone, b)
    return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"],
            "name": acct.get("name") or acct["username"], "isNew": is_new}


@app.post("/api/auth/logout")
async def logout():
    return {"ok": True}


@app.get("/api/auth/verify")
async def verify(authorization: str = Header(default="")):
    payload = require_auth(authorization)
    return {"ok": True, "username": payload["username"]}


# -------- 微信登录 / 我的 / 绑定手机 --------
@app.post("/api/auth/wx-login")
async def wx_login(req: Request):
    b = await req.json()
    code = str(b.get("code", ""))
    if not code:
        raise HTTPException(400, "缺少微信登录凭证")

    # 严格真实登录：必须配置有效 AppID/Secret，且 code2session 成功拿到 openid，否则一律明确失败（绝不用伪 openid 冒充）
    if not WX_HAS_CONFIG:
        raise HTTPException(500, "后端未配置微信 AppID/Secret，无法进行微信登录")
    try:
        wresp = await _wx_code2session(code)
    except Exception:
        raise HTTPException(502, "微信服务调用失败，请稍后重试")
    if wresp.get("errcode") or not wresp.get("openid"):
        errmsg = wresp.get("errmsg", "invalid code")
        raise HTTPException(401, f"微信登录失败：{errmsg}")
    openid = wresp.get("openid")

    acct = await asyncio.to_thread(db.fetch_one, "accounts", wxid=openid)
    if not acct:
        name = str(b.get("nickName", "")).strip() or "微信用户"
        acct = {"id": db.gen_id("U"), "username": openid, "wxid": openid, "phone": "", "name": name,
                "avatar": str(b.get("avatarUrl", "")), "phone_bound": 0, "identities": json.dumps(["wx"]),
                "created_at": now_iso()}
        await asyncio.to_thread(db.insert, "accounts", acct)
    return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"],
            "name": acct.get("name") or "微信用户", "avatar": abs_url(acct.get("avatar")), "phoneBound": bool(acct.get("phone_bound")),
            "configured": WX_HAS_CONFIG}


@app.get("/api/auth/me")
async def me(authorization: str = Header(default="")):
    payload = require_auth(authorization)
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    if not acct:
        raise HTTPException(404, "账号不存在")
    return {"ok": True, "user": _user_payload(acct)}


@app.post("/api/auth/update-profile")
async def update_profile(req: Request, authorization: str = Header(default="")):
    """更新当前用户资料：昵称/头像 + 报名资料（status/topics/source）"""
    payload = require_auth(authorization)
    b = await req.json()
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    if not acct:
        raise HTTPException(404, "账号不存在")
    patch = {}
    if b.get("name") is not None and str(b["name"]).strip():
        patch["name"] = str(b["name"]).strip()
    if b.get("avatar") is not None:
        patch["avatar"] = str(b["avatar"]).strip()
    if b.get("status") is not None:
        patch["status_label"] = str(b["status"]).strip()
    if b.get("source") is not None:
        patch["source"] = str(b["source"]).strip()
    if b.get("topics") is not None:
        patch["topics"] = json.dumps(b["topics"], ensure_ascii=False) if isinstance(b["topics"], list) else str(b["topics"])
    if patch:
        await asyncio.to_thread(db.update_row, "accounts", acct["id"], patch)
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    return {"ok": True, "user": _user_payload(acct)}


@app.post("/api/auth/wx-phone")
async def wx_phone(req: Request, authorization: str = Header(default="")):
    """微信官方手机号一键绑定：前端 <Button openType="getPhoneNumber"> 回调的 code → 手机号"""
    if not WX_HAS_CONFIG:
        raise HTTPException(400, "后端未配置微信 AppID/Secret，无法使用微信手机号绑定")
    payload = require_auth(authorization)
    b = await req.json()
    pcode = str(b.get("code", ""))
    if not pcode:
        raise HTTPException(400, "缺少手机号授权凭证")
    token = await _wx_get_access_token()
    if not token:
        raise HTTPException(400, "微信 access_token 获取失败，请检查 AppID/Secret 配置")
    url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={token}"
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.post(url, json={"code": pcode})
        data = r.json()
    if data.get("errcode"):
        raise HTTPException(401, f"获取手机号失败：{data.get('errmsg', '')}")
    phone = (data.get("phone_info") or {}).get("purePhoneNumber", "")
    if not phone:
        raise HTTPException(400, "未获取到手机号")
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    if not acct:
        raise HTTPException(404, "账号不存在")
    # 绑号即合并：若该手机号下有其它账号（如网页手机号登录账号），把其数据并入当前微信账号
    acct = await asyncio.to_thread(_adopt_phone, acct, phone)
    return {"ok": True, "phoneBound": True, "phone": phone}


@app.post("/api/auth/bind-phone")
async def bind_phone(req: Request, authorization: str = Header(default="")):
    payload = require_auth(authorization)
    b = await req.json()
    phone = str(b.get("phone", "")).strip()
    code = str(b.get("code", "")).strip()
    if not PHONE_RE.match(phone):
        raise HTTPException(400, "请输入正确的 11 位手机号")
    if not check_sms_code(phone, code):
        raise HTTPException(401, "验证码错误或已过期")
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    if not acct:
        raise HTTPException(404, "账号不存在")
    # 短信绑定同样合并同一手机号下的其它账号
    acct = await asyncio.to_thread(_adopt_phone, acct, phone)
    return {"ok": True, "phoneBound": True, "phone": phone}


# ================= 排期 / 活动 =================
def _upcoming(all_events):
    now = int(__import__("time").time() * 1000)
    upcoming = []
    for e in all_events:
        from datetime import datetime
        try:
            t = datetime.fromisoformat(e["start_at"]).timestamp() * 1000
        except Exception:
            t = 0
        if t >= now - 1000 * 60 * 30:
            upcoming.append(e)
    upcoming.sort(key=lambda e: e["start_at"])
    return upcoming


def event_out(e):
    """SQLite 行(snake_case) → 前端契约(camelCase) 统一映射"""
    if not e:
        return e
    return {
        "id": e.get("id"),
        "type": e.get("type"),
        "mode": e.get("mode"),
        "title": e.get("title"),
        "subtitle": e.get("subtitle"),
        "desc": e.get("desc"),
        "location": e.get("location"),
        "host": e.get("host"),
        "image": abs_url(e.get("image")),
        "link": e.get("link"),
        "startAt": e.get("start_at"),
        "endAt": e.get("end_at"),
        "checkinAt": e.get("checkin_at"),
        "durationMin": e.get("duration_min"),
        "capacity": e.get("capacity"),
        "status": e.get("status"),
        "auditMode": e.get("audit_mode") or "auto",
        "showCapacity": bool(e.get("show_capacity")),
        "enrolled": _count_enrolled(e.get("id")),
    }


@app.get("/api/events")
async def events(request: Request, current: Optional[str] = None, bookable: Optional[str] = None):
    all_events = await asyncio.to_thread(db.list_all, "events")
    if current is not None or bookable is not None:
        upcoming = _upcoming(all_events)
        if current is not None:
            free = next((e for e in upcoming if e["type"] == "free"), None)
            salon = next((e for e in upcoming if e["type"] == "salon"), None)
            return {"ok": True, "next": await asyncio.to_thread(event_out, upcoming[0] if upcoming else None),
                    "free": await asyncio.to_thread(event_out, free), "salon": await asyncio.to_thread(event_out, salon),
                    "upcoming": [await asyncio.to_thread(event_out, e) for e in upcoming[:6]]}
        if bookable is not None:
            # 可公开报名：排除 待开放(pending)/邀请中(invite)/已结束(done)/已满员(full)
            lst = [e for e in upcoming if e["status"] not in ("closed", "done", "pending", "invite", "full")][:30]
            return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in lst]}
    return {"ok": True, "list": [await asyncio.to_thread(event_out, e) for e in all_events]}


@app.get("/api/events/{eid}")
async def event_detail(eid: str):
    e = await asyncio.to_thread(db.fetch_by_id, "events", eid)
    if not e:
        raise HTTPException(404, "活动不存在")
    return {"ok": True, "event": await asyncio.to_thread(event_out, e)}


@app.post("/api/events")
async def create_event(req: Request, authorization: str = Header(default="")):
    require_auth(authorization)
    b = await req.json()
    if not b.get("type") or not b.get("title") or not b.get("startAt"):
        raise HTTPException(400, "请填写类型 / 主题 / 开始时间")
    entry = {
        "id": db.gen_id("E-S" if b.get("type") == "salon" else "E-F"),
        "type": "salon" if b.get("type") == "salon" else "free",
        "mode": "online" if b.get("mode") == "online" else "offline",
        "title": str(b.get("title", "")).strip(),
        "subtitle": str(b.get("subtitle", "") or ""),
        "desc": str(b.get("desc", "") or ""),
        "location": str(b.get("location", "") or ""),
        "host": str(b.get("host", "") or "").strip(),
        "image": str(b.get("image", "") or "").strip(),
        "link": str(b.get("link", "") or ""),
        "start_at": str(b.get("startAt", "") or ""),
        "end_at": str(b.get("endAt", "") or ""),
        "checkin_at": str(b.get("checkinAt", "") or ""),
        "duration_min": int(b.get("durationMin") or 90),
        "capacity": int(b.get("capacity") or 0),
        "status": str(b.get("status", "") or "open"),
        "audit_mode": 'manual' if b.get("auditMode") == "manual" else "auto",
        "show_capacity": 1 if b.get("showCapacity") else 0,
    }
    await asyncio.to_thread(db.insert, "events", entry)
    return {"ok": True, "entry": await asyncio.to_thread(event_out, entry)}


@app.put("/api/events/{eid}")
async def update_event(eid: str, req: Request, authorization: str = Header(default="")):
    require_auth(authorization)
    b = await req.json()
    patch = {}
    for k in ["title", "subtitle", "desc", "location", "link", "start_at", "end_at", "checkin_at", "status", "host", "image"]:
        # 前端发 startAt/start_at、endAt/end_at、checkinAt/checkin_at 兼容
        alias = {"start_at": "startAt", "end_at": "endAt", "checkin_at": "checkinAt"}.get(k)
        src = k if k in b else (alias if alias else None)
        if src and b.get(src) is not None:
            patch[k] = b[src]
    if b.get("type") in ("salon", "free"):
        patch["type"] = b["type"]
    if b.get("mode") in ("online", "offline"):
        patch["mode"] = b["mode"]
    if b.get("durationMin") is not None:
        patch["duration_min"] = int(b.get("durationMin") or 90)
    if b.get("capacity") is not None:
        patch["capacity"] = int(b.get("capacity") or 0)
    if b.get("auditMode") is not None:
        patch["audit_mode"] = "manual" if b["auditMode"] == "manual" else "auto"
    if b.get("showCapacity") is not None:
        patch["show_capacity"] = 1 if b["showCapacity"] else 0
    if not patch:
        raise HTTPException(400, "无更新字段")
    if not await asyncio.to_thread(db.fetch_by_id, "events", eid):
        raise HTTPException(404, "排期不存在")
    await asyncio.to_thread(db.update_row, "events", eid, patch)
    return {"ok": True, "entry": await asyncio.to_thread(event_out, await asyncio.to_thread(db.fetch_by_id, "events", eid))}


@app.delete("/api/events/{eid}")
async def delete_event(eid: str, authorization: str = Header(default="")):
    require_auth(authorization)
    await asyncio.to_thread(db.delete_row, "events", eid)
    return {"ok": True}


# ================= 报名 =================
@app.post("/api/bookings")
async def create_booking(req: Request, authorization: str = Header(default="")):
    b = await req.json()
    event = await asyncio.to_thread(db.fetch_by_id, "events", str(b.get("eventId", "")).strip())
    # 校验活动可报名：公开报名仅 open/full；待开放/邀请中/已结束不可报名
    if not event:
        raise HTTPException(404, "活动不存在")
    if event["status"] not in ("open", "full"):
        label = {"pending": "活动待开放", "invite": "本场为定向邀请", "done": "本场已结束"}.get(event["status"], "暂不可报名")
        raise HTTPException(400, label)

    # 强制登录：必须携带有效 token（无登录 → 401）
    payload = require_auth(authorization)
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    if not acct:
        raise HTTPException(401, "登录状态异常，请重新登录")
    # 报名必须已绑定手机号（登录后自动/引导绑定），保证报名有联系方式
    if not acct.get("phone"):
        raise HTTPException(400, "请先绑定手机号后再报名")

    username = acct["username"]
    # 报名资料（姓名/状态/主题/来源）取账号中已保存的个人中心设置，报名区无需再填；question 按场次从请求体取
    name = (acct.get("name") or "") or (str(b.get("name", "")).strip() or username)
    status_label = (acct.get("status_label") or "") or str(b.get("status", "") or "").strip()
    topics_val = _parse_topics(acct.get("topics"))
    if not topics_val:
        topics_val = b.get("topics", []) if isinstance(b.get("topics"), list) else []
    source = (acct.get("source") or "") or str(b.get("source", "") or "").strip()
    entry = {
        "id": db.gen_id("B"), "created_at": now_iso(), "status": "pending", "username": username, "name": name,
        "contact": (acct.get("phone") or ""), "status_label": status_label,
        "want": b.get("want", ""),
        "event_id": event["id"], "event_title": event["title"], "event_start": event["start_at"],
        "topics": json.dumps(topics_val, ensure_ascii=False),
        "question": b.get("question", ""), "source": source,
        "audit_status": "approved" if event.get("audit_mode") != "manual" else "pending",
    }
    await asyncio.to_thread(db.insert, "bookings", entry)
    return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"],
            "username": username, "name": name,
            "auditStatus": entry["audit_status"]}


def _decode_bk(row):
    d = dict(row)
    try:
        d["topics"] = json.loads(d.get("topics") or "[]")
    except Exception:
        d["topics"] = []
    return d


@app.get("/api/bookings/mine")
async def my_bookings(authorization: str = Header(default="")):
    payload = require_auth(authorization)
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    ids = {payload["username"]}
    if acct and acct.get("phone"):
        ids.add(acct["phone"])
    conn = await asyncio.to_thread(db.get_conn, )
    rows = conn.execute("SELECT * FROM bookings").fetchall()
    events = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM events").fetchall()}
    conn.close()
    lst = [_decode_bk(r) for r in rows if r["username"] in ids or (r["contact"] or "") in ids]
    now_ms = int(time.time() * 1000)
    for x in lst:
        # 前端（我的报名）用 camelCase，补别名保证一致的契约（snake 亦保留）
        x["eventTitle"] = x.get("event_title")
        x["eventStart"] = x.get("event_start")
        x["createdAt"] = x.get("created_at")
        x["checkinAt"] = x.get("checkin_at")
        x["auditStatus"] = x.get("audit_status") or "pending"
        ev = events.get(x.get("event_id"))
        if ev:
            open_ms = _parse_ms(ev.get("checkin_at") or ev.get("start_at"))
            end_ms = _parse_ms(ev.get("end_at")) or (open_ms + (ev.get("duration_min") or 90) * 60000)
            x["event_status"] = ev.get("status")
            x["event_end"] = ev.get("end_at")
            x["event_checkin_at"] = ev.get("checkin_at")
            x["can_checkin"] = bool(open_ms <= now_ms <= end_ms and ev.get("status") != "done")
        else:
            x["event_status"] = None
            x["event_end"] = None
            x["event_checkin_at"] = None
            x["can_checkin"] = False
    lst.sort(key=lambda x: x["created_at"], reverse=True)
    return {"ok": True, "list": lst}


@app.get("/api/bookings/by-event/{event_id}")
async def my_booking_for_event(event_id: str, authorization: str = Header(default="")):
    """查当前登录用户是否已报名该活动及其审核状态（详情页判断用）"""
    payload = require_auth(authorization)
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    ids = {payload["username"]}
    if acct and acct.get("phone"):
        ids.add(acct["phone"])
    conn = await asyncio.to_thread(db.get_conn, )
    rows = conn.execute("SELECT * FROM bookings WHERE event_id=?", (event_id,)).fetchall()
    conn.close()
    hit = None
    for r in rows:
        if r["username"] in ids or (r["contact"] or "") in ids:
            hit = _decode_bk(r)
            break
    if not hit:
        return {"ok": True, "booked": False, "auditStatus": None}
    return {"ok": True, "booked": True, "id": hit["id"], "auditStatus": hit.get("audit_status") or "pending",
            "status": hit.get("status"), "checkinAt": hit.get("checkin_at")}


@app.post("/api/checkins")
async def checkin(req: Request, authorization: str = Header(default="")):
    payload = require_auth(authorization)
    b = await req.json()
    acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"])
    ids = {payload["username"]}
    if acct and acct.get("phone"):
        ids.add(acct["phone"])
    bk = await asyncio.to_thread(db.fetch_by_id, "bookings", b.get("bookingId", ""))
    if not bk:
        raise HTTPException(404, "报名记录不存在")
    if bk["username"] not in ids and (bk.get("contact") or "") not in ids:
        raise HTTPException(403, "无权操作该记录")
    if bk.get("checkin_at"):
        raise HTTPException(400, "该场次已签到")
    # 时间闸：只能在「签到开放时间 ≤ now ≤ 结束时间」内签到
    ev = await asyncio.to_thread(db.fetch_by_id, "events", bk.get("event_id", ""))
    open_ms = _parse_ms(ev.get("checkin_at") or ev.get("start_at")) if ev else 0
    end_ms = (_parse_ms(ev.get("end_at")) if ev and ev.get("end_at") else 0) or (_parse_ms(ev.get("start_at")) + (ev.get("duration_min") or 90) * 60000 if ev else 0)
    now_ms = int(time.time() * 1000)
    if now_ms < open_ms:
        raise HTTPException(400, "未到签到时间，活动开始后再签到")
    if now_ms > end_ms or (ev and ev.get("status") == "done"):
        raise HTTPException(400, "本场已结束，无法签到")
    await asyncio.to_thread(db.update_row, "bookings", bk["id"], {"checkin_at": now_iso()})
    return {"ok": True, "entry": _decode_bk(await asyncio.to_thread(db.fetch_by_id, "bookings", bk["id"]))}


@app.get("/api/bookings")
async def admin_bookings(authorization: str = Header(default=""), status: Optional[str] = None, audit: Optional[str] = None):
    require_auth(authorization)
    lst = [_decode_bk(r) for r in await asyncio.to_thread(db.list_all, "bookings")]
    lst.sort(key=lambda x: x["created_at"], reverse=True)
    if status:
        lst = [x for x in lst if x["status"] == status]
    if audit:
        lst = [x for x in lst if x.get("audit_status") == audit]
    return {"ok": True, "list": lst}


@app.patch("/api/bookings/{bid}")
async def update_booking(bid: str, req: Request, authorization: str = Header(default="")):
    require_auth(authorization)
    b = await req.json()
    if not await asyncio.to_thread(db.fetch_by_id, "bookings", bid):
        raise HTTPException(404, "预约不存在")
    patch = {}
    if b.get("status") in ("pending", "confirmed", "arrived", "converted"):
        patch["status"] = b["status"]
    if b.get("auditStatus") in ("pending", "approved", "rejected"):
        patch["audit_status"] = b["auditStatus"]
    if patch:
        await asyncio.to_thread(db.update_row, "bookings", bid, patch)
    return {"ok": True, "entry": _decode_bk(await asyncio.to_thread(db.fetch_by_id, "bookings", bid))}


@app.delete("/api/bookings/{bid}")
async def delete_booking(bid: str, authorization: str = Header(default="")):
    require_auth(authorization)
    await asyncio.to_thread(db.delete_row, "bookings", bid)
    return {"ok": True}


# ================= OPC 测评 =================
@app.get("/api/tests/opc/questions")
async def opc_questions(version: Optional[str] = "full"):
    v = "quick" if version == "quick" else "full"
    return {
        "ok": True, "version": v,
        "questions": [{"id": q["id"], "part": q["part"], "question": q["question"], "A": q["A"], "B": q["B"]}
                      for q in opc_engine.current_questions(v)],
        "sections": SECTIONS, "axisNames": AXIS_NAMES, "dimLabels": ADAPT_LABELS,
        "disclaimer": PROFILES["disclaimer"], "quickNote": PROFILES["quickNote"],
    }


@app.post("/api/tests/opc/calculate")
async def opc_calculate(req: Request):
    b = await req.json()
    version = "quick" if b.get("version") == "quick" else "full"
    answers = read_answers(b)
    try:
        result = opc_engine.expand_result(opc_engine.calculate(answers, version))
        return {"ok": True, "result": result}
    except Exception:
        raise HTTPException(400, "测评计算失败，请稍后重试")


@app.post("/api/tests")
async def report_test(req: Request):
    b = await req.json()
    if not b.get("typeCode"):
        raise HTTPException(400, "缺少测评结果")
    entry = {"id": db.gen_id("T"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
             "type_code": b.get("typeCode"), "persona": b.get("persona", ""),
             "adapt_index": int(b.get("adaptIndex") or 0), "adapt_level": b.get("adaptLevel", ""),
             "tracks": json.dumps(b.get("tracks", []), ensure_ascii=False), "version": b.get("version", "")}
    await asyncio.to_thread(db.insert, "tests", entry)
    return {"ok": True, "id": entry["id"]}


@app.get("/api/tests")
async def admin_tests(authorization: str = Header(default="")):
    require_auth(authorization)
    return {"ok": True, "list": await asyncio.to_thread(db.list_all, "tests")}


# ================= 政策 / 流程 / 调研：题目下发 + 结果生成（逻辑后置于后端） =================
@app.get("/api/policy/questions")
async def policy_questions():
    return {"ok": True, "questions": policy_data.PT_QUESTIONS}


@app.post("/api/policy/calculate")
async def policy_calculate(req: Request):
    b = await req.json()
    ans = b.get("answers") if isinstance(b.get("answers"), dict) else {}
    result = policy_engine.compute_policy(ans)
    return {"ok": True, "result": result}


@app.get("/api/plan/config")
async def plan_config():
    return {"ok": True, "regions": policy_data.SP_REGIONS, "status": policy_data.SP_STATUS}


@app.post("/api/plan/generate")
async def plan_generate(req: Request):
    b = await req.json()
    plan = policy_engine.build_plan({
        "needPark": bool(b.get("needPark")),
        "needRegister": bool(b.get("needRegister")),
        "hasStaff": bool(b.get("hasStaff")),
    })
    return {"ok": True, "plan": plan}


@app.get("/api/survey/questions")
async def survey_questions():
    return {"ok": True, "likert": survey_data.LIKERT_OPTIONS,
            "sections": survey_data.SURVEY_SECTIONS, "questions": survey_data.SURVEY_QUESTIONS}


@app.post("/api/survey/submit")
async def survey_submit(req: Request):
    b = await req.json()
    if not isinstance(b.get("answers"), dict):
        raise HTTPException(400, "缺少调研答案")
    entry = {"id": db.gen_id("S"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
             "source": str(b.get("source", "")).strip() or "web", "answers": json.dumps(b.get("answers"), ensure_ascii=False)}
    await asyncio.to_thread(db.insert, "survey_logs", entry)
    return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"]}


# ================= 日志上报（政策 / 流程 / 调研） =================
@app.post("/api/policy-logs")
async def report_policy(req: Request):
    b = await req.json()
    if not b.get("answers"):
        raise HTTPException(400, "缺少测评结果")
    entry = {"id": db.gen_id("P"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
             "answers": json.dumps(b.get("answers"), ensure_ascii=False),
             "policies_count": int(b.get("policiesCount") or 0), "subsidies_count": int(b.get("subsidiesCount") or 0),
             "loans_count": int(b.get("loansCount") or 0), "summary": str(b.get("summary", "") or "")[:200]}
    await asyncio.to_thread(db.insert, "policy_logs", entry)
    return {"ok": True, "id": entry["id"]}


@app.get("/api/policy-logs")
async def admin_policy(authorization: str = Header(default="")):
    require_auth(authorization)
    return {"ok": True, "list": await asyncio.to_thread(db.list_all, "policy_logs")}


@app.post("/api/plan-logs")
async def report_plan(req: Request):
    b = await req.json()
    if not b.get("region"):
        raise HTTPException(400, "缺少流程信息")
    entry = {"id": db.gen_id("L"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
             "region": str(b.get("region", "")).strip(), "status": str(b.get("status", "")).strip(),
             "need_park": 1 if b.get("needPark") else 0, "has_staff": 1 if b.get("hasStaff") else 0,
             "steps_count": int(b.get("stepsCount") or 0)}
    await asyncio.to_thread(db.insert, "plan_logs", entry)
    return {"ok": True, "id": entry["id"]}


@app.get("/api/plan-logs")
async def admin_plan(authorization: str = Header(default="")):
    require_auth(authorization)
    return {"ok": True, "list": await asyncio.to_thread(db.list_all, "plan_logs")}


@app.post("/api/survey-logs")
async def report_survey(req: Request):
    b = await req.json()
    if not isinstance(b.get("answers"), dict):
        raise HTTPException(400, "缺少调研答案")
    entry = {"id": db.gen_id("S"), "created_at": now_iso(), "username": str(b.get("username", "")).strip(),
             "source": str(b.get("source", "")).strip() or "web", "answers": json.dumps(b.get("answers"), ensure_ascii=False)}
    await asyncio.to_thread(db.insert, "survey_logs", entry)
    return {"ok": True, "id": entry["id"], "createdAt": entry["created_at"]}


@app.get("/api/survey-logs")
async def admin_survey(authorization: str = Header(default="")):
    require_auth(authorization)
    return {"ok": True, "list": await asyncio.to_thread(db.list_all, "survey_logs")}


# ================= 运营统计 =================
@app.get("/api/ops/stats")
async def ops_stats(authorization: str = Header(default="")):
    require_auth(authorization)
    conn = await asyncio.to_thread(db.get_conn, )
    def count(t):
        return conn.execute(f"SELECT COUNT(*) c FROM {t}").fetchone()["c"]
    def count_where(t, where, val):
        return conn.execute(f"SELECT COUNT(*) c FROM {t} WHERE {where}", (val,)).fetchone()["c"]
    now_ms = int(__import__("time").time() * 1000)
    upcoming = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if _parse_ms(e["start_at"]) >= now_ms)
    free = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if e["type"] == "free" and _parse_ms(e["start_at"]) >= now_ms)
    salon = sum(1 for e in await asyncio.to_thread(db.list_all, "events") if e["type"] == "salon" and _parse_ms(e["start_at"]) >= now_ms)
    # 先全部算完再关连接
    stats = {
        "bookings": count("bookings"),
        "pending": await asyncio.to_thread(count_where, "bookings", "status=?", "pending"),
        "confirmed": await asyncio.to_thread(count_where, "bookings", "status=?", "confirmed"),
        "arrived": await asyncio.to_thread(count_where, "bookings", "status=?", "arrived"),
        "converted": await asyncio.to_thread(count_where, "bookings", "status=?", "converted"),
        "events": count("events"), "upcomingEvents": upcoming, "free": free, "salon": salon,
        "tests": count("tests"), "policyLogs": count("policy_logs"), "planLogs": count("plan_logs"),
        "surveyLogs": count("survey_logs"),
    }
    conn.close()
    return {"ok": True, "stats": stats}


def _parse_ms(iso):
    from datetime import datetime
    try:
        return datetime.fromisoformat(iso).timestamp() * 1000
    except Exception:
        return 0


# ================= 图像上传 =================
@app.post("/api/upload")
async def upload(request: Request, file: UploadFile = File(...), authorization: str = Header(default="")):
    # 管理端上传需登录（pine 后台）；公开端也可保留（依据调用方），这里允许管理端 token
    require_auth(authorization)
    if not file.filename:
        raise HTTPException(400, "未选择文件")
    ext = os.path.splitext(file.filename or "")[1].lower()
    if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
        raise HTTPException(400, "仅支持 jpg/png/webp/gif 图片")
    fname = f"up_{int(time.time())}_{os.urandom(4).hex()}{ext}"
    dest = os.path.join(UPLOAD_DIR, fname)
    content = await file.read()
    if len(content) > 5 * 1024 * 1024:
        raise HTTPException(400, "文件过大（>5MB）")
    with open(dest, "wb") as f:
        f.write(content)
    url = f"/uploads/{fname}"
    return {"ok": True, "url": abs_url(url)}
