package com.example.leaidemo;

/**
 * 乐读派 AI 绘本创作系统 — 企业对接 Demo (Spring Boot)
 *
 * 功能：
 *   1. 令牌交换 + H5 跳转（/create）
 *   2. 认证回调（/leai-auth）— H5 token失效后跳回
 *   3. Webhook 接收 + HMAC-SHA256 签名验证（/webhook/leai）
 *   4. B3 定时对账（每30分钟）
 *   5. iframe 嵌入模式（/embed）— 通过 iframe + postMessage 内嵌H5
 *   6. Token 刷新接口（/api/refresh-token）— 供 iframe 中 JS 调用
 *
 * 启动后访问 http://localhost:9090
 *
 * V4.0 核心规则：
 *   - 状态为 INT: -1(FAILED), 1(PENDING), 2(PROCESSING), 3(COMPLETED), 4(CATALOGED), 5(DUBBED)
 *   - 同步判断: remote_status > local_status → 更新
 *   - status=2 和 status=-1 → 强制更新（无视数值比较）
 *   - 不再有 dataVersion / completionStep
 *   - H5 使用 history 路由: 重定向 URL 格式为 https://{h5-domain}/?token=xxx&orgId=xxx&phone=xxx
 *
 * 依赖：Spring Boot 2.7+ (spring-boot-starter-web)
 * JDK：1.8+
 *
 * application.properties 中添加:
 *   server.port=9090
 */

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

@SpringBootApplication
@EnableScheduling
@RestController
public class LeaiDemoApplication {

    private static final Logger log = Logger.getLogger(LeaiDemoApplication.class.getName());

    // ══════════════════════════════════════════
    // ★ 企业配置 — 请替换为乐读派管理后台分配的真实值
    // ══════════════════════════════════════════
    private static final String ORG_ID     = "LESINGLE888888888";                      // 机构ID（管理后台获取）
    private static final String APP_SECRET = "leai_test_secret_2026_abc123xyz";       // 机构密钥（管理后台获取）
    private static final String LEAI_API_URL = "http://192.168.1.72:8080";            // 乐读派API地址
    private static final String LEAI_H5_URL  = "http://192.168.1.72:3001";            // 乐读派H5地址

    // ── 状态常量（V4.0 数值状态机）──
    private static final int STATUS_FAILED     = -1;
    private static final int STATUS_PENDING    =  1;
    private static final int STATUS_PROCESSING =  2;
    private static final int STATUS_COMPLETED  =  3;
    private static final int STATUS_CATALOGED  =  4;
    private static final int STATUS_DUBBED     =  5;

    // 简易内存存储（生产环境请替换为数据库）
    // key = workId, value = { work_id, status, progress, title, phone, page_list, fail_reason, ... }
    private final Map<String, Map<String, Object>> worksDb = new ConcurrentHashMap<>();

    // Webhook 幂等去重集合
    private final Set<String> processedEvents = ConcurrentHashMap.newKeySet();

    public static void main(String[] args) {
        SpringApplication.run(LeaiDemoApplication.class, args);
    }

    // ══════════════════════════════════════════
    // 功能1：首页 — 简易HTML表单 + 已同步作品列表
    // ══════════════════════════════════════════

    /**
     * 企业首页 — 输入手机号后跳转到乐读派H5创作
     * 同时展示已通过 Webhook / B3 同步的作品列表
     */
    @GetMapping("/")
    @ResponseBody
    public String index() {
        StringBuilder workRows = new StringBuilder();
        if (worksDb.isEmpty()) {
            workRows.append("<tr><td colspan='6' style='text-align:center;color:#999;padding:20px;'>"
                    + "暂无作品。用户在乐读派H5完成创作后，Webhook 会推送到这里。</td></tr>");
        } else {
            for (Map.Entry<String, Map<String, Object>> entry : worksDb.entrySet()) {
                Map<String, Object> w = entry.getValue();
                int status = toInt(w.get("status"), 0);
                String statusText = statusName(status);
                String title = toString(w.get("title"), "未命名");
                String phone = toString(w.get("phone"), "?");
                int progress = toInt(w.get("progress"), 0);
                String workId = entry.getKey();

                workRows.append("<tr>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;font-family:monospace;font-size:12px;'>")
                        .append(escapeHtml(workId)).append("</td>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;'>")
                        .append(escapeHtml(title)).append("</td>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;text-align:center;'>")
                        .append("<span style='").append(statusStyle(status)).append("'>")
                        .append(status).append(" (").append(statusText).append(")</span></td>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;text-align:center;'>")
                        .append(progress).append("%</td>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;text-align:center;'>")
                        .append(escapeHtml(phone)).append("</td>")
                        .append("<td style='padding:8px;border-bottom:1px solid #e5e0ff;font-size:12px;'>")
                        .append(escapeHtml(toString(w.get("fail_reason"), "-"))).append("</td>")
                        .append("</tr>");
            }
        }

        return "<!DOCTYPE html>\n"
                + "<html><head><meta charset='utf-8'><title>企业Demo - AI绘本创作</title>\n"
                + "<style>\n"
                + "  body { font-family: -apple-system, 'Segoe UI', sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; background: #fafafa; }\n"
                + "  input, button { padding: 12px; font-size: 16px; margin: 8px 0; width: 100%; box-sizing: border-box; }\n"
                + "  button { background: #7C3AED; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }\n"
                + "  button:hover { background: #6D28D9; }\n"
                + "  .card { background: #fff; border: 1px solid #e5e0ff; border-radius: 12px; padding: 24px; margin: 20px 0; box-shadow: 0 2px 8px rgba(124,58,237,0.06); }\n"
                + "  h2 { color: #7C3AED; margin-top: 0; }\n"
                + "  h3 { color: #4B5563; margin-top: 0; }\n"
                + "  table { width: 100%; border-collapse: collapse; }\n"
                + "  th { background: #7C3AED; color: white; padding: 10px 8px; text-align: center; font-size: 13px; }\n"
                + "  .badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }\n"
                + "  .info { background: #f0f0ff; padding: 12px; border-radius: 8px; color: #555; font-size: 13px; line-height: 1.8; margin-top: 12px; }\n"
                + "</style></head>\n"
                + "<body>\n"
                + "  <h2>企业Demo — AI绘本创作入口</h2>\n"
                + "  <div class='card'>\n"
                + "    <p style='color:#555;'>本页面模拟企业系统中的\"开始创作\"入口。<br>\n"
                + "       用户输入手机号后，企业后端换取 Session Token 并跳转到乐读派 H5 创作页面。</p>\n"
                + "    <form action='/create' method='GET'>\n"
                + "      <input name='phone' placeholder='请输入手机号' maxlength='11' required pattern='^1\\d{10}$'>\n"
                + "      <button type='submit'>开始创作 &rarr;</button>\n"
                + "    </form>\n"
                + "    <div style='margin-top:8px;'>\n"
                + "      <a id='iframe-link' href='/enterprise-sim.html' style='display:block;text-align:center;padding:12px;background:#059669;color:white;border-radius:8px;font-weight:600;font-size:16px;text-decoration:none;'>企业模拟C端（iframe 4TAB模式）&rarr;</a>\n"
                + "      <script>document.querySelector('form').addEventListener('input',function(e){var p=e.target.value;document.getElementById('iframe-link').href='/enterprise-sim.html?phone='+encodeURIComponent(p);})</" + "script>\n"
                + "    </div>\n"
                + "    </form>\n"
                + "    <div class='info'>\n"
                + "      <b>V4.0 状态机:</b> PENDING(1) &rarr; PROCESSING(2) &rarr; COMPLETED(3) &rarr; CATALOGED(4) &rarr; DUBBED(5)<br>\n"
                + "      <b>同步规则:</b> remote_status &gt; local_status &rarr; 更新 | status=2/-1 &rarr; 强制更新<br>\n"
                + "      <b>Webhook:</b> POST /webhook/leai &nbsp;&nbsp; <b>B3对账:</b> 每30分钟自动执行\n"
                + "    </div>\n"
                + "  </div>\n"
                + "  <div class='card'>\n"
                + "    <h3>已同步的作品（Webhook + B3 对账）</h3>\n"
                + "    <table>\n"
                + "      <tr><th>workId</th><th>标题</th><th>状态</th><th>进度</th><th>手机号</th><th>失败原因</th></tr>\n"
                + workRows.toString()
                + "    </table>\n"
                + "  </div>\n"
                + "</body></html>";
    }

    // ══════════════════════════════════════════
    // 功能1（续）：令牌交换 + H5 跳转
    // ══════════════════════════════════════════

    /**
     * 入口：用户点击"开始创作"
     * 1. 从企业 session 获取用户手机号（demo 中从 URL 参数获取）
     * 2. 调用乐读派 POST /api/v1/auth/session 用 appSecret 换取 sessionToken
     * 3. 302 重定向到乐读派 H5（注意 hash 路由 /#/? 格式）
     */
    @GetMapping("/create")
    public void create(@RequestParam String phone, HttpServletResponse response) throws Exception {
        if (phone == null || phone.trim().isEmpty()) {
            response.sendError(400, "缺少手机号");
            return;
        }

        // Step 1: 令牌交换（企业后端 → 乐读派后端, S2S 调用）
        String token = exchangeToken(phone);

        // Step 2: 302 重定向到乐读派 H5
        // 重要: H5 使用 hash 路由, 参数必须放在 /#/? 之后
        String url = LEAI_H5_URL + "/?token=" + URLEncoder.encode(token, "UTF-8")
                + "&orgId=" + URLEncoder.encode(ORG_ID, "UTF-8")
                + "&phone=" + URLEncoder.encode(phone, "UTF-8");
        log.info("[Create] Redirecting phone=" + phone + " to H5");
        response.sendRedirect(url);
    }

    // ══════════════════════════════════════════
    // 功能5：iframe 嵌入模式
    // ══════════════════════════════════════════

    /**
     * iframe 嵌入模式演示
     * 展示企业如何通过 iframe 内嵌乐读派创作C端
     * 支持：新建创作、续编目(status=3)、续配音(status=4)
     */
    @GetMapping("/embed")
    @ResponseBody
    public String embed(@RequestParam String phone,
                        @RequestParam(required = false) String workId,
                        @RequestParam(required = false) String page) throws Exception {
        // 换取 token
        String token = exchangeToken(phone);

        // 构建 iframe src URL
        String targetPath = "";
        if ("edit-info".equals(page) && workId != null) {
            targetPath = "/edit-info/" + workId;
        } else if ("dubbing".equals(page) && workId != null) {
            targetPath = "/dubbing/" + workId;
        } else if ("creating".equals(page) && workId != null) {
            targetPath = "/creating";
        }

        String iframeSrc = LEAI_H5_URL + targetPath
                + "?token=" + URLEncoder.encode(token, "UTF-8")
                + "&orgId=" + URLEncoder.encode(ORG_ID, "UTF-8")
                + "&phone=" + URLEncoder.encode(phone, "UTF-8")
                + "&embed=1";

        // Return HTML with iframe + postMessage JS
        return "<!DOCTYPE html>\n<html><head><meta charset='utf-8'><title>iframe嵌入Demo</title>\n"
            + "<style>body{margin:0;font-family:sans-serif;} .toolbar{background:#7C3AED;color:white;padding:12px 20px;display:flex;align-items:center;justify-content:between;}\n"
            + ".toolbar h3{margin:0;flex:1;} .toolbar button{background:white;color:#7C3AED;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;margin-left:8px;font-weight:600;}\n"
            + "#status{font-size:12px;color:rgba(255,255,255,0.8);margin-left:12px;}\n"
            + "iframe{width:100%;height:calc(100vh - 50px);border:none;}\n"
            + "#log{position:fixed;bottom:0;right:0;width:400px;max-height:200px;overflow-y:auto;background:rgba(0,0,0,0.85);color:#0f0;font-size:11px;padding:8px;font-family:monospace;z-index:999;border-radius:8px 0 0 0;}</style></head>\n"
            + "<body>\n"
            + "<div class='toolbar'>\n"
            + "  <h3>企业系统 — iframe 嵌入 Demo</h3>\n"
            + "  <span id='status'>等待H5加载...</span>\n"
            + "  <button onclick=\"location.href='/'\">返回首页</button>\n"
            + "  <button onclick=\"switchPage('')\">新建创作</button>\n"
            + "  <button onclick=\"switchPage('/edit-info/" + (workId != null ? workId : "WORK_ID") + "')\">编目页</button>\n"
            + "  <button onclick=\"switchPage('/dubbing/" + (workId != null ? workId : "WORK_ID") + "')\">配音页</button>\n"
            + "</div>\n"
            + "<iframe id='leai' src='" + escapeHtml(iframeSrc) + "' allow='camera;microphone'></iframe>\n"
            + "<div id='log'></div>\n"
            + "<script>\n"
            + "const LEAI_H5 = '" + LEAI_H5_URL + "';\n"
            + "const PHONE = '" + phone + "';\n"
            + "const ORG_ID = '" + ORG_ID + "';\n"
            + "const logEl = document.getElementById('log');\n"
            + "const statusEl = document.getElementById('status');\n"
            + "\n"
            + "function log(msg) {\n"
            + "  const t = new Date().toLocaleTimeString();\n"
            + "  logEl.innerHTML = '<div>[' + t + '] ' + msg + '</div>' + logEl.innerHTML;\n"
            + "}\n"
            + "\n"
            + "// ★ 核心：监听乐读派 H5 的 postMessage\n"
            + "window.addEventListener('message', function(event) {\n"
            + "  var msg = event.data;\n"
            + "  if (!msg || msg.source !== 'leai-creation') return;\n"
            + "  log('收到: ' + msg.type + ' ' + JSON.stringify(msg.payload || {}));\n"
            + "\n"
            + "  switch (msg.type) {\n"
            + "    case 'READY':\n"
            + "      statusEl.textContent = 'H5已就绪';\n"
            + "      break;\n"
            + "    case 'TOKEN_EXPIRED':\n"
            + "      statusEl.textContent = '刷新Token中...';\n"
            + "      log('Token过期，正在刷新...');\n"
            + "      // 调企业后端换新 token\n"
            + "      fetch('/api/refresh-token?phone=' + PHONE)\n"
            + "        .then(function(r) { return r.json(); })\n"
            + "        .then(function(data) {\n"
            + "          // 回传新 token 给 H5\n"
            + "          document.getElementById('leai').contentWindow.postMessage({\n"
            + "            source: 'leai-creation', version: 1, type: 'TOKEN_REFRESHED',\n"
            + "            payload: { messageId: msg.payload.messageId, token: data.token, orgId: ORG_ID, phone: PHONE }\n"
            + "          }, '*');\n"
            + "          statusEl.textContent = 'Token已刷新';\n"
            + "          log('Token刷新成功');\n"
            + "        })\n"
            + "        .catch(function(e) { log('Token刷新失败: ' + e); });\n"
            + "      break;\n"
            + "    case 'WORK_CREATED':\n"
            + "      statusEl.textContent = '创作中: ' + msg.payload.workId;\n"
            + "      break;\n"
            + "    case 'WORK_COMPLETED':\n"
            + "      statusEl.textContent = '创作完成: ' + msg.payload.workId;\n"
            + "      log('★ 创作完成！可同步到企业作品列表');\n"
            + "      break;\n"
            + "    case 'CREATION_ERROR':\n"
            + "      statusEl.textContent = '创作失败';\n"
            + "      break;\n"
            + "  }\n"
            + "});\n"
            + "\n"
            + "// 切换 iframe 页面（企业作品列表跳转到编目/配音）\n"
            + "function switchPage(path) {\n"
            + "  fetch('/api/refresh-token?phone=' + PHONE)\n"
            + "    .then(function(r) { return r.json(); })\n"
            + "    .then(function(data) {\n"
            + "      var url = LEAI_H5 + path + '?token=' + data.token + '&orgId=' + ORG_ID + '&phone=' + PHONE + '&embed=1';\n"
            + "      document.getElementById('leai').src = url;\n"
            + "      statusEl.textContent = '已跳转: ' + (path || '新建创作');\n"
            + "      log('跳转: ' + path);\n"
            + "    });\n"
            + "}\n"
            + "</script></body></html>";
    }

    // ══════════════════════════════════════════
    // 功能6：Token 刷新接口
    // ══════════════════════════════════════════

    /**
     * Token 刷新接口（供企业前端 JS 在 TOKEN_EXPIRED 时调用）
     */
    @GetMapping("/api/refresh-token")
    @ResponseBody
    public Map<String, String> refreshToken(@RequestParam String phone) throws Exception {
        String token = exchangeToken(phone);
        Map<String, String> result = new HashMap<>();
        result.put("token", token);
        result.put("orgId", ORG_ID);
        return result;
    }

    // ══════════════════════════════════════════
    // 功能2：认证回调（H5 token 失效后跳回）
    // ══════════════════════════════════════════

    /**
     * H5 token 失效后，H5 自动跳转到此页面（企业在管理后台配置的"认证回调URL"）。
     *
     * 流程:
     *   1. 验证用户身份（企业自身的登录/session 机制）
     *   2. 重新调 auth/session 换取新 token
     *   3. 重定向回 H5（带 returnPath 恢复创作进度）
     *
     * H5 跳转过来的 URL 格式:
     *   /leai-auth?returnPath=/creating&orgId=ORG001
     */
    @GetMapping("/leai-auth")
    public void authCallback(@RequestParam(required = false) String returnPath,
                             @RequestParam(required = false) String orgId,
                             HttpServletRequest request,
                             HttpServletResponse response) throws Exception {
        // 1. 验证用户身份
        //    生产环境: 检查企业 session/JWT, 获取用户手机号
        //    demo 中简化处理, 返回测试手机号
        String phone = getPhoneFromSession(request);
        if (phone == null || phone.isEmpty()) {
            response.setContentType("text/html;charset=UTF-8");
            response.setStatus(401);
            response.getWriter().write("<h2>请先登录企业系统</h2>"
                    + "<p>token 已失效, 请 <a href='/'>返回首页</a> 重新进入。</p>");
            return;
        }

        // 2. 重新换 token
        String newToken = exchangeToken(phone);

        // 3. 重定向回 H5（带 returnPath 恢复创作进度）
        //    returnPath 必须原样传回, H5 用它恢复到用户之前的创作页面
        StringBuilder url = new StringBuilder(LEAI_H5_URL)
                .append("/?token=").append(URLEncoder.encode(newToken, "UTF-8"))
                .append("&orgId=").append(URLEncoder.encode(ORG_ID, "UTF-8"))
                .append("&phone=").append(URLEncoder.encode(phone, "UTF-8"));
        if (returnPath != null && !returnPath.isEmpty()) {
            url.append("&returnPath=").append(URLEncoder.encode(returnPath, "UTF-8"));
        }

        log.info("[AuthCallback] Re-authenticated phone=" + phone
                + ", returnPath=" + returnPath);
        response.sendRedirect(url.toString());
    }

    // ══════════════════════════════════════════
    // 功能3：Webhook 接收 + HMAC-SHA256 签名验证
    // ══════════════════════════════════════════

    /**
     * 接收乐读派 Webhook 回调。
     *
     * Header:
     *   X-Webhook-Id:        事件唯一 ID (如 evt_190368671438289)
     *   X-Webhook-Event:     事件类型 (work.status_changed / work.progress)
     *   X-Webhook-Timestamp: 毫秒时间戳
     *   X-Webhook-Signature: HMAC-SHA256={hex}
     *
     * 签名体: "{X-Webhook-Id}.{X-Webhook-Timestamp}.{请求body原文}"
     * 签名值: HMAC-SHA256(签名体, appSecret).toHex()
     */
    @PostMapping("/webhook/leai")
    @ResponseBody
    public Map<String, String> webhook(
            @RequestBody String rawBody,
            @RequestHeader("X-Webhook-Id") String webhookId,
            @RequestHeader("X-Webhook-Event") String webhookEvent,
            @RequestHeader("X-Webhook-Timestamp") String timestamp,
            @RequestHeader("X-Webhook-Signature") String signature) {

        log.info("[Webhook] Received event=" + webhookEvent + ", id=" + webhookId);

        // 1. 时间窗口检查（5分钟, 防重放攻击）
        long ts;
        try {
            ts = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            log.warning("[Webhook] Invalid timestamp: " + timestamp);
            throw new RuntimeException("invalid timestamp");
        }
        if (Math.abs(System.currentTimeMillis() - ts) > 300_000) {
            log.warning("[Webhook] Timestamp expired: " + timestamp);
            throw new RuntimeException("timestamp expired");
        }

        // 2. 幂等去重
        if (processedEvents.contains(webhookId)) {
            log.info("[Webhook] Duplicate event, skipping: " + webhookId);
            return Collections.singletonMap("status", "duplicate");
        }

        // 3. 验证 HMAC-SHA256 签名
        String signData = webhookId + "." + timestamp + "." + rawBody;
        String expected = "HMAC-SHA256=" + hmacSha256(signData, APP_SECRET);
        if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                   signature.getBytes(StandardCharsets.UTF_8))) {
            log.warning("[Webhook] Signature mismatch! expected=" + expected);
            throw new RuntimeException("invalid signature");
        }

        // 4. 解析事件 payload
        //    简易 JSON 解析（生产环境建议使用 Jackson 或 Gson）
        Map<String, Object> payload = parseJsonObject(rawBody);
        String event = toString(payload.get("event"), "");
        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) payload.get("data");
        if (data == null) {
            data = new HashMap<>();
        }

        String workId = toString(data.get("work_id"), null);

        // 5. 按 V4.0 同步规则处理
        // ════════════════════════════════════════════════════════════
        // TODO [企业开发者]: 将下面的 syncWork() 替换为你的数据库操作
        //   示例:
        //     if (remoteStatus == -1) → UPDATE your_table SET status=-1, fail_reason=? WHERE work_id=?
        //     if (remoteStatus == 2)  → UPDATE your_table SET status=2, progress=? WHERE work_id=?
        //     if (remoteStatus > localStatus) → UPDATE your_table SET status=?, title=?, page_list=? WHERE work_id=?
        //   本 Demo 使用内存 Map 存储，生产环境请替换为 MyBatis/JPA 数据库操作
        // ════════════════════════════════════════════════════════════
        if (workId != null && !workId.isEmpty()) {
            syncWork(workId, data, "Webhook[" + event + "]");
        }

        // 6. 标记已处理
        processedEvents.add(webhookId);
        return Collections.singletonMap("status", "ok");
    }

    // ══════════════════════════════════════════
    // 功能4：B3 定时对账（每30分钟）
    // ══════════════════════════════════════════

    /**
     * 每30分钟调用 B3 接口（GET /api/v1/query/works）对账，补偿 Webhook 遗漏。
     *
     * <p>为什么用30分钟而不是5分钟：
     * <ul>
     *   <li>Webhook 是主通道，实时性已经足够；B3 对账只是兜底补偿偶发遗漏。</li>
     *   <li>5分钟轮询对乐读派 API 造成不必要的查询压力，尤其在并发机构较多时。</li>
     *   <li>30分钟已能覆盖绝大多数 Webhook 重试超时场景（乐读派 Webhook 重试策略最长约10分钟）。</li>
     * </ul>
     * 建议：生产环境不要低于30分钟；如需更高实时性，应优先接入 Webhook，而非缩短 B3 轮询间隔。
     *
     * <p>查询范围：最近2小时内更新的作品（覆盖2个对账周期，确保不遗漏边界数据）。
     */
    @Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
    public void b3Reconcile() {
        log.info("[B3] Starting reconciliation...");
        try {
            // 每30分钟对账一次（兜底补偿Webhook遗漏）
            // 建议不要低于30分钟，避免对乐读派API造成查询压力
            // 查询范围：最近2小时内更新的作品（覆盖2个对账周期）
            Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
            cal.add(Calendar.HOUR_OF_DAY, -2);
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
            sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
            String updatedAfter = sdf.format(cal.getTime());

            // 构建查询参数
            Map<String, String> queryParams = new TreeMap<>();
            queryParams.put("orgId", ORG_ID);
            queryParams.put("updatedAfter", updatedAfter);
            queryParams.put("page", "1");
            queryParams.put("size", "100");

            // 生成 HMAC 签名头
            Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);

            // 拼接 URL
            StringBuilder queryString = new StringBuilder();
            for (Map.Entry<String, String> e : queryParams.entrySet()) {
                if (queryString.length() > 0) queryString.append("&");
                queryString.append(URLEncoder.encode(e.getKey(), "UTF-8"))
                           .append("=")
                           .append(URLEncoder.encode(e.getValue(), "UTF-8"));
            }
            String url = LEAI_API_URL + "/api/v1/query/works?" + queryString.toString();

            // 发起 HTTP GET
            String responseBody = httpGet(url, hmacHeaders);
            Map<String, Object> responseJson = parseJsonObject(responseBody);

            int code = toInt(responseJson.get("code"), 0);
            if (code != 200) {
                log.warning("[B3] API error: " + toString(responseJson.get("msg"), "unknown"));
                return;
            }

            // B3 返回分页对象: { records: [...], total, page, pageSize }
            @SuppressWarnings("unchecked")
            Map<String, Object> dataMap = (Map<String, Object>) responseJson.get("data");
            Object recordsObj = (dataMap != null) ? dataMap.get("records") : null;
            if (recordsObj instanceof Object[]) {
                Object[] records = (Object[]) recordsObj;
                int synced = 0;
                for (Object record : records) {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> r = (Map<String, Object>) record;
                    String workId = toString(r.get("workId"), null);
                    if (workId == null) continue;

                    int remoteStatus = toInt(r.get("status"), 0);
                    // TODO [企业开发者]: 替换为你的数据库查询和更新操作
                    //   1. 从你的数据库查询本地作品: SELECT status FROM your_table WHERE work_id = ?
                    //   2. 按V4.0规则判断是否需要同步: remote_status > local_status || remote_status == 2 || remote_status == -1
                    //   3. 如需同步，调B2获取完整数据: GET /api/v1/query/work/{workId}
                    //   4. 更新到你的数据库: UPDATE your_table SET ... WHERE work_id = ?
                    Map<String, Object> local = worksDb.get(workId);
                    int localStatus = (local != null) ? toInt(local.get("status"), 0) : Integer.MIN_VALUE;

                    // 需要同步: 新作品, 或符合 V4.0 同步规则
                    boolean needSync = (local == null)
                            || (remoteStatus == STATUS_FAILED)
                            || (remoteStatus == STATUS_PROCESSING)
                            || (remoteStatus > localStatus);

                    if (needSync) {
                        // 如果B3返回的是摘要信息, 可调 B2 获取完整数据
                        // 这里简化处理, 直接用B3返回的数据更新
                        try {
                            Map<String, Object> fullData = fetchB2Detail(workId);
                            if (fullData != null) {
                                // 转为 snake_case 存储
                                Map<String, Object> normalized = normalizeB2Response(fullData);
                                worksDb.put(workId, normalized);
                                synced++;
                                log.info("[B3] Synced work " + workId
                                        + ": status " + localStatus + " -> " + remoteStatus);
                            }
                        } catch (Exception e) {
                            // B2 调用失败时, 用 B3 摘要数据做简易同步
                            Map<String, Object> normalized = normalizeB3Summary(r);
                            worksDb.put(workId, normalized);
                            synced++;
                            log.info("[B3] Synced work " + workId + " (from B3 summary)");
                        }
                    }
                }
                log.info("[B3] Reconciliation complete: checked " + records.length
                        + " works, synced " + synced);
            } else {
                log.info("[B3] No data returned or unexpected format");
            }

        } catch (Exception e) {
            log.log(Level.WARNING, "[B3] Reconciliation error", e);
        }
    }

    // ══════════════════════════════════════════════════════════════
    // V4.0 核心同步逻辑（Webhook 和 B3 共用）
    // ══════════════════════════════════════════════════════════════

    /**
     * V4.0 核心同步逻辑（Webhook 和 B3 对账共用）
     *
     * 企业开发者请将此方法中的 worksDb（内存Map）替换为你的数据库操作。
     *
     * ═══════════════════════════════════════════════════════════════
     * 【各事件处理指南】
     *
     * 1. work.status_changed 事件（状态变更，最重要）
     *    - status=1 (PENDING):  作品刚提交，创建本地记录
     *      → INSERT INTO your_table (work_id, status, phone, style, org_id) VALUES (...)
     *
     *    - status=2 (PROCESSING): AI创作进行中（此状态通常由work.progress推送）
     *      → UPDATE your_table SET status=2 WHERE work_id=?
     *
     *    - status=3 (COMPLETED): 图片生成完成，作品可预览
     *      → UPDATE your_table SET status=3, title=?, pages=?, page_list=? WHERE work_id=?
     *      → 此时 page_list 包含所有页面的图片URL，企业应保存
     *
     *    - status=4 (CATALOGED): 用户完成编目（填写了作者/标题等）
     *      → UPDATE your_table SET status=4, title=?, author=?, tags=? WHERE work_id=?
     *
     *    - status=5 (DUBBED): 用户完成配音（乐读派终态）
     *      → UPDATE your_table SET status=5, page_list=? WHERE work_id=?
     *      → page_list 中的 audio_url 字段此时已填充
     *      → 企业可从 status>=5 开始自己的二次编辑状态(6,7,8...)
     *
     *    - status=-1 (FAILED): 创作失败
     *      → UPDATE your_table SET status=-1, fail_reason=? WHERE work_id=?
     *
     * 2. work.progress 事件（创作进度，可选处理）
     *    - 在PROCESSING阶段推送，包含 progress(百分比) 和 progress_message(阶段描述)
     *    - progress=30 → "故事生成完成，开始绘制插画"
     *    - progress=70 → "插画绘制完成，开始配音合成"
     *    - progress=90 → "配音合成完成"
     *    → UPDATE your_table SET progress=?, progress_message=? WHERE work_id=?
     *
     * 3. work.video_completed 事件（视频合成完成，可选）
     *    - 仅在调用A4视频合成后触发
     *    → UPDATE your_table SET video_status='COMPLETED', video_url=? WHERE work_id=?
     *
     * ═══════════════════════════════════════════════════════════════
     * 【并发安全】
     *
     * Webhook 和 B3 对账可能同时触发同一个作品的更新。建议：
     * 1. 使用数据库行锁: SELECT ... FOR UPDATE WHERE work_id=?
     * 2. 或使用 CAS 乐观锁: UPDATE ... SET status=? WHERE work_id=? AND status < ?
     * 3. 使用 webhook_event_id 做幂等去重，避免同一事件重复处理
     *
     * 示例SQL（CAS乐观锁）:
     *   UPDATE your_table
     *   SET status = #{remoteStatus}, title = #{title}, page_list = #{pageList}
     *   WHERE work_id = #{workId}
     *     AND (status < #{remoteStatus} OR #{remoteStatus} = 2 OR #{remoteStatus} = -1)
     * ═══════════════════════════════════════════════════════════════
     */
    private void syncWork(String workId, Map<String, Object> remoteData, String source) {
        int remoteStatus = toInt(remoteData.get("status"), 0);
        Map<String, Object> local = worksDb.get(workId);

        if (local == null) {
            // ★ 新作品：首次收到此workId，创建本地记录
            // TODO [企业开发者]: INSERT INTO your_table (...) VALUES (...)
            worksDb.put(workId, new HashMap<>(remoteData));
            log.info("[" + source + "] INSERT work " + workId + " status=" + remoteStatus);
            return;
        }

        int localStatus = toInt(local.get("status"), 0);

        if (remoteStatus == STATUS_FAILED) {
            // ★ FAILED(-1): 创作失败，强制更新（无视状态比较）
            // TODO [企业开发者]: UPDATE your_table SET status=-1, fail_reason=? WHERE work_id=?
            local.putAll(remoteData);
            log.info("[" + source + "] FORCE UPDATE (FAILED) work " + workId);
            return;
        }

        if (remoteStatus == STATUS_PROCESSING) {
            // ★ PROCESSING(2): 创作进行中，强制更新进度
            // data 中包含 progress(百分比) 和 progress_message(阶段描述)
            // TODO [企业开发者]: UPDATE your_table SET status=2, progress=?, progress_message=? WHERE work_id=?
            local.put("status", remoteStatus);
            if (remoteData.containsKey("progress")) {
                local.put("progress", remoteData.get("progress"));
            }
            if (remoteData.containsKey("progress_message")) {
                local.put("progress_message", remoteData.get("progress_message"));
            }
            log.info("[" + source + "] FORCE UPDATE (PROCESSING) work " + workId
                    + " progress=" + remoteData.get("progress"));
            return;
        }

        if (remoteStatus > localStatus) {
            // ★ 状态前进: 全量覆盖所有字段
            // status=3(COMPLETED): page_list包含图片URL
            // status=4(CATALOGED): title/author/tags已更新
            // status=5(DUBBED): page_list中audio_url已填充
            // TODO [企业开发者]:
            //   UPDATE your_table SET status=?, title=?, author=?, tags=?,
            //     page_list=?, phone=?, ... WHERE work_id=? AND status < ?
            local.putAll(remoteData);
            log.info("[" + source + "] UPDATE work " + workId
                    + " status: " + localStatus + " -> " + remoteStatus);
            return;
        }

        // 旧数据或重复推送，忽略
        log.info("[" + source + "] SKIP work " + workId
                + " remote=" + remoteStatus + " <= local=" + localStatus);
    }

    // ══════════════════════════════════════════
    // 工具方法：令牌交换
    // ══════════════════════════════════════════

    /**
     * 调用乐读派 POST /api/v1/auth/session 换取 sessionToken
     *
     * 请求: { "orgId": "xxx", "appSecret": "xxx", "phone": "138xxx" }
     * 响应: { "code": 200, "data": { "sessionToken": "sess_xxx", "expiresIn": 7200 } }
     */
    private String exchangeToken(String phone) throws Exception {
        String jsonBody = "{\"orgId\":\"" + jsonEscape(ORG_ID)
                + "\",\"appSecret\":\"" + jsonEscape(APP_SECRET)
                + "\",\"phone\":\"" + jsonEscape(phone) + "\"}";

        String url = LEAI_API_URL + "/api/v1/auth/session";
        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setRequestMethod("POST");
        conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
        conn.setConnectTimeout(10_000);
        conn.setReadTimeout(10_000);
        conn.setDoOutput(true);

        try (OutputStream os = conn.getOutputStream()) {
            os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
            os.flush();
        }

        int httpCode = conn.getResponseCode();
        String responseBody = readStream(conn);

        if (httpCode != 200) {
            throw new RuntimeException("令牌交换失败: HTTP " + httpCode + ", body=" + responseBody);
        }

        // 解析 JSON 响应
        Map<String, Object> response = parseJsonObject(responseBody);
        int code = toInt(response.get("code"), 0);
        if (code != 200) {
            throw new RuntimeException("令牌交换失败: code=" + code
                    + ", msg=" + toString(response.get("msg"), "unknown"));
        }

        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) response.get("data");
        if (data == null) {
            throw new RuntimeException("令牌交换失败: data 为 null");
        }

        String sessionToken = toString(data.get("sessionToken"), null);
        if (sessionToken == null || sessionToken.isEmpty()) {
            throw new RuntimeException("令牌交换失败: sessionToken 为空");
        }

        log.info("[Token] Exchanged token for phone=" + phone + ", expires="
                + data.get("expiresIn") + "s");
        return sessionToken;
    }

    // ══════════════════════════════════════════
    // 工具方法：B2 单作品查询
    // ══════════════════════════════════════════

    /**
     * 调用 B2 GET /api/v1/query/work/{workId} 获取完整作品数据
     * 使用 HMAC 认证
     */
    private Map<String, Object> fetchB2Detail(String workId) throws Exception {
        Map<String, String> queryParams = new TreeMap<>();
        queryParams.put("orgId", ORG_ID);

        Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);

        String url = LEAI_API_URL + "/api/v1/query/work/" + URLEncoder.encode(workId, "UTF-8")
                + "?orgId=" + URLEncoder.encode(ORG_ID, "UTF-8");

        String responseBody = httpGet(url, hmacHeaders);
        Map<String, Object> response = parseJsonObject(responseBody);

        int code = toInt(response.get("code"), 0);
        if (code != 200) {
            return null;
        }

        @SuppressWarnings("unchecked")
        Map<String, Object> data = (Map<String, Object>) response.get("data");
        return data;
    }

    // ══════════════════════════════════════════
    // 工具方法：HMAC-SHA256 签名
    // ══════════════════════════════════════════

    /**
     * HMAC-SHA256 签名
     * @param data 待签名字符串
     * @param secret 密钥
     * @return hex 编码的签名值
     */
    private String hmacSha256(String data, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec keySpec = new SecretKeySpec(
                    secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(keySpec);
            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            // 转 hex
            StringBuilder sb = new StringBuilder(hash.length * 2);
            for (byte b : hash) {
                sb.append(String.format("%02x", b & 0xff));
            }
            return sb.toString();
        } catch (Exception e) {
            throw new RuntimeException("HMAC-SHA256 error", e);
        }
    }

    /**
     * 生成 C 端 API HMAC 签名请求头
     *
     * 签名字符串: 所有 query params（含 nonce, timestamp）按 key 字母序排列,
     *            用 & 拼接成 "key1=val1&key2=val2&..." 格式
     * 签名算法: HMAC-SHA256(签名字符串, appSecret), hex 编码
     *
     * 返回4个 Header:
     *   X-App-Key:    机构ID
     *   X-Timestamp:  毫秒时间戳
     *   X-Nonce:      随机串
     *   X-Signature:  签名值
     */
    private Map<String, String> buildHmacHeaders(Map<String, String> queryParams) {
        String ts = String.valueOf(System.currentTimeMillis());
        String nonce = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(System.nanoTime());

        // 将 timestamp 和 nonce 加入签名参数
        TreeMap<String, String> allParams = new TreeMap<>(queryParams);
        allParams.put("timestamp", ts);
        allParams.put("nonce", nonce);

        // 按 key 排序后拼接
        StringBuilder signStr = new StringBuilder();
        for (Map.Entry<String, String> entry : allParams.entrySet()) {
            if (signStr.length() > 0) signStr.append("&");
            signStr.append(entry.getKey()).append("=").append(entry.getValue());
        }

        String sig = hmacSha256(signStr.toString(), APP_SECRET);

        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("X-App-Key", ORG_ID);
        headers.put("X-Timestamp", ts);
        headers.put("X-Nonce", nonce);
        headers.put("X-Signature", sig);
        return headers;
    }

    // ══════════════════════════════════════════
    // 工具方法：HTTP 请求
    // ══════════════════════════════════════════

    private String httpGet(String url, Map<String, String> headers) throws Exception {
        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setRequestMethod("GET");
        conn.setConnectTimeout(10_000);
        conn.setReadTimeout(10_000);
        for (Map.Entry<String, String> h : headers.entrySet()) {
            conn.setRequestProperty(h.getKey(), h.getValue());
        }
        return readStream(conn);
    }

    private String readStream(HttpURLConnection conn) throws Exception {
        BufferedReader reader;
        try {
            reader = new BufferedReader(new InputStreamReader(
                    conn.getInputStream(), StandardCharsets.UTF_8));
        } catch (Exception e) {
            // 读取错误流
            if (conn.getErrorStream() != null) {
                reader = new BufferedReader(new InputStreamReader(
                        conn.getErrorStream(), StandardCharsets.UTF_8));
            } else {
                throw e;
            }
        }
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        reader.close();
        return sb.toString();
    }

    // ══════════════════════════════════════════
    // 工具方法：企业 Session（demo 简化实现）
    // ══════════════════════════════════════════

    /**
     * 从企业 session 获取用户手机号。
     *
     * 生产环境: 读取企业自身的 session/JWT 中的用户手机号。
     * Demo 中: 返回硬编码的测试手机号。
     *
     * 如果用户未登录（session 中没有手机号）, 返回 null。
     */
    private String getPhoneFromSession(HttpServletRequest request) {
        // ── 生产环境示例 ──
        // HttpSession session = request.getSession(false);
        // if (session == null) return null;
        // return (String) session.getAttribute("phone");

        // ── Demo: 返回测试手机号 ──
        return "13800001111";
    }

    // ══════════════════════════════════════════
    // 工具方法：B2/B3 响应字段归一化
    // ══════════════════════════════════════════

    /**
     * 将 B2 返回的 camelCase 字段转为 snake_case 内部存储格式
     */
    private Map<String, Object> normalizeB2Response(Map<String, Object> b2Data) {
        Map<String, Object> normalized = new HashMap<>();
        normalized.put("work_id", b2Data.get("workId"));
        normalized.put("status", b2Data.get("status"));
        normalized.put("progress", b2Data.get("progress"));
        normalized.put("title", b2Data.get("title"));
        normalized.put("phone", b2Data.get("phone"));
        normalized.put("page_list", b2Data.get("pageList"));
        normalized.put("original_image_url", b2Data.get("originalImageUrl"));
        normalized.put("fail_reason", b2Data.get("failReason"));
        normalized.put("author", b2Data.get("author"));
        return normalized;
    }

    /**
     * 将 B3 摘要的 camelCase 字段转为 snake_case 内部存储格式
     */
    private Map<String, Object> normalizeB3Summary(Map<String, Object> b3Data) {
        Map<String, Object> normalized = new HashMap<>();
        normalized.put("work_id", b3Data.get("workId"));
        normalized.put("status", b3Data.get("status"));
        normalized.put("title", b3Data.get("title"));
        normalized.put("phone", b3Data.get("phone"));
        normalized.put("original_image_url", b3Data.get("originalImageUrl"));
        return normalized;
    }

    // ══════════════════════════════════════════
    // 工具方法：简易 JSON 解析
    //
    // 注意: 这是一个极简的 JSON 解析器, 仅处理 demo 所需的基本场景。
    // 生产环境请务必使用 Jackson (spring-boot-starter-web 已内置)
    // 或 Gson 等成熟的 JSON 库。
    //
    // Jackson 示例:
    //   ObjectMapper mapper = new ObjectMapper();
    //   Map<String, Object> result = mapper.readValue(json, new TypeReference<Map<String, Object>>(){});
    //
    // Gson 示例:
    //   Gson gson = new Gson();
    //   Map<String, Object> result = gson.fromJson(json, new TypeToken<Map<String, Object>>(){}.getType());
    // ══════════════════════════════════════════

    /**
     * 简易 JSON 对象解析器。
     * 支持: 嵌套对象, 数组, 字符串, 数字, boolean, null。
     * 不支持: Unicode 转义 (\\uXXXX), 科学计数法等边缘情况。
     *
     * 生产环境请替换为 Jackson ObjectMapper。
     */
    private Map<String, Object> parseJsonObject(String json) {
        if (json == null || json.trim().isEmpty()) {
            return new HashMap<>();
        }
        JsonParser parser = new JsonParser(json.trim());
        Object result = parser.parseValue();
        if (result instanceof Map) {
            @SuppressWarnings("unchecked")
            Map<String, Object> map = (Map<String, Object>) result;
            return map;
        }
        return new HashMap<>();
    }

    /**
     * 极简递归下降 JSON 解析器（内部类）。
     * 仅用于 demo 演示, 避免引入额外依赖。
     */
    private static class JsonParser {
        private final String json;
        private int pos;

        JsonParser(String json) {
            this.json = json;
            this.pos = 0;
        }

        Object parseValue() {
            skipWhitespace();
            if (pos >= json.length()) return null;
            char c = json.charAt(pos);
            if (c == '{') return parseObject();
            if (c == '[') return parseArray();
            if (c == '"') return parseString();
            if (c == 't' || c == 'f') return parseBoolean();
            if (c == 'n') return parseNull();
            return parseNumber();
        }

        Map<String, Object> parseObject() {
            Map<String, Object> map = new LinkedHashMap<>();
            pos++; // skip {
            skipWhitespace();
            if (pos < json.length() && json.charAt(pos) == '}') {
                pos++;
                return map;
            }
            while (pos < json.length()) {
                skipWhitespace();
                String key = parseString();
                skipWhitespace();
                if (pos < json.length() && json.charAt(pos) == ':') pos++;
                skipWhitespace();
                Object value = parseValue();
                map.put(key, value);
                skipWhitespace();
                if (pos < json.length() && json.charAt(pos) == ',') {
                    pos++;
                } else {
                    break;
                }
            }
            if (pos < json.length() && json.charAt(pos) == '}') pos++;
            return map;
        }

        Object[] parseArray() {
            java.util.List<Object> list = new java.util.ArrayList<>();
            pos++; // skip [
            skipWhitespace();
            if (pos < json.length() && json.charAt(pos) == ']') {
                pos++;
                return list.toArray();
            }
            while (pos < json.length()) {
                skipWhitespace();
                list.add(parseValue());
                skipWhitespace();
                if (pos < json.length() && json.charAt(pos) == ',') {
                    pos++;
                } else {
                    break;
                }
            }
            if (pos < json.length() && json.charAt(pos) == ']') pos++;
            return list.toArray();
        }

        String parseString() {
            if (pos >= json.length() || json.charAt(pos) != '"') return "";
            pos++; // skip opening quote
            StringBuilder sb = new StringBuilder();
            while (pos < json.length()) {
                char c = json.charAt(pos);
                if (c == '\\' && pos + 1 < json.length()) {
                    pos++;
                    char escaped = json.charAt(pos);
                    switch (escaped) {
                        case '"':  sb.append('"'); break;
                        case '\\': sb.append('\\'); break;
                        case '/':  sb.append('/'); break;
                        case 'n':  sb.append('\n'); break;
                        case 'r':  sb.append('\r'); break;
                        case 't':  sb.append('\t'); break;
                        case 'b':  sb.append('\b'); break;
                        case 'f':  sb.append('\f'); break;
                        default:   sb.append(escaped); break;
                    }
                } else if (c == '"') {
                    pos++;
                    return sb.toString();
                } else {
                    sb.append(c);
                }
                pos++;
            }
            return sb.toString();
        }

        Object parseNumber() {
            int start = pos;
            if (pos < json.length() && json.charAt(pos) == '-') pos++;
            while (pos < json.length() && Character.isDigit(json.charAt(pos))) pos++;
            boolean isFloat = false;
            if (pos < json.length() && json.charAt(pos) == '.') {
                isFloat = true;
                pos++;
                while (pos < json.length() && Character.isDigit(json.charAt(pos))) pos++;
            }
            String numStr = json.substring(start, pos);
            if (isFloat) {
                return Double.parseDouble(numStr);
            }
            long val = Long.parseLong(numStr);
            if (val >= Integer.MIN_VALUE && val <= Integer.MAX_VALUE) {
                return (int) val;
            }
            return val;
        }

        Boolean parseBoolean() {
            if (json.startsWith("true", pos)) {
                pos += 4;
                return Boolean.TRUE;
            }
            if (json.startsWith("false", pos)) {
                pos += 5;
                return Boolean.FALSE;
            }
            return Boolean.FALSE;
        }

        Object parseNull() {
            if (json.startsWith("null", pos)) {
                pos += 4;
            }
            return null;
        }

        void skipWhitespace() {
            while (pos < json.length() && Character.isWhitespace(json.charAt(pos))) {
                pos++;
            }
        }
    }

    // ══════════════════════════════════════════
    // 工具方法：杂项
    // ══════════════════════════════════════════

    private static int toInt(Object obj, int defaultValue) {
        if (obj == null) return defaultValue;
        if (obj instanceof Number) return ((Number) obj).intValue();
        try {
            return Integer.parseInt(obj.toString());
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }

    private static String toString(Object obj, String defaultValue) {
        if (obj == null) return defaultValue;
        return obj.toString();
    }

    private static String jsonEscape(String s) {
        if (s == null) return "";
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }

    private static String escapeHtml(String s) {
        if (s == null) return "";
        return s.replace("&", "&amp;").replace("<", "&lt;")
                .replace(">", "&gt;").replace("\"", "&quot;");
    }

    private static String statusName(int status) {
        switch (status) {
            case STATUS_FAILED:     return "FAILED";
            case STATUS_PENDING:    return "PENDING";
            case STATUS_PROCESSING: return "PROCESSING";
            case STATUS_COMPLETED:  return "COMPLETED";
            case STATUS_CATALOGED:  return "CATALOGED";
            case STATUS_DUBBED:     return "DUBBED";
            default:                return "UNKNOWN(" + status + ")";
        }
    }

    private static String statusStyle(int status) {
        switch (status) {
            case STATUS_FAILED:     return "color:#DC2626;font-weight:600;";
            case STATUS_PENDING:    return "color:#2563EB;";
            case STATUS_PROCESSING: return "color:#FF6B35;font-weight:600;";
            case STATUS_COMPLETED:  return "color:#059669;";
            case STATUS_CATALOGED:  return "color:#059669;font-weight:600;";
            case STATUS_DUBBED:     return "color:#7C3AED;font-weight:600;";
            default:                return "color:#6B7280;";
        }
    }
}
