const state = {
  accounts: [],
  selected: [],          // [{path, filename, size_mb, folder}]
  scanRoot: null,        // absolute path that was scanned
  scanGroups: [],        // [{folder, items: [...]}]
  scheduleManuallyEdited: false,  // user touched first-schedule → stop auto-refresh
  expandedAccounts: new Set(),    // container_codes whose batch detail is open
};

const $ = (id) => document.getElementById(id);

// ────────────────────── client-side event log ──────────────────────
//
// v0.1.30: emit structured events at each decision point of the
// submit flow so operators can trace "click submit, no response"
// from the uploaded log bundle. Server endpoint `/api/client-log`
// writes each call into app.log via the Python logger.
//
// Design:
//  - Also logs to browser console.log so live DevTools debugging
//    stays ergonomic.
//  - Fire-and-forget (best-effort); a failing /api/client-log must
//    never block the user flow. Wrapped in try/catch + fetch-w-timeout
//    so a hung server doesn't stall the UI.
//  - Payload is small JSON — one click ~= a few hundred bytes.
async function clientLog(level, event, data = {}) {
  // Console first so DevTools users see the event immediately
  // regardless of network state.
  try {
    const fn = level === "error" ? console.error
             : level === "warn" ? console.warn
             : console.log;
    fn(`[tkp] ${event}`, data);
  } catch (_) {}
  // Best-effort forward to server
  try {
    const controller = new AbortController();
    const to = setTimeout(() => controller.abort(), 3000);
    await fetch("/api/client-log", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ level, event, data }),
      signal: controller.signal,
    });
    clearTimeout(to);
  } catch (_) { /* swallow — never block UI on logging */ }
}

async function api(path, opts = {}) {
  const res = await fetch(path, {
    headers: { "Content-Type": "application/json" },
    ...opts,
  });
  if (res.status === 401) {
    // Session expired or never logged in — go to /login
    location.href = "/login";
    throw new Error("401: redirecting to login");
  }
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`${res.status}: ${text}`);
  }
  return await res.json();
}

// ────────────────────── accounts ──────────────────────

async function loadVersion() {
  try {
    const r = await fetch("/api/version");
    if (r.ok) {
      const { version } = await r.json();
      $("version-tag").textContent = "v" + version;
    }
  } catch (e) { /* non-fatal */ }
}

async function loadUser() {
  const uploadBtn = $("upload-app-log");
  try {
    const r = await fetch("/api/auth/me");
    if (r.status === 401) {
      // Hide upload button if not authenticated
      uploadBtn.style.display = "none";
      location.href = "/login";
      return;
    }
    const data = await r.json();
    const pill = $("user-pill");
    if (data.auth_enabled) {
      pill.textContent = "👤 " + (data.username || "?") + " · 退出";
      pill.onclick = async () => {
        await fetch("/api/auth/logout", { method: "POST" });
        location.href = "/login";
      };
      // Show upload button when authenticated
      uploadBtn.style.display = "";
    } else {
      pill.textContent = "（auth disabled）";
      pill.style.cursor = "default";
      // Show upload button when auth is disabled (dev mode)
      uploadBtn.style.display = "";
    }
  } catch (e) {
    console.error("loadUser:", e);
    // Hide upload button on error
    uploadBtn.style.display = "none";
  }
}

async function loadHubStatus() {
  const el = $("hub-status");
  try {
    const s = await api("/api/hubstudio-status");
    if (s.connected) {
      el.innerHTML = `<span class="hub-ok">● HubStudio</span> ${s.base_url} · <strong>${s.env_count}</strong> 账号`;
      el.title = (s.sample_names || []).filter(Boolean).join(" / ");
    } else {
      el.innerHTML = `<span class="hub-err">● HubStudio 未连接</span> ${s.base_url}`;
      el.title = s.error || "";
    }
  } catch (e) {
    el.innerHTML = `<span class="hub-err">● HubStudio 不可达</span>`;
  }
}

async function loadAccounts() {
  const sel = $("account");
  sel.innerHTML = `<option>加载中…</option>`;
  try {
    const data = await api("/api/accounts");
    state.accounts = data.accounts;
    sel.innerHTML = "";
    for (const a of data.accounts) {
      const opt = document.createElement("option");
      opt.value = String(a.container_code);
      opt.textContent = `${a.name}  (${a.container_code}${a.last_country ? "·" + a.last_country : ""})`;
      opt.dataset.name = a.name;
      sel.appendChild(opt);
    }
    if (!data.accounts.length) sel.innerHTML = `<option>（HubStudio 里还没有环境）</option>`;
  } catch (e) {
    sel.innerHTML = `<option>加载失败：${e.message}</option>`;
  }
}

// ────────────────────── music ──────────────────────
//
// v0.1.28 replaced the select-from-favorites UI with a free text
// input + "不加音乐" checkbox. The select was lying — it suggested
// favorites were validated server-side, but the server has no way to
// know what a specific account has favorited (HubStudio API doesn't
// expose it). The real validation happens at publish time when we
// scan the Favoritos panel and either find the name or raise
// `music_not_found`. A text input makes the contract honest: "we
// type exactly what the account has favorited, the publisher
// matches it character-for-character."
//
// Default: unchecked (i.e., DO add music). Checking "不加音乐"
// disables the text input and tells the worker to skip the music
// step entirely.

async function loadMusic() {
  // Prefill from server's configured default (if any). The backend
  // /api/music-favorites endpoint still serves the config value —
  // we just stopped rendering it as a dropdown.
  try {
    const data = await api("/api/music-favorites");
    const input = $("music-name");
    const defaultName = data.default || "";
    if (!input.value) input.value = defaultName;
  } catch (e) {
    console.error("loadMusic:", e);
  }
}

function wireMusicSkipToggle() {
  const checkbox = $("skip-music");
  const input = $("music-name");
  if (!checkbox || !input) return;
  const sync = () => {
    input.disabled = checkbox.checked;
    if (checkbox.checked) {
      input.dataset.savedValue = input.value;
      input.value = "";
      input.placeholder = "（已跳过）";
    } else {
      input.placeholder = "音乐名（和账号收藏夹里保存的名字完全一致）";
      if (input.dataset.savedValue) input.value = input.dataset.savedValue;
    }
  };
  checkbox.addEventListener("change", sync);
  sync();
}

// ────────────────────── scan ──────────────────────

async function scanPath() {
  const path = $("scan-path").value.trim();
  if (!path) return;
  $("scan-meta").textContent = "扫描中…";
  $("scan-groups").innerHTML = "";
  try {
    const data = await api("/api/scan", {
      method: "POST",
      body: JSON.stringify({ path }),
    });
    state.scanRoot = data.root;
    state.scanGroups = data.groups || [];
    renderScanGroups();
    $("scan-meta").textContent =
      `根：${data.root}  共 ${data.count} 个视频，${state.scanGroups.length} 组`;
  } catch (e) {
    $("scan-meta").textContent = `扫描失败：${e.message}`;
  }
}

function renderScanGroups() {
  const root = $("scan-groups");
  root.innerHTML = "";
  for (const g of state.scanGroups) {
    const section = document.createElement("section");
    section.className = "scan-group";

    const h = document.createElement("h3");
    h.textContent = g.folder === "." ? "（根目录）" : g.folder + "/";
    const count = document.createElement("span");
    count.className = "muted group-count";
    count.textContent = ` ${g.items.length}`;
    h.appendChild(count);
    section.appendChild(h);

    const grid = document.createElement("div");
    grid.className = "video-grid";
    for (const it of g.items) {
      const card = document.createElement("div");
      card.className = "video-card";

      const cb = document.createElement("input");
      cb.type = "checkbox";
      cb.dataset.path = it.path;
      cb.checked = !!state.selected.find(s => s.path === it.path);
      cb.onchange = () => toggleSelected({ ...it, folder: g.folder }, cb.checked);
      card.appendChild(cb);

      const vid = document.createElement("video");
      vid.src = `/api/file?path=${encodeURIComponent(it.path)}`;
      vid.preload = "metadata";
      vid.controls = true;
      vid.className = "video-thumb";
      card.appendChild(vid);

      const label = document.createElement("div");
      label.className = "video-label";
      label.textContent = `${it.filename}  (${it.size_mb}MB)`;
      card.appendChild(label);

      grid.appendChild(card);
    }
    section.appendChild(grid);
    root.appendChild(section);
  }
}

// ────────────────────── selected ──────────────────────

function toggleSelected(item, on) {
  if (on) {
    if (!state.selected.find(s => s.path === item.path)) {
      state.selected.push({ ...item });
    }
  } else {
    state.selected = state.selected.filter(s => s.path !== item.path);
  }
  renderSelected();
}

function renderSelected() {
  const ol = $("selected-list");
  ol.innerHTML = "";
  state.selected.forEach((s, idx) => {
    const li = document.createElement("li");
    li.className = "selected-item";
    li.draggable = true;
    li.dataset.idx = idx;

    const pos = document.createElement("div");
    pos.className = "selected-pos";
    pos.textContent = String(idx + 1);
    li.appendChild(pos);

    const vid = document.createElement("video");
    vid.src = `/api/file?path=${encodeURIComponent(s.path)}`;
    vid.preload = "metadata";
    vid.muted = true;
    vid.className = "selected-thumb";
    li.appendChild(vid);

    const meta = document.createElement("div");
    meta.className = "selected-meta";
    meta.innerHTML =
      `<div class="fname">${s.filename}</div>` +
      `<div class="muted">${s.folder === "." ? "/" : s.folder + "/"}</div>`;
    li.appendChild(meta);

    const del = document.createElement("button");
    del.className = "selected-del";
    del.type = "button";
    del.title = "删除";
    del.textContent = "✕";
    del.onclick = () => {
      state.selected.splice(idx, 1);
      renderSelected();
      syncCheckboxes();
    };
    li.appendChild(del);

    // Drag reorder
    li.ondragstart = e => {
      e.dataTransfer.setData("text/plain", String(idx));
      li.classList.add("dragging");
    };
    li.ondragend = () => li.classList.remove("dragging");
    li.ondragover = e => { e.preventDefault(); li.classList.add("over"); };
    li.ondragleave = () => li.classList.remove("over");
    li.ondrop = e => {
      e.preventDefault();
      li.classList.remove("over");
      const from = parseInt(e.dataTransfer.getData("text/plain"), 10);
      const to = idx;
      if (Number.isNaN(from) || from === to) return;
      const [moved] = state.selected.splice(from, 1);
      state.selected.splice(to, 0, moved);
      renderSelected();
    };

    ol.appendChild(li);
  });
  $("selected-count").textContent = String(state.selected.length);
  const btn = $("submit-btn");
  const wasDisabled = btn.disabled;
  const shouldDisable = state.selected.length === 0;
  btn.disabled = shouldDisable;
  // v0.1.30: log transitions so the post-hoc bundle shows whether
  // the button was grey-out when the operator clicked. Disabled
  // buttons in HTML eat click events entirely — without this
  // breadcrumb we can't tell "they clicked a disabled button"
  // from "they never clicked".
  if (wasDisabled !== shouldDisable) {
    clientLog("info", "submit_btn_state_change", {
      disabled: shouldDisable,
      selected_count: state.selected.length,
    });
  }
  refreshSchedulePreview();
}

// v0.1.30: parent-level click listener on the submit section so
// we can record "user clicked NEAR the disabled submit button"
// (the browser blocks click events on disabled <button>s, but the
// click still hits the surrounding pane and bubbles from there).
// Wired once on DOMContentLoaded.
function wireSubmitPaneLogger() {
  const pane = $("pane-submit");
  if (!pane) return;
  pane.addEventListener("click", (ev) => {
    const btn = $("submit-btn");
    // Only care about clicks inside / onto the submit button itself
    // — clicks on the result line below are noise.
    if (!ev.target.closest("#submit-btn, button[id='submit-btn']")
        && !(btn && btn.getBoundingClientRect && _clickWasOnBtn(ev, btn))) {
      return;
    }
    if (btn && btn.disabled) {
      clientLog("warn", "submit_click_on_disabled_btn", {
        selected_count: state.selected.length,
      });
    }
    // If enabled, `submit()` runs via onclick and logs itself.
  });
}
function _clickWasOnBtn(ev, btn) {
  const r = btn.getBoundingClientRect();
  return ev.clientX >= r.left && ev.clientX <= r.right
      && ev.clientY >= r.top  && ev.clientY <= r.bottom;
}

function syncCheckboxes() {
  const chosen = new Set(state.selected.map(s => s.path));
  for (const cb of document.querySelectorAll("#scan-groups input[type=checkbox]")) {
    cb.checked = chosen.has(cb.dataset.path);
  }
}

// ────────────────────── schedule preview ──────────────────────

function getPublishMode() {
  const r = document.querySelector('input[name="publish-mode"]:checked');
  return r ? r.value : "schedule";
}

function applyPublishModeUI() {
  const mode = getPublishMode();
  const row = $("schedule-row");
  row.style.display = mode === "schedule" ? "" : "none";
  refreshSchedulePreview();
}

async function refreshSchedulePreview() {
  const mode = getPublishMode();
  const count = state.selected.length;
  if (mode === "now") {
    $("schedule-preview").innerHTML =
      '<li class="muted">立即发布模式 — 每条视频按"Ahora"发，视频间自动间隔 60–180s</li>';
    return;
  }
  // If the user hasn't manually touched the field, keep the default
  // in sync with "Mexico City now + 30 min". Without this, a page
  // left open for 30+ min ends up with a schedule time in the past.
  if (!state.scheduleManuallyEdited) {
    setFirstScheduleDefault();
  }
  const first = $("first-schedule").value || null;
  if (count === 0) { $("schedule-preview").innerHTML = ""; return; }
  try {
    const data = await api("/api/preview-schedule", {
      method: "POST",
      body: JSON.stringify({ count, first_schedule_at: first }),
    });
    const ol = $("schedule-preview");
    ol.innerHTML = "";
    if (!data.enabled) {
      ol.innerHTML = '<li class="muted">Programación 未启用</li>';
      return;
    }
    $("interval-hint").textContent =
      `后续间隔 ${data.interval_minutes[0]}–${data.interval_minutes[1]} 分钟随机（5 分钟粒度）`;
    for (const t of data.times) {
      const li = document.createElement("li");
      li.textContent = t.replace("T", " ");
      ol.appendChild(li);
    }
  } catch (e) {
    $("schedule-preview").innerHTML = `<li class="err">预览失败: ${e.message}</li>`;
  }
}

// ────────────────────── folder picker ──────────────────────

async function openFolderPicker() {
  // Prefer whatever's in the input. Empty → HOME. Server-side walks
  // up to nearest existing dir, so bad paths self-heal (no 400).
  const start = $("scan-path").value.trim() || "~";
  await navigateFolderTo(start);
  $("folder-modal").classList.remove("modal-hidden");
}
function closeFolderPicker() { $("folder-modal").classList.add("modal-hidden"); }
async function navigateFolderTo(path) {
  try {
    const data = await api("/api/list-dirs", {
      method: "POST",
      body: JSON.stringify({ path }),
    });
    $("folder-path").value = data.path;
    const ul = $("folder-list");
    ul.innerHTML = "";
    for (const d of data.dirs) {
      const li = document.createElement("li");
      li.textContent = "📁 " + d;
      li.onclick = () => navigateFolderTo(data.path + (data.path.endsWith("/") ? "" : "/") + d);
      ul.appendChild(li);
    }
    if (!data.dirs.length) {
      ul.innerHTML = '<li class="muted">（无子目录）</li>';
    }
  } catch (e) {
    alert("浏览失败：" + e.message);
  }
}

// ────────────────────── submit ──────────────────────

async function submit() {
  // Snapshot UI state up front so the log reflects what the
  // operator saw when they clicked, even if we early-return.
  const sel = $("account");
  const btn = $("submit-btn");
  const skipMusic = $("skip-music").checked;
  const contentCheck = $("content-check") ? $("content-check").checked : true;
  const musicRaw = ($("music-name").value || "").trim();
  const publishMode = getPublishMode();
  const firstSchedule = publishMode === "schedule"
    ? ($("first-schedule").value || null)
    : null;
  const caption = $("shared-caption").value;
  const selectedCount = state.selected.length;

  await clientLog("info", "submit_click_entered", {
    selected_count: selectedCount,
    btn_disabled: btn ? btn.disabled : "(missing)",
    account_value: sel.value || "",
    account_text: sel.value
      ? (sel.options[sel.selectedIndex]?.textContent || "").slice(0, 80)
      : "",
    music_text_len: musicRaw.length,
    skip_music: skipMusic,
    content_check: contentCheck,
    publish_mode: publishMode,
    scan_root: state.scanRoot,
    first_schedule: firstSchedule,
    caption_len: (caption || "").length,
  });

  // Defense-in-depth: button.disabled should already prevent this,
  // but if CSS / event wiring drift leaks through, surface it.
  if (selectedCount === 0) {
    await clientLog("warn", "submit_no_videos", {});
    $("submit-result").className = "err";
    $("submit-result").textContent = "请先勾选要发布的视频";
    return;
  }

  if (!sel.value) {
    // Was a silent return before v0.1.30 — the #1 suspect for
    // "click, no response" reports. Now visible + logged.
    await clientLog("warn", "submit_no_account", {
      account_options_count: sel.options.length,
    });
    $("submit-result").className = "err";
    $("submit-result").textContent =
      "请先在右上角选择发布账号（HubStudio 环境）";
    return;
  }

  const containerCode = parseInt(sel.value, 10);
  const accountName = sel.options[sel.selectedIndex].dataset.name;

  // If "不加音乐" is checked, ignore the text input entirely and
  // send skip_music=true. Otherwise require a non-empty trimmed
  // name — the worker matches character-for-character against the
  // account's Favoritos, so leading/trailing whitespace would
  // silently break the match.
  const music = skipMusic ? null : (musicRaw || null);
  if (!skipMusic && !music) {
    await clientLog("warn", "submit_no_music", {});
    $("submit-result").className = "err";
    $("submit-result").textContent =
      "请输入音乐名，或勾选“不加音乐”";
    return;
  }

  $("submit-result").className = "";
  $("submit-result").textContent = "提交中…";
  await clientLog("info", "submit_post_firing", {
    container_code: containerCode,
    account_name: accountName,
    items_count: state.selected.length,
    music,
    skip_music: skipMusic,
    content_check: contentCheck,
    publish_mode: publishMode,
  });
  try {
    const data = await api("/api/submit", {
      method: "POST",
      body: JSON.stringify({
        container_code: containerCode,
        account_name: accountName,
        source_root: state.scanRoot,
        caption,
        music,
        skip_music: skipMusic,
        content_check: contentCheck,
        publish_mode: publishMode,
        first_schedule_at: firstSchedule,
        items: state.selected.map(s => ({ path: s.path })),
      }),
    });
    await clientLog("info", "submit_post_ok", { job_id: data.job_id });
    $("submit-result").className = "ok";
    $("submit-result").textContent = `✓ 任务已创建：${data.job_id}`;
    state.selected = [];
    renderSelected();
    if ($("scan-path").value.trim()) scanPath();  // refresh; moved videos now gone
    loadDashboard();
  } catch (e) {
    await clientLog("error", "submit_post_error", {
      message: (e && e.message) ? String(e.message).slice(0, 400) : String(e),
    });
    $("submit-result").className = "err";
    $("submit-result").textContent = `✗ ${e.message}`;
  }
}

// ────────────────────── tasks ──────────────────────

async function loadOverview() {
  try {
    // Default = today (backend picks today when date param absent)
    const o = await api("/api/overview");
    $("ov-accounts-active").textContent = o.accounts_active;
    $("ov-accounts-total").textContent = o.accounts_total;
    $("ov-batches-pending").textContent = o.batches.pending || 0;
    $("ov-batches-running").textContent = o.batches.running || 0;
    $("ov-videos-done").textContent = o.videos.done || 0;
    $("ov-videos-total").textContent = o.videos.total || 0;
    $("ov-videos-failed").textContent = o.videos.failed || 0;
    const total = o.videos.total || 0;
    const done = o.videos.done || 0;
    const pct = total ? Math.round(100 * done / total) : 0;
    $("overview-bar-fill").style.width = `${pct}%`;
    $("overview-bar-fill").textContent = total ? `${done}/${total} · ${pct}%` : "";
    // Show the date the counts represent. Today by default; backend
    // echoes the resolved date.
    const dateEl = $("overview-date");
    if (dateEl) {
      dateEl.textContent = o.date === o.today
        ? `（${o.date}）`
        : `（${o.date}）`;
    }
  } catch (e) {
    console.error("loadOverview:", e);
  }
}

function formatAgo(sec) {
  if (sec == null) return "—";
  if (sec < 60) return `${sec}s 前`;
  if (sec < 3600) return `${Math.floor(sec/60)} 分钟前`;
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  return `${h} 小时 ${m} 分钟前`;
}

const BADGE = {
  stuck:        { label: "⚠ 疑似卡",  cls: "badge-stuck" },
  needs_resend: { label: "⚠ 需补发",  cls: "badge-needs" },
  running:      { label: "▶ 运行中",  cls: "badge-running" },
  pending:      { label: "⏳ 排队",    cls: "badge-pending" },
  done:         { label: "✓ 已完成",  cls: "badge-done" },
  idle:         { label: "— 空闲",    cls: "badge-idle" },
};

// Error-category metadata. Keys MUST match `categorize_error()` in
// src/web.py. `severity` drives color: red = user must act, yellow =
// usually retryable, grey = other.
const ERROR_CATEGORY_META = {
  product_not_found: {
    label: "商品不在店铺",
    hint: "product_id 不在该账号的 Shop。确认商品存在或换账号。",
    severity: "red",
  },
  music_tab_stuck: {
    label: "音乐切换失败",
    hint: "Favoritos tab 点击没切到（React 竞态）。v0.1.21 已加 3 次重试+verify，若再出现请重跑。",
    severity: "yellow",
  },
  music_not_found: {
    label: "音乐未收藏",
    hint: "Favoritos tab 打开了，但这个账号的收藏里没有目标歌曲。需要在 TikTok Studio 里给该账号收藏这首歌，或者换一个已收藏的账号。重跑没用。",
    severity: "red",
  },
  post_timeout: {
    label: "发布超时",
    hint: "180 秒内 TikTok 没 redirect。视频可能其实已发布，请到 TikTok Studio 确认再重跑。",
    severity: "yellow",
  },
  content_restricted: {
    label: "内容受限",
    hint: "TikTok 判定重复 / 违规 / 低质。同一视频重传无效，需换素材。截图记录具体弹窗。",
    severity: "red",
    has_screenshot: true,
  },
  login_required: {
    label: "需重新登录",
    hint: "AuthGate session 过期。右上角点退出再登录。",
    severity: "yellow",
  },
  captcha: {
    label: "验证码 / 风控",
    hint: "TikTok 要人机验证。在 HubStudio 浏览器里手动过掉再重跑。",
    severity: "yellow",
  },
  hubstudio_kernel: {
    label: "HubStudio 内核缺失",
    hint: "Chrome 指定版本没下载。到 HubStudio 管理该账号内核。",
    severity: "red",
  },
  product_commit_stuck: {
    label: "商品挂载卡住",
    hint: "产品名称含非法字符（如 |），TikTok 拒绝提交。v0.1.31 已自动清理名称，重跑应修复。",
    severity: "yellow",
  },
  hubstudio_busy: {
    label: "环境占用",
    hint: "HubStudio 启动浏览器失败（常见：同账号被另一台机器/操作员占用，或网络抖动）。等对方释放后重新提交即可。",
    severity: "yellow",
  },
  unknown_dialog: {
    label: "未知 dialog",
    hint: "TikTok 弹出我们不识别的窗口，已失败退出并保留截图。点 📸 看具体内容，反馈给开发者加识别规则。",
    severity: "grey",
    has_screenshot: true,
  },
  tiktok_http_error: {
    label: "TK 限速",
    hint: "TikTok upload 页返回非 2xx（短期限速/风控）。等 2–3 分钟再重跑同一批。",
    severity: "yellow",
    has_screenshot: true,
  },
  other: {
    label: "其他",
    hint: "见日志（📤 上传日志）。",
    severity: "grey",
  },
};

/** Render per-category failure badges inline.
 *  `categories` is {category: count, ...} as returned by backend.
 *  Returns HTML string. Empty when no failures.
 */
function renderFailCategoryBadges(categories) {
  if (!categories || !Object.keys(categories).length) return "";
  const parts = [];
  for (const [cat, count] of Object.entries(categories)) {
    const meta = ERROR_CATEGORY_META[cat] || ERROR_CATEGORY_META.other;
    const cls = `fail-cat fail-cat-${meta.severity}`;
    parts.push(
      `<span class="${cls}" title="${meta.hint.replace(/"/g, '&quot;')}">` +
      `${meta.label}×${count}</span>`
    );
  }
  return parts.join(" ");
}

// v0.1.25 tree view: originally date → account → batch → video.
// v0.1.27 flattened the middle layer: now date → batch → video, sorted
// by batch created_at desc inside each date. The account layer was
// redundant — each batch is already pinned to one account — and the
// operator reported it added nesting without information. Account
// name + container_code now appear inline on the batch label instead.
// Each level is a collapsible <details>. Today's date is expanded by
// default; prior days collapsed. Open/close state preserved in
// `state.expandedKeys` across 5s auto-refreshes.
state.expandedKeys = state.expandedKeys || new Set();

// Strip the `_YYYYMMDD_HHMMSS` timestamp that build_job_id() and the
// inbox-drop importer append, so the label shows only the
// human-meaningful prefix. `Src_acct-pnf_20260422_214706` → `Src_acct-pnf`.
// If the pattern doesn't match (legacy / custom job_id), fall back to
// the full job_id.
function _stripJobIdTimestamp(jobId) {
  return (jobId || "").replace(/_\d{8}_\d{6}$/, "");
}

function _statusIcon(status) {
  return {
    done: "✅", failed: "❌", running: "▶", pending: "⏳",
    cancelled: "⊘", source_missing: "💀",
  }[status] || "•";
}

function _videoRow(v, batchArea, batchJobId, videoIdx) {
  const status = v.status || "pending";
  const cat = v.error_category || "none";
  const meta = ERROR_CATEGORY_META[cat] || null;
  const parts = [`<span class="vrow-icon status-${status}">${_statusIcon(status)}</span>`];
  parts.push(`<span class="vrow-name">${v.filename}</span>`);
  if (status === "done" && v.posted_url) {
    parts.push(`<a class="vrow-link" href="${v.posted_url}" target="_blank">🔗</a>`);
  }
  if (status === "failed" && meta) {
    const cls = `fail-cat fail-cat-${meta.severity}`;
    const hint = (meta.hint || "").replace(/"/g, '&quot;');
    parts.push(`<span class="${cls}" title="${hint}">${meta.label}</span>`);
    if (v.screenshot_url) {
      parts.push(
        `<a class="vrow-shot" href="${v.screenshot_url}" target="_blank" ` +
        `title="截图：${v.error ? v.error.slice(0, 120).replace(/"/g,'&quot;') : ''}">` +
        `📸</a>`
      );
    }
  }
  if (v.attempts > 1) {
    parts.push(`<span class="muted">· ${v.attempts} 次尝试</span>`);
  }
  return `<div class="video-row">${parts.join(" ")}</div>`;
}

function _batchNode(batch) {
  const key = `batch:${batch.area}:${batch.job_id}`;
  const open = state.expandedKeys.has(key) ? " open" : "";
  const t = batch.totals;
  // Label: "<stripped job_id> · 👤 <account_name> (<container_code>)".
  // Stripping the `_YYYYMMDD_HHMMSS` suffix makes the human-written
  // prefix (e.g. "Src_acct-pnf") readable; the timestamp is already
  // visible to the right of the label in its own HH:MM form.
  const shortId = _stripJobIdTimestamp(batch.job_id);
  const acctLabel = `👤 ${batch.account_name} (${batch.container_code})`;
  const isSourceMissing = batch.status === "source_missing";
  const sourceMissingBadge = isSourceMissing
    ? `<span class="fail-cat fail-cat-red" title="批次源文件夹被外部删除/移动（iCloud 同步清理/手动删除）。视频已丢失无法恢复。">来源丢失</span>`
    : "";
  const summary = [
    `<span class="batch-title">📦 ${shortId} · ${acctLabel}</span>`,
    `<span class="batch-counts">
       <span class="c-done">✓ ${t.done}</span>
       ${t.failed ? `<span class="c-fail">✗ ${t.failed}</span>` : ""}
       ${t.running ? `<span class="c-run">▶ ${t.running}</span>` : ""}
       ${t.pending ? `<span class="c-pend">⏳ ${t.pending}</span>` : ""}
     </span>`,
    sourceMissingBadge,
    t.failed ? `<span class="fail-cats-inline">${renderFailCategoryBadges(batch.fail_categories)}</span>` : "",
    `<span class="muted">· ${batch.created_at.slice(11, 16)}</span>`,
  ].join(" ");
  const canCancel = ["pending", "running"].includes(batch.status);
  const actions = [
    canCancel ? `<button class="batch-cancel" data-area="${batch.area}" data-id="${batch.job_id}">取消</button>` : "",
    `<a class="batch-dl-log" href="/api/task/${batch.area}/${batch.job_id}/log-bundle">下载 zip</a>`,
  ].join(" ");
  const videos = batch.videos.map((v, i) => _videoRow(v, batch.area, batch.job_id, i)).join("");
  return `
    <details class="batch-node" data-key="${key}"${open}>
      <summary>${summary}</summary>
      <div class="batch-body">
        <div class="batch-actions-row">${actions}</div>
        <div class="video-list">${videos}</div>
      </div>
    </details>`;
}

function _dateNode(group, isToday) {
  const key = `date:${group.date}`;
  // Today defaults OPEN; others CLOSED unless explicitly opened
  const defaultOpen = isToday;
  const open = state.expandedKeys.has(key)
    ? " open"
    : (state.expandedKeys.has(`date:${group.date}:_explicit_closed`) ? "" : (defaultOpen ? " open" : ""));
  const t = group.totals;
  const label = isToday ? `📅 ${group.date} · 今天` : `📅 ${group.date}`;
  const summary = `
    <span class="date-title">${label}</span>
    <span class="date-counts">
      <span class="c-done">✓ ${t.done}</span>
      ${t.failed ? `<span class="c-fail">✗ ${t.failed}</span>` : ""}
      ${t.running ? `<span class="c-run">▶ ${t.running}</span>` : ""}
      ${t.pending ? `<span class="c-pend">⏳ ${t.pending}</span>` : ""}
    </span>`;
  const batches = group.batches.map(_batchNode).join("");
  return `
    <details class="date-node" data-key="${key}"${open}>
      <summary>${summary}</summary>
      <div class="date-body">${batches}</div>
    </details>`;
}

async function loadTasks() {
  try {
    const data = await api("/api/by-date");
    const root = $("date-tree");
    if (!data.dates || !data.dates.length) {
      root.innerHTML = `<div class="muted">暂无任务</div>`;
      return;
    }
    const today = data.today;
    root.innerHTML = data.dates.map(g => _dateNode(g, g.date === today)).join("");

    // Track <details> open/close to persist across refresh
    root.querySelectorAll("details").forEach(d => {
      const key = d.dataset.key;
      d.addEventListener("toggle", () => {
        if (d.open) {
          state.expandedKeys.add(key);
          state.expandedKeys.delete(`${key}:_explicit_closed`);
        } else {
          state.expandedKeys.delete(key);
          // For today (default open), track explicit close
          if (key.startsWith("date:")) {
            state.expandedKeys.add(`${key}:_explicit_closed`);
          }
        }
      });
    });

    // Wire cancel buttons
    root.querySelectorAll(".batch-cancel").forEach(btn => {
      btn.onclick = async (ev) => {
        ev.preventDefault();
        ev.stopPropagation();
        if (!confirm(`确定取消批次 ${btn.dataset.id}？已经发完的不受影响，后面剩下的不再发。`)) return;
        try {
          await api(`/api/task/${btn.dataset.area}/${btn.dataset.id}/cancel`, { method: "POST" });
          loadDashboard();
        } catch (e) { alert("取消失败：" + e.message); }
      };
    });
    // Make sure clicks on the log-bundle / screenshot links inside
    // <summary> don't toggle the parent <details>.
    root.querySelectorAll(".batch-dl-log, .vrow-shot, .vrow-link").forEach(el => {
      el.addEventListener("click", (ev) => ev.stopPropagation());
    });
  } catch (e) {
    console.error("loadTasks:", e);
  }
}

// Legacy stubs kept for any callers that may still reference them.
// New code should not use these.
function buildBatchesDetailRow(acc) { return document.createElement("tr"); }

function toggleBatchesRow(row, acc) {
  // Legacy no-op. Pre-v0.1.25 had an expand-on-click flat table;
  // v0.1.25 moved to a <details>-driven tree view and clicks are
  // handled natively by the browser.
  return;
  state.expandedAccounts.add(code);
}

function loadDashboard() {
  loadOverview();
  loadTasks();
}

// ────────────────────── init ──────────────────────

function mexicoCityNowPlus(minutes) {
  // Compute "now + minutes" in America/Mexico_City. JS has no direct
  // Date arithmetic in another tz, so we format with Intl and parse
  // the pieces back.
  const t = new Date(Date.now() + minutes * 60 * 1000);
  const parts = Object.fromEntries(
    new Intl.DateTimeFormat("en-CA", {
      timeZone: "America/Mexico_City",
      year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit", hour12: false,
    }).formatToParts(t).map(p => [p.type, p.value])
  );
  return parts;  // {year, month, day, hour, minute}
}

function setFirstScheduleDefault() {
  // Default: Mexico City + 30 min, rounded up to 5-min granularity
  // (TikTok timepicker quantum). Writes into #first-schedule.
  const p = mexicoCityNowPlus(30);
  let h = parseInt(p.hour, 10);
  let m = parseInt(p.minute, 10);
  const r = m % 5;
  if (r) {
    m += (5 - r);
    if (m >= 60) { h = (h + 1) % 24; m = 0; }
  }
  const pad = n => String(n).padStart(2, "0");
  $("first-schedule").value =
    `${p.year}-${p.month}-${p.day}T${pad(h)}:${pad(m)}`;
}

function initDefaults() {
  $("shared-caption").value = window.DEFAULT_CAPTION || "";
  setFirstScheduleDefault();
}

window.addEventListener("DOMContentLoaded", () => {
  initDefaults();
  loadVersion();
  loadUser();
  loadHubStatus();
  loadAccounts();
  loadMusic();
  wireMusicSkipToggle();
  wireSubmitPaneLogger();
  loadDashboard();
  refreshSchedulePreview();

  // v0.1.30: log account-change so we can tell (from the bundle)
  // whether the operator had actually picked an account before the
  // ill-fated submit click.
  const acctEl = $("account");
  if (acctEl) {
    acctEl.addEventListener("change", () => {
      clientLog("info", "account_changed", {
        value: acctEl.value || "",
        text: acctEl.value
          ? (acctEl.options[acctEl.selectedIndex]?.textContent || "").slice(0, 80)
          : "",
      });
    });
  }
  // And log skip-music toggle state changes — hint for diagnosing
  // "submit validation refused me" reports.
  const skipEl = $("skip-music");
  if (skipEl) {
    skipEl.addEventListener("change", () => {
      clientLog("info", "skip_music_toggled", { checked: skipEl.checked });
    });
  }

  $("refresh-accounts").onclick = loadAccounts;
  $("refresh-tasks").onclick = loadDashboard;
  $("upload-app-log").onclick = async () => {
    const btn = $("upload-app-log");
    const orig = btn.textContent;
    btn.disabled = true;
    btn.textContent = "检查中…";

    try {
      // Check auth status first
      const authCheck = await fetch("/api/auth/me");
      if (authCheck.status === 401) {
        alert("请先登录后再上传日志。\n将跳转到登录页面…");
        location.href = "/login";
        return;
      }

      btn.textContent = "上传中…";
      const data = await api("/api/upload-app-log", { method: "POST" });
      alert(
        "已上传应用日志到服务器：\n" +
        (data.server_response?.saved || data.batch_id || "(无路径)")
      );
    } catch (e) {
      let errorMsg = "上传失败：" + e.message;
      if (e.message.includes("not logged in") || e.message.includes("no session")) {
        errorMsg = "登录已过期，请重新登录后再试。\n将跳转到登录页面…";
        setTimeout(() => location.href = "/login", 2000);
      }
      alert(errorMsg);
    } finally {
      btn.disabled = false;
      btn.textContent = orig;
    }
  };
  $("scan-btn").onclick = scanPath;
  $("browse-btn").onclick = openFolderPicker;
  $("folder-cancel").onclick = closeFolderPicker;
  $("folder-select").onclick = () => {
    $("scan-path").value = $("folder-path").value;
    closeFolderPicker();
  };
  $("folder-up").onclick = async () => {
    const p = $("folder-path").value;
    const parent = p.replace(/\/+[^/]+\/*$/, "") || "/";
    await navigateFolderTo(parent || "/");
  };
  const clearSelection = () => {
    state.selected = [];
    renderSelected();
    syncCheckboxes();
  };
  $("clear-selected").onclick = clearSelection;
  $("select-all").onclick = () => {
    if (!state.scanGroups.length) {
      $("scan-meta").textContent = "请先点『扫描』加载视频，然后再全选。";
      return;
    }
    let added = 0;
    for (const g of state.scanGroups) {
      for (const it of g.items) {
        if (!state.selected.find(s => s.path === it.path)) {
          state.selected.push({ ...it, folder: g.folder });
          added += 1;
        }
      }
    }
    renderSelected();
    syncCheckboxes();
    const total = state.selected.length;
    $("scan-meta").textContent =
      `已全选 ${total} 条（本次新增 ${added} 条）— 请拉到下方"已选"面板排序。`;
  };
  $("submit-btn").onclick = submit;
  $("scan-path").addEventListener("keydown", e => { if (e.key === "Enter") scanPath(); });
  // Any user touch on the datetime input flips the "manually edited"
  // flag so refreshSchedulePreview() stops overwriting their value.
  // `input` fires on every keystroke / native picker select.
  $("first-schedule").addEventListener("input", () => {
    state.scheduleManuallyEdited = true;
    refreshSchedulePreview();
  });
  document.querySelectorAll('input[name="publish-mode"]').forEach(r => {
    r.addEventListener("change", applyPublishModeUI);
  });
  applyPublishModeUI();
  setInterval(loadDashboard, 5000);
});
