import { execFile } from "node:child_process";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { once } from "node:events";
import { createServer } from "node:http";
import { connect as netConnect } from "node:net";
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { connect as tlsConnect } from "node:tls";
import { fileURLToPath } from "node:url";

import {
  LiveDataCollectorPlugin,
  classifyExternalReportSource,
  createLiveDataCollectorRecoveryHandoff,
  exportComparisonCsv,
  exportSettlementCorrectionsCsv,
  readLiveDataCollectorFacts,
  shouldSendLiveDataCollectorRecoveryAlert
} from "../../packages/live-data-collector-plugin/src/index.js";

const rootDir = resolve(fileURLToPath(new URL("../..", import.meta.url)));
loadEnvFile(resolve(rootDir, "config", "email.env"));
loadEnvFile(process.env.LIVE_RUSH_COLLECTOR_EMAIL_ENV_FILE);
const port = Number(process.env.LIVE_RUSH_COLLECTOR_PORT ?? 18766);
const serviceLogFile = process.env.LIVE_RUSH_COLLECTOR_LOG_FILE
  ? resolve(process.env.LIVE_RUSH_COLLECTOR_LOG_FILE)
  : null;
const defaultDataDir = process.env.LIVE_RUSH_COLLECTOR_DATA_DIR
  ? resolve(process.env.LIVE_RUSH_COLLECTOR_DATA_DIR)
  : join(rootDir, ".runtime", "live-data-collector-field");
loadEnvFile(join(defaultDataDir, "email.env"));
const operatorSettingsFile = join(defaultDataDir, "operator-settings.json");
const recoverySecretFile = join(defaultDataDir, "recovery-secret.key");
const recoveryOutboxFile = join(defaultDataDir, "recovery-alerts.ndjson");
const recoveryLinkTtlMs = numberFromEnv("LIVE_RUSH_COLLECTOR_RECOVERY_LINK_TTL_MS", 15 * 60 * 1000);
const recoveryAlertThrottleMs = numberFromEnv("LIVE_RUSH_COLLECTOR_RECOVERY_ALERT_THROTTLE_MS", 10 * 60 * 1000);
const recoveryPublicBaseUrl = firstText(process.env.LIVE_RUSH_COLLECTOR_PUBLIC_BASE_URL, `http://127.0.0.1:${port}`);
const recoveryAlertWebhookUrl = firstText(
  process.env.LIVE_RUSH_COLLECTOR_ALERT_EMAIL_WEBHOOK_URL,
  process.env.LIVE_RUSH_COLLECTOR_ALERT_WEBHOOK_URL
);
const recoveryAlertWebhookToken = firstText(process.env.LIVE_RUSH_COLLECTOR_ALERT_WEBHOOK_TOKEN);
const recoveryAlertEmailFromEnv = firstText(process.env.LIVE_RUSH_COLLECTOR_ALERT_EMAIL);
const recoveryAlertSmtpHost = firstText(process.env.LIVE_RUSH_COLLECTOR_SMTP_HOST, process.env.SMTP_HOST);
const recoveryAlertSmtpPort = numberFromEnv("LIVE_RUSH_COLLECTOR_SMTP_PORT", numberFromEnv("SMTP_PORT", 465));
const recoveryAlertSmtpSsl = booleanFromEnv("LIVE_RUSH_COLLECTOR_SMTP_SSL", booleanFromEnv("SMTP_SSL", true));
const recoveryAlertSmtpUser = firstText(process.env.LIVE_RUSH_COLLECTOR_SMTP_USER, process.env.SMTP_USER);
const recoveryAlertSmtpPassword = firstText(
  process.env.LIVE_RUSH_COLLECTOR_SMTP_PASSWORD,
  process.env.LIVE_RUSH_COLLECTOR_SMTP_PASS,
  process.env.SMTP_PASSWORD,
  process.env.SMTP_PASS
);
const recoveryAlertSmtpFrom = firstText(process.env.LIVE_RUSH_COLLECTOR_SMTP_FROM, process.env.SMTP_FROM, recoveryAlertSmtpUser);
const defaultPrimaryChannel = process.env.LIVE_RUSH_COLLECTOR_PRIMARY_CHANNEL
  ?? process.env.LIVE_RUSH_COLLECTOR_PRIMARY_SIGNER_URL
  ?? process.env.LIVE_RUSH_COLLECTOR_PRIMARY_WEBSOCKET_URL
  ?? "";
const defaultBackupChannel = process.env.LIVE_RUSH_COLLECTOR_BACKUP_CHANNEL
  ?? process.env.LIVE_RUSH_COLLECTOR_BACKUP_SIGNER_URL
  ?? process.env.LIVE_RUSH_COLLECTOR_BACKUP_WEBSOCKET_URL
  ?? "";

let plugin = null;
let lastComparison = null;
let lastSettlement = null;
let lastError = null;
let lastRecoveryAlert = null;

process.on("uncaughtException", (error) => {
  writeServiceLog(`uncaughtException ${error?.stack ?? error?.message ?? String(error)}`);
  process.exit(1);
});

process.on("unhandledRejection", (error) => {
  writeServiceLog(`unhandledRejection ${error?.stack ?? error?.message ?? String(error)}`);
  process.exit(1);
});

const server = createServer(async (request, response) => {
  try {
    const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`);
    if (request.method === "GET" && url.pathname === "/") {
      return sendHtml(response, renderApp());
    }
    if (request.method === "GET" && url.pathname === "/recover/tiktok") {
      const token = url.searchParams.get("token") ?? "";
      return sendHtml(response, renderRecoveryPage(validateRecoveryToken(token)));
    }
    if (request.method === "GET" && url.pathname === "/api/status") {
      const status = createStatus();
      await maybeSendBrowserRecoveryAlert(status);
      return sendJson(response, createStatus());
    }
    if (request.method === "GET" && url.pathname === "/api/preflight") {
      return sendJson(response, createPreflightStatus());
    }
    if (request.method === "GET" && url.pathname === "/api/operator-settings") {
      return sendJson(response, readOperatorSettingsForClient());
    }
    if (request.method === "POST" && url.pathname === "/api/operator-settings") {
      const body = await readJson(request);
      const settings = saveOperatorSettings(createOperatorSettingsFromRequest(body));
      return sendJson(response, {
        ok: true,
        settings: readOperatorSettingsForClient(settings)
      });
    }
    if (request.method === "GET" && url.pathname === "/api/status.realtime") {
      return sendJson(response, createStatus().realtime ?? {
        generated_at: new Date().toISOString(),
        state: "not_started",
        level: "idle",
        headline: "还没开始采集",
        operator_message: "可以在开播前填写 TikTok 主播账号或直播间链接并点击开始采集。",
        next_actions: ["开播前先点开始采集，让插件进入待机驻守。"]
      });
    }
    if (request.method === "POST" && url.pathname === "/api/start") {
      const body = await readJson(request);
      plugin = new LiveDataCollectorPlugin({
        config: createPluginConfigFromRequest(body)
      });
      const started = await plugin.start();
      lastError = started.ok ? null : started;
      if (started.ok) {
        saveOperatorSettings(createOperatorSettingsFromRequest(body));
      }
      return sendJson(response, {
        ok: started.ok,
        started,
        status: createStatus()
      });
    }
    if (request.method === "POST" && url.pathname === "/api/stop") {
      const stopped = plugin?.stop() ?? { ok: true, reason_code: "not_started" };
      return sendJson(response, {
        ok: true,
        stopped,
        status: createStatus()
      });
    }
    if (request.method === "POST" && url.pathname === "/api/open-tiktok-backup") {
      if (!plugin) {
        return sendJson(response, {
          ok: false,
          reason_code: "collector_not_started",
          message: "先点击开始驻守，再打开 TikTok 验证页。"
        });
      }
      const recovery = await plugin.openBrowserRecovery();
      if (recovery.ok !== true && recovery.target_url) {
        openUrl(recovery.target_url);
      }
      return sendJson(response, {
        ok: recovery.ok === true,
        recovery,
        status: createStatus()
      });
    }
    if (request.method === "POST" && url.pathname === "/api/send-recovery-email") {
      const body = await readJson(request);
      if (firstText(body.alert_email, body.alertEmail)) {
        saveOperatorSettings({
          ...readOperatorSettings(),
          alert_email: firstText(body.alert_email, body.alertEmail)
        });
      }
      const status = createStatus();
      const alert = await sendBrowserRecoveryAlert(status, { force: true });
      return sendJson(response, {
        ok: alert.ok === true,
        alert,
        status: createStatus()
      });
    }
    if (request.method === "POST" && url.pathname === "/api/frame") {
      const role = url.searchParams.get("role") ?? "primary_realtime";
      const bodyText = await readText(request);
      const body = parseMaybeJson(bodyText);
      const frame = typeof body === "object" && body?.frame !== undefined ? body.frame : bodyText;
      ensurePlugin();
      const ingested = plugin.ingestWebcastFrame(typeof frame === "string" ? frame : JSON.stringify(frame), { role });
      return sendJson(response, {
        ok: ingested.ok,
        ingested,
        status: createStatus()
      });
    }
    if (request.method === "POST" && url.pathname === "/api/compare") {
      ensurePlugin();
      const body = await readJson(request);
      const compared = plugin.compareWithExternalReport({
        sourceName: body.source_name ?? body.sourceName ?? "external",
        format: body.format ?? "csv",
        content: body.content ?? "",
        rows: Array.isArray(body.rows) ? body.rows : null
      });
      lastComparison = compared;
      return sendJson(response, compared);
    }
    if (request.method === "POST" && url.pathname === "/api/settlement") {
      ensurePlugin();
      const body = await readJson(request);
      const sourceName = body.source_name ?? body.sourceName ?? "guild_backend";
      const sourcePolicy = classifyExternalReportSource(sourceName);
      if (!sourcePolicy.production_allowed) {
        return sendJson(response, {
          ok: false,
          reason_code: "settlement_source_not_allowed",
          source_policy: sourcePolicy,
          message: "MCA 或第三方测试报表只能做测试对照，不能生成正式补数报告。"
        });
      }
      const settled = plugin.createSettlementReport({
        sourceName,
        format: body.format ?? "csv",
        content: body.content ?? "",
        rows: Array.isArray(body.rows) ? body.rows : null,
        aggregate: body.aggregate ?? body.summary ?? body.totals ?? null
      });
      lastSettlement = settled;
      return sendJson(response, settled);
    }
    if (request.method === "GET" && url.pathname === "/api/summary") {
      ensurePlugin();
      return sendJson(response, {
        snapshot: plugin.snapshot(),
        manifests: plugin.createManifests(),
        replay: plugin.replay(),
        health: plugin.createHealthReport(),
        diagnostics: plugin.createCollectionDiagnostics({ write: true }),
        comparison: lastComparison?.report ?? null,
        settlement: lastSettlement?.report ?? null
      });
    }
    if (request.method === "GET" && url.pathname === "/api/facts.csv") {
      ensurePlugin();
      return sendText(response, exportFactsCsv(readLiveDataCollectorFacts(plugin.snapshot().output_files.facts)), {
        contentType: "text/csv; charset=utf-8",
        filename: "live-data-facts.csv"
      });
    }
    if (request.method === "GET" && url.pathname === "/api/comparison.csv") {
      if (!lastComparison?.report) {
        response.writeHead(404, { "content-type": "application/json; charset=utf-8" });
        response.end(JSON.stringify({ ok: false, reason_code: "comparison_not_ready" }));
        return;
      }
      return sendText(response, exportComparisonCsv(lastComparison.report), {
        contentType: "text/csv; charset=utf-8",
        filename: "comparison-report.csv"
      });
    }
    if (request.method === "GET" && url.pathname === "/api/settlement.csv") {
      if (!lastSettlement?.report) {
        response.writeHead(404, { "content-type": "application/json; charset=utf-8" });
        response.end(JSON.stringify({ ok: false, reason_code: "settlement_not_ready" }));
        return;
      }
      return sendText(response, exportSettlementCorrectionsCsv(lastSettlement.report), {
        contentType: "text/csv; charset=utf-8",
        filename: "settlement-corrections.csv"
      });
    }
    if (request.method === "POST" && url.pathname === "/api/open-output") {
      ensurePlugin();
      openFolder(plugin.config.session_dir);
      return sendJson(response, {
        ok: true,
        folder: plugin.config.session_dir
      });
    }
    response.writeHead(404, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify({ ok: false, reason_code: "not_found" }));
  } catch (error) {
    lastError = {
      reason_code: "server_error",
      message: error?.message ?? String(error)
    };
    response.writeHead(500, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify({ ok: false, ...lastError }));
  }
});

server.on("error", (error) => {
  writeServiceLog(`server error ${error?.stack ?? error?.message ?? String(error)}`);
  console.error(error);
  process.exit(1);
});

server.listen(port, "127.0.0.1", () => {
  const readyMessage = `Live Rush Data Collector is running at http://127.0.0.1:${port}`;
  console.log(readyMessage);
  console.log(`Data directory: ${defaultDataDir}`);
  writeServiceLog(readyMessage);
  writeServiceLog(`data directory ${defaultDataDir}`);
  autoStartSavedCollector();
});

function writeServiceLog(message) {
  if (!serviceLogFile) {
    return;
  }
  try {
    mkdirSync(dirname(serviceLogFile), { recursive: true });
    appendFileSync(serviceLogFile, `${new Date().toISOString()} ${message}\n`, "utf8");
  } catch {
    // Logging must never prevent the local collector from starting.
  }
}

function loadEnvFile(filePath) {
  const target = firstText(filePath);
  if (!target || !existsSync(target)) {
    return;
  }
  try {
    for (const line of readFileSync(target, "utf8").split(/\r?\n/u)) {
      const text = line.trim();
      if (!text || text.startsWith("#") || !text.includes("=")) {
        continue;
      }
      const separatorIndex = text.indexOf("=");
      const key = text.slice(0, separatorIndex).trim().replace(/^\uFEFF/u, "");
      const value = text.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/gu, "");
      if (key && process.env[key] === undefined) {
        process.env[key] = value;
      }
    }
  } catch {
    // Optional email configuration must never block local collection startup.
  }
}

function readOperatorSettings() {
  try {
    if (!existsSync(operatorSettingsFile)) {
      return {};
    }
    const parsed = JSON.parse(readFileSync(operatorSettingsFile, "utf8"));
    return typeof parsed === "object" && parsed !== null ? parsed : {};
  } catch (error) {
    writeServiceLog(`operator settings read failed ${error?.message ?? String(error)}`);
    return {};
  }
}

function readOperatorSettingsForClient(settings = readOperatorSettings()) {
  return {
    room_input: firstText(settings.room_input, settings.roomInput),
    tenant_label: firstText(settings.tenant_label, settings.tenantLabel, "field_customer"),
    primary_channel: firstText(settings.primary_channel, settings.primaryChannel),
    backup_channel: firstText(settings.backup_channel, settings.backupChannel),
    alert_email: firstText(settings.alert_email, settings.alertEmail, recoveryAlertEmailFromEnv),
    auto_start: settings.auto_start !== false,
    updated_at: settings.updated_at ?? null,
    default_primary_channel_configured: Boolean(defaultPrimaryChannel),
    default_backup_channel_configured: Boolean(defaultBackupChannel)
  };
}

function saveOperatorSettings(settings = {}) {
  const normalized = {
    room_input: firstText(settings.room_input, settings.roomInput),
    tenant_label: firstText(settings.tenant_label, settings.tenantLabel, "field_customer"),
    primary_channel: firstText(settings.primary_channel, settings.primaryChannel),
    backup_channel: firstText(settings.backup_channel, settings.backupChannel),
    alert_email: firstText(settings.alert_email, settings.alertEmail, recoveryAlertEmailFromEnv),
    auto_start: settings.auto_start === true,
    updated_at: new Date().toISOString()
  };
  mkdirSync(defaultDataDir, { recursive: true });
  writeFileSync(operatorSettingsFile, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
  return normalized;
}

function createOperatorSettingsFromRequest(body = {}) {
  const roomInput = firstText(body.room_input, body.roomInput, body.live_room_id, body.liveRoomId);
  return {
    room_input: normalizeRoomInputForSettings(roomInput),
    tenant_label: firstText(body.tenant_label, body.tenantLabel, body.tenant_id, body.tenantId, "field_customer"),
    primary_channel: firstText(body.primary_channel, body.primaryChannel, body.primary_websocket_url, body.primaryWebsocketUrl, body.primary_signer_url, body.primarySignerUrl),
    backup_channel: firstText(body.backup_channel, body.backupChannel, body.backup_websocket_url, body.backupWebsocketUrl, body.backup_signer_url, body.backupSignerUrl),
    alert_email: firstText(body.alert_email, body.alertEmail, recoveryAlertEmailFromEnv),
    auto_start: booleanFromRequest(body.auto_start ?? body.autoStart, true)
  };
}

async function autoStartSavedCollector() {
  const settings = readOperatorSettings();
  const roomInput = firstText(settings.room_input, settings.roomInput);
  if (plugin || settings.auto_start !== true || !roomInput) {
    return;
  }
  try {
    plugin = new LiveDataCollectorPlugin({
      config: createPluginConfigFromRequest(settings)
    });
    const started = await plugin.start();
    lastError = started.ok ? null : started;
    writeServiceLog(started.ok
      ? `auto start standby enabled for ${roomInput}`
      : `auto start failed ${started.reason_code ?? "unknown"}`);
  } catch (error) {
    lastError = {
      reason_code: "auto_start_failed",
      message: error?.message ?? String(error)
    };
    writeServiceLog(`auto start failed ${error?.stack ?? error?.message ?? String(error)}`);
  }
}

function createPluginConfigFromRequest(body = {}) {
  const roomInfo = parseRoomInput(body.room_input ?? body.roomInput ?? body.live_room_id ?? body.liveRoomId ?? "room_field_test");
  if (roomInfo.invalid) {
    throw new Error(roomInfo.invalid_message);
  }
  const liveRoomId = roomInfo.liveRoomId;
  const primaryWebcast = createWebcastConfig({
    role: "primary_realtime",
    roomInfo,
    channel: firstText(body.primary_channel, body.primaryChannel, body.primary_websocket_url, body.primaryWebsocketUrl, body.primary_signer_url, body.primarySignerUrl, defaultPrimaryChannel)
  });
  const backupWebcast = createWebcastConfig({
    role: "backup_realtime",
    roomInfo,
    channel: firstText(body.backup_channel, body.backupChannel, body.backup_websocket_url, body.backupWebsocketUrl, body.backup_signer_url, body.backupSignerUrl, defaultBackupChannel)
  });
  const invalidChannel = [primaryWebcast, backupWebcast].find((webcast) => webcast.invalid_channel);
  if (invalidChannel) {
    throw new Error(invalidChannel.invalid_channel_message);
  }
  if (liveRoomId && !hasAutoConnectorTarget(primaryWebcast) && !hasConnectionTarget(primaryWebcast)) {
    throw new Error("只填房间号时，需要技术同学配置主采集通道。运营请优先填写 TikTok 主播 @账号或直播间链接。");
  }
  const tenantLabel = slugForId(body.tenant_label ?? body.tenantLabel ?? body.tenant_id ?? body.tenantId ?? "field_customer");
  const sessionId = body.session_id ?? body.sessionId ?? createFieldSessionId(body.session_note ?? body.sessionNote);
  return {
    tenant_id: tenantLabel,
    session_id: sessionId,
    live_room_id: liveRoomId,
    collector_id: body.collector_id ?? body.collectorId ?? "field_collector",
    data_dir: body.data_dir ?? body.dataDir ?? defaultDataDir,
    primary_webcast: primaryWebcast,
    ...(hasConnectionTarget(backupWebcast) || hasAutoConnectorTarget(backupWebcast) ? { backup_webcast: backupWebcast } : {})
  };
}

function createWebcastConfig({ role, roomInfo, channel }) {
  const target = parseChannelInput(channel);
  const useLocalBrowserBackup = role === "backup_realtime" && !target.websocketUrl && !target.signerUrl;
  return {
    role,
    provider: useLocalBrowserBackup ? "tiktok_browser_observer" : "tiktok_webcast",
    auto_connector_enabled: true,
    ...(roomInfo.liveRoomId ? { live_room_id: roomInfo.liveRoomId } : {}),
    ...(roomInfo.liveRoomUrl ? { live_room_url: roomInfo.liveRoomUrl } : {}),
    ...(roomInfo.creatorHandle ? { creator_handle: roomInfo.creatorHandle } : {}),
    ...(target.websocketUrl ? { websocket_url: target.websocketUrl } : {}),
    ...(target.signerUrl ? { signer_url: target.signerUrl } : {}),
    ...(!useLocalBrowserBackup ? {
      connector_connect_with_unique_id: booleanFromEnv("LIVE_RUSH_TIKTOK_CONNECT_WITH_UNIQUE_ID", false),
      connector_process_initial_data: false,
      connector_fetch_room_info_on_connect: false,
      connector_enable_extended_gift_info: booleanFromEnv("LIVE_RUSH_TIKTOK_EXTENDED_GIFT_INFO", false),
      connector_enable_request_polling: true,
      connector_request_polling_interval_ms: 15000
    } : {}),
    ...(target.invalid ? {
      invalid_channel: true,
      invalid_channel_message: "采集通道地址只能填写 wss:// WebSocket 地址或 https:// 托管通道地址，请不要填写备注、账号或中文说明。"
    } : {}),
    reconnect: {
      max_attempts: useLocalBrowserBackup ? 20 : 3,
      max_total_attempts: useLocalBrowserBackup ? 200 : 20,
      backoff_ms: useLocalBrowserBackup ? 1000 : 1500,
      max_delay_ms: useLocalBrowserBackup ? 10000 : 5000
    },
    ...(useLocalBrowserBackup ? {
      browser_auto_minimize_after_live: true,
      browser_open_as_app: true,
      browser_attach_auxiliary_targets: true,
      browser_auxiliary_target_types: ["service_worker", "worker", "shared_worker"],
      browser_accept_unmapped_auxiliary_frames: true,
      browser_target_poll_interval_ms: 2000,
      browser_page_health_check_interval_ms: 5000,
      browser_runtime_capture_enabled: true,
      browser_polling_response_url_patterns: ["webcast/im/fetch", "/webcast/im/fetch", "webcast/fetch"],
      relevant_websocket_url_patterns: ["webcast", "im-ws"],
      require_relevant_websocket_url: true
    } : {}),
    standby: {
      enabled: true,
      poll_interval_ms: 3000,
      max_wait_ms: 0
    }
  };
}

function parseRoomInput(value) {
  const text = String(value ?? "").trim();
  if (!text || text === "room_field_test") {
    return {
      liveRoomId: "room_field_test",
      liveRoomUrl: null,
      creatorHandle: null
    };
  }
  if (/^https?:\/\//iu.test(text)) {
    const parsedUrl = parseUrlOrNull(text);
    if (!parsedUrl) {
      return {
        liveRoomId: null,
        liveRoomUrl: null,
        creatorHandle: null,
        invalid: true,
        invalid_message: "直播间链接格式不对。请填写 TikTok 主播 @账号，或粘贴 tiktok.com 的直播间链接。"
      };
    }
    if (!isTiktokUrl(parsedUrl)) {
      const localRoomHint = extractLocalRoomHintFromUrl(parsedUrl);
      if (localRoomHint) {
        return /^\d+$/u.test(localRoomHint)
          ? {
              liveRoomId: localRoomHint,
              liveRoomUrl: null,
              creatorHandle: null,
              normalizedFromLocalPluginUrl: true
            }
          : {
              liveRoomId: null,
              liveRoomUrl: null,
              creatorHandle: localRoomHint.replace(/^@/u, ""),
              normalizedFromLocalPluginUrl: true
            };
      }
      return {
        liveRoomId: null,
        liveRoomUrl: null,
        creatorHandle: null,
        invalid: true,
        invalid_message: "你填的是本地插件/OBS 页面，不是 TikTok 直播间。请填写 TikTok 主播 @账号，或粘贴 tiktok.com 的直播链接。"
      };
    }
    return {
      liveRoomId: extractRoomIdFromUrl(parsedUrl),
      liveRoomUrl: text,
      creatorHandle: extractCreatorHandleFromTiktokUrl(parsedUrl)
    };
  }
  if (text.startsWith("@")) {
    return {
      liveRoomId: null,
      liveRoomUrl: null,
      creatorHandle: text.slice(1)
    };
  }
  if (!/^\d+$/u.test(text)) {
    return {
      liveRoomId: null,
      liveRoomUrl: null,
      creatorHandle: text
    };
  }
  return {
    liveRoomId: text,
    liveRoomUrl: null,
    creatorHandle: null
  };
}

function normalizeRoomInputForSettings(value) {
  const roomInfo = parseRoomInput(value);
  if (roomInfo.invalid) {
    return firstText(value);
  }
  if (roomInfo.creatorHandle) {
    return `@${roomInfo.creatorHandle}`;
  }
  if (roomInfo.liveRoomUrl) {
    return roomInfo.liveRoomUrl;
  }
  if (roomInfo.liveRoomId && roomInfo.liveRoomId !== "room_field_test") {
    return roomInfo.liveRoomId;
  }
  return firstText(value);
}

function parseChannelInput(value) {
  const text = String(value ?? "").trim();
  if (!text) {
    return {
      websocketUrl: null,
      signerUrl: null
    };
  }
  if (/^wss?:\/\//iu.test(text)) {
    return {
      websocketUrl: text,
      signerUrl: null
    };
  }
  if (/^https?:\/\//iu.test(text)) {
    return {
      websocketUrl: null,
      signerUrl: text
    };
  }
  return {
    websocketUrl: null,
    signerUrl: null,
    invalid: true
  };
}

function extractRoomIdFromText(text) {
  const parsed = parseUrlOrNull(text);
  return parsed ? extractRoomIdFromUrl(parsed) : null;
}

function extractRoomIdFromUrl(parsed) {
  return parsed.searchParams.get("room_id") ?? parsed.searchParams.get("roomId") ?? parsed.searchParams.get("room");
}

function extractCreatorHandleFromTiktokUrl(parsed) {
  const match = parsed.pathname.match(/\/@([^/?#]+)/u);
  return match?.[1] ? match[1].replace(/^@/u, "") : null;
}

function extractLocalRoomHintFromUrl(parsed) {
  const hint = firstText(
    parsed.searchParams.get("creator"),
    parsed.searchParams.get("creator_handle"),
    parsed.searchParams.get("creatorHandle"),
    parsed.searchParams.get("handle"),
    parsed.searchParams.get("username"),
    parsed.searchParams.get("user"),
    parsed.searchParams.get("room"),
    parsed.searchParams.get("room_id"),
    parsed.searchParams.get("roomId")
  );
  if (!hint) {
    return null;
  }
  return hint
    .replace(/^@/u, "")
    .replace(/[^a-zA-Z0-9_.-]+/g, "")
    .slice(0, 64) || null;
}

function parseUrlOrNull(value) {
  try {
    return new URL(String(value ?? "").trim());
  } catch {
    return null;
  }
}

function isTiktokUrl(value) {
  const parsed = value instanceof URL ? value : parseUrlOrNull(value);
  if (!parsed) {
    return false;
  }
  const hostname = parsed.hostname.toLowerCase();
  return hostname === "tiktok.com" || hostname.endsWith(".tiktok.com");
}

function createFieldSessionId(note = null) {
  const suffix = slugForId(note ?? new Date().toISOString()).slice(0, 32);
  return `field_${Date.now()}${suffix ? `_${suffix}` : ""}`;
}

function slugForId(value) {
  return String(value ?? "")
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9_-]+/g, "_")
    .replace(/^_+|_+$/g, "")
    .slice(0, 64) || "field";
}

function firstText(...values) {
  for (const value of values) {
    const text = String(value ?? "").trim();
    if (text) {
      return text;
    }
  }
  return "";
}

function booleanFromRequest(value, fallback = false) {
  if (value === undefined || value === null || value === "") {
    return fallback;
  }
  if (typeof value === "boolean") {
    return value;
  }
  const text = String(value).trim().toLowerCase();
  if (["1", "true", "yes", "y", "on"].includes(text)) {
    return true;
  }
  if (["0", "false", "no", "n", "off"].includes(text)) {
    return false;
  }
  return fallback;
}

function numberFromEnv(name, fallback) {
  const value = process.env[name];
  if (value === undefined || value === null || value === "") {
    return fallback;
  }
  const number = Number(value);
  return Number.isFinite(number) && number > 0 ? number : fallback;
}

function booleanFromEnv(name, fallback = false) {
  return booleanFromRequest(process.env[name], fallback);
}

function hasConnectionTarget(webcast = {}) {
  return Boolean(webcast.websocket_url || webcast.websocket_url_template || webcast.signer_url);
}

function hasAutoConnectorTarget(webcast = {}) {
  return Boolean(webcast.creator_handle || webcast.live_room_url);
}

function createStatus() {
  const snapshot = plugin?.snapshot?.() ?? null;
  const diagnostics = snapshot?.diagnostics ?? (plugin ? plugin.createCollectionDiagnostics({ write: false }) : null);
  const realtime = snapshot?.realtime ?? (plugin ? plugin.createRealtimeStatus() : null);
  const browserRecovery = createBrowserRecoveryStatus(realtime);
  return {
    ok: lastError === null,
    server: {
      port,
      data_dir: defaultDataDir
    },
    plugin: snapshot,
    realtime,
    browser_recovery: browserRecovery,
    recovery_alert: createRecoveryAlertStatus(browserRecovery),
    health: plugin ? plugin.createHealthReport() : null,
    diagnostics,
    replay: plugin ? plugin.replay() : null,
    operator_settings: readOperatorSettingsForClient(),
    preflight: createPreflightStatus(),
    comparison: lastComparison?.report ? {
      accuracy_score: lastComparison.report.accuracy_score,
      ok: lastComparison.report.ok,
      ...lastComparison.report.summary
    } : null,
    settlement: lastSettlement?.report ? {
      source_name: lastSettlement.report.source_name,
      production_allowed: lastSettlement.report.source_policy?.production_allowed === true,
      settlement_mode: lastSettlement.report.settlement_mode ?? null,
      ready_for_production_settlement: lastSettlement.report.ready_for_production_settlement === true,
      accuracy_score: lastSettlement.report.summary?.accuracy_score ?? null,
      unresolved_count: lastSettlement.report.summary?.unresolved_count ?? null,
      correction_count: lastSettlement.report.corrections?.length ?? 0,
      aggregate_ok: lastSettlement.report.aggregate_reconciliation?.present ? lastSettlement.report.aggregate_reconciliation.ok === true : null,
      aggregate_deltas: lastSettlement.report.aggregate_reconciliation?.deltas ?? {},
      settlement_gate_ok: lastSettlement.report.settlement_readiness_gate?.ok ?? null,
      settlement_gate_issue_count: lastSettlement.report.settlement_readiness_gate?.issue_count ?? 0
    } : null,
    output_files: plugin?.outputFiles?.() ?? null,
    last_error: lastError
  };
}

function createBrowserRecoveryStatus(realtime = null) {
  const route = (realtime?.routes ?? []).find((item) => item.target_source === "tiktok_browser_observer");
  if (!route) {
    return {
      available: false,
      required: false,
      message: "当前没有启用本机浏览器备用线路。"
    };
  }
  const loginRequired = route.browser_login_required === true
    || (Number(route.browser_not_logged_in_count ?? 0) > 0
      && route.browser_event_stream_connected !== true
      && Number(route.gift_count ?? 0) + Number(route.p1_event_count ?? 0) === 0);
  const headless = route.browser_headless === true;
  const interactiveRecoveryAvailable = route.browser_interactive_recovery_available !== false && !headless;
  const required = route.browser_recovery_required === true
    || loginRequired
    || route.health_level === "blocked"
    || route.last_issue_code === "tiktok_browser_observer_page_verification_blocked"
    || Number(route.browser_page_blocked_count ?? 0) > 0;
  return {
    available: true,
    required,
    login_required: loginRequired,
    headless,
    interactive_recovery_available: interactiveRecoveryAvailable,
    target_url: route.browser_manual_recovery_url ?? route.target_url ?? null,
    browser_user_data_dir: route.browser_user_data_dir ?? null,
    can_minimize: route.browser_can_minimize !== false,
    can_close: route.browser_can_close === true,
    message: required
      ? interactiveRecoveryAvailable
        ? "TikTok 备用页需要登录或验证。点“打开 TikTok 验证页”，完成后把浏览器最小化，不要关闭。"
        : "TikTok 备用页需要登录或验证，但当前是云端无界面浏览器；请用远程桌面打开可视浏览器处理，或把采集器装到运营电脑。"
      : "TikTok 备用页可以最小化，但不要关闭；关闭后备线会断开并尝试重开。"
  };
}

function createRecoveryAlertStatus(recovery = {}) {
  const settings = readOperatorSettingsForClient();
  const alertEmail = firstText(settings.alert_email, recoveryAlertEmailFromEnv);
  const decision = shouldSendLiveDataCollectorRecoveryAlert({
    lastAlert: lastRecoveryAlert,
    recovery,
    alertEmail,
    nowMs: Date.now(),
    throttleMs: recoveryAlertThrottleMs
  });
  return {
    configured: Boolean(alertEmail),
    delivery_configured: isRecoveryAlertDeliveryConfigured(),
    smtp_configured: isRecoveryAlertSmtpConfigured(),
    webhook_configured: Boolean(recoveryAlertWebhookUrl),
    alert_email_masked: maskEmail(alertEmail),
    outbox_file: recoveryOutboxFile,
    last_alert_at: lastRecoveryAlert?.sent_at ?? null,
    last_delivery_status: lastRecoveryAlert?.delivery_status ?? null,
    last_reason_code: lastRecoveryAlert?.reason_code ?? decision.reason_code ?? null,
    next_retry_after_ms: decision.retry_after_ms ?? null,
    message: createRecoveryAlertMessage(alertEmail, decision, recovery)
  };
}

async function maybeSendBrowserRecoveryAlert(status) {
  const recovery = status?.browser_recovery ?? {};
  const settings = readOperatorSettingsForClient();
  const alertEmail = firstText(settings.alert_email, recoveryAlertEmailFromEnv);
  const decision = shouldSendLiveDataCollectorRecoveryAlert({
    lastAlert: lastRecoveryAlert,
    recovery,
    alertEmail,
    nowMs: Date.now(),
    throttleMs: recoveryAlertThrottleMs
  });
  if (!decision.should_send) {
    return {
      ok: false,
      sent: false,
      ...decision
    };
  }
  return sendBrowserRecoveryAlert(status, { force: false });
}

async function sendBrowserRecoveryAlert(status, { force = false } = {}) {
  const recovery = status?.browser_recovery ?? {};
  const settings = readOperatorSettingsForClient();
  const alertEmail = firstText(settings.alert_email, recoveryAlertEmailFromEnv);
  if (!force) {
    const decision = shouldSendLiveDataCollectorRecoveryAlert({
      lastAlert: lastRecoveryAlert,
      recovery,
      alertEmail,
      nowMs: Date.now(),
      throttleMs: recoveryAlertThrottleMs
    });
    if (!decision.should_send) {
      return {
        ok: false,
        sent: false,
        ...decision
      };
    }
  }
  const generatedAt = new Date().toISOString();
  const token = createRecoveryToken({
    type: "tiktok_browser_recovery",
    jti: randomBytes(12).toString("hex"),
    room_input: settings.room_input ?? "",
    target_url: recovery.target_url ?? "",
    issued_at: generatedAt,
    expires_at: new Date(Date.now() + recoveryLinkTtlMs).toISOString()
  });
  const handoff = createLiveDataCollectorRecoveryHandoff({
    recovery,
    roomInput: settings.room_input,
    alertEmail,
    recoveryLink: createRecoveryLink(token),
    generatedAt,
    ttlMs: recoveryLinkTtlMs
  });
  if (!handoff.ok) {
    lastRecoveryAlert = {
      ok: false,
      sent: false,
      to: handoff.to,
      target_url: recovery.target_url ?? null,
      generated_at: generatedAt,
      reason_code: handoff.reason_code,
      reason_codes: handoff.reason_codes,
      delivery_status: "not_sent"
    };
    return lastRecoveryAlert;
  }

  const delivery = await deliverRecoveryAlert(handoff);
  lastRecoveryAlert = {
    ok: delivery.ok === true,
    sent: delivery.sent === true,
    to: handoff.to,
    target_url: handoff.target_url,
    room_input: handoff.room_input,
    recovery_link: handoff.recovery_link,
    generated_at: generatedAt,
    sent_at: delivery.sent === true ? new Date().toISOString() : null,
    reason_code: delivery.reason_code ?? null,
    delivery_status: delivery.delivery_status,
    smtp_configured: isRecoveryAlertSmtpConfigured(),
    webhook_configured: Boolean(recoveryAlertWebhookUrl),
    outbox_file: recoveryOutboxFile
  };
  writeServiceLog(`browser recovery alert ${lastRecoveryAlert.delivery_status} ${lastRecoveryAlert.reason_code ?? ""}`);
  return lastRecoveryAlert;
}

async function deliverRecoveryAlert(handoff) {
  mkdirSync(defaultDataDir, { recursive: true });
  appendFileSync(recoveryOutboxFile, `${JSON.stringify({
    type: "tiktok_browser_recovery_required",
    created_at: new Date().toISOString(),
    to: handoff.to,
    subject: handoff.subject,
    recovery_link: handoff.recovery_link,
    target_url: handoff.target_url,
    room_input: handoff.room_input,
    expires_at: handoff.expires_at,
    safety: handoff.safety
  })}\n`, "utf8");

  if (isRecoveryAlertSmtpConfigured()) {
    try {
      await sendRecoverySmtpEmail(handoff.email);
      return {
        ok: true,
        sent: true,
        delivery_status: "sent_smtp",
        outbox_file: recoveryOutboxFile
      };
    } catch (error) {
      writeServiceLog(`browser recovery smtp failed ${error?.stack ?? error?.message ?? String(error)}`);
      if (!recoveryAlertWebhookUrl) {
        return {
          ok: false,
          sent: false,
          delivery_status: "smtp_failed",
          reason_code: "browser_recovery_alert_smtp_failed",
          message: error?.message ?? String(error),
          outbox_file: recoveryOutboxFile
        };
      }
    }
  }

  if (recoveryAlertWebhookUrl) {
    try {
      const headers = { "content-type": "application/json" };
      if (recoveryAlertWebhookToken) {
        headers.authorization = `Bearer ${recoveryAlertWebhookToken}`;
      }
      const response = await fetch(recoveryAlertWebhookUrl, {
        method: "POST",
        headers,
        body: JSON.stringify({
          type: "tiktok_browser_recovery_required",
          to: handoff.to,
          subject: handoff.subject,
          text: handoff.text,
          html: handoff.html,
          recovery_link: handoff.recovery_link,
          target_url: handoff.target_url,
          room_input: handoff.room_input,
          generated_at: handoff.generated_at,
          expires_at: handoff.expires_at,
          safety: handoff.safety
        })
      });
      if (response.ok) {
        return {
          ok: true,
          sent: true,
          delivery_status: "sent_webhook",
          http_status: response.status,
          outbox_file: recoveryOutboxFile
        };
      }
      return {
        ok: false,
        sent: false,
        delivery_status: "webhook_failed",
        reason_code: "browser_recovery_alert_webhook_failed",
        http_status: response.status,
        message: await response.text(),
        outbox_file: recoveryOutboxFile
      };
    } catch (error) {
      writeServiceLog(`browser recovery webhook failed ${error?.stack ?? error?.message ?? String(error)}`);
      return {
        ok: false,
        sent: false,
        delivery_status: "webhook_failed",
        reason_code: "browser_recovery_alert_webhook_failed",
        message: error?.message ?? String(error),
        outbox_file: recoveryOutboxFile
      };
    }
  }

  return {
    ok: false,
    sent: false,
    delivery_status: "outbox_only",
    reason_code: "browser_recovery_alert_delivery_not_configured",
    message: "已生成处理链接并写入本机待发件，但未配置 SMTP 或邮件通知服务。",
    outbox_file: recoveryOutboxFile
  };
}

function isRecoveryAlertDeliveryConfigured() {
  return isRecoveryAlertSmtpConfigured() || Boolean(recoveryAlertWebhookUrl);
}

function isRecoveryAlertSmtpConfigured() {
  return Boolean(recoveryAlertSmtpHost && recoveryAlertSmtpUser && recoveryAlertSmtpPassword && recoveryAlertSmtpFrom);
}

function createRecoveryAlertMessage(alertEmail, decision = {}, recovery = {}) {
  if (recovery.required === true && recovery.interactive_recovery_available === false) {
    return "当前是云端无界面浏览器；邮件只能通知处理入口，真正的登录/验证码需要远程桌面可视化处理，或改装到运营电脑。";
  }
  if (!alertEmail) {
    return "未配置验证码通知邮箱；需要人工验证时只能在本机页面处理。";
  }
  if (!isRecoveryAlertDeliveryConfigured()) {
    return "已填写通知邮箱，但还没有配置 SMTP 或邮件通知服务；系统会先写本机待发件。";
  }
  if (decision.reason_code === "browser_recovery_alert_throttled") {
    return "验证码通知已发送过，短时间内不会重复轰炸邮箱。";
  }
  return "TikTok 需要验证时会自动发送邮件，并保留本机处理入口。";
}

function createPreflightStatus() {
  const settings = readOperatorSettingsForClient();
  const writable = createWritableDataDirCheck();
  const browserPath = findLocalBrowserExecutableForPreflight();
  const browserInteractive = isInteractiveBrowserHost();
  const stablePrimarySigning = hasStableTiktokSigningConfig();
  const roomInput = settings.room_input;
  const parsedRoom = parseRoomInput(roomInput || "");
  const roomReady = !parsedRoom.invalid && Boolean(parsedRoom.creatorHandle || parsedRoom.liveRoomUrl || parsedRoom.liveRoomId !== "room_field_test");
  const checks = [
    createPreflightCheck({
      code: "local_service",
      label: "本机采集服务",
      level: "good",
      ok: true,
      message: `本机服务已启动：http://127.0.0.1:${port}`
    }),
    createPreflightCheck({
      code: "data_dir",
      label: "本地保存目录",
      level: writable.ok ? "good" : "blocked",
      ok: writable.ok,
      message: writable.ok ? "原始包、礼物底稿和对账文件可以正常落盘。" : writable.message
    }),
    createPreflightCheck({
      code: "room_input",
      label: "主播信息",
      level: parsedRoom.invalid ? "blocked" : roomReady ? "good" : "warning",
      ok: roomReady,
      message: parsedRoom.invalid
        ? parsedRoom.invalid_message
        : roomReady
        ? "已保存主播账号/直播间链接，打开后可自动驻守。"
        : "还没有保存主播账号。运营只需要填写 TikTok @账号或直播间链接。"
    }),
    createPreflightCheck({
      code: "primary_route",
      label: "主线路",
      level: parsedRoom.invalid ? "blocked" : defaultPrimaryChannel || parsedRoom.creatorHandle || parsedRoom.liveRoomUrl ? "good" : "warning",
      ok: !parsedRoom.invalid && Boolean(defaultPrimaryChannel || parsedRoom.creatorHandle || parsedRoom.liveRoomUrl),
      message: parsedRoom.invalid
        ? "先把主播信息改成 TikTok @账号或 tiktok.com 直播链接，主线才会启动。"
        : defaultPrimaryChannel
        ? "已内置主线路托管通道。"
        : parsedRoom.creatorHandle || parsedRoom.liveRoomUrl
          ? "会按主播账号自动连接 TikTok LIVE 事件流。"
          : "只填房间号时主线可能无法自动找到主播，建议填 @账号或直播链接。"
    }),
    createPreflightCheck({
      code: "primary_signing",
      label: "主线稳定性",
      level: parsedRoom.invalid ? "blocked" : defaultPrimaryChannel || stablePrimarySigning ? "good" : "warning",
      ok: !parsedRoom.invalid && Boolean(defaultPrimaryChannel || stablePrimarySigning),
      message: parsedRoom.invalid
        ? "先修正主播信息。"
        : defaultPrimaryChannel
        ? "已配置托管主线路。"
        : stablePrimarySigning
          ? "已配置稳定签名通道。"
          : "当前主线会用默认公开签名入口，TikTok 限流时会自动重试，但正式压测不能只依赖这一种入口。",
      action: "配置 Live Rush 托管主线路或稳定签名通道；配置前必须确认浏览器备线已经能拿到礼物/评论明细。"
    }),
    createPreflightCheck({
      code: "backup_browser",
      label: "本机浏览器备线",
      level: browserPath ? browserInteractive ? "good" : "warning" : "blocked",
      ok: Boolean(browserPath),
      message: browserPath
        ? browserInteractive
          ? "已找到 Chrome/Edge，备线会自动打开 TikTok 直播页并可最小化驻守。"
          : "已找到 Chrome/Edge，但当前像是云端无界面环境；TikTok 登录/验证码不能自动处理，备线只能做外围信号驻守。"
        : "没有找到 Chrome 或 Edge，备线无法启动。请安装 Edge/Chrome 后重试。"
    })
  ];
  const level = checks.some((check) => check.level === "blocked")
    ? "blocked"
    : checks.some((check) => check.level === "warning") ? "warning" : "good";
  const nextActions = checks
    .filter((check) => check.level !== "good")
    .map((check) => check.action)
    .filter(Boolean);
  return {
    ok: !checks.some((check) => check.level === "blocked"),
    level,
    generated_at: new Date().toISOString(),
    checks,
    browser_path: browserPath,
    next_actions: nextActions.length > 0 ? nextActions : ["环境检查通过。直播前点击开始驻守，直播中不要退出托盘采集器。"]
  };
}

function createPreflightCheck({ code, label, level, ok, message, action = null } = {}) {
  const actionMap = {
    data_dir: "检查本地磁盘权限，或把采集器安装到当前用户有权限的位置。",
    room_input: "填写 TikTok 主播 @账号或直播间链接，并勾选下次自动驻守。",
    primary_route: "优先填写 TikTok @账号或直播链接；不要只填一串房间号。",
    primary_signing: "配置 Live Rush 托管主线路或稳定签名通道；配置前必须确认浏览器备线已经能拿到礼物/评论明细。",
    backup_browser: "安装或修复 Microsoft Edge / Google Chrome，然后重新打开采集器。"
  };
  return {
    code,
    label,
    level,
    ok: ok === true,
    message,
    action: action ?? actionMap[code] ?? null
  };
}

function hasStableTiktokSigningConfig() {
  return Boolean(firstText(
    process.env.LIVE_RUSH_TIKTOK_SIGN_API_KEY,
    process.env.LIVE_RUSH_TIKTOK_SIGN_API_URL,
    process.env.SIGN_API_KEY,
    process.env.SIGN_API_URL
  ));
}

function isInteractiveBrowserHost() {
  if (process.platform === "win32" || process.platform === "darwin") {
    return true;
  }
  return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}

function createWritableDataDirCheck() {
  try {
    mkdirSync(defaultDataDir, { recursive: true });
    const probeFile = join(defaultDataDir, `.preflight-${process.pid}-${Date.now()}.tmp`);
    writeFileSync(probeFile, "ok", "utf8");
    unlinkSync(probeFile);
    return { ok: true, message: "ok" };
  } catch (error) {
    return {
      ok: false,
      message: error?.message ?? String(error)
    };
  }
}

function findLocalBrowserExecutableForPreflight() {
  for (const candidate of browserCandidatesForPreflight()) {
    if (candidate && existsSync(candidate)) {
      return candidate;
    }
  }
  return null;
}

function browserCandidatesForPreflight() {
  const candidates = [
    process.env.LIVE_RUSH_BROWSER_PATH,
    process.env.PUPPETEER_EXECUTABLE_PATH
  ];
  if (process.platform === "win32") {
    const localAppData = process.env.LOCALAPPDATA;
    const programFiles = process.env.PROGRAMFILES;
    const programFilesX86 = process.env["PROGRAMFILES(X86)"];
    candidates.push(
      localAppData ? join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe") : null,
      programFiles ? join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe") : null,
      programFilesX86 ? join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe") : null,
      localAppData ? join(localAppData, "Google", "Chrome", "Application", "chrome.exe") : null,
      programFiles ? join(programFiles, "Google", "Chrome", "Application", "chrome.exe") : null,
      programFilesX86 ? join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe") : null
    );
  } else if (process.platform === "darwin") {
    candidates.push(
      "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
      "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
    );
  } else {
    candidates.push(
      "/usr/bin/google-chrome",
      "/usr/bin/google-chrome-stable",
      "/usr/bin/microsoft-edge",
      "/usr/bin/microsoft-edge-stable",
      "/usr/bin/chromium",
      "/usr/bin/chromium-browser"
    );
  }
  return candidates.filter(Boolean);
}

function ensurePlugin() {
  if (!plugin) {
    mkdirSync(defaultDataDir, { recursive: true });
    plugin = new LiveDataCollectorPlugin({
      config: {
        tenant_id: "tenant_field_test",
        session_id: `manual_${Date.now()}`,
        live_room_id: "room_field_test",
        collector_id: "manual_collector",
        data_dir: defaultDataDir
      }
    });
  }
  return plugin;
}

function renderApp() {
  return `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Live Rush 直播数据采集</title>
  <style>
    :root { color-scheme: dark; --bg: #101114; --panel: #191b21; --line: #30333c; --text: #f3f4f6; --muted: #a8adb8; --good: #40c980; --warn: #e8b44f; --bad: #ee6b6e; --accent: #69a7ff; }
    * { box-sizing: border-box; }
    body { margin: 0; font-family: Inter, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
    header { padding: 18px 22px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 16px; }
    h1 { margin: 0; font-size: 20px; font-weight: 760; }
    p { margin: 8px 0 0; color: var(--muted); line-height: 1.6; }
    main { padding: 20px; display: grid; grid-template-columns: minmax(320px, 430px) minmax(0, 1fr); gap: 18px; }
    section { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; }
    h2 { margin: 0 0 14px; font-size: 15px; }
    label { display: block; margin-top: 12px; color: var(--muted); font-size: 12px; }
    input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--line); border-radius: 6px; background: #0d0f13; color: var(--text); padding: 10px; font: inherit; }
    textarea { min-height: 170px; resize: vertical; font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px; }
    button, a.button { border: 1px solid var(--line); background: #242833; color: var(--text); border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; min-height: 36px; }
    button.primary { background: #1f5fbf; border-color: #2b73df; }
    button:disabled { opacity: .5; cursor: default; }
    .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
    .grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }
    .metric { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #12151b; min-height: 78px; }
    .metric span { display: block; color: var(--muted); font-size: 12px; }
    .metric strong { display: block; margin-top: 8px; font-size: 24px; }
    pre { overflow: auto; white-space: pre-wrap; border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: #0d0f13; color: #dbe4f0; max-height: 360px; }
    .status { color: var(--muted); }
    .good { color: var(--good); }
    .warn { color: var(--warn); }
    .bad { color: var(--bad); }
    .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .hint { color: var(--muted); font-size: 12px; line-height: 1.6; }
    .pill { border: 1px solid var(--line); border-radius: 999px; padding: 4px 9px; font-size: 12px; color: var(--muted); }
    .autopilot { margin-top: 12px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
    .autopilot div { border: 1px solid var(--line); border-radius: 8px; background: #12151b; padding: 10px; min-height: 58px; }
    .autopilot strong { display: block; font-size: 13px; }
    .autopilot span { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; line-height: 1.4; }
    .switch { display: flex; align-items: center; gap: 8px; margin-top: 12px; color: var(--text); font-size: 13px; }
    .switch input { width: auto; margin: 0; }
    details { margin-top: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: #12151b; }
    summary { cursor: pointer; color: var(--text); }
    .decision { margin-top: 14px; padding: 12px; border-radius: 8px; background: #12151b; border: 1px solid var(--line); }
    .decision strong { display: block; margin-bottom: 6px; }
    .steps { padding-left: 18px; margin: 8px 0 0; color: var(--muted); line-height: 1.7; }
    .notice { margin-top: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: #12151b; color: var(--muted); font-size: 12px; line-height: 1.6; }
    .notice strong { display: block; color: var(--text); font-size: 13px; margin-bottom: 3px; }
    .notice.warn { border-color: rgba(232,180,79,.45); background: rgba(232,180,79,.10); }
    .hero-status { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: #12151b; display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 12px; }
    .hero-status span { color: var(--muted); font-size: 12px; }
    .hero-status strong { display: block; margin-top: 6px; font-size: 22px; }
    .badge { border-radius: 999px; border: 1px solid var(--line); padding: 5px 9px; font-size: 12px; white-space: nowrap; }
    .badge.good { border-color: rgba(64,201,128,.45); background: rgba(64,201,128,.12); }
    .badge.warn { border-color: rgba(232,180,79,.45); background: rgba(232,180,79,.12); }
    .badge.bad { border-color: rgba(238,107,110,.45); background: rgba(238,107,110,.12); }
    .timeline { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
    .timeline div, .route-row, .gift-row, .activity-row, .pipeline-stage, .guardrail-row { border: 1px solid var(--line); border-radius: 8px; background: #12151b; padding: 10px; min-width: 0; }
    .timeline span, .route-row span, .gift-row span, .activity-row span, .pipeline-stage span, .guardrail-row span { display: block; color: var(--muted); font-size: 12px; }
    .timeline strong, .route-row strong, .gift-row strong, .activity-row strong, .pipeline-stage strong, .guardrail-row strong { display: block; margin-top: 6px; font-size: 14px; overflow-wrap: anywhere; }
    .route-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; }
    .route-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
    .route-meta .pill { border-radius: 6px; }
    .pipeline-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
    .guardrail-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
    .feed { display: grid; gap: 8px; margin-top: 10px; max-height: 280px; overflow: auto; }
    .gift-row { display: grid; grid-template-columns: minmax(120px, 1fr) 88px 90px 92px; gap: 8px; align-items: center; }
    .activity-row { display: grid; grid-template-columns: 88px minmax(120px, 1fr) 110px 92px; gap: 8px; align-items: center; }
    .gift-row strong { margin-top: 0; }
    .activity-row strong { margin-top: 0; }
    .empty { color: var(--muted); border: 1px dashed var(--line); border-radius: 8px; padding: 12px; background: #12151b; }
    .mono { font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px; }
    @media (max-width: 980px) { main, .two { grid-template-columns: 1fr; } .grid, .autopilot { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
    @media (max-width: 980px) { .timeline, .route-list, .pipeline-list, .guardrail-list { grid-template-columns: 1fr; } .gift-row, .activity-row { grid-template-columns: 1fr 72px; } }
    @media (max-width: 620px) { .grid, .autopilot { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>Live Rush 直播数据采集</h1>
      <p>打开后保持运行，系统自动驻守直播间并保存礼物、公屏、人气底稿。</p>
    </div>
    <div class="status" id="topStatus">还没开始</div>
  </header>
  <main>
    <div>
      <section>
        <h2>一键驻守</h2>
        <label>主播账号或直播间链接
          <input id="roomInput" placeholder="推荐填 @主播账号；也可以粘贴 TikTok 直播间链接">
        </label>
        <label>验证码通知邮箱（可选）
          <input id="alertEmail" placeholder="TikTok 需要人工验证时，把处理入口发到这个邮箱">
        </label>
        <label class="switch"><input type="checkbox" id="autoStartToggle" checked>以后打开采集器自动驻守这个主播</label>
        <div class="autopilot">
          <div><strong>开播前</strong><span>提前等直播间出现</span></div>
          <div><strong>开播后</strong><span>自动接入实时数据</span></div>
          <div><strong>不稳定时</strong><span>自动重连并标记缺口</span></div>
          <div><strong>下播后</strong><span>用后台数据核账</span></div>
        </div>
        <div class="notice warn">
          <strong>自动打开的 TikTok 备用直播页不要关闭</strong>
          <span>这个采集控制页可以关闭；系统自动打开的 TikTok 直播页是独立备线，可以最小化。误关后系统会自动重开，但中间可能少一段备线证据。</span>
        </div>
        <p class="hint">运营只需要填主播账号并保持电脑联网。托盘里的采集器不要退出。</p>

        <details>
          <summary>高级设置（技术同学才需要打开）</summary>
          <p class="hint">这里可以覆盖托管采集通道。普通运营不要改。</p>
          <label>主线路托管地址
            <input id="primaryChannel" placeholder="${defaultPrimaryChannel ? "已内置主线路；需要覆盖时再填写" : "粘贴主线路地址"}">
          </label>
          <label>备线路托管地址
            <input id="backupChannel" placeholder="${defaultBackupChannel ? "已内置备线路；需要覆盖时再填写" : "粘贴备线路地址，可先留空"}">
          </label>
          <label>客户或公会备注
            <input id="tenantLabel" value="field_customer">
          </label>
        </details>

        <div class="actions">
          <button class="primary" onclick="startCollector()">开始驻守</button>
          <button onclick="stopCollector()">结束并保存</button>
          <button id="openTiktokButton" onclick="openTiktokBackup()" disabled>打开 TikTok 验证页</button>
          <button id="sendRecoveryEmailButton" onclick="sendRecoveryEmail()" disabled>发送验证码处理邮件</button>
          <button onclick="openOutput()">打开本地目录</button>
        </div>
      </section>
      <section style="margin-top:18px">
        <h2>下播核账</h2>
        <p class="hint">日常先看右侧状态；直播结束后，财务或技术同学再导入后台数据复核。</p>
        <details>
          <summary>导入后台/MCA 数据复核</summary>
          <ol class="steps">
            <li>后台只有总流水、付费人数这些大数，也可以直接粘贴一行后台大数。</li>
            <li>后台能导出逐笔礼物明细时，导入逐笔明细；系统会自动生成补数表。</li>
            <li>MCA 只在测试期陪跑校准，正式使用不依赖它。</li>
          </ol>
          <div class="two">
            <label>对账来源
              <select id="compareSource">
                <option value="guild_backend">公会后台（正式补数）</option>
                <option value="tiktok_official_export">TikTok 官方导出（正式补数）</option>
                <option value="creator_center_export">达人后台导出（正式补数）</option>
                <option value="live_rush_after_live_import">Live Rush 下播导入（正式补数）</option>
                <option value="field_test_mca">MCA 测试对照（非正式）</option>
                <option value="third_party_test">第三方测试对照（非正式）</option>
              </select>
            </label>
            <label>报表格式
              <select id="compareFormat"><option value="csv">CSV</option><option value="json">JSON</option><option value="ndjson">NDJSON</option></select>
            </label>
          </div>
          <label>粘贴后台大数、下播礼物明细或测试对照报表
            <textarea id="compareContent" placeholder="后台只有大数时：直播间ID,总流水,流水单位,付费人数,礼物笔数&#10;room_123,20000,钻石,58,320&#10;&#10;如果有逐笔明细：msg_id,user_id,gift_id,gift_name,gift_count,gift_total_value,event_time"></textarea>
          </label>
          <div class="actions">
            <button onclick="compareReport()">生成对账报告</button>
            <button onclick="settlementReport()">生成正式补数报告</button>
            <a class="button" href="/api/facts.csv">导出我们采集的数据</a>
            <a class="button" href="/api/comparison.csv">导出对账结果</a>
            <a class="button" href="/api/settlement.csv">导出补数修正表</a>
          </div>
        </details>
      </section>
    </div>
    <div>
      <section>
        <h2>采集结果</h2>
        <div class="hero-status">
          <div>
            <span>运行判断</span>
            <strong id="realtimeHeadline">等待开始</strong>
            <p id="realtimeMessage">开播前先开始采集，插件会在后台驻守直播入口。</p>
          </div>
          <div class="badge" id="realtimeBadge">未开始</div>
        </div>
        <div class="grid">
          <div class="metric"><span>开播保障</span><strong id="liveStartState">-</strong></div>
          <div class="metric"><span>数据延迟</span><strong id="freshnessAge">-</strong></div>
          <div class="metric"><span>礼物流水</span><strong id="giftValueTotal">0</strong></div>
          <div class="metric"><span>付费人数</span><strong id="paidUserCount">0</strong></div>
        </div>
        <div class="grid">
          <div class="metric"><span>礼物数</span><strong id="giftCount">0</strong></div>
          <div class="metric"><span>评论/人气</span><strong id="p1Count">0</strong></div>
          <div class="metric"><span>主备差异</span><strong id="routeIssueCount">0</strong></div>
          <div class="metric"><span>对账分</span><strong id="compareScore">-</strong></div>
          <div class="metric"><span>正式补数</span><strong id="settlementState">-</strong></div>
        </div>
        <div class="grid" style="margin-top:10px">
          <div class="metric"><span>收到数据包</span><strong id="frameCount">0</strong></div>
          <div class="metric"><span>异常</span><strong id="issueCount">0</strong></div>
          <div class="metric"><span>主通道</span><strong id="primaryCount">0</strong></div>
          <div class="metric"><span>备用通道</span><strong id="backupCount">0</strong></div>
        </div>
        <div class="decision">
          <strong id="decisionTitle">等待开始</strong>
          <span class="hint" id="decisionText">开始采集后，这里会显示是否正常、有无缺失、是否需要复核。</span>
        </div>
        <details>
          <summary>技术日志</summary>
          <pre id="statusJson">{}</pre>
        </details>
      </section>
      <section style="margin-top:18px">
        <h2>自动保障</h2>
        <div class="timeline">
          <div><span>提前驻守</span><strong id="standbyTimeline">未开始</strong></div>
          <div><span>开播识别</span><strong id="liveDetectedTimeline">未确认</strong></div>
          <div><span>第一条数据</span><strong id="firstAttachTimeline">未收到</strong></div>
          <div><span>缺口风险</span><strong id="gapRiskTimeline">未评估</strong></div>
        </div>
        <div class="notice" id="pipelineSummary">等待开始采集。</div>
        <div class="pipeline-list" id="pipelineList"></div>
        <div class="guardrail-list" id="guardrailList"></div>
        <div class="route-list" id="routeList"></div>
      </section>
      <section style="margin-top:18px">
        <h2>最近明细</h2>
        <p class="hint" id="recentGiftHint">这里会同时显示礼物、公屏、进房、点赞和人气。测试期可以和 MCA、后台大数或直播录屏做现场对照。</p>
        <div class="feed" id="recentGifts"></div>
      </section>
      <section style="margin-top:18px">
        <h2>本地测试</h2>
        <details>
          <summary>技术同学手动写入测试数据</summary>
          <div class="two">
            <label>写入通道<select id="frameRole"><option value="primary_realtime">主通道</option><option value="backup_realtime">备用通道</option></select></label>
          </div>
          <label>测试数据<textarea id="framePayload">{"events":[{"type":"chat","msgId":"manual_chat_001","roomId":"room_field_test","timestamp":"${new Date().toISOString()}","comment":"hello","user":{"userId":"fan_manual","nickname":"Fan"}}]}</textarea></label>
          <div class="actions"><button onclick="sendFrame()">写入测试数据</button></div>
        </details>
      </section>
    </div>
  </main>
  <script>
    async function api(path, body) {
      const res = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) });
      return res.json();
    }
    let operatorSettingsApplied = false;
    let roomInputTouched = false;
    function text(value, fallback) {
      return value === null || value === undefined || value === '' ? fallback : String(value);
    }
    function escapeHtml(value) {
      return text(value, '').replace(/[&<>"']/g, (char) => ({
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#39;'
      })[char]);
    }
    function levelClass(level) {
      if (level === 'good' || level === 'fresh' || level === 'healthy' || level === 'none') return 'good';
      if (level === 'blocked' || level === 'blocker' || level === 'stale') return 'bad';
      return 'warn';
    }
    function levelText(level) {
      if (level === 'good') return '正常';
      if (level === 'blocked') return '需处理';
      if (level === 'warning') return '关注';
      if (level === 'idle') return '未开始';
      return text(level, '-');
    }
    function formatSeconds(value) {
      if (value === null || value === undefined) return '-';
      const seconds = Number(value);
      if (!Number.isFinite(seconds)) return '-';
      if (seconds < 60) return seconds + '秒';
      const minutes = Math.floor(seconds / 60);
      const rest = seconds % 60;
      return rest ? minutes + '分' + rest + '秒' : minutes + '分';
    }
    function formatTime(value) {
      if (!value) return '-';
      const date = new Date(value);
      if (Number.isNaN(date.getTime())) return String(value);
      return date.toLocaleTimeString('zh-CN', { hour12: false });
    }
    function routeRoleName(role) {
      return role === 'backup_realtime' ? '备线' : '主线';
    }
    function applyOperatorSettings(settings) {
      if (!settings || operatorSettingsApplied) return;
      if (!roomInputTouched && settings.room_input) roomInput.value = settings.room_input;
      if (settings.primary_channel) primaryChannel.value = settings.primary_channel;
      if (settings.backup_channel) backupChannel.value = settings.backup_channel;
      if (settings.tenant_label) tenantLabel.value = settings.tenant_label;
      if (settings.alert_email) alertEmail.value = settings.alert_email;
      autoStartToggle.checked = settings.auto_start !== false;
      operatorSettingsApplied = true;
    }
    function renderRoutePills(route) {
      const pills = [];
      if (route.target_source === 'tiktok_browser_observer') {
        pills.push(['事件流包', route.browser_event_transport_frame_count || 0]);
        pills.push(['外围请求', route.browser_peripheral_frame_count || 0]);
        pills.push(['运行时捕获', route.browser_runtime_capture_count || 0]);
        if (route.browser_login_required) pills.push(['登录/验证', '需处理']);
        if (route.browser_headless) pills.push(['浏览器', '云端无界面']);
        if (route.browser_interactive_recovery_available === false) pills.push(['人工处理', '需远程桌面']);
        if (route.browser_runtime_capture_target_gone_count || route.browser_auxiliary_target_gone_count) {
          pills.push(['瞬时目标丢失', (route.browser_runtime_capture_target_gone_count || 0) + (route.browser_auxiliary_target_gone_count || 0)]);
        }
        pills.push(['误关', route.browser_detached_count || 0]);
        pills.push(['重开', route.browser_relaunch_count || 0]);
        pills.push(['备用页', route.browser_headless ? '后台驻守，验证需远程桌面' : route.browser_can_close === false ? '可最小化，别关闭' : '可关闭']);
      } else if (route.target_source === 'tiktok_live_connector') {
        pills.push(['签名', route.connector_uses_default_signing ? '默认公开入口' : route.connector_signing_configured ? '稳定通道' : '-']);
        pills.push(['轮询兜底', route.connector_request_polling_enabled === false ? '未开' : '已开']);
        if (route.standby_last_reason_code) pills.push(['最近原因', route.standby_last_reason_code]);
      }
      if (route.last_issue_code) {
        pills.push(['诊断', route.last_issue_code]);
      }
      return pills
        .filter((item) => item[1] !== null && item[1] !== undefined && item[1] !== '')
        .map((item) => '<span class="pill">' + escapeHtml(item[0]) + ' ' + escapeHtml(item[1]) + '</span>')
        .join('');
    }
    function renderRoutes(routes) {
      if (!routes || routes.length === 0) {
        routeList.innerHTML = '<div class="empty">还没有线路状态。</div>';
        return;
      }
      routeList.innerHTML = routes.map((route) => {
        const cls = levelClass(route.health_level);
        const routePills = renderRoutePills(route);
        const targetMeta = route.target_url
          ? '<p class="hint">应打开的 TikTok 监控页：<span class="mono">' + escapeHtml(route.target_url) + '</span></p>'
          : '';
        return '<div class="route-row">'
          + '<span>' + routeRoleName(route.role) + '</span>'
          + '<strong class="' + cls + '">' + escapeHtml(text(route.message, '等待数据')) + '</strong>'
          + '<div class="route-meta">'
          + '<span class="pill">状态 ' + escapeHtml(text(route.state, '-')) + '</span>'
          + '<span class="pill">礼物 ' + (route.gift_count || 0) + '</span>'
          + '<span class="pill">互动 ' + (route.p1_event_count || 0) + '</span>'
          + '<span class="pill">数据包 ' + (route.frame_count || 0) + '</span>'
          + '<span class="pill">解码 ' + (route.decoded_frame_count || 0) + '</span>'
          + '<span class="pill">延迟 ' + formatSeconds(route.freshness_seconds) + '</span>'
          + '<span class="pill">重连 ' + (route.reconnect_count || 0) + '</span>'
          + routePills
          + '</div>'
          + '<p class="hint mono">' + escapeHtml(text(route.target_source, '-')) + '</p>'
          + targetMeta
          + '</div>';
      }).join('');
    }
    function categoryName(category) {
      const map = {
        gift: '礼物',
        public_chat: '公屏',
        member_join: '进房',
        follow: '关注',
        share: '分享',
        like: '点赞',
        room_metric: '人气',
        user_labels: '标签',
        live_state: '开播',
        source_health: '健康',
        system: '系统'
      };
      return map[category] || '互动';
    }
    function renderPipeline(pipeline) {
      const stages = pipeline?.stages || [];
      pipelineSummary.textContent = pipeline?.summary || '等待开始采集。';
      pipelineSummary.className = 'notice ' + levelClass(pipeline?.level || 'idle');
      if (!stages.length) {
        pipelineList.innerHTML = '<div class="empty">还没有采集链路状态。</div>';
        return;
      }
      pipelineList.innerHTML = stages.map((stage) => {
        const counts = stage.counts
          ? '<div class="route-meta">'
            + '<span class="pill">包 ' + (stage.counts.frames || 0) + '</span>'
            + '<span class="pill">解码 ' + (stage.counts.decoded_frames || 0) + '</span>'
            + '<span class="pill">礼物 ' + (stage.counts.gifts || 0) + '</span>'
            + '<span class="pill">互动 ' + (stage.counts.p1 || 0) + '</span>'
            + '</div>'
          : '';
        return '<div class="pipeline-stage">'
          + '<span>' + escapeHtml(stage.label || '-') + '</span>'
          + '<strong class="' + levelClass(stage.level) + '">' + escapeHtml(text(stage.message, '-')) + '</strong>'
          + counts
          + '<p class="hint">' + escapeHtml(text(stage.action, '')) + '</p>'
          + '</div>';
      }).join('');
    }
    function renderGuardrails(guardrails, preflight) {
      const rows = [];
      if (guardrails?.control_panel) {
        rows.push({ label: '控制面板', level: 'good', message: guardrails.control_panel.message });
      }
      if (guardrails?.tiktok_browser_page) {
        rows.push({
          label: 'TikTok 备用页',
          level: guardrails.tiktok_browser_page.can_close ? 'good' : 'warning',
          message: guardrails.tiktok_browser_page.message
        });
      }
      if (guardrails?.auto_recovery) {
        rows.push({
          label: '自动恢复',
          level: guardrails.auto_recovery.raw_replay_enabled || guardrails.auto_recovery.browser_relaunch ? 'good' : 'warning',
          message: guardrails.auto_recovery.message
        });
      }
      if (preflight?.checks?.length) {
        const bad = preflight.checks.find((check) => check.level === 'blocked' || check.level === 'warning');
        rows.push({
          label: '启动前检查',
          level: preflight.level || 'good',
          message: bad ? bad.message : '本机环境检查通过。'
        });
      }
      guardrailList.innerHTML = rows.length
        ? rows.map((row) => '<div class="guardrail-row"><span>' + escapeHtml(row.label) + '</span><strong class="' + levelClass(row.level) + '">' + escapeHtml(row.message) + '</strong></div>').join('')
        : '<div class="empty">还没有防呆状态。</div>';
    }
    function renderRecentGifts(items, visibility) {
      if (!items || items.length === 0) {
        const emptyText = visibility?.state === 'raw_signal_only'
          ? '已经收到原始包，但还没有解出可展示明细。系统会保存原始包，便于下播后回放。'
          : visibility?.state === 'semantic_no_gift'
            ? '已经收到互动，但还没有礼物。测试时可以先送一个小礼物，确认礼物链路。'
            : '还没有明细。开播前这是正常状态；开播后应看到公屏、进房、点赞或礼物。';
        recentGifts.innerHTML = '<div class="empty">' + escapeHtml(emptyText) + '</div>';
        return;
      }
      recentGifts.innerHTML = items.slice(0, 18).map((item) => {
        if (item.category === 'gift' || item.gift_name) {
          return '<div class="gift-row">'
            + '<div><strong>' + escapeHtml(text(item.gift_name || item.title, '未知礼物')) + '</strong><span>' + escapeHtml(text(item.user_name || item.actor || item.user_id, '未知用户')) + '</span></div>'
            + '<div><span>数量</span><strong>' + (item.gift_count || 0) + '</strong></div>'
            + '<div><span>价值</span><strong>' + (item.gift_total_value || 0) + '</strong></div>'
            + '<div><span>' + routeRoleName(item.route_role) + '</span><strong>' + formatTime(item.event_time || item.received_at) + '</strong></div>'
            + '</div>';
        }
        return '<div class="activity-row">'
          + '<div><span>类型</span><strong>' + escapeHtml(categoryName(item.category)) + '</strong></div>'
          + '<div><strong>' + escapeHtml(text(item.title, '互动')) + '</strong><span>' + escapeHtml(text(item.actor, '直播间')) + '</span></div>'
          + '<div><span>内容</span><strong>' + escapeHtml(text(item.value, '-')) + '</strong></div>'
          + '<div><span>' + routeRoleName(item.route_role) + '</span><strong>' + formatTime(item.event_time || item.received_at) + '</strong></div>'
          + '</div>';
      }).join('');
    }
    function normalizeRoomTextBeforeStart(value) {
      const text = String(value || '').trim();
      if (!/^https?:\\/\\//i.test(text)) return { ok: true, value: text };
      try {
        const parsed = new URL(text);
        const host = parsed.hostname.toLowerCase();
        const isTiktok = host === 'tiktok.com' || host.endsWith('.tiktok.com');
        if (isTiktok) return { ok: true, value: text };
        const hint = parsed.searchParams.get('creator')
          || parsed.searchParams.get('creator_handle')
          || parsed.searchParams.get('creatorHandle')
          || parsed.searchParams.get('handle')
          || parsed.searchParams.get('username')
          || parsed.searchParams.get('user')
          || parsed.searchParams.get('room')
          || parsed.searchParams.get('room_id')
          || parsed.searchParams.get('roomId');
        if (hint) {
          const normalized = String(hint).replace(/^@/, '').replace(/[^a-zA-Z0-9_.-]+/g, '').slice(0, 64);
          if (normalized) return { ok: true, value: /^\\d+$/u.test(normalized) ? normalized : '@' + normalized, changed: true };
        }
        return { ok: false, message: '你填的是本地插件/OBS 页面，不是 TikTok 直播间。请填写 TikTok 主播 @账号，或粘贴 tiktok.com 的直播链接。' };
      } catch {
        return { ok: false, message: '直播间链接格式不对。请填写 TikTok 主播 @账号，或粘贴 tiktok.com 的直播链接。' };
      }
    }
    async function startCollector() {
      const normalizedRoom = normalizeRoomTextBeforeStart(roomInput.value);
      if (!normalizedRoom.ok) {
        alert(normalizedRoom.message);
        return;
      }
      const roomText = normalizedRoom.value;
      if (normalizedRoom.changed) {
        roomInput.value = roomText;
      }
      const primaryText = primaryChannel.value.trim();
      const backupText = backupChannel.value.trim();
      const hasBuiltInTechChannel = ${JSON.stringify(Boolean(defaultPrimaryChannel || defaultBackupChannel))};
      if (!roomText) {
        alert('请先填写 TikTok 主播 @账号或直播间链接');
        return;
      }
      if ((primaryText && !/^wss?:\\/\\//i.test(primaryText) && !/^https?:\\/\\//i.test(primaryText))
        || (backupText && !/^wss?:\\/\\//i.test(backupText) && !/^https?:\\/\\//i.test(backupText))) {
        alert('采集通道只能填写 wss:// 或 https:// 地址；运营备注、主播账号不要填在技术通道里。');
        return;
      }
      if (/^\\d+$/u.test(roomText) && !primaryText && !backupText && !hasBuiltInTechChannel) {
        alert('只填房间号无法自动找到主播。请改填 TikTok 主播 @账号 / 直播间链接，或让技术同学配置采集通道。');
        return;
      }
      const result = await api('/api/start', {
        room_input: roomText,
        primary_channel: primaryText,
        backup_channel: backupText,
        tenant_label: tenantLabel.value,
        alert_email: alertEmail.value,
        auto_start: autoStartToggle.checked
      });
      if (!result.ok) {
        alert(result.message || result.reason_code || '采集器启动失败，请检查主播账号或日志。');
      }
      refresh();
    }
    async function stopCollector() { await api('/api/stop', {}); refresh(); }
    async function openTiktokBackup() {
      const result = await api('/api/open-tiktok-backup', {});
      if (!result.ok) {
        alert(result.message || result.recovery?.reason_code || 'TikTok 验证页暂时打不开，请先开始驻守，或检查浏览器是否安装。');
      }
      refresh();
    }
    async function sendRecoveryEmail() {
      const result = await api('/api/send-recovery-email', { alert_email: alertEmail.value });
      if (!result.ok) {
        alert(result.alert?.message || result.alert?.reason_code || '验证码处理邮件没有发出。请检查通知邮箱和 SMTP/通知服务配置。');
      }
      refresh();
    }
    async function openOutput() { await api('/api/open-output', {}); }
    async function sendFrame() {
      const role = encodeURIComponent(frameRole.value);
      const res = await fetch('/api/frame?role=' + role, { method: 'POST', headers: { 'content-type': 'application/json' }, body: framePayload.value });
      await res.json();
      refresh();
    }
    async function compareReport() {
      await api('/api/compare', { source_name: compareSource.value, format: compareFormat.value, content: compareContent.value });
      refresh();
    }
    async function settlementReport() {
      if (compareSource.value.includes('mca') || compareSource.value.includes('third_party')) {
        alert('这个来源只能做测试对照，不能生成正式补数报告。正式补数请选公会后台、TikTok 官方导出、达人后台或 Live Rush 下播导入。');
        return;
      }
      await api('/api/settlement', { source_name: compareSource.value, format: compareFormat.value, content: compareContent.value });
      refresh();
    }
    async function refresh() {
      const res = await fetch('/api/status');
      const data = await res.json();
      applyOperatorSettings(data.operator_settings);
      const stats = data.plugin?.stats || {};
      const realtime = data.realtime || data.plugin?.realtime || {};
      const totals = realtime.totals || {};
      const liveStart = realtime.live_start || {};
      const freshness = realtime.data_freshness || {};
      const gapRisk = realtime.gap_risk || {};
      const visibility = realtime.data_visibility || {};
      const recovery = data.browser_recovery || {};
      const recoveryAlert = data.recovery_alert || {};
      const route = data.replay?.route_comparison || {};
      const routes = data.plugin?.routes || [];
      const primary = routes.find((item) => item.role === 'primary_realtime') || {};
      const backup = routes.find((item) => item.role === 'backup_realtime') || {};
      const routeCollectors = routes.map((item) => item.collector || {}).filter(Boolean);
      const isStandby = realtime.state === 'standby' || liveStart.standby_active === true;
      const isConnected = routeCollectors.some((item) => item.connected === true);
      const standbyCollector = routeCollectors.find((item) => item.state === 'standby_waiting_for_live') || {};
      const standbyReason = standbyCollector.stats?.standby_last_reason_code || '';
      const standbyHttpStatus = standbyCollector.stats?.standby_last_http_status || '';
      const lastErrorRoutes = Array.isArray(data.last_error?.routes) ? data.last_error.routes : [];
      const missingChannelResult = lastErrorRoutes
        .map((item) => item.result || {})
        .find((item) => item.reason_code === 'tiktok_webcast_connection_target_required' || item.reason_code === 'tiktok_live_connector_unique_id_required');
      const missingChannelRoomId = missingChannelResult?.live_room_id || '';
      const connectorRetryReasons = [
        'tiktok_live_connector_connect_failed',
        'tiktok_live_connector_room_not_live',
        'tiktok_live_connector_signer_failed',
        'tiktok_live_connector_signer_rate_limited',
        'tiktok_live_connector_fetch_blocked',
        'tiktok_live_connector_timeout',
        'tiktok_live_connector_stream_end',
        'tiktok_live_connector_disconnected'
      ];
      const isConnectorRetry = connectorRetryReasons.includes(standbyReason);
      topStatus.textContent = !data.plugin
        ? '还没开始'
        : missingChannelResult
          ? '缺主播账号或技术通道'
          : recovery.required
            ? 'TikTok 需要登录或验证'
          : realtime.headline
            ? realtime.headline
            : isStandby
              ? (standbyReason === 'tiktok_live_room_fetch_blocked' ? '入口被拦，继续重试' : isConnectorRetry ? '正在重试 TikTok 事件流' : '待机中，找直播入口')
              : isConnected
                ? '采集中，可以对账'
                : data.plugin.ok
                  ? '已启动，正在连接'
                  : '需要复核';
      topStatus.className = data.plugin?.ok ? levelClass(realtime.level || (isStandby ? 'warning' : 'good')) : 'warn';
      realtimeHeadline.textContent = realtime.headline || '等待开始';
      realtimeMessage.textContent = realtime.operator_message || '开播前先开始采集，插件会在后台驻守直播入口。';
      realtimeBadge.textContent = levelText(realtime.level);
      realtimeBadge.className = 'badge ' + levelClass(realtime.level);
      liveStartState.textContent = liveStart.standby_active ? '驻守中' : liveStart.first_attach_delay_seconds !== null && liveStart.first_attach_delay_seconds !== undefined ? formatSeconds(liveStart.first_attach_delay_seconds) : liveStart.pre_live_standby_enabled ? '可驻守' : '-';
      freshnessAge.textContent = formatSeconds(freshness.age_seconds);
      giftValueTotal.textContent = totals.gift_value_total ?? 0;
      paidUserCount.textContent = totals.paid_user_count ?? 0;
      giftCount.textContent = totals.gift_event_count ?? stats.gift_count ?? 0;
      p1Count.textContent = totals.p1_event_count ?? stats.p1_event_count ?? 0;
      frameCount.textContent = totals.frame_count ?? stats.frame_count ?? 0;
      issueCount.textContent = stats.issue_count || 0;
      routeIssueCount.textContent = totals.route_gap_count ?? ((route.primary_only_count || 0) + (route.backup_only_count || 0) + (route.mismatched_count || 0));
      compareScore.textContent = data.last_error ? '-' : (data.comparison ? data.comparison.accuracy_score : '-');
      settlementState.textContent = data.settlement
        ? (data.settlement.ready_for_production_settlement ? '可归档' : data.settlement.production_allowed ? '需复核' : '测试源')
        : '-';
      primaryCount.textContent = totals.primary_gift_count ?? primary.stats?.gift_count ?? 0;
      backupCount.textContent = totals.backup_gift_count ?? backup.stats?.gift_count ?? 0;
      standbyTimeline.textContent = liveStart.standby_active ? '正在驻守' : liveStart.pre_live_standby_enabled ? '已开启' : '未开启';
      liveDetectedTimeline.textContent = liveStart.live_detected_at ? formatTime(liveStart.live_detected_at) : '未确认';
      firstAttachTimeline.textContent = liveStart.first_attach_delay_seconds !== null && liveStart.first_attach_delay_seconds !== undefined
        ? formatSeconds(liveStart.first_attach_delay_seconds)
        : liveStart.first_frame_at ? formatTime(liveStart.first_frame_at) : liveStart.first_event_at ? formatTime(liveStart.first_event_at) : '未收到';
      gapRiskTimeline.textContent = gapRisk.level === 'none' ? '暂无' : gapRisk.level === 'blocker' ? '高风险' : '关注';
      gapRiskTimeline.className = levelClass(gapRisk.level);
      renderPipeline(realtime.pipeline || {});
      renderGuardrails(realtime.guardrails || {}, data.preflight || {});
      renderRoutes(realtime.routes || []);
      renderRecentGifts(realtime.recent_activity || realtime.recent_gifts || [], visibility);
      openTiktokButton.disabled = !(data.plugin && recovery.available);
      openTiktokButton.textContent = recovery.required ? '打开 TikTok 验证页' : '打开 TikTok 备用页';
      sendRecoveryEmailButton.disabled = !(data.plugin && recovery.available && recovery.required);
      sendRecoveryEmailButton.textContent = recoveryAlert.delivery_configured ? '发送验证码处理邮件' : '生成验证码处理入口';
      if (!data.plugin) {
        decisionTitle.textContent = '等待开始';
        decisionText.textContent = '填写主播账号后点开始驻守；下次打开采集器会按这个主播自动驻守。这个控制页可以关闭；自动打开的 TikTok 备用直播页不要关闭。';
      } else if (recovery.required) {
        decisionTitle.textContent = recovery.interactive_recovery_available === false ? '云端浏览器需要人工处理' : 'TikTok 需要登录或验证';
        decisionText.textContent = recovery.interactive_recovery_available === false
          ? '当前备线是云端无界面浏览器，TikTok 登录/验证码不能靠后台自动完成。请用远程桌面打开可视浏览器处理，或把采集器安装到运营电脑；处理后采集器会自动恢复。' + (recoveryAlert.message ? ' ' + recoveryAlert.message : '')
          : '点“打开 TikTok 验证页”，在自动打开的浏览器里完成登录/验证。通过后把浏览器最小化，不要关闭；采集器会自动恢复并继续记录。' + (recoveryAlert.message ? ' ' + recoveryAlert.message : '');
      } else if (visibility.title && ['raw_signal_only', 'semantic_no_gift', 'signal_seen'].includes(visibility.state)) {
        decisionTitle.textContent = visibility.title;
        decisionText.textContent = visibility.message;
      } else if (isStandby) {
        decisionTitle.textContent = standbyReason === 'tiktok_live_room_fetch_blocked' ? 'TikTok 暂时拦住了采集入口' : isConnectorRetry ? '正在重试 TikTok 事件流' : '待机中，正在找直播入口';
        decisionText.textContent = standbyReason === 'tiktok_live_room_fetch_blocked'
          ? '主播可能已经开播，但 TikTok 没有把直播间入口返回给当前电脑。请保持采集器运行，必要时切换登录浏览器线路或联系技术同学检查网络。' + (standbyHttpStatus ? ' HTTP ' + standbyHttpStatus + '。' : '')
          : isConnectorRetry
            ? '采集器会保持在后台，按主播账号持续重连 TikTok LIVE 事件流；恢复后会自动继续记录礼物、公屏、人气和互动。'
            : '系统会持续检查直播间，一旦拿到当前直播间入口就自动切到主备采集。';
      } else if (realtime.state === 'degraded' || realtime.state === 'needs_review') {
        decisionTitle.textContent = realtime.headline || '需要复核';
        decisionText.textContent = (gapRisk.issues || []).length
          ? (gapRisk.issues[0].message || realtime.operator_message || '本场存在缺口风险，需要下播后做正式对账。')
          : (realtime.operator_message || '本场存在缺口风险，需要下播后做正式对账。');
      } else if (data.last_error && missingChannelResult) {
        decisionTitle.textContent = '缺主播账号或技术通道';
        decisionText.textContent = missingChannelResult.reason_code === 'tiktok_live_connector_unique_id_required'
          ? '请填写 TikTok 主播账号、@账号或直播间链接。只填一串 room id 时，系统不知道该连哪个主播账号，需要技术通道才能采集。'
          : '系统已经拿到直播间' + (missingChannelRoomId ? ' ' + missingChannelRoomId : '') + '，但当前只填了 room id。请改填 TikTok 主播账号/直播链接，或让技术同学配置 LiveRush 托管通道。';
      } else if (data.last_error) {
        decisionTitle.textContent = '需要技术同学处理';
        decisionText.textContent = '采集通道没有连上，常见原因是通道地址没填、地址过期、房间填错或网络不稳定。';
      } else if (route.checked && !route.ok) {
        decisionTitle.textContent = '主备数据不一致';
        decisionText.textContent = '不要直接用于结算，先导入公会后台大数或下播明细做核对。';
      } else if (data.settlement && data.settlement.ready_for_production_settlement) {
        decisionTitle.textContent = data.settlement.settlement_mode === 'aggregate_control_total' ? '后台大数已对齐' : '正式补数已对齐';
        decisionText.textContent = data.settlement.settlement_mode === 'aggregate_control_total'
          ? '后台总流水和已导入的辅助大数与本地逐笔底稿一致。正式底稿以 Live Rush 本地采集为准，后台大数和截图一起归档。'
          : '本场下播明细和实时采集已对齐，可以把补数报告和原始证据一起归档。';
      } else if (data.settlement && data.settlement.settlement_gate_ok === false) {
        decisionTitle.textContent = '本地底稿还不能归档';
        decisionText.textContent = '后台大数只能做总账校验，不能覆盖主备线路异常。请先修复采集健康门禁，再做正式结算。';
      } else if (data.settlement && !data.settlement.production_allowed) {
        decisionTitle.textContent = '这是测试对照，不是正式补数';
        decisionText.textContent = 'MCA 或第三方报表只能用于测试期评估准确性；正式使用请导入公会后台、TikTok 官方导出、达人后台或 Live Rush 下播导入。';
      } else if (data.settlement) {
        decisionTitle.textContent = '正式补数需要复核';
        decisionText.textContent = data.settlement.settlement_mode === 'aggregate_control_total'
          ? '后台大数和本地底稿不一致。请先看总流水、付费人数、礼物笔数差多少，再用原始证据、录屏和测试期 MCA 定位漏采窗口。'
          : '请导出补数修正表，按缺失礼物、金额调整、实时多出记录逐项检查。';
      } else if (data.comparison) {
        decisionTitle.textContent = '已生成对账结果';
        decisionText.textContent = '请打开对账结果 CSV，看是否有缺失礼物、额外礼物或金额不一致。';
      } else {
        decisionTitle.textContent = '采集正常';
        decisionText.textContent = '直播结束后导入公会后台总流水/付费人数做正式验收；如果能拿到下播逐笔明细，再生成逐笔补数报告。';
      }
      statusJson.textContent = JSON.stringify(data, null, 2);
    }
    roomInput.addEventListener('input', () => { roomInputTouched = true; });
    setInterval(refresh, 2500);
    refresh();
  </script>
</body>
</html>`;
}

function renderRecoveryPage(validation) {
  const payload = validation?.payload ?? {};
  const ok = validation?.ok === true;
  const targetUrl = firstText(payload.target_url);
  const roomLabel = firstText(payload.room_input, targetUrl, "TikTok 直播间");
  return `<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Live Rush TikTok 验证处理</title>
  <style>
    body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;background:#f7f8fa;color:#111827}
    main{max-width:720px;margin:0 auto;padding:32px 18px}
    section{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:20px}
    h1{font-size:22px;margin:0 0 12px}
    p{line-height:1.65}
    .bad{color:#b91c1c}.hint{color:#6b7280}.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;word-break:break-all}
    button{border:0;border-radius:6px;padding:11px 15px;background:#111827;color:#fff;font-weight:700;cursor:pointer}
    button:disabled{opacity:.55;cursor:not-allowed}
    .status{margin-top:14px;padding:10px 12px;border-radius:6px;background:#f3f4f6}
  </style>
</head>
<body>
  <main>
    <section>
      <h1>${ok ? "TikTok 需要人工验证" : "处理链接不可用"}</h1>
      ${ok ? `
        <p>直播间：<span class="mono">${escapeServerHtml(roomLabel)}</span></p>
        <p>请在采集电脑或远程桌面点击下面按钮。系统会把采集器正在使用的 TikTok 浏览器带到前台，完成登录或验证码后，把浏览器最小化，不要关闭。</p>
        <p class="hint">这个入口不包含 TikTok 密码、Cookie 或浏览器资料目录；它只负责唤起本机采集器的备用浏览器。</p>
        <button id="openButton" onclick="openTikTokRecovery()">打开 TikTok 验证页</button>
        <div class="status" id="resultBox">等待处理。</div>
        ${targetUrl ? `<p class="hint">备用直播页：<span class="mono">${escapeServerHtml(targetUrl)}</span></p>` : ""}
      ` : `
        <p class="bad">${escapeServerHtml(validation?.message ?? "链接已过期或签名不正确。")}</p>
        <p class="hint">请回到 Live Rush 采集器，重新发送验证码处理邮件。</p>
      `}
    </section>
  </main>
  <script>
    async function openTikTokRecovery() {
      const button = document.getElementById('openButton');
      const box = document.getElementById('resultBox');
      button.disabled = true;
      box.textContent = '正在打开采集器备用浏览器...';
      try {
        const res = await fetch('/api/open-tiktok-backup', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: '{}'
        });
        const data = await res.json();
        if (data.ok) {
          box.textContent = '已打开。请在 TikTok 浏览器里完成验证，完成后最小化，不要关闭。';
        } else {
          box.textContent = data.message || data.recovery?.reason_code || '暂时打不开，请确认采集器正在运行。';
          button.disabled = false;
        }
      } catch (error) {
        box.textContent = '没有连上采集器。请确认这封邮件是在采集电脑或远程桌面里打开的。';
        button.disabled = false;
      }
    }
  </script>
</body>
</html>`;
}

function createRecoveryLink(token) {
  return `${recoveryPublicBaseUrl.replace(/\/+$/u, "")}/recover/tiktok?token=${encodeURIComponent(token)}`;
}

function createRecoveryToken(payload) {
  const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
  return `${body}.${signRecoveryTokenBody(body)}`;
}

function validateRecoveryToken(token) {
  const text = firstText(token);
  const [body, signature, extra] = text.split(".");
  if (!body || !signature || extra !== undefined) {
    return {
      ok: false,
      message: "处理链接格式不正确。"
    };
  }
  const expected = signRecoveryTokenBody(body);
  if (!safeEqualText(signature, expected)) {
    return {
      ok: false,
      message: "处理链接签名不正确。"
    };
  }
  try {
    const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
    const expiresAt = Date.parse(payload.expires_at ?? "");
    if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) {
      return {
        ok: false,
        message: "处理链接已过期。"
      };
    }
    if (payload.type !== "tiktok_browser_recovery") {
      return {
        ok: false,
        message: "处理链接类型不正确。"
      };
    }
    return {
      ok: true,
      payload
    };
  } catch {
    return {
      ok: false,
      message: "处理链接内容无法解析。"
    };
  }
}

function signRecoveryTokenBody(body) {
  return createHmac("sha256", ensureRecoverySecret()).update(body).digest("base64url");
}

function ensureRecoverySecret() {
  mkdirSync(defaultDataDir, { recursive: true });
  if (existsSync(recoverySecretFile)) {
    const existing = readFileSync(recoverySecretFile, "utf8").trim();
    if (existing) {
      return existing;
    }
  }
  const secret = randomBytes(32).toString("hex");
  writeFileSync(recoverySecretFile, `${secret}\n`, "utf8");
  return secret;
}

function safeEqualText(left, right) {
  const leftBuffer = Buffer.from(String(left ?? ""), "utf8");
  const rightBuffer = Buffer.from(String(right ?? ""), "utf8");
  if (leftBuffer.length !== rightBuffer.length) {
    return false;
  }
  return timingSafeEqual(leftBuffer, rightBuffer);
}

async function sendRecoverySmtpEmail(email) {
  const host = recoveryAlertSmtpHost;
  const port = recoveryAlertSmtpPort;
  let socket = recoveryAlertSmtpSsl
    ? tlsConnect({ host, port, servername: host })
    : netConnect({ host, port });
  attachSmtpTimeout(socket);
  await once(socket, recoveryAlertSmtpSsl ? "secureConnect" : "connect");
  let reader = createSmtpReader(socket);
  await expectSmtp(reader.read(), [220]);
  await smtpCommand(socket, reader, `EHLO ${smtpClientName()}`, [250]);
  if (!recoveryAlertSmtpSsl) {
    await smtpCommand(socket, reader, "STARTTLS", [220]);
    reader.dispose();
    socket = tlsConnect({ socket, servername: host });
    attachSmtpTimeout(socket);
    await once(socket, "secureConnect");
    reader = createSmtpReader(socket);
    await smtpCommand(socket, reader, `EHLO ${smtpClientName()}`, [250]);
  }
  await smtpCommand(socket, reader, "AUTH LOGIN", [334]);
  await smtpCommand(socket, reader, Buffer.from(recoveryAlertSmtpUser, "utf8").toString("base64"), [334]);
  await smtpCommand(socket, reader, Buffer.from(recoveryAlertSmtpPassword, "utf8").toString("base64"), [235]);
  await smtpCommand(socket, reader, `MAIL FROM:<${smtpAddress(recoveryAlertSmtpFrom)}>`, [250]);
  await smtpCommand(socket, reader, `RCPT TO:<${smtpAddress(email.to)}>`, [250, 251]);
  await smtpCommand(socket, reader, "DATA", [354]);
  socket.write(`${createMimeEmail(email)}\r\n.\r\n`);
  await expectSmtp(reader.read(), [250]);
  await smtpCommand(socket, reader, "QUIT", [221]);
  reader.dispose();
  socket.end();
}

function attachSmtpTimeout(socket) {
  socket.setTimeout(30000, () => {
    socket.destroy(new Error("SMTP connection timed out"));
  });
}

function createSmtpReader(socket) {
  let buffer = "";
  let pending = null;
  const onData = (chunk) => {
    buffer += chunk.toString("utf8");
    flush();
  };
  const onError = (error) => {
    if (pending) {
      const current = pending;
      pending = null;
      current.reject(error);
    }
  };
  const onClose = () => {
    if (pending) {
      const current = pending;
      pending = null;
      current.reject(new Error("SMTP connection closed"));
    }
  };
  socket.on("data", onData);
  socket.on("error", onError);
  socket.on("close", onClose);

  function takeResponse() {
    const parts = buffer.split(/\r?\n/u);
    if (parts.length <= 1) {
      return null;
    }
    let consumed = 0;
    const lines = [];
    for (const part of parts) {
      if (part === "" && consumed === parts.length - 1) {
        break;
      }
      lines.push(part);
      consumed += 1;
      if (/^\d{3} /u.test(part)) {
        buffer = parts.slice(consumed).join("\n");
        const code = Number(part.slice(0, 3));
        return {
          code,
          text: lines.join("\n")
        };
      }
    }
    return null;
  }

  function flush() {
    if (!pending) {
      return;
    }
    const response = takeResponse();
    if (response) {
      const current = pending;
      pending = null;
      current.resolve(response);
    }
  }

  return {
    read() {
      const response = takeResponse();
      if (response) {
        return Promise.resolve(response);
      }
      return new Promise((resolve, reject) => {
        pending = { resolve, reject };
      });
    },
    dispose() {
      socket.off("data", onData);
      socket.off("error", onError);
      socket.off("close", onClose);
    }
  };
}

async function smtpCommand(socket, reader, command, expectedCodes) {
  socket.write(`${command}\r\n`);
  return expectSmtp(reader.read(), expectedCodes);
}

async function expectSmtp(responsePromise, expectedCodes) {
  const response = await responsePromise;
  if (!expectedCodes.includes(response.code)) {
    throw new Error(`SMTP ${response.code}: ${response.text}`);
  }
  return response;
}

function createMimeEmail(email) {
  const boundary = `liverush_${randomBytes(10).toString("hex")}`;
  return [
    `From: ${formatMailHeaderAddress(recoveryAlertSmtpFrom)}`,
    `To: ${formatMailHeaderAddress(email.to)}`,
    `Subject: ${encodeMimeHeader(email.subject)}`,
    "MIME-Version: 1.0",
    `Content-Type: multipart/alternative; boundary="${boundary}"`,
    "",
    `--${boundary}`,
    "Content-Type: text/plain; charset=utf-8",
    "Content-Transfer-Encoding: base64",
    "",
    wrapBase64(email.text),
    `--${boundary}`,
    "Content-Type: text/html; charset=utf-8",
    "Content-Transfer-Encoding: base64",
    "",
    wrapBase64(email.html),
    `--${boundary}--`
  ].join("\r\n");
}

function encodeMimeHeader(value) {
  return `=?UTF-8?B?${Buffer.from(String(value ?? ""), "utf8").toString("base64")}?=`;
}

function wrapBase64(value) {
  return Buffer.from(String(value ?? ""), "utf8").toString("base64").match(/.{1,76}/gu)?.join("\r\n") ?? "";
}

function formatMailHeaderAddress(address) {
  return `<${smtpAddress(address)}>`;
}

function smtpAddress(address) {
  return String(address ?? "").trim().replace(/^.*<([^>]+)>.*$/u, "$1");
}

function smtpClientName() {
  return "live-rush-collector.local";
}

function maskEmail(email) {
  const text = firstText(email);
  if (!text || !text.includes("@")) {
    return "";
  }
  const [name, domain] = text.split("@");
  const safeName = name.length <= 2 ? `${name[0] ?? "*"}*` : `${name.slice(0, 2)}***${name.slice(-1)}`;
  return `${safeName}@${domain}`;
}

function escapeServerHtml(value) {
  return String(value ?? "").replace(/[&<>"']/gu, (char) => ({
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    "\"": "&quot;",
    "'": "&#39;"
  })[char]);
}

function sendHtml(response, html) {
  response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
  response.end(html);
}

function sendJson(response, value) {
  response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  response.end(JSON.stringify(value, null, 2));
}

function sendText(response, value, { contentType, filename }) {
  response.writeHead(200, {
    "content-type": contentType,
    "content-disposition": `attachment; filename="${filename}"`
  });
  response.end(value);
}

async function readJson(request) {
  return parseMaybeJson(await readText(request)) ?? {};
}

async function readText(request) {
  const chunks = [];
  for await (const chunk of request) {
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

function parseMaybeJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    return null;
  }
}

function exportFactsCsv(facts = []) {
  const rows = [
    ["category", "route_role", "source_event_id", "actor_user_id", "event_time", "gift_id", "gift_name", "gift_count", "gift_total_value", "gameplay_score_applied"]
  ];
  for (const fact of facts) {
    rows.push([
      fact.category ?? "",
      fact.route_role ?? "",
      fact.source_event_id ?? "",
      fact.actor_user_id ?? fact.normalized?.gifter_user_id ?? "",
      fact.event_time ?? "",
      fact.normalized?.gift_id ?? "",
      fact.normalized?.gift_name ?? "",
      fact.normalized?.gift_count ?? "",
      fact.normalized?.gift_total_value ?? "",
      fact.gameplay_score_applied === false ? "false" : ""
    ]);
  }
  return rows.map((row) => row.map(csvCell).join(",")).join("\n");
}

function csvCell(value) {
  const text = String(value ?? "");
  if (!/[",\n\r]/u.test(text)) {
    return text;
  }
  return `"${text.replaceAll("\"", "\"\"")}"`;
}

function openFolder(folder) {
  if (!existsSync(folder)) {
    mkdirSync(folder, { recursive: true });
  }
  if (process.platform === "win32") {
    execFile("explorer.exe", [folder]);
  } else if (process.platform === "darwin") {
    execFile("open", [folder]);
  } else {
    execFile("xdg-open", [folder]);
  }
}

function openUrl(url) {
  const target = String(url ?? "").trim();
  if (!target) {
    return;
  }
  if (process.platform === "win32") {
    execFile("cmd.exe", ["/c", "start", "", target]);
  } else if (process.platform === "darwin") {
    execFile("open", [target]);
  } else {
    execFile("xdg-open", [target]);
  }
}
