const state = {
  currentTab: "",
  photos: [],          // currently displayed (filtered by currentTab)
  selectedIds: new Set(),
  lastSelectIndex: null, // anchor index in state.photos for Shift+click range select
  lightboxPhotos: [],   // snapshot taken when the lightbox opens, so background
                         // grid refreshes (e.g. import polling) can't reshuffle
                         // it out from under you mid-browse
  lightboxIndex: null,
  lightboxZoom: 1,
  lightboxPanX: 0,
  lightboxPanY: 0,
  lightboxRotation: 0,
  lightboxFlipH: false,
  activeProject: null,
  exportPollHandle: null,
  importPollHandle: null,
  analyzePollHandle: null,
  qualityPollHandle: null,
  defectMinTier: loadDefectMinTier(),
  blurType: "all",       // "all" | "soft_focus" | "camera_shake" — sub-filter within the blur tab
  exposureType: "all",   // "all" | "overexposed" | "underexposed" — sub-filter within the exposure tab
  licenseStatus: null,
  licenseReactivateOpen: false,
  pendingUpdate: null,
  cropEdit: null, // set while the crop/straighten editor is open; see openCropEditor()
};

const UPDATE_DISMISS_KEY = "photopick-update-dismissed";

// #app-header (update banner + topbar) is `position: fixed` so it never
// scrolls away, but its height changes (selection bar / defect filter show
// or hide, text wraps at narrow widths, update banner appears) — keep
// #photo-grid's top padding in sync so content never sits under it.
(function watchAppHeaderHeight() {
  const header = document.getElementById("app-header");
  if (!header) return;
  const sync = () => {
    document.documentElement.style.setProperty("--topbar-height", `${header.offsetHeight}px`);
  };
  new ResizeObserver(sync).observe(header);
  sync();
})();

const TRASH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
  <path d="M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-8.5 0 .9 12.1a1 1 0 0 0 1 .9h6.2a1 1 0 0 0 1-.9L18.5 7"/>
  <path d="M10 11v6M14 11v6"/>
</svg>`;

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

function softScore(photo) {
  const v = photo.soft_focus_score;
  return v == null ? null : Number(v);
}
function shakeScore(photo) {
  const v = photo.camera_shake_score;
  return v == null ? null : Number(v);
}
const DEFECT_TIER_MIN = { low: 20, medium: 40, high: 60 };

function loadDefectMinTier() {
  const saved = localStorage.getItem("photopick-defect-tier");
  if (saved === "low" || saved === "medium" || saved === "high") return saved;
  const legacy = Number(localStorage.getItem("photopick-defect-min"));
  if (legacy >= 60) return "high";
  if (legacy >= 40) return "medium";
  if (legacy >= 20) return "low";
  return "high";
}

function defectMinScore() {
  return DEFECT_TIER_MIN[state.defectMinTier] ?? 60;
}

function defectTierFromScore(score) {
  if (score == null || score < 20) return null;
  if (score >= 60) return "high";
  if (score >= 40) return "medium";
  return "low";
}

function defectTierLabel(tier) {
  if (!tier) return "";
  const key = `defect.tier.${tier}`;
  return typeof t === "function" ? t(key) : tier;
}

function formatDefectTag(kindKey, score) {
  const tier = defectTierFromScore(score);
  if (!tier) return "";
  const kind = typeof t === "function" ? t(kindKey) : kindKey;
  return `${kind}·${defectTierLabel(tier)}`;
}

function bucketCountForTier(buckets, tier) {
  if (!buckets) return 0;
  if (tier === "high") return buckets.ge_60 || 0;
  if (tier === "medium") return buckets.ge_40 || 0;
  return buckets.ge_20 || 0;
}

// Include file_hash so a reused photo id after reset/reimport cannot keep
// serving a previously-cached image for a different file.
function photoHash(photo) {
  return (photo.file_hash || "").slice(0, 16);
}
// Every field that changes what the server actually renders for this photo
// (baked into /preview and, once straighten/crop is set, into /original
// too — see backend/main.py's get_original) has to be reflected here, or an
// <img> whose src string comes out byte-identical to its previous value
// just keeps showing the stale already-decoded image — the browser never
// re-fetches on a same-string src reassignment.
function transformToken(photo) {
  const crop = photo.crop ? `${photo.crop.x},${photo.crop.y},${photo.crop.w},${photo.crop.h}` : "";
  return `${photo.rotation || 0}${photo.flip_h ? "f" : ""}_${photo.straighten_angle || 0}_${crop}`;
}
function thumbSrc(photo) {
  return `/preview/${photo.id}?h=${encodeURIComponent(photoHash(photo))}&t=${encodeURIComponent(transformToken(photo))}`;
}
function originalSrc(photo) {
  return `/original/${photo.id}?h=${encodeURIComponent(photoHash(photo))}&t=${encodeURIComponent(transformToken(photo))}`;
}

async function api(path, options) {
  const res = await fetch(path, options);
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const detail = body.detail;
    const msg =
      typeof detail === "object" && detail !== null
        ? detail.message || detail.code || res.statusText
        : detail || res.statusText;
    throw new Error(msg || res.statusText);
  }
  return res.json();
}

function formatBytes(bytes) {
  if (!bytes) return "0 MB";
  const gb = bytes / 1e9;
  if (gb >= 1) return gb.toFixed(2) + " GB";
  return (bytes / 1e6).toFixed(1) + " MB";
}

function missingReasonLabel(reason) {
  if (reason === "not_in_folder") return "重新匯入時不在資料夾內（可能已刪除或移走）";
  return "磁碟上找不到檔案（可能已刪除或移走）";
}

function missingBadgeText(reason) {
  if (reason === "not_in_folder") return "不在資料夾";
  return "檔案不存在";
}

function setLightboxInfoRow(rowId, textEntries) {
  const row = el(rowId);
  const hasContent = textEntries.some(([, value]) => !!value);
  row.classList.toggle("hidden", !hasContent);
  textEntries.forEach(([fieldId, value]) => {
    el(fieldId).textContent = value || "";
  });
}

function renderLightboxCaptureInfo(photo, info) {
  setLightboxInfoRow("lightbox-row-taken", [["lightbox-taken-at", info && info.taken_at]]);
  setLightboxInfoRow("lightbox-row-camera", [
    ["lightbox-camera", info && [info.camera, info.lens].filter(Boolean).join(" · ")],
    ["lightbox-exposure", info && info.exposure_line],
  ]);
  const dimensions = info && info.width && info.height ? `${info.width}×${info.height}` : "";
  setLightboxInfoRow("lightbox-row-file", [
    ["lightbox-dimensions", dimensions],
    ["lightbox-filesize", photo.file_size ? formatBytes(photo.file_size) : ""],
  ]);

  const hasGps = info && typeof info.gps_lat === "number" && typeof info.gps_lon === "number";
  el("lightbox-row-gps").classList.toggle("hidden", !hasGps);
  el("lightbox-map-frame").classList.toggle("hidden", !hasGps);
  if (hasGps) {
    const lat = info.gps_lat.toFixed(6);
    const lon = info.gps_lon.toFixed(6);
    el("lightbox-gps").textContent = `${lat}, ${lon}`;
    el("lightbox-gps-link").href = `https://www.google.com/maps?q=${lat},${lon}`;
    // OSM's own embeddable-map widget — sanctioned first-party embed (the
    // same "Share > Embed" markup openstreetmap.org itself hands out), so no
    // key/account and it draws its own marker + attribution natively.
    const lonD = 0.003, latD = 0.0015;
    const bbox = [
      (info.gps_lon - lonD).toFixed(6),
      (info.gps_lat - latD).toFixed(6),
      (info.gps_lon + lonD).toFixed(6),
      (info.gps_lat + latD).toFixed(6),
    ].join(",");
    el("lightbox-map-frame").src =
      `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lon}`;
  } else {
    el("lightbox-map-frame").src = "";
  }
}

async function ensureLightboxCapture(photo) {
  if (photo.capture_info) return photo.capture_info;
  try {
    const info = await api(`/api/photos/${photo.id}/capture`);
    photo.capture_info = info;
    const inGrid = state.photos.find((p) => p.id === photo.id);
    if (inGrid) inGrid.capture_info = info;
    return info;
  } catch {
    photo.capture_info = {};
    return photo.capture_info;
  }
}

const EXPOSURE_VERDICT_ICONS = {
  overexposed: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4.5"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></svg>`,
  underexposed: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg>`,
  normal: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12.5l5 5L20 6"/></svg>`,
};

function renderExposureVerdict(photo) {
  const verdict = photo.quality_metrics && photo.quality_metrics.exposure_verdict;
  const verdictEl = el("lightbox-exposure-verdict");
  verdictEl.className = "lightbox-exposure-verdict" + (verdict ? ` ${verdict}` : "");
  if (!verdict) {
    verdictEl.innerHTML = "";
    return;
  }
  const icon = EXPOSURE_VERDICT_ICONS[verdict] || "";
  verdictEl.innerHTML = `${icon}<span>${t(`exposure.${verdict}`)}</span>`;
}

// Log-scaled filled-area path: pixel counts are extremely peaky, so a linear
// scale would flatten everything but the tallest spike.
function histogramPathD(bins) {
  const max = Math.max(...bins, 1);
  const denom = Math.log(max + 1) || 1;
  let d = "M0,80";
  bins.forEach((v, i) => {
    const h = (Math.log(v + 1) / denom) * 78;
    d += ` L${i},${(80 - h).toFixed(1)}`;
  });
  d += " L255,80 Z";
  return d;
}

function renderHistogramBins(hist) {
  el("lightbox-hist-r").setAttribute("d", histogramPathD(hist.r));
  el("lightbox-hist-g").setAttribute("d", histogramPathD(hist.g));
  el("lightbox-hist-b").setAttribute("d", histogramPathD(hist.b));
}

async function ensureLightboxHistogram(photo) {
  if (photo.histogram) return photo.histogram;
  const hist = await api(`/api/photos/${photo.id}/histogram`);
  photo.histogram = hist;
  return hist;
}

// --- data loading -----------------------------------------------------

async function refreshAll() {
  await Promise.all([refreshProjects(), refreshStats(), refreshGrid()]);
}

async function refreshProjects() {
  const data = await api("/api/projects");
  state.activeProject = data.active || null;
  el("open-project-folder-btn").disabled = !(state.activeProject && state.activeProject.root_exists);
  const select = el("project-select");
  const activeId = data.active ? String(data.active.id) : "";
  select.innerHTML = "";
  el("pick-folder-btn").classList.toggle("emphasize", !data.projects.length);
  if (!data.projects.length) {
    const opt = document.createElement("option");
    opt.value = "";
    opt.textContent = t("project.emptyHint");
    select.appendChild(opt);
    select.disabled = true;
    el("delete-project-btn").disabled = true;
    return;
  }
  select.disabled = false;
  data.projects.forEach((p) => {
    const opt = document.createElement("option");
    opt.value = String(p.id);
    const missing = p.root_exists ? "" : t("project.missingRoot");
    opt.textContent = `${p.name} · ${t("project.photos", { n: p.photo_count })}${missing}`;
    if (!p.root_exists) opt.className = "project-missing";
    select.appendChild(opt);
  });
  select.value = activeId || String(data.projects[0].id);
  el("delete-project-btn").disabled = !select.value;
}

function photosQueryForTab(tab) {
  if (tab === "keep") return "?status=keep";
  if (tab === "starred") return "?starred=true";
  if (tab === "reject") return "?status=reject";
  const min = defectMinScore();
  if (tab === "blur") {
    if (state.blurType === "soft_focus") return `?min_soft_focus=${encodeURIComponent(min)}`;
    if (state.blurType === "camera_shake") return `?min_camera_shake=${encodeURIComponent(min)}`;
    return `?min_blur=${encodeURIComponent(min)}`;
  }
  if (tab === "exposure") {
    const verdicts = state.exposureType === "all"
      ? "overexposed,underexposed"
      : state.exposureType;
    return `?exposure_verdict=${encodeURIComponent(verdicts)}`;
  }
  return "";
}

function photoStillInTab(photo, tab) {
  if (!tab) return true;
  if (tab === "keep") return photo.status === "keep";
  if (tab === "starred") return !!photo.starred;
  if (tab === "reject") return photo.status === "reject";
  if (tab === "blur") {
    const min = defectMinScore();
    if (state.blurType === "soft_focus") {
      const s = softScore(photo);
      return s != null && s >= min;
    }
    if (state.blurType === "camera_shake") {
      const s = shakeScore(photo);
      return s != null && s >= min;
    }
    const soft = softScore(photo) || 0;
    const shake = shakeScore(photo) || 0;
    return soft + shake >= min;
  }
  if (tab === "exposure") {
    const verdict = photo.quality_metrics && photo.quality_metrics.exposure_verdict;
    if (!verdict) return false;
    if (state.exposureType === "all") return verdict === "overexposed" || verdict === "underexposed";
    return verdict === state.exposureType;
  }
  return true;
}

function updateDefectFilterUI() {
  const bar = el("defect-filter");
  const isBlur = state.currentTab === "blur";
  const isExposure = state.currentTab === "exposure";
  bar?.classList.toggle("hidden", !isBlur && !isExposure);
  el("blur-type-group").classList.toggle("hidden", !isBlur);
  el("severity-filter-group").classList.toggle("hidden", !isBlur);
  el("exposure-type-group").classList.toggle("hidden", !isExposure);

  document.querySelectorAll("#blur-type-group .defect-tier-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.blurType === state.blurType);
  });
  document.querySelectorAll("#severity-filter-group .defect-tier-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.tier === state.defectMinTier);
  });
  document.querySelectorAll("#exposure-type-group .defect-tier-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.exposureType === state.exposureType);
  });
}

async function refreshStats() {
  const stats = await api("/api/stats");
  const by = stats.by_status || {};
  const countOf = (s) => (by[s] ? by[s].count : 0);
  const blurBuckets = state.blurType === "soft_focus" ? stats.soft_focus_buckets
    : state.blurType === "camera_shake" ? stats.camera_shake_buckets
    : stats.blur_buckets;
  const blurN = bucketCountForTier(blurBuckets, state.defectMinTier);
  const exposureCounts = stats.exposure_counts || { overexposed: 0, underexposed: 0 };
  const exposureN = state.exposureType === "all"
    ? (exposureCounts.overexposed || 0) + (exposureCounts.underexposed || 0)
    : (exposureCounts[state.exposureType] || 0);
  // 保留 = status=keep（加星號也算保留，不會被排除）；後端缺欄位時用 by_status 推算
  const keepN = typeof stats.keep_count === "number" ? stats.keep_count : countOf("keep");
  el("count-all").textContent = String(stats.total_count || 0);
  el("count-keep").textContent = String(keepN);
  el("count-starred").textContent = String(stats.starred_count || 0);
  el("count-blur").textContent = String(blurN || 0);
  el("count-exposure").textContent = String(exposureN || 0);
  el("count-reject").textContent = String(countOf("reject") || 0);

  const rejectSize = by.reject ? by.reject.size_bytes : 0;
  const missingCount = stats.missing_count || 0;
  el("stats-bar").innerHTML =
    t("stats.total", { size: formatBytes(stats.total_size_bytes), count: stats.total_count || 0 }) +
    (stats.starred_count ? t("stats.starred", { n: stats.starred_count }) : "") +
    (rejectSize ? t("stats.rejectFree", { size: formatBytes(rejectSize) }) : "") +
    (missingCount ? t("stats.missing", { n: missingCount }) : "");
  el("purge-missing-btn").disabled = missingCount === 0;
  el("purge-missing-btn").title = missingCount
    ? `清除 ${missingCount} 筆找不到原檔的資料庫紀錄（不會再動垃圾桶）`
    : "目前沒有遺失紀錄";
  updateDefectFilterUI();
}

async function refreshGrid() {
  const qs = photosQueryForTab(state.currentTab);
  state.photos = await api(`/api/photos${qs}`);
  // drop selections for photos no longer in view
  const visibleIds = new Set(state.photos.map((p) => p.id));
  for (const id of [...state.selectedIds]) {
    if (!visibleIds.has(id)) state.selectedIds.delete(id);
  }
  // Exact count for the active defect tab (matches current slider/type picker).
  if (state.currentTab === "blur") {
    el("count-blur").textContent = String(state.photos.length);
  } else if (state.currentTab === "exposure") {
    el("count-exposure").textContent = String(state.photos.length);
  }
  renderGrid();
  renderSelectionBar();
}

// --- grid rendering -----------------------------------------------------

function renderGrid() {
  const grid = el("photo-grid");
  grid.innerHTML = "";
  if (state.photos.length === 0) {
    grid.innerHTML = `<div class="grid-empty">${t("grid.empty")}</div>`;
    return;
  }
  state.photos.forEach((photo) => {
    const cell = document.createElement("div");
    cell.className = "grid-cell"
      + (photo.status === "reject" ? " is-reject" : "")
      + (photo.starred ? " is-starred" : "")
      + (state.selectedIds.has(photo.id) ? " selected" : "");
    cell.dataset.id = photo.id;

    const thumb = document.createElement("div");
    thumb.className = "grid-thumb" + (photo.file_missing ? " missing" : "");
    thumb.title = photo.file_missing
      ? `${photo.filename} — ${missingReasonLabel(photo.missing_reason)}`
      : photo.filename;

    const img = document.createElement("img");
    img.src = thumbSrc(photo);
    img.loading = "lazy";
    img.alt = photo.filename;
    thumb.appendChild(img);

    const checkbox = document.createElement("div");
    checkbox.className = "grid-checkbox";
    checkbox.title = "選取（Shift+點另一張可連續選）";
    checkbox.onclick = (e) => {
      e.stopPropagation();
      handleSelectClick(photo.id, e);
    };
    thumb.appendChild(checkbox);

    const iconActions = document.createElement("div");
    iconActions.className = "grid-icon-actions";

    const isReject = photo.status === "reject";
    const starBtn = document.createElement("button");
    starBtn.type = "button";
    starBtn.className = "grid-icon-btn qa-star" + (photo.starred ? " active" : "");
    starBtn.textContent = "★";
    starBtn.title = photo.starred ? t("star.remove") : t("star.add");
    starBtn.setAttribute("aria-label", starBtn.title);
    starBtn.onclick = (e) => {
      e.stopPropagation();
      setPhotoStarred(photo.id, !photo.starred);
    };
    iconActions.appendChild(starBtn);

    const rejectBtn = document.createElement("button");
    rejectBtn.type = "button";
    rejectBtn.className = "grid-icon-btn qa-reject" + (isReject ? " active" : "");
    rejectBtn.innerHTML = TRASH_ICON_SVG;
    rejectBtn.title = isReject
      ? t("reject.unset")
      : (photo.starred ? "已加星，請先取消星號" : t("reject.action"));
    rejectBtn.setAttribute("aria-label", rejectBtn.title);
    rejectBtn.disabled = !isReject && !!photo.starred;
    rejectBtn.onclick = (e) => {
      e.stopPropagation();
      setPhotoStatus(photo.id, isReject ? "keep" : "reject");
    };
    iconActions.appendChild(rejectBtn);
    thumb.appendChild(iconActions);

    if (photo.file_missing) {
      const badge = document.createElement("div");
      badge.className = "missing-badge";
      badge.textContent = missingBadgeText(photo.missing_reason);
      thumb.appendChild(badge);
    } else if (photo.is_raw) {
      const badge = document.createElement("div");
      badge.className = "raw-badge";
      const sidecars = photo.sidecar_paths || [];
      const hasJpeg = sidecars.some((p) => /\.(jpe?g|png)$/i.test(p));
      badge.textContent = hasJpeg ? "RAW+J" : "RAW";
      thumb.appendChild(badge);
    } else if (/\.(heic|heif)$/i.test(photo.original_path || photo.filename || "")) {
      const badge = document.createElement("div");
      badge.className = "raw-badge";
      badge.textContent = "HEIC";
      thumb.appendChild(badge);
    }

    const defectLocked = isFeatureLocked("defect_detection");
    const exposureLocked = isFeatureLocked("exposure_detection");
    const soft = softScore(photo);
    const shake = shakeScore(photo);
    const showSoft = !defectLocked && soft != null && soft >= 20;
    const showShake = !defectLocked && shake != null && shake >= 20;
    const exposureVerdict = photo.quality_metrics && photo.quality_metrics.exposure_verdict;
    const showExposure = !exposureLocked && (exposureVerdict === "overexposed" || exposureVerdict === "underexposed");
    if (showSoft || showShake || showExposure) {
      const tagRow = document.createElement("div");
      tagRow.className = "grid-quality-tags";
      if (showSoft) {
        const pill = document.createElement("span");
        const tier = defectTierFromScore(soft);
        pill.className = `quality-tag tag-soft_focus tier-${tier}`;
        pill.textContent = formatDefectTag("soft", soft);
        tagRow.appendChild(pill);
      }
      if (showShake) {
        const pill = document.createElement("span");
        const tier = defectTierFromScore(shake);
        pill.className = `quality-tag tag-camera_shake tier-${tier}`;
        pill.textContent = formatDefectTag("shake", shake);
        tagRow.appendChild(pill);
      }
      if (showExposure) {
        const pill = document.createElement("span");
        pill.className = `quality-tag tag-${exposureVerdict}`;
        pill.textContent = t(`exposure.${exposureVerdict}`);
        tagRow.appendChild(pill);
      }
      thumb.appendChild(tagRow);
    }

    thumb.onclick = (e) => {
      // Shift+click on the photo also range-selects (doesn't open lightbox).
      if (e.shiftKey) {
        e.preventDefault();
        handleSelectClick(photo.id, e);
        return;
      }
      openLightbox(photo.id);
    };
    cell.appendChild(thumb);

    const caption = document.createElement("div");
    caption.className = "grid-filename";
    caption.textContent = photo.filename;
    cell.appendChild(caption);

    grid.appendChild(cell);
  });
}

async function setPhotoStatus(id, status) {
  try {
    await api(`/api/photos/${id}/status`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ status }),
    });
  } catch (e) {
    alert(e.message || "更新狀態失敗");
    return;
  }
  await refreshAll();
}

async function setPhotoStarred(id, starred) {
  try {
    await api(`/api/photos/${id}/star`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ starred }),
    });
  } catch (e) {
    alert(e.message || "更新加星號失敗");
    return;
  }
  await refreshAll();
}

function handleSelectClick(id, event) {
  const idx = state.photos.findIndex((p) => p.id === id);
  if (idx === -1) return;

  if (event.shiftKey && state.lastSelectIndex != null) {
    const from = Math.min(state.lastSelectIndex, idx);
    const to = Math.max(state.lastSelectIndex, idx);
    for (let i = from; i <= to; i++) {
      state.selectedIds.add(state.photos[i].id);
    }
    // Keep the original anchor so further Shift+clicks extend from the same start.
  } else {
    if (state.selectedIds.has(id)) state.selectedIds.delete(id);
    else state.selectedIds.add(id);
    state.lastSelectIndex = idx;
  }

  // Update selected styles without rebuilding every image.
  document.querySelectorAll(".grid-cell").forEach((cell) => {
    const cellId = Number(cell.dataset.id);
    cell.classList.toggle("selected", state.selectedIds.has(cellId));
  });
  renderSelectionBar();
}

function renderSelectionBar() {
  const total = state.photos.length;
  const n = state.selectedIds.size;
  const has = n > 0;
  const allSelected = total > 0 && state.photos.every((p) => state.selectedIds.has(p.id));

  const countEl = el("selection-count");
  countEl.textContent = total
    ? t("select.countOf", { n, total })
    : t("select.count", { n: 0 });
  countEl.classList.toggle("has-selection", has);

  el("select-all-btn").textContent = allSelected ? t("select.none") : t("select.all");
  el("select-all-btn").disabled = total === 0;

  const locked = new Set(state.licenseStatus?.locked_features || []);
  const renameLocked = locked.has("batch_rename");
  el("batch-rename-btn").disabled = !has && !renameLocked;
  el("batch-rename-btn").classList.toggle("feature-locked", renameLocked);
  if (renameLocked) {
    el("batch-rename-btn").title = featureLockTip("batch_rename");
  } else {
    el("batch-rename-btn").removeAttribute("title");
  }
  const exportLocked = locked.has("export");
  el("batch-export-btn").disabled = !has && !exportLocked;
  el("batch-export-btn").classList.toggle("feature-locked", exportLocked);
  if (exportLocked) {
    el("batch-export-btn").title = featureLockTip("export");
  } else {
    el("batch-export-btn").removeAttribute("title");
  }
  el("batch-rotate-ccw-btn").disabled = !has;
  el("batch-rotate-cw-btn").disabled = !has;
  el("clear-selection-btn").disabled = !has;

  const selected = state.photos.filter((p) => state.selectedIds.has(p.id));
  const allStarred = has && selected.every((p) => p.starred);
  const starBtn = el("toggle-star-btn");
  starBtn.disabled = !has;
  starBtn.classList.toggle("active", allStarred);
  starBtn.dataset.nextStar = allStarred ? "false" : "true";
  el("toggle-star-label").textContent = allStarred ? t("star.remove") : t("star.add");

  const allRejected = has && selected.every((p) => p.status === "reject");
  const rejectBtn = el("toggle-reject-btn");
  rejectBtn.disabled = !has;
  rejectBtn.classList.toggle("active", allRejected);
  rejectBtn.dataset.nextStatus = allRejected ? "keep" : "reject";
  el("toggle-reject-label").textContent = allRejected ? t("reject.unset") : t("reject.set");
}

function selectAllVisible() {
  if (state.photos.length === 0) return;
  const allSelected = state.photos.every((p) => state.selectedIds.has(p.id));
  if (allSelected) {
    state.selectedIds.clear();
    state.lastSelectIndex = null;
  } else {
    state.photos.forEach((p) => state.selectedIds.add(p.id));
    state.lastSelectIndex = 0;
  }
  renderGrid();
  renderSelectionBar();
}

el("select-all-btn").onclick = selectAllVisible;

el("clear-selection-btn").onclick = () => {
  state.selectedIds.clear();
  state.lastSelectIndex = null;
  document.querySelectorAll(".grid-cell.selected").forEach((c) => c.classList.remove("selected"));
  renderSelectionBar();
};

el("toggle-reject-btn").onclick = async () => {
  const status = el("toggle-reject-btn").dataset.nextStatus;
  try {
    const result = await api("/api/photos/bulk-status", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ids: [...state.selectedIds], status }),
    });
    const skipped = (result.skipped || []).length;
    if (skipped) {
      alert(`已更新 ${result.updated.length} 張；略過 ${skipped} 張（已加星不能設為待刪除）`);
    }
  } catch (e) {
    alert(e.message || "批次更新失敗");
    return;
  }
  state.selectedIds.clear();
  state.lastSelectIndex = null;
  await refreshAll();
};

el("toggle-star-btn").onclick = async () => {
  const starred = el("toggle-star-btn").dataset.nextStar === "true";
  try {
    await api("/api/photos/bulk-star", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ids: [...state.selectedIds], starred }),
    });
  } catch (e) {
    alert(e.message || "批次加星號失敗");
    return;
  }
  state.selectedIds.clear();
  state.lastSelectIndex = null;
  await refreshAll();
};

async function batchTransform(action) {
  try {
    await api("/api/photos/bulk-transform", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ids: [...state.selectedIds], action }),
    });
  } catch (e) {
    alert(e.message || "批次旋轉失敗");
    return;
  }
  state.selectedIds.clear();
  state.lastSelectIndex = null;
  await refreshAll();
}
el("batch-rotate-ccw-btn").onclick = () => batchTransform("rotate_ccw");
el("batch-rotate-cw-btn").onclick = () => batchTransform("rotate_cw");

// --- tabs -----------------------------------------------------------

document.querySelectorAll(".tab").forEach((tab) => {
  tab.onclick = async () => {
    const filter = tab.dataset.filter || "";
    if (filter === "blur" && isFeatureLocked("defect_detection")) {
      showUpgradePrompt("defect_detection");
      return;
    }
    if (filter === "exposure" && isFeatureLocked("exposure_detection")) {
      showUpgradePrompt("exposure_detection");
      return;
    }
    document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
    tab.classList.add("active");
    state.currentTab = tab.dataset.filter || "";
    state.selectedIds.clear();
    state.lastSelectIndex = null;
    updateDefectFilterUI();
    await refreshStats();
    await refreshGrid();
  };
});

el("defect-filter")?.addEventListener("click", async (e) => {
  const btn = e.target.closest(".defect-tier-btn");
  if (!btn) return;
  if (btn.dataset.tier) {
    if (btn.dataset.tier === state.defectMinTier) return;
    state.defectMinTier = btn.dataset.tier;
    localStorage.setItem("photopick-defect-tier", btn.dataset.tier);
  } else if (btn.dataset.blurType) {
    if (btn.dataset.blurType === state.blurType) return;
    state.blurType = btn.dataset.blurType;
  } else if (btn.dataset.exposureType) {
    if (btn.dataset.exposureType === state.exposureType) return;
    state.exposureType = btn.dataset.exposureType;
  } else {
    return;
  }
  updateDefectFilterUI();
  state.selectedIds.clear();
  state.lastSelectIndex = null;
  await refreshStats();
  await refreshGrid();
});

// --- lightbox -----------------------------------------------------------

const LIGHTBOX_ZOOM_MIN = 1;
const LIGHTBOX_ZOOM_MAX = 6;

function applyLightboxTransform() {
  const img = el("lightbox-img");
  const rotation = state.lightboxRotation || 0;
  const flipScale = state.lightboxFlipH ? -1 : 1;
  img.style.transform =
    `rotate(${rotation}deg) scaleX(${flipScale}) ` +
    `translate(${state.lightboxPanX}px, ${state.lightboxPanY}px) scale(${state.lightboxZoom})`;
  img.classList.toggle("zoomed", state.lightboxZoom > 1);
}

// A 90/270 rotation swaps the image's effective footprint, so the CSS
// max-width/max-height (computed pre-transform against the unrotated art)
// has to swap too or a sideways photo overflows the stage.
function applyLightboxOrientation() {
  const img = el("lightbox-img");
  const stage = el("lightbox").querySelector(".lightbox-stage");
  const rotation = state.lightboxRotation || 0;
  if (rotation === 90 || rotation === 270) {
    const rect = stage.getBoundingClientRect();
    img.style.maxWidth = `${rect.height}px`;
    img.style.maxHeight = `${rect.width}px`;
  } else {
    img.style.maxWidth = "";
    img.style.maxHeight = "";
  }
  applyLightboxTransform();
}

window.addEventListener("resize", () => {
  if (!el("lightbox").classList.contains("hidden")) applyLightboxOrientation();
});

function resetLightboxZoom() {
  state.lightboxZoom = 1;
  state.lightboxPanX = 0;
  state.lightboxPanY = 0;
  applyLightboxOrientation();
}

function setLightboxZoom(next) {
  state.lightboxZoom = Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, next));
  if (state.lightboxZoom === LIGHTBOX_ZOOM_MIN) {
    state.lightboxPanX = 0;
    state.lightboxPanY = 0;
  }
  applyLightboxTransform();
}

function openLightbox(photoId) {
  // Copy the list so a background refresh can't reshuffle prev/next, but keep
  // the same photo objects so status edits stay visible in the grid.
  state.lightboxPhotos = state.photos.slice();
  const idx = state.lightboxPhotos.findIndex((p) => p.id === photoId);
  if (idx === -1) return;
  state.lightboxIndex = idx;
  renderLightbox();
  el("lightbox").classList.remove("hidden");
}

function closeLightbox() {
  if (state.cropEdit) closeCropEditor();
  el("lightbox").classList.add("hidden");
  state.lightboxIndex = null;
  const img = el("lightbox-img");
  img.removeAttribute("src");
  resetLightboxZoom();
}

function renderLightbox() {
  const photo = state.lightboxPhotos[state.lightboxIndex];
  if (!photo) return closeLightbox();
  const renderToken = `${photo.id}:${state.lightboxIndex}`;
  state.lightboxRenderToken = renderToken;

  const img = el("lightbox-img");
  // Clear first: with multi‑MB camera JPEGs the previous photo otherwise stays
  // on screen under the newly updated filename until the next decode finishes.
  img.onload = null;
  img.removeAttribute("src");
  state.lightboxRotation = photo.rotation || 0;
  state.lightboxFlipH = !!photo.flip_h;
  resetLightboxZoom();

  if (photo.file_missing) {
    el("lightbox-filename").textContent = `${photo.filename}（${missingReasonLabel(photo.missing_reason)}）`;
  } else {
    const sidecars = photo.sidecar_paths || [];
    const hasJpeg = sidecars.some((p) => /\.(jpe?g|png)$/i.test(p));
    let pair = "";
    if (photo.is_raw) pair = hasJpeg ? " · RAW+J" : " · RAW";
    else if (/\.(heic|heif)$/i.test(photo.original_path || photo.filename || "")) pair = " · HEIC";
    el("lightbox-filename").textContent = `${photo.filename}${pair}`;
    img.src = originalSrc(photo);
  }

  const info = photo.capture_info;
  renderLightboxCaptureInfo(photo, info);
  if (!info) {
    ensureLightboxCapture(photo).then((loaded) => {
      if (state.lightboxRenderToken !== renderToken) return;
      renderLightboxCaptureInfo(photo, loaded);
    });
  }

  const soft = softScore(photo);
  const shake = shakeScore(photo);
  const qualityBits = isFeatureLocked("defect_detection")
    ? []
    : [
        formatDefectTag("soft", soft),
        formatDefectTag("shake", shake),
      ].filter(Boolean);
  const aiBit = photo.analysis_state === "analyzed"
    ? `AI ${photo.overall_score} 分 · ${photo.recommendation}`
    : "";
  el("lightbox-ai-badge").textContent = [...qualityBits, aiBit].filter(Boolean).join(" · ");

  const histogramLocked = isFeatureLocked("exposure_detection") || photo.file_missing;
  el("lightbox-histogram-section").classList.toggle("hidden", histogramLocked);
  if (!histogramLocked) {
    renderExposureVerdict(photo);
    ensureLightboxHistogram(photo).then((hist) => {
      if (state.lightboxRenderToken !== renderToken) return;
      renderHistogramBins(hist);
    }).catch(() => {});
  }

  const isReject = photo.status === "reject";
  document.querySelectorAll(".lightbox-icon-actions button").forEach((btn) => {
    const action = btn.dataset.lightboxAction;
    if (action === "star") {
      btn.classList.toggle("active", !!photo.starred);
      btn.title = photo.starred ? t("star.remove") : t("star.add");
      btn.setAttribute("aria-label", btn.title);
      btn.disabled = false;
    } else if (action === "reject") {
      btn.classList.toggle("active", isReject);
      btn.disabled = !isReject && !!photo.starred;
      btn.title = isReject ? t("reject.unset") : (photo.starred ? "已加星，請先取消星號" : t("reject.action"));
      btn.setAttribute("aria-label", btn.title);
    }
  });

  el("lightbox-prev").disabled = state.lightboxIndex <= 0;
  el("lightbox-next").disabled = state.lightboxIndex >= state.lightboxPhotos.length - 1;
}

el("lightbox-close").onclick = closeLightbox;
el("lightbox-prev").onclick = () => {
  if (state.lightboxIndex > 0) {
    state.lightboxIndex -= 1;
    renderLightbox();
  }
};
el("lightbox-next").onclick = () => {
  if (state.lightboxIndex < state.lightboxPhotos.length - 1) {
    state.lightboxIndex += 1;
    renderLightbox();
  }
};
el("lightbox").addEventListener("click", (e) => {
  if (e.target.id === "lightbox") closeLightbox();
});

// --- lightbox zoom/pan: trackpad pinch, ctrl/cmd+wheel, and double-click ---
// WKWebView (the packaged desktop app) never enables native page magnification,
// so trackpad pinch only reaches us as WebKit's gesturestart/-change/-end events;
// ctrl/cmd+wheel covers Chromium/Firefox trackpad-pinch-as-wheel behavior.
(() => {
  const lbImg = el("lightbox-img");
  let gestureStartZoom = 1;
  let panning = false;
  let panStartX = 0;
  let panStartY = 0;
  let panOriginX = 0;
  let panOriginY = 0;

  lbImg.addEventListener("wheel", (e) => {
    if (!(e.ctrlKey || e.metaKey)) return;
    e.preventDefault();
    setLightboxZoom(state.lightboxZoom * Math.exp(-e.deltaY * 0.01));
  }, { passive: false });

  lbImg.addEventListener("gesturestart", (e) => {
    e.preventDefault();
    gestureStartZoom = state.lightboxZoom;
  });
  lbImg.addEventListener("gesturechange", (e) => {
    e.preventDefault();
    setLightboxZoom(gestureStartZoom * e.scale);
  });
  lbImg.addEventListener("gestureend", (e) => e.preventDefault());

  lbImg.addEventListener("dblclick", (e) => {
    e.preventDefault();
    setLightboxZoom(state.lightboxZoom > 1 ? 1 : 2.5);
  });

  lbImg.addEventListener("pointerdown", (e) => {
    if (state.lightboxZoom <= 1) return;
    panning = true;
    panStartX = e.clientX;
    panStartY = e.clientY;
    panOriginX = state.lightboxPanX;
    panOriginY = state.lightboxPanY;
    lbImg.setPointerCapture(e.pointerId);
    lbImg.classList.add("panning");
  });
  lbImg.addEventListener("pointermove", (e) => {
    if (!panning) return;
    state.lightboxPanX = panOriginX + (e.clientX - panStartX);
    state.lightboxPanY = panOriginY + (e.clientY - panStartY);
    applyLightboxTransform();
  });
  const endPan = () => {
    if (!panning) return;
    panning = false;
    lbImg.classList.remove("panning");
  };
  lbImg.addEventListener("pointerup", endPan);
  lbImg.addEventListener("pointercancel", endPan);
})();

document.addEventListener("keydown", (e) => {
  if (!el("account-menu-dropdown")?.classList.contains("hidden")) {
    if (e.key === "Escape") {
      closeAccountMenu();
      return;
    }
  }
  if (!el("profile-modal")?.classList.contains("hidden")) {
    if (e.key === "Escape") closeProfileModal();
    return;
  }
  if (!el("plans-modal")?.classList.contains("hidden")) {
    if (e.key === "Escape") closePlansModal();
    return;
  }
  if (!el("settings-modal").classList.contains("hidden")) {
    if (e.key === "Escape") {
      commitSettingsFromForm();
      closeSettingsModal();
    }
    return;
  }
  if (!el("upgrade-modal")?.classList.contains("hidden")) {
    if (e.key === "Escape") closeUpgradeModal();
    return;
  }
  if (el("lightbox").classList.contains("hidden")) {
    const tag = (e.target && e.target.tagName) || "";
    const isFormField = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
    // Cmd/Ctrl+A selects all visible photos (not when typing in an input)
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a") {
      if (isFormField) return;
      e.preventDefault();
      selectAllVisible();
      return;
    }
    // Arrow keys have no effect in the grid (no lightbox open) — swallow
    // them so WKWebView doesn't pass the unhandled key to AppKit and beep.
    // Skip form fields: a range/select/input legitimately uses arrow keys.
    if (!isFormField && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) {
      e.preventDefault();
    }
    return;
  }
  if (e.key === "Escape") {
    if (state.cropEdit) closeCropEditor();
    else closeLightbox();
    return;
  }
  if (state.cropEdit) return; // arrows/etc. don't navigate photos while editing
  if (e.key === "ArrowLeft") {
    e.preventDefault();
    el("lightbox-prev").click();
  } else if (e.key === "ArrowRight") {
    e.preventDefault();
    el("lightbox-next").click();
  }
});

document.querySelectorAll(".lightbox-icon-actions button").forEach((btn) => {
  btn.onclick = async () => {
    const photo = state.lightboxPhotos[state.lightboxIndex];
    if (!photo) return;
    const action = btn.dataset.lightboxAction;
    const prevIndex = state.lightboxIndex;

    if (action === "rotate_ccw" || action === "rotate_cw" || action === "flip_h") {
      try {
        await api(`/api/photos/${photo.id}/transform`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action }),
        });
      } catch (e) {
        alert("更新失敗：" + e.message);
        return;
      }
      if (action === "flip_h") {
        photo.flip_h = !photo.flip_h;
      } else {
        const delta = action === "rotate_cw" ? 90 : -90;
        photo.rotation = ((photo.rotation || 0) + delta + 360) % 360;
      }
      const inGrid = state.photos.find((p) => p.id === photo.id);
      if (inGrid) {
        inGrid.rotation = photo.rotation;
        inGrid.flip_h = photo.flip_h;
      }
      renderGrid();
      renderLightbox();
      return;
    }

    try {
      if (action === "star") {
        const starred = !photo.starred;
        await api(`/api/photos/${photo.id}/star`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ starred }),
        });
        photo.starred = starred;
        if (starred) photo.status = "keep";
      } else if (action === "reject") {
        const status = photo.status === "reject" ? "keep" : "reject";
        await api(`/api/photos/${photo.id}/status`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ status }),
        });
        photo.status = status;
      } else {
        return;
      }
    } catch (e) {
      alert("更新失敗：" + e.message);
      return;
    }
    // Keep grid list in sync (lightbox may hold a sliced copy of the array).
    const inGrid = state.photos.find((p) => p.id === photo.id);
    if (inGrid) {
      inGrid.status = photo.status;
      inGrid.starred = photo.starred;
    }

    await refreshStats();

    // If the current tab filters, this photo may no longer belong —
    // drop it from the lightbox sequence and reload the grid from the server.
    if (!photoStillInTab(photo, state.currentTab)) {
      state.lightboxPhotos = state.lightboxPhotos.filter((p) => p.id !== photo.id);
      await refreshGrid();
      if (state.lightboxPhotos.length === 0) {
        closeLightbox();
        return;
      }
      state.lightboxIndex = Math.min(prevIndex, state.lightboxPhotos.length - 1);
      renderLightbox();
      return;
    }

    renderGrid();
    // auto-advance after reject / restore (not after star toggle)
    if (action === "reject") {
      if (state.lightboxIndex < state.lightboxPhotos.length - 1) {
        state.lightboxIndex += 1;
      }
    }
    renderLightbox();
  };
});

// --- crop / straighten editor --------------------------------------------
// Canvas-based: the backend's /edit-source already bakes in the 90°
// rotation + flip, so this only ever has to deal with the continuous
// straighten angle on top of that — one rotate transform, not two.

const CROP_ASPECT_RATIOS = { "1:1": 1, "4:3": 4 / 3, "3:2": 3 / 2, "16:9": 16 / 9 };

function clamp(v, lo, hi) {
  return Math.max(lo, Math.min(hi, v));
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error("load failed"));
    img.src = src;
  });
}

// Largest axis-aligned rectangle inscribed in a w×h rectangle after it's
// rotated by angleRad — i.e. the biggest crop that avoids the empty
// corners a non-90° rotation leaves behind. Standard closed-form solution.
function largestInscribedRect(w, h, angleRad) {
  const widthIsLonger = w >= h;
  const sideLong = widthIsLonger ? w : h;
  const sideShort = widthIsLonger ? h : w;
  const sinA = Math.abs(Math.sin(angleRad));
  const cosA = Math.abs(Math.cos(angleRad));
  let wr, hr;
  if (sideShort <= 2 * sinA * cosA * sideLong || Math.abs(sinA - cosA) < 1e-10) {
    const x = 0.5 * sideShort;
    if (widthIsLonger) { wr = x / sinA; hr = x / cosA; } else { wr = x / cosA; hr = x / sinA; }
  } else {
    const cos2a = cosA * cosA - sinA * sinA;
    wr = (w * cosA - h * sinA) / cos2a;
    hr = (h * cosA - w * sinA) / cos2a;
  }
  return { w: wr, h: hr };
}

function currentAspectRatio() {
  const ce = state.cropEdit;
  if (!ce || ce.aspect === "free") return null;
  if (ce.aspect === "original") return ce.rw / ce.rh;
  return CROP_ASPECT_RATIOS[ce.aspect] || null;
}

// rect fields are fractions of canvas width/height respectively, NOT of a
// common unit — so a target pixel ratio (e.g. 1:1) has to be converted
// through the canvas's own bbW/bbH before comparing/assigning w vs h,
// otherwise "square" comes out skewed by whatever shape the canvas is.
function fitRectToRatio(rect, ratio, bbW, bbH) {
  const cx = rect.x + rect.w / 2, cy = rect.y + rect.h / 2;
  let pxW = rect.w * bbW, pxH = rect.h * bbH;
  if (pxW / pxH > ratio) pxW = pxH * ratio; else pxH = pxW / ratio;
  let w = Math.min(pxW / bbW, 1);
  let h = Math.min(pxH / bbH, 1);
  return { x: clamp(cx - w / 2, 0, 1 - w), y: clamp(cy - h / 2, 0, 1 - h), w, h };
}

function defaultRectForAngle(ce) {
  const angleRad = (ce.angle * Math.PI) / 180;
  const r = largestInscribedRect(ce.rw, ce.rh, angleRad);
  return {
    x: (ce.bbW - r.w) / 2 / ce.bbW,
    y: (ce.bbH - r.h) / 2 / ce.bbH,
    w: r.w / ce.bbW,
    h: r.h / ce.bbH,
  };
}

function redrawCropCanvas() {
  const ce = state.cropEdit;
  if (!ce) return;
  const angleRad = (ce.angle * Math.PI) / 180;
  const bbW = Math.round(Math.abs(ce.rw * Math.cos(angleRad)) + Math.abs(ce.rh * Math.sin(angleRad)));
  const bbH = Math.round(Math.abs(ce.rw * Math.sin(angleRad)) + Math.abs(ce.rh * Math.cos(angleRad)));
  const canvas = el("crop-canvas");
  canvas.width = bbW;
  canvas.height = bbH;
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, bbW, bbH);
  ctx.save();
  ctx.translate(bbW / 2, bbH / 2);
  ctx.rotate(angleRad); // matches backend's img.rotate(-angle): positive angle = clockwise
  ctx.drawImage(ce.img, -ce.rw / 2, -ce.rh / 2, ce.rw, ce.rh);
  ctx.restore();
  ce.bbW = bbW;
  ce.bbH = bbH;
}

function layoutCropOverlay() {
  const ce = state.cropEdit;
  if (!ce) return;
  const canvas = el("crop-canvas");
  const box = canvas.getBoundingClientRect();
  const stageBox = el("crop-stage").getBoundingClientRect();
  const overlay = el("crop-overlay");
  overlay.style.left = `${box.left - stageBox.left}px`;
  overlay.style.top = `${box.top - stageBox.top}px`;
  overlay.style.width = `${box.width}px`;
  overlay.style.height = `${box.height}px`;

  const r = ce.rect;
  const cx = r.x * box.width, cy = r.y * box.height, cw = r.w * box.width, ch = r.h * box.height;
  const cropRectEl = el("crop-rect");
  cropRectEl.style.left = `${cx}px`;
  cropRectEl.style.top = `${cy}px`;
  cropRectEl.style.width = `${cw}px`;
  cropRectEl.style.height = `${ch}px`;

  overlay.querySelector(".crop-mask-top").style.cssText = `left:0;top:0;width:${box.width}px;height:${cy}px;`;
  overlay.querySelector(".crop-mask-bottom").style.cssText = `left:0;top:${cy + ch}px;width:${box.width}px;height:${box.height - (cy + ch)}px;`;
  overlay.querySelector(".crop-mask-left").style.cssText = `left:0;top:${cy}px;width:${cx}px;height:${ch}px;`;
  overlay.querySelector(".crop-mask-right").style.cssText = `left:${cx + cw}px;top:${cy}px;width:${box.width - (cx + cw)}px;height:${ch}px;`;
}

function cropPointerDown(e) {
  const ce = state.cropEdit;
  if (!ce) return;
  const handle = e.target.dataset && e.target.dataset.handle;
  const isMove = e.target.id === "crop-rect" || e.target.classList.contains("crop-grid-line");
  if (!handle && !isMove) return;
  e.preventDefault();
  const box = el("crop-canvas").getBoundingClientRect();
  ce.drag = {
    handle: handle || "move",
    startClientX: e.clientX,
    startClientY: e.clientY,
    startRect: { ...ce.rect },
    boxW: box.width,
    boxH: box.height,
  };
  document.addEventListener("pointermove", cropPointerMove);
  document.addEventListener("pointerup", cropPointerUp);
}

function cropPointerMove(e) {
  const ce = state.cropEdit;
  if (!ce || !ce.drag) return;
  const d = ce.drag;
  const dx = (e.clientX - d.startClientX) / d.boxW;
  const dy = (e.clientY - d.startClientY) / d.boxH;
  const ratio = currentAspectRatio();
  let { x, y, w, h } = d.startRect;

  if (d.handle === "move") {
    x = clamp(d.startRect.x + dx, 0, 1 - w);
    y = clamp(d.startRect.y + dy, 0, 1 - h);
  } else {
    let left = x, top = y, right = x + w, bottom = y + h;
    if (d.handle.includes("w")) left = clamp(x + dx, 0, right - 0.02);
    if (d.handle.includes("e")) right = clamp(right + dx, left + 0.02, 1);
    if (d.handle.includes("n")) top = clamp(y + dy, 0, bottom - 0.02);
    if (d.handle.includes("s")) bottom = clamp(bottom + dy, top + 0.02, 1);
    x = left; y = top; w = right - left; h = bottom - top;

    if (ratio) {
      // w/h are fractions of canvas width/height respectively, not a shared
      // unit — converting a target pixel ratio into that space needs the
      // canvas's own bbW/bbH, or "locked" ratios come out skewed.
      const wFromH = (hVal) => (hVal * ce.bbH * ratio) / ce.bbW;
      const hFromW = (wVal) => (wVal * ce.bbW) / (ratio * ce.bbH);
      if (d.handle === "n" || d.handle === "s") {
        const cx = d.startRect.x + d.startRect.w / 2;
        w = wFromH(h);
        x = cx - w / 2;
      } else if (d.handle === "e" || d.handle === "w") {
        const cy = d.startRect.y + d.startRect.h / 2;
        h = hFromW(w);
        y = cy - h / 2;
      } else {
        const anchorX = d.handle.includes("w") ? right : left;
        const anchorY = d.handle.includes("n") ? bottom : top;
        h = hFromW(w);
        x = d.handle.includes("w") ? anchorX - w : anchorX;
        y = d.handle.includes("n") ? anchorY - h : anchorY;
      }
      w = Math.min(w, 1);
      h = Math.min(h, 1);
      x = clamp(x, 0, 1 - w);
      y = clamp(y, 0, 1 - h);
    }
  }
  ce.rect = { x, y, w, h };
  layoutCropOverlay();
}

function cropPointerUp() {
  document.removeEventListener("pointermove", cropPointerMove);
  document.removeEventListener("pointerup", cropPointerUp);
  if (state.cropEdit) state.cropEdit.drag = null;
}

function showCropEditMode() {
  el("crop-canvas").classList.remove("hidden");
  el("crop-overlay").classList.remove("hidden");
  el("crop-preview-img").classList.add("hidden");
  el("crop-controls-edit").classList.remove("hidden");
  el("crop-controls-preview").classList.add("hidden");
}

function showCropPreviewMode() {
  el("crop-canvas").classList.add("hidden");
  el("crop-overlay").classList.add("hidden");
  el("crop-preview-img").classList.remove("hidden");
  el("crop-controls-edit").classList.add("hidden");
  el("crop-controls-preview").classList.remove("hidden");
}

async function openCropEditor(photo) {
  if (!photo || photo.file_missing) return;
  const panel = el("crop-editor");
  panel.classList.remove("hidden");
  showCropEditMode();
  el("crop-stage").style.cursor = "wait";
  let img;
  try {
    img = await loadImage(`/api/photos/${photo.id}/edit-source?t=${Date.now()}`);
  } catch (e) {
    alert("無法載入編輯用圖片：" + e.message);
    panel.classList.add("hidden");
    return;
  }
  el("crop-stage").style.cursor = "";
  const angle = photo.straighten_angle || 0;
  state.cropEdit = {
    photo, img, rw: img.naturalWidth, rh: img.naturalHeight,
    angle, aspect: "free", rect: null, drag: null,
    bbW: img.naturalWidth, bbH: img.naturalHeight,
  };
  redrawCropCanvas();
  const ce = state.cropEdit;
  ce.rect = photo.crop ? { ...photo.crop } : defaultRectForAngle(ce);

  el("straighten-slider").value = String(angle);
  el("straighten-value").textContent = `${angle.toFixed(1)}°`;
  document.querySelectorAll(".crop-aspect-btn").forEach((b) => b.classList.toggle("active", b.dataset.aspect === "free"));
  layoutCropOverlay();
}

function closeCropEditor() {
  el("crop-editor").classList.add("hidden");
  document.removeEventListener("pointermove", cropPointerMove);
  document.removeEventListener("pointerup", cropPointerUp);
  state.cropEdit = null;
}

el("lightbox-edit-btn").onclick = () => {
  if (isFeatureLocked("editing")) {
    showUpgradePrompt("editing");
    return;
  }
  openCropEditor(state.lightboxPhotos[state.lightboxIndex]);
};
el("crop-overlay").addEventListener("pointerdown", cropPointerDown);
el("crop-cancel-btn").onclick = closeCropEditor;

el("crop-aspect-group").addEventListener("click", (e) => {
  const btn = e.target.closest(".crop-aspect-btn");
  const ce = state.cropEdit;
  if (!btn || !ce) return;
  document.querySelectorAll(".crop-aspect-btn").forEach((b) => b.classList.toggle("active", b === btn));
  ce.aspect = btn.dataset.aspect;
  const ratio = currentAspectRatio();
  if (ratio) ce.rect = fitRectToRatio(ce.rect, ratio, ce.bbW, ce.bbH);
  layoutCropOverlay();
});

el("straighten-slider").addEventListener("input", (e) => {
  const ce = state.cropEdit;
  if (!ce) return;
  ce.angle = parseFloat(e.target.value);
  el("straighten-value").textContent = `${ce.angle.toFixed(1)}°`;
  redrawCropCanvas();
  ce.rect = defaultRectForAngle(ce);
  const ratio = currentAspectRatio();
  if (ratio) ce.rect = fitRectToRatio(ce.rect, ratio, ce.bbW, ce.bbH);
  layoutCropOverlay();
});

el("straighten-reset-btn").onclick = () => {
  el("straighten-slider").value = "0";
  el("straighten-slider").dispatchEvent(new Event("input"));
};

el("crop-reset-btn").onclick = () => {
  const ce = state.cropEdit;
  if (!ce) return;
  ce.aspect = "free";
  document.querySelectorAll(".crop-aspect-btn").forEach((b) => b.classList.toggle("active", b.dataset.aspect === "free"));
  ce.rect = defaultRectForAngle(ce);
  layoutCropOverlay();
};

function isRawOrHeicPhoto(photo) {
  if (photo.is_raw) return true;
  const path = photo.original_path || photo.filename || "";
  return /\.(heic|heif)$/i.test(path);
}

function syncPhotoFields(photo, fields) {
  Object.assign(photo, fields);
  const inGrid = state.photos.find((p) => p.id === photo.id);
  if (inGrid && inGrid !== photo) Object.assign(inGrid, fields);
  const inLightbox = state.lightboxPhotos.find((p) => p.id === photo.id);
  if (inLightbox && inLightbox !== photo) Object.assign(inLightbox, fields);
}

el("crop-preview-btn").onclick = async () => {
  const ce = state.cropEdit;
  if (!ce) return;
  const photo = ce.photo;
  const rect = ce.rect;
  try {
    await api(`/api/photos/${photo.id}/straighten`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ angle: ce.angle }),
    });
    await api(`/api/photos/${photo.id}/crop`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(rect),
    });
  } catch (e) {
    alert("套用失敗：" + e.message);
    return;
  }
  syncPhotoFields(photo, {
    straighten_angle: ce.angle,
    crop: { ...rect },
    histogram: null, // stale — the cropped/straightened frame changed
  });
  renderGrid();
  renderLightbox();

  const overwriteBtn = el("crop-overwrite-btn");
  const isRawHeic = isRawOrHeicPhoto(photo);
  overwriteBtn.disabled = isRawHeic;
  overwriteBtn.title = isRawHeic ? "RAW／HEIC 原始檔無法覆蓋，請用「另存新檔」" : "";
  el("crop-preview-img").src = originalSrc(photo);
  showCropPreviewMode();
};

el("crop-back-btn").onclick = showCropEditMode;

el("crop-save-as-btn").onclick = async () => {
  const ce = state.cropEdit;
  if (!ce) return;
  const btn = el("crop-save-as-btn");
  btn.disabled = true;
  try {
    const result = await api(`/api/photos/${ce.photo.id}/save-as`, { method: "POST" });
    closeCropEditor();
    if (confirm(`已另存新檔：${result.output}\n\n要開啟資料夾嗎？`)) {
      await api("/api/open-folder", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ path: result.output_dir }),
      });
    }
  } catch (e) {
    alert("另存新檔失敗：" + e.message);
  } finally {
    btn.disabled = false;
  }
};

el("crop-overwrite-btn").onclick = async () => {
  const ce = state.cropEdit;
  if (!ce) return;
  const photo = ce.photo;
  if (isRawOrHeicPhoto(photo)) return;
  if (!confirm("此動作會覆蓋原始檔案，且無法復原。確定要繼續嗎？")) return;
  const btn = el("crop-overwrite-btn");
  btn.disabled = true;
  try {
    await api(`/api/photos/${photo.id}/overwrite-original`, { method: "POST" });
    const updated = await api(`/api/photos/${photo.id}`);
    syncPhotoFields(photo, updated);
    closeCropEditor();
    renderGrid();
    renderLightbox();
    refreshStats();
  } catch (e) {
    alert("存檔失敗：" + e.message);
  } finally {
    btn.disabled = false;
  }
};

window.addEventListener("resize", () => {
  if (state.cropEdit) layoutCropOverlay();
});

// --- import / pick folder / analyze -------------------------------------
// Import runs as a background job on the backend (hashing real camera files
// is too slow to do inline in the request).

async function doImport(folder) {
  if (!folder) return;
  if (state.importPollHandle) return alert("已經有匯入在執行，請稍候");
  try {
    await api("/api/import", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ folder }),
    });
    startImportPolling();
  } catch (e) {
    alert("匯入失敗：" + e.message);
  }
}

function startImportPolling() {
  if (state.importPollHandle) return;
  state.importPollHandle = setInterval(async () => {
    const p = await api("/api/import/progress");
    el("progress-text").textContent = p.running
      ? `匯入中（含失焦／手震掃描）… ${p.done}/${p.total}`
      : p.total
      ? `匯入完成：新增 ${p.imported}` +
        (p.already_present ? `、重複略過 ${p.already_present}（先前已匯入過）` : "") +
        (p.moved_or_renamed ? `、搬移/更名 ${p.moved_or_renamed}` : "") +
        (p.newly_missing ? `、找不到 ${p.newly_missing}` : "")
      : "";
    await refreshAll(); // grid fills in progressively as photos import
    if (!p.running) {
      clearInterval(state.importPollHandle);
      state.importPollHandle = null;
      if (p.error) alert("匯入時發生錯誤：" + p.error);
      await refreshLicenseStatus();
      // Catch any leftover unchecked photos from the post-import safety net.
      await ensureAutoQualityScan();
    }
  }, 1000);
}

el("pick-folder-btn").onclick = async () => {
  el("pick-folder-btn").disabled = true;
  try {
    const { path } = await api("/api/pick-folder", { method: "POST" });
    if (!path) return; // user cancelled
    await doImport(path);
  } catch (e) {
    alert("開啟選擇視窗失敗：" + e.message);
  } finally {
    el("pick-folder-btn").disabled = false;
  }
};

el("open-project-folder-btn").onclick = async () => {
  const root = state.activeProject?.root_path;
  if (!root) return;
  try {
    await api("/api/open-folder", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: root }),
    });
  } catch (e) {
    alert("開啟資料夾失敗：" + e.message);
  }
};

el("project-select").onchange = async () => {
  const id = el("project-select").value;
  if (!id) return;
  try {
    await api(`/api/projects/${id}/activate`, { method: "POST" });
    state.selectedIds.clear();
    state.lastSelectIndex = null;
    state.currentTab = "";
    document.querySelectorAll(".tab").forEach((t) => {
      t.classList.toggle("active", (t.dataset.filter || "") === "");
    });
    await refreshAll();
    await ensureAutoQualityScan();
  } catch (e) {
    alert("切換專案失敗：" + e.message);
    await refreshProjects();
  }
};

el("delete-project-btn").onclick = async () => {
  const id = el("project-select").value;
  if (!id) return;
  const opt = el("project-select").selectedOptions[0];
  const label = opt ? opt.textContent : id;
  const confirmMsg = typeof t === "function"
    ? t("project.deleteConfirm", { label })
    : `確定從列表移除此專案？\n\n${label}\n\n只會移除 PhotoPick 中的索引、狀態與 preview，不會刪除磁碟上的原始照片。`;
  if (!confirm(confirmMsg)) return;
  try {
    await api(`/api/projects/${id}`, { method: "DELETE" });
    state.selectedIds.clear();
    state.lastSelectIndex = null;
    await refreshAll();
  } catch (e) {
    const failMsg = typeof t === "function"
      ? t("project.deleteFail", { message: e.message })
      : `移除專案失敗：${e.message}`;
    alert(failMsg);
  }
};

const analyzeBtn = el("analyze-btn");
if (analyzeBtn) {
  analyzeBtn.onclick = async () => {
    try {
      await api("/api/analyze", { method: "POST" });
      startAnalyzePolling();
    } catch (e) {
      alert("啟動分析失敗：" + e.message);
    }
  };
}

function startQualityPolling() {
  if (state.qualityPollHandle) return;
  state.qualityPollHandle = setInterval(async () => {
    const p = await api("/api/quality-scan/progress");
    el("progress-text").textContent = p.running
      ? `品質掃描中… ${p.done + p.failed}/${p.total}（失敗 ${p.failed}）`
      : p.total
      ? `品質掃描完成：${p.done}/${p.total}（失敗 ${p.failed}）`
      : "";
    await refreshAll();
    if (!p.running) {
      clearInterval(state.qualityPollHandle);
      state.qualityPollHandle = null;
    }
  }, 1000);
}

async function ensureAutoQualityScan() {
  // The scan computes blur + exposure metrics together, so it's worth
  // running if either license flag is unlocked (matches the backend's
  // _quality_scan_allowed()); per-feature display gating happens elsewhere.
  if (isFeatureLocked("defect_detection") && isFeatureLocked("exposure_detection")) return;
  try {
    const [stats, progress] = await Promise.all([
      api("/api/stats"),
      api("/api/quality-scan/progress"),
    ]);
    if (progress.running) {
      startQualityPolling();
      return;
    }
    const total = stats.total_count || 0;
    const checked = stats.quality_checked || 0;
    if (total === 0 || checked >= total) return;
    await api("/api/quality-scan", { method: "POST" });
    startQualityPolling();
  } catch (e) {
    const msg = String(e.message || "");
    if (msg.includes("already") || msg.includes("running")) {
      startQualityPolling();
    }
  }
}

function startAnalyzePolling() {
  if (state.analyzePollHandle) return;
  state.analyzePollHandle = setInterval(async () => {
    const p = await api("/api/analyze/progress");
    el("progress-text").textContent = p.running
      ? `AI 分析中… ${p.done + p.failed}/${p.total}（失敗 ${p.failed}）`
      : p.total
      ? `AI 分析完成：${p.done}/${p.total}（失敗 ${p.failed}）`
      : "";
    if (!p.running) {
      clearInterval(state.analyzePollHandle);
      state.analyzePollHandle = null;
      await refreshAll();
    }
  }, 1500);
}

// --- grid size ------------------------------------------------------------
// 10 steps: index 2 (180px) matches the old default. The last step isn't a
// --thumb-size at all — auto-fill/minmax can only make a *typical* window
// land on 2 columns, not guarantee it, so the max step instead switches the
// grid to a fixed 2-column layout (see .grid-two-col in style.css).
const GRID_SIZE_STEPS = [100, 140, 180, 220, 260, 300, 350, 420, 500, "2col"];

function applyGridSize(index) {
  const step = GRID_SIZE_STEPS[index];
  const grid = el("photo-grid");
  if (step === "2col") {
    grid.classList.add("grid-two-col");
  } else {
    grid.classList.remove("grid-two-col");
    grid.style.setProperty("--thumb-size", step + "px");
  }
}

const gridSizeSlider = el("grid-size-slider");
const savedGridSizeIndex = localStorage.getItem("photopick-grid-size-index");
if (savedGridSizeIndex !== null && GRID_SIZE_STEPS[savedGridSizeIndex] !== undefined) {
  gridSizeSlider.value = savedGridSizeIndex;
}
applyGridSize(gridSizeSlider.value);
gridSizeSlider.addEventListener("input", () => {
  const index = gridSizeSlider.value;
  applyGridSize(index);
  localStorage.setItem("photopick-grid-size-index", index);
});

// --- batch delete / purge missing ---------------------------------------

el("batch-delete-btn").onclick = async () => {
  if (isFeatureLocked("batch_delete")) {
    showUpgradePrompt("batch_delete");
    return;
  }
  const rejects = await api("/api/photos?status=reject");
  if (rejects.length === 0) return alert("目前沒有標記為待刪除的照片");
  if (!confirm(`確定要把 ${rejects.length} 張待刪除照片移到垃圾桶嗎？`)) return;
  const result = await api("/api/batch/delete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ids: rejects.map((p) => p.id) }),
  });
  const moved = result.moved_to_trash?.length || 0;
  const cleared = result.cleared_missing?.length || 0;
  const failed = result.errors?.length || 0;
  let msg = `已移至垃圾桶：${moved} 張`;
  if (cleared) msg += `\n原檔已不存在、已清資料庫：${cleared} 張`;
  if (failed) {
    msg += `\n失敗：${failed} 張`;
    const sample = (result.errors || []).slice(0, 5)
      .map((e) => `· ${e.filename || e.id}: ${e.error}`)
      .join("\n");
    if (sample) msg += "\n" + sample;
  }
  alert(msg);
  await refreshAll();
};

el("purge-missing-btn").onclick = async () => {
  const missing = await api("/api/photos/missing");
  if (missing.length === 0) return alert("目前沒有遺失紀錄");
  const byReason = {};
  missing.forEach((p) => {
    const key = missingReasonLabel(p.missing_reason);
    byReason[key] = (byReason[key] || 0) + 1;
  });
  const detail = Object.entries(byReason).map(([k, n]) => `· ${k}：${n} 張`).join("\n");
  if (!confirm(
    `確定清除 ${missing.length} 筆遺失紀錄？\n\n${detail}\n\n` +
    `只會刪除 PhotoPick 裡的索引與 preview，不會再動系統垃圾桶。`
  )) return;
  const result = await api("/api/photos/purge-missing", { method: "POST" });
  alert(`已清除 ${result.count} 筆遺失紀錄`);
  await refreshAll();
};

// --- batch rename -------------------------------------------------------

function selectedPhotosInViewOrder() {
  return state.photos.filter((p) => state.selectedIds.has(p.id));
}

function buildRenameName(photo, index, { prefix, separator, start, pad }) {
  const n = start + index;
  const serial = pad > 0 ? String(n).padStart(pad, "0") : String(n);
  const ext = (photo.filename.match(/\.[^.]+$/) || [".jpg"])[0].toLowerCase();
  return `${prefix}${separator}${serial}${ext}`;
}

function updateRenamePreview() {
  const photos = selectedPhotosInViewOrder();
  const opts = {
    prefix: el("rename-prefix").value.trim(),
    separator: el("rename-separator").value,
    start: Math.max(0, parseInt(el("rename-start").value, 10) || 0),
    pad: parseInt(el("rename-pad").value, 10) || 0,
  };
  if (!opts.prefix) {
    el("rename-preview").textContent = "請輸入 Prefix";
    return;
  }
  const lines = photos.slice(0, 5).map((p, i) => buildRenameName(p, i, opts));
  if (photos.length > 5) lines.push(`…共 ${photos.length} 張`);
  el("rename-preview").textContent = lines.join("\n");
}

function openRenameModal() {
  if (isFeatureLocked("batch_rename")) {
    showUpgradePrompt("batch_rename");
    return;
  }
  const photos = selectedPhotosInViewOrder();
  if (photos.length === 0) return alert("請先選取要重新命名的照片");
  const saved = localStorage.getItem("photopick-rename-prefix") || "";
  el("rename-prefix").value = saved;
  el("rename-separator").value = localStorage.getItem("photopick-rename-separator") || "-";
  el("rename-start").value = localStorage.getItem("photopick-rename-start") || "1";
  el("rename-pad").value = localStorage.getItem("photopick-rename-pad") || "0";
  updateRenamePreview();
  el("rename-modal").classList.remove("hidden");
  el("rename-prefix").focus();
}

function closeRenameModal() {
  el("rename-modal").classList.add("hidden");
}

el("batch-rename-btn").onclick = openRenameModal;
el("rename-cancel").onclick = closeRenameModal;
el("rename-modal").addEventListener("click", (e) => {
  if (e.target.id === "rename-modal") closeRenameModal();
});
["rename-prefix", "rename-separator", "rename-start", "rename-pad"].forEach((id) => {
  el(id).addEventListener("input", updateRenamePreview);
});

el("rename-confirm").onclick = async () => {
  const photos = selectedPhotosInViewOrder();
  if (photos.length === 0) return;
  const prefix = el("rename-prefix").value.trim();
  if (!prefix) return alert("請輸入 Prefix");
  const separator = el("rename-separator").value;
  const start = Math.max(0, parseInt(el("rename-start").value, 10) || 0);
  const pad = parseInt(el("rename-pad").value, 10) || 0;
  const sample = buildRenameName(photos[0], 0, { prefix, separator, start, pad });
  if (!confirm(`將把 ${photos.length} 張照片重新命名？\n例如：${sample}`)) return;

  localStorage.setItem("photopick-rename-prefix", prefix);
  localStorage.setItem("photopick-rename-separator", separator);
  localStorage.setItem("photopick-rename-start", String(start));
  localStorage.setItem("photopick-rename-pad", String(pad));

  el("rename-confirm").disabled = true;
  try {
    const result = await api("/api/batch/rename", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        ids: photos.map((p) => p.id),
        prefix,
        separator,
        start,
        pad,
      }),
    });
    closeRenameModal();
    state.selectedIds.clear();
    state.lastSelectIndex = null;
    await refreshAll();
    alert(`已重新命名 ${result.renamed.length} 張`);
  } catch (e) {
    alert("重新命名失敗：" + e.message);
  } finally {
    el("rename-confirm").disabled = false;
  }
};

// --- batch export / convert ---------------------------------------------
// Full-res RAW conversion can take a while for a big batch, so this runs as
// a background job on the backend (same pattern as import) and the modal
// polls /api/batch/export/progress for a live progress bar.

const EXPORT_FORMAT_FOLDER = { jpeg: "JPG", png: "PNG", webp: "WEBP" };
const EXPORT_QUALITY_VALUE = { low: 50, medium: 75, high: 90 };
let exportOutputDir = null;

function setExportQualityTier(tier) {
  document.querySelectorAll("#export-quality-group .defect-tier-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.quality === tier);
  });
}

function getExportQualityTier() {
  return document.querySelector("#export-quality-group .defect-tier-btn.active")?.dataset.quality || "high";
}

// Tracks the last value we auto-filled, so switching format updates the
// path only while the user hasn't typed/browsed their own — once they
// customize it, format changes stop overwriting their choice.
let exportPathAutoValue = "";

function defaultExportPath(format) {
  const root = state.activeProject?.root_path;
  if (!root) return "";
  const folder = EXPORT_FORMAT_FOLDER[format] || "";
  return `${root.replace(/\/+$/, "")}/${folder}`;
}

function updateExportOutputHint() {
  const format = el("export-format").value;
  el("export-quality-field").classList.toggle("hidden", format === "png");
  const input = el("export-path-input");
  const nextDefault = defaultExportPath(format);
  if (!input.value || input.value === exportPathAutoValue) {
    input.value = nextDefault;
  }
  exportPathAutoValue = nextDefault;
}

let exportEstimateToken = 0;
const exportEstimateCache = new Map(); // key -> { result, at }
const EXPORT_ESTIMATE_CACHE_TTL_MS = 2 * 60 * 1000;
const EXPORT_ESTIMATE_CACHE_MAX = 20;

// Photo ids (not filenames/file size) are the key: a non-destructive crop or
// straighten leaves the original file's name and size untouched, so either
// of those would keep serving a stale estimate after an edit. ids + format
// + quality is exactly the estimate request's own inputs, so it can't be
// stale as long as the ids' underlying edits haven't changed within the TTL.
function exportEstimateCacheKey(ids, format, quality) {
  return `${ids.slice().sort((a, b) => a - b).join(",")}|${format}|${quality}`;
}

async function updateExportEstimate() {
  const photos = selectedPhotosInViewOrder();
  if (photos.length === 0) {
    el("export-estimate-hint").textContent = "";
    return;
  }
  const format = el("export-format").value;
  const quality = EXPORT_QUALITY_VALUE[getExportQualityTier()];
  const token = ++exportEstimateToken;
  const key = exportEstimateCacheKey(photos.map((p) => p.id), format, quality);

  const cached = exportEstimateCache.get(key);
  let result;
  if (cached && Date.now() - cached.at < EXPORT_ESTIMATE_CACHE_TTL_MS) {
    result = cached.result;
  } else {
    el("export-estimate-hint").textContent = t("export.estimating");
    try {
      result = await api("/api/batch/export/estimate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ ids: photos.map((p) => p.id), format, quality }),
      });
    } catch (e) {
      if (token === exportEstimateToken) el("export-estimate-hint").textContent = t("export.estimateFailed");
      return;
    }
    if (exportEstimateCache.size >= EXPORT_ESTIMATE_CACHE_MAX) {
      exportEstimateCache.delete(exportEstimateCache.keys().next().value); // evict oldest
    }
    exportEstimateCache.set(key, { result, at: Date.now() });
  }
  if (token !== exportEstimateToken) return; // a newer estimate superseded this one
  const lines = [
    t("export.estimateResult", {
      current: formatBytes(result.current_total_bytes),
      estimated: formatBytes(result.estimated_total_bytes),
    }),
  ];
  const changes = result.dimension_changes || [];
  if (changes.length > 0) {
    lines.push(t("export.dimensionChangeHeader", { n: changes.length }));
    const shown = changes.slice(0, 8);
    for (const c of shown) {
      lines.push(
        "· " +
          t("export.dimensionChangeLine", {
            filename: c.filename,
            ow: c.original[0], oh: c.original[1],
            nw: c.output[0], nh: c.output[1],
          })
      );
    }
    if (changes.length > shown.length) {
      lines.push(t("export.dimensionChangeMore", { n: changes.length - shown.length }));
    }
  }
  el("export-estimate-hint").textContent = lines.join("\n");
}

function setExportRunningUI(running) {
  el("export-form-fields").classList.toggle("hidden", running);
  el("export-progress-block").classList.toggle("hidden", !running);
  el("export-cancel").disabled = running;
  el("export-confirm").classList.toggle("hidden", running);
  if (running) {
    el("export-open-folder").classList.add("hidden");
    el("export-progress-fill").style.width = "0%";
    el("export-progress-text").textContent = "";
  }
}

function openExportModal() {
  if (isFeatureLocked("export")) {
    showUpgradePrompt("export");
    return;
  }
  const photos = selectedPhotosInViewOrder();
  if (photos.length === 0) return alert("請先選取要轉檔的照片");
  el("export-modal-hint").textContent = `將把選取的 ${photos.length} 張照片轉檔。`;
  el("export-format").value = localStorage.getItem("photopick-export-format") || "jpeg";
  setExportQualityTier(localStorage.getItem("photopick-export-quality") || "high");
  updateExportOutputHint();
  setExportRunningUI(false);
  el("export-modal").classList.remove("hidden");
  updateExportEstimate();
}

function closeExportModal() {
  if (state.exportPollHandle) return; // don't let the modal disappear mid-run
  el("export-modal").classList.add("hidden");
}

el("batch-export-btn").onclick = openExportModal;
el("export-cancel").onclick = closeExportModal;
el("export-modal").addEventListener("click", (e) => {
  if (e.target.id === "export-modal") closeExportModal();
});
el("export-format").addEventListener("change", () => {
  updateExportOutputHint();
  updateExportEstimate();
});
el("export-quality-group").addEventListener("click", (e) => {
  const btn = e.target.closest(".defect-tier-btn");
  if (!btn) return;
  setExportQualityTier(btn.dataset.quality);
  updateExportEstimate();
});
el("export-path-browse-btn").onclick = async () => {
  try {
    const result = await api(`/api/pick-folder?prompt=${encodeURIComponent(t("export.outputPath"))}`, {
      method: "POST",
    });
    if (result.path) {
      el("export-path-input").value = result.path;
      exportPathAutoValue = null; // no longer matches any auto-computed default
    }
  } catch (e) {
    alert("選擇資料夾失敗：" + e.message);
  }
};

function startExportPolling() {
  if (state.exportPollHandle) return;
  state.exportPollHandle = setInterval(async () => {
    let p;
    try {
      p = await api("/api/batch/export/progress");
    } catch {
      return;
    }
    const pct = p.total ? Math.round((p.done / p.total) * 100) : 0;
    el("export-progress-fill").style.width = `${pct}%`;
    el("export-progress-text").textContent = `轉檔中… ${p.done}/${p.total}`;
    if (!p.running) {
      clearInterval(state.exportPollHandle);
      state.exportPollHandle = null;
      exportOutputDir = p.output_dir;
      const done = (p.exported || []).length;
      const failed = (p.errors || []).length;
      let text = `已轉檔 ${done} 張`;
      if (failed) text += `，失敗 ${failed} 張`;
      el("export-progress-text").textContent = text;
      el("export-cancel").disabled = false;
      el("export-open-folder").classList.toggle("hidden", !exportOutputDir);
      if (failed) {
        const sample = (p.errors || []).slice(0, 5)
          .map((e) => `· ${e.filename || e.id}: ${e.error}`)
          .join("\n");
        if (sample) alert(`部分照片轉檔失敗：\n${sample}`);
      }
    }
  }, 500);
}

el("export-confirm").onclick = async () => {
  const photos = selectedPhotosInViewOrder();
  if (photos.length === 0) return;
  const format = el("export-format").value;
  const qualityTier = getExportQualityTier();
  const quality = EXPORT_QUALITY_VALUE[qualityTier];
  localStorage.setItem("photopick-export-format", format);
  localStorage.setItem("photopick-export-quality", qualityTier);

  const outputDir = el("export-path-input").value.trim() || null;

  try {
    await api("/api/batch/export", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ids: photos.map((p) => p.id), format, quality, output_dir: outputDir }),
    });
    setExportRunningUI(true);
    startExportPolling();
  } catch (e) {
    alert("轉檔失敗：" + e.message);
  }
};

el("export-open-folder").onclick = async () => {
  if (!exportOutputDir) return;
  try {
    await api("/api/open-folder", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: exportOutputDir }),
    });
  } catch (e) {
    alert("開啟資料夾失敗：" + e.message);
  }
};

// --- settings -------------------------------------------------------------

const LOCKED_FEATURE_I18N = {
  batch_rename: "license.locked.batchRename",
  batch_delete: "license.locked.batchDelete",
  defect_detection: "license.locked.defectDetection",
  exposure_detection: "license.locked.exposureDetection",
  editing: "license.locked.editing",
  export: "license.locked.export",
};

function isFeatureLocked(feature) {
  return (state.licenseStatus?.locked_features || []).includes(feature);
}

function isTrialMode() {
  return state.licenseStatus?.mode === "trial";
}

function featureLockTip(feature) {
  if (!isFeatureLocked(feature)) return "";
  const name = typeof t === "function" ? t(LOCKED_FEATURE_I18N[feature]) : feature;
  const hint = typeof t === "function" ? t("license.upgradeHint") : "";
  return hint ? `${name} — ${hint}` : name;
}

function showUpgradePrompt(feature) {
  const modal = el("upgrade-modal");
  const msgEl = el("upgrade-modal-message");
  if (!modal || !msgEl) return;
  const name = typeof t === "function" ? t(LOCKED_FEATURE_I18N[feature]) : feature;
  msgEl.textContent =
    typeof t === "function" ? t("license.upgrade.body", { feature: name }) : name;
  const purchase = el("upgrade-modal-purchase");
  if (purchase && state.licenseStatus?.purchase_url) {
    purchase.href = state.licenseStatus.purchase_url;
  }
  modal.classList.remove("hidden");
}

function closeUpgradeModal() {
  el("upgrade-modal")?.classList.add("hidden");
}

function openPlansModal() {
  closeAccountMenu();
  const purchase = el("plans-modal-purchase");
  if (purchase && state.licenseStatus?.purchase_url) {
    purchase.href = state.licenseStatus.purchase_url;
  }
  el("plans-modal")?.classList.remove("hidden");
}

function closePlansModal() {
  el("plans-modal")?.classList.add("hidden");
}

function renderAccountIndicator() {
  const tag = el("account-trial-tag");
  const btn = el("account-menu-btn");
  if (!btn) return;
  const status = state.licenseStatus;
  const isTrial = !status || status.mode !== "licensed";
  if (tag) {
    tag.classList.toggle("hidden", !isTrial);
    if (isTrial) {
      tag.textContent = typeof t === "function" ? t("license.mode.trial") : "試用版";
    }
  }
  btn.title = typeof t === "function" ? t("account.menu") : "帳戶";
  const initialEl = el("account-btn-initial");
  if (initialEl) {
    const email = status?.license?.customer_email;
    initialEl.textContent = email ? email.trim().charAt(0).toUpperCase() : "P";
  }
}

async function refreshLicenseStatus() {
  try {
    state.licenseStatus = await api("/api/license/status");
    applyLicenseLocks();
    renderAccountIndicator();
    renderSelectionBar();
    return state.licenseStatus;
  } catch {
    state.licenseStatus = null;
    renderAccountIndicator();
    return null;
  }
}

function applyLicenseLocks() {
  const locked = new Set(state.licenseStatus?.locked_features || []);

  const deleteBtn = el("batch-delete-btn");
  if (deleteBtn) {
    const dLocked = locked.has("batch_delete");
    deleteBtn.disabled = false;
    deleteBtn.classList.toggle("feature-locked", dLocked);
    deleteBtn.title = dLocked ? featureLockTip("batch_delete") : "";
  }

  const renameBtn = el("batch-rename-btn");
  if (renameBtn && locked.has("batch_rename")) {
    renameBtn.classList.add("feature-locked");
  } else if (renameBtn) {
    renameBtn.classList.remove("feature-locked");
  }

  const defectLocked = locked.has("defect_detection");
  const exposureLocked = locked.has("exposure_detection");
  const lockTab = (selector, isLocked, featureKey) => {
    const tab = document.querySelector(selector);
    if (!tab) return;
    tab.classList.toggle("feature-locked", isLocked);
    if (isLocked) {
      tab.title = featureLockTip(featureKey);
    } else {
      tab.removeAttribute("title");
    }
  };
  lockTab('.tab[data-filter="blur"]', defectLocked, "defect_detection");
  lockTab('.tab[data-filter="exposure"]', exposureLocked, "exposure_detection");
  const strandedOnLockedTab =
    (defectLocked && state.currentTab === "blur") ||
    (exposureLocked && state.currentTab === "exposure");
  if (strandedOnLockedTab) {
    state.currentTab = "";
    document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
    document.querySelector('.tab[data-filter=""]')?.classList.add("active");
    updateDefectFilterUI();
    refreshStats().catch(() => {});
    refreshGrid().catch(() => {});
  }

  const editLocked = locked.has("editing");
  const editBtn = el("lightbox-edit-btn");
  if (editBtn) {
    editBtn.classList.toggle("feature-locked", editLocked);
    if (editLocked) {
      editBtn.title = featureLockTip("editing");
    } else {
      // Restore the normal i18n tooltip rather than leaving it blank.
      const key = editBtn.getAttribute("data-i18n-title");
      editBtn.title = key ? t(key) : "";
    }
  }
}

function renderProfileModal() {
  const status = state.licenseStatus;
  const modeEl = el("license-mode-text");
  const purchaseLink = el("license-purchase-link");
  const deactivateBlock = el("license-deactivate-block");
  const activateBlock = el("license-activate-block");
  const licensedSummary = el("license-licensed-summary");
  const accountInfo = el("license-account-info");
  const emailEl = el("license-customer-email");
  const keyHintEl = el("license-key-hint");
  const reactivateCancelBtn = el("license-reactivate-cancel-btn");
  const deviceNameEl = el("license-device-name");
  const machineIdEl = el("license-machine-id");
  const msgEl = el("license-message");
  if (!modeEl || !status) return;

  const isLicensed = status.mode === "licensed";
  const showActivateForm = !isLicensed || state.licenseReactivateOpen;
  modeEl.textContent = typeof t === "function"
    ? t(isLicensed ? "license.mode.licensed" : "license.mode.trial")
    : (isLicensed ? "已啟用正式版" : "試用版");

  if (purchaseLink && status.purchase_url) {
    purchaseLink.href = status.purchase_url;
  }
  if (activateBlock) {
    activateBlock.classList.toggle("hidden", !showActivateForm);
  }
  if (purchaseLink) {
    purchaseLink.classList.toggle("hidden", isLicensed && !state.licenseReactivateOpen);
  }
  if (reactivateCancelBtn) {
    reactivateCancelBtn.classList.toggle("hidden", !isLicensed || !state.licenseReactivateOpen);
  }
  if (licensedSummary) {
    licensedSummary.classList.toggle("hidden", !isLicensed || state.licenseReactivateOpen);
  }
  if (accountInfo) {
    accountInfo.classList.toggle("hidden", !isLicensed || state.licenseReactivateOpen);
  }
  if (emailEl) {
    const email = status.license?.customer_email;
    emailEl.textContent = email || (typeof t === "function" ? t("license.emailUnknown") : "—");
  }
  if (keyHintEl) {
    keyHintEl.textContent = status.license?.license_key_hint || "—";
  }
  if (deactivateBlock) {
    deactivateBlock.classList.toggle("hidden", !isLicensed);
  }
  if (msgEl) {
    msgEl.classList.add("hidden");
    msgEl.textContent = "";
  }
  if (deviceNameEl) {
    deviceNameEl.textContent = status.device_name || "—";
  }
  if (machineIdEl) {
    const mid = status.machine_id || "—";
    machineIdEl.textContent = mid;
    machineIdEl.title = typeof t === "function" ? t("license.machineIdCopyHint") : "點擊複製";
  }
}

function closeAccountMenu() {
  const dropdown = el("account-menu-dropdown");
  const btn = el("account-menu-btn");
  dropdown?.classList.add("hidden");
  if (btn) btn.setAttribute("aria-expanded", "false");
}

function toggleAccountMenu() {
  const dropdown = el("account-menu-dropdown");
  const btn = el("account-menu-btn");
  if (!dropdown || !btn) return;
  const open = dropdown.classList.contains("hidden");
  dropdown.classList.toggle("hidden", !open);
  btn.setAttribute("aria-expanded", open ? "true" : "false");
}

function openProfileModal() {
  closeAccountMenu();
  state.licenseReactivateOpen = false;
  const keyInput = el("license-key-input");
  if (keyInput) keyInput.value = "";
  refreshLicenseStatus().then(() => renderProfileModal());
  el("profile-modal")?.classList.remove("hidden");
}

function closeProfileModal() {
  el("profile-modal")?.classList.add("hidden");
}

function openSettingsModal() {
  closeAccountMenu();
  const settings = loadSettings();
  el("settings-lang").value = settings.lang;
  el("settings-theme").value = settings.theme;
  void populateSettingsVersion();
  resetSettingsUpdateHint();
  el("settings-modal").classList.remove("hidden");
}

function resetSettingsUpdateHint() {
  const hint = el("settings-update-hint");
  if (!hint) return;
  hint.textContent = "";
  hint.classList.add("hidden");
  hint.replaceChildren();
}

async function populateSettingsVersion() {
  const verEl = el("settings-app-version");
  if (!verEl) return;
  try {
    const info = await api("/api/version");
    verEl.textContent = info.version ? `v${info.version}` : "—";
  } catch {
    verEl.textContent = "—";
  }
}

async function fetchReleaseCheck() {
  return api("/api/releases/check");
}

function getDismissedUpdateVersion() {
  try {
    return localStorage.getItem(UPDATE_DISMISS_KEY) || "";
  } catch {
    return "";
  }
}

function setDismissedUpdateVersion(version) {
  try {
    localStorage.setItem(UPDATE_DISMISS_KEY, version);
  } catch {
    // ignore
  }
}

function hideUpdateBanner() {
  el("update-banner")?.classList.add("hidden");
}

function renderUpdateBanner(info) {
  const banner = el("update-banner");
  const title = el("update-banner-title");
  const notes = el("update-banner-notes");
  const download = el("update-banner-download");
  const dismiss = el("update-banner-dismiss");
  if (!banner || !title || !notes || !download || !info?.update_available || !info.download_url) {
    hideUpdateBanner();
    return;
  }

  state.pendingUpdate = info;
  title.textContent = typeof t === "function"
    ? t("update.banner", { version: info.latest_version })
    : `PhotoPick ${info.latest_version} is available`;
  download.href = info.download_url;
  download.textContent = typeof t === "function" ? t("update.download") : "Download update";

  const noteText = (info.release_notes || "").trim();
  if (noteText) {
    notes.textContent = noteText;
    notes.classList.remove("hidden");
  } else {
    notes.textContent = "";
    notes.classList.add("hidden");
  }

  if (dismiss) {
    dismiss.textContent = typeof t === "function" ? t("update.dismiss") : "Later";
    dismiss.title = typeof t === "function" ? t("update.dismissTitle") : "Remind me later";
  }

  banner.classList.remove("hidden");
}

function maybeShowUpdateBanner(info) {
  if (!info?.ok || !info.update_available || !info.download_url) {
    hideUpdateBanner();
    return;
  }
  if (getDismissedUpdateVersion() === info.latest_version) {
    hideUpdateBanner();
    return;
  }
  renderUpdateBanner(info);
}

async function runStartupUpdateCheck() {
  try {
    const info = await fetchReleaseCheck();
    maybeShowUpdateBanner(info);
  } catch {
    // Silent on startup — user can still check manually in settings.
  }
}

function renderSettingsUpdateHint(info) {
  const hint = el("settings-update-hint");
  if (!hint) return;
  hint.replaceChildren();

  if (!info.ok) {
    hint.textContent = typeof t === "function" ? t("update.checkFailed") : "Could not check for updates";
    hint.classList.remove("hidden");
    return;
  }

  if (info.update_available && info.download_url) {
    const msg = typeof t === "function"
      ? t("update.available", { version: info.latest_version })
      : `Version ${info.latest_version} is available`;
    hint.textContent = msg;
    hint.classList.remove("hidden");
    const link = document.createElement("a");
    link.href = info.download_url;
    link.target = "_blank";
    link.rel = "noopener";
    link.className = "secondary-link";
    link.textContent = typeof t === "function" ? t("update.download") : "Download update";
    hint.append(" ", link);
    if (info.release_notes) {
      const noteSpan = document.createElement("span");
      noteSpan.className = "settings-update-notes";
      noteSpan.textContent = ` — ${info.release_notes}`;
      hint.append(noteSpan);
    }
    return;
  }

  hint.textContent = typeof t === "function" ? t("update.upToDate") : "You're up to date";
  hint.classList.remove("hidden");
}

async function checkForAppUpdate() {
  const btn = el("settings-check-update");
  const hint = el("settings-update-hint");
  if (!btn || !hint) return;

  const prevLabel = btn.textContent;
  btn.disabled = true;
  btn.textContent = typeof t === "function" ? t("settings.checkingUpdate") : "Checking…";
  resetSettingsUpdateHint();

  try {
    const info = await fetchReleaseCheck();
    renderSettingsUpdateHint(info);
    if (info.ok && info.update_available) {
      maybeShowUpdateBanner(info);
    }
  } catch {
    hint.textContent = typeof t === "function" ? t("update.checkFailed") : "Could not check for updates";
    hint.classList.remove("hidden");
  } finally {
    btn.disabled = false;
    btn.textContent = prevLabel;
  }
}

function closeSettingsModal() {
  el("settings-modal").classList.add("hidden");
}

function commitSettingsFromForm() {
  const picked = el("settings-lang").value;
  applySettings({
    lang: (typeof normalizeLang === "function" && normalizeLang(picked)) || picked || "en",
    theme: el("settings-theme").value === "light" ? "light" : "dark",
  });
}

el("account-menu-trigger").onclick = (e) => {
  e.stopPropagation();
  toggleAccountMenu();
};
document.addEventListener("click", (e) => {
  const menu = el("account-menu");
  if (!menu || menu.contains(e.target)) return;
  closeAccountMenu();
});
document.querySelectorAll("[data-account-action]").forEach((item) => {
  item.onclick = () => {
    const action = item.getAttribute("data-account-action");
    closeAccountMenu();
    if (action === "profile") openProfileModal();
    else if (action === "plans") openPlansModal();
    else if (action === "settings") openSettingsModal();
  };
});

el("profile-close").onclick = closeProfileModal;
el("profile-modal").addEventListener("click", (e) => {
  if (e.target.id === "profile-modal") closeProfileModal();
});

el("settings-close").onclick = () => {
  commitSettingsFromForm();
  closeSettingsModal();
};
el("settings-modal").addEventListener("click", (e) => {
  if (e.target.id === "settings-modal") {
    commitSettingsFromForm();
    closeSettingsModal();
  }
});
el("settings-lang").onchange = commitSettingsFromForm;
el("settings-theme").onchange = commitSettingsFromForm;
el("settings-check-update")?.addEventListener("click", () => {
  void checkForAppUpdate();
});

el("update-banner-dismiss")?.addEventListener("click", () => {
  const version = state.pendingUpdate?.latest_version;
  if (version) setDismissedUpdateVersion(version);
  hideUpdateBanner();
});

el("upgrade-modal-close").onclick = closeUpgradeModal;
el("upgrade-modal-plans").onclick = () => {
  closeUpgradeModal();
  openPlansModal();
};
el("upgrade-modal-profile").onclick = () => {
  closeUpgradeModal();
  openProfileModal();
};
el("upgrade-modal").addEventListener("click", (e) => {
  if (e.target.id === "upgrade-modal") closeUpgradeModal();
});
el("plans-modal-close").onclick = closePlansModal;
el("plans-modal").addEventListener("click", (e) => {
  if (e.target.id === "plans-modal") closePlansModal();
});

el("license-activate-btn").onclick = async () => {
  const key = el("license-key-input").value.trim();
  const msgEl = el("license-message");
  if (!key) {
    msgEl.textContent = typeof t === "function" ? t("license.error.empty") : "請輸入授權碼";
    msgEl.classList.remove("hidden");
    return;
  }
  el("license-activate-btn").disabled = true;
  try {
    await api("/api/license/activate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ license_key: key }),
    });
    el("license-key-input").value = "";
    state.licenseReactivateOpen = false;
    await refreshLicenseStatus();
    renderProfileModal();
    msgEl.textContent = typeof t === "function" ? t("license.success") : "授權已啟用";
    msgEl.classList.remove("hidden");
    await refreshAll();
  } catch (e) {
    msgEl.textContent = e.message;
    msgEl.classList.remove("hidden");
  } finally {
    el("license-activate-btn").disabled = false;
  }
};

el("license-reactivate-btn").onclick = () => {
  state.licenseReactivateOpen = true;
  renderProfileModal();
  el("license-key-input")?.focus();
};

el("license-reactivate-cancel-btn").onclick = () => {
  state.licenseReactivateOpen = false;
  el("license-key-input").value = "";
  const msgEl = el("license-message");
  if (msgEl) {
    msgEl.classList.add("hidden");
    msgEl.textContent = "";
  }
  renderProfileModal();
};

el("license-machine-id")?.addEventListener("click", async () => {
  const mid = state.licenseStatus?.machine_id;
  const msgEl = el("license-message");
  if (!mid || !msgEl) return;
  try {
    await navigator.clipboard.writeText(mid);
    msgEl.textContent = typeof t === "function" ? t("license.machineIdCopied") : "已複製 Machine ID";
    msgEl.classList.remove("hidden");
  } catch {
    msgEl.textContent = mid;
    msgEl.classList.remove("hidden");
  }
});

el("license-deactivate-btn").onclick = async () => {
  const msgEl = el("license-message");
  const confirmMsg = typeof t === "function"
    ? t("license.deactivate.confirm")
    : "確定解除本機授權？此 Mac 將恢復為試用版，授權碼可在其他電腦重新啟用。";
  if (!confirm(confirmMsg)) return;

  const btn = el("license-deactivate-btn");
  btn.disabled = true;
  try {
    await api("/api/license/deactivate", { method: "POST" });
    state.licenseReactivateOpen = false;
    el("license-key-input").value = "";
    await refreshLicenseStatus();
    renderProfileModal();
    msgEl.textContent = typeof t === "function" ? t("license.deactivate.success") : "已解除本機授權";
    msgEl.classList.remove("hidden");
    await refreshAll();
  } catch (e) {
    msgEl.textContent = e.message;
    msgEl.classList.remove("hidden");
  } finally {
    btn.disabled = false;
  }
};

window.__photopickOnSettingsChange = () => {
  renderSelectionBar();
  renderAccountIndicator();
  refreshStats().catch(() => {});
  refreshProjects().catch(() => {});
  if (!el("lightbox").classList.contains("hidden")) renderLightbox();
  if (state.pendingUpdate && !el("update-banner")?.classList.contains("hidden")) {
    renderUpdateBanner(state.pendingUpdate);
  }
  // Re-render grid captions/tooltips in the new language.
  renderGrid();
};

// --- boot -------------------------------------------------------------

async function loadAppVersion() {
  try {
    const info = await api("/api/version");
    const ver = info.version || "";
    if (ver) document.title = `PhotoPick v${ver}`;
  } catch {
    // ignore
  }
}

async function bootSettings() {
  const settings = await loadSettingsFromServer();
  applySettings(settings, { refreshUi: false });
}

bootSettings();
loadAppVersion();
void runStartupUpdateCheck();
refreshLicenseStatus().then(() => {
  refreshAll().then(() => ensureAutoQualityScan());
});
