const SECTION_GROUPS = [
  ["Ground", [
    ["neutral_a", "Neutral A", "attack"],
    ["side_a", "Side A", "attack"],
    ["up_a", "Up A", "attack"],
    ["down_a", "Down A", "attack"],
    ["dash_attack", "Dash Attack", "attack"],
  ]],
  ["Specials", [
    ["neutral_b", "Neutral B", "special"],
    ["side_b", "Side B", "special"],
    ["up_b", "Up B", "special"],
    ["down_b", "Down B", "special"],
  ]],
  ["Aerials", [
    ["neutral_air", "Neutral Air", "air"],
    ["forward_air", "Forward Air", "air"],
    ["back_air", "Back Air", "air"],
    ["up_air", "Up Air", "air"],
    ["down_air", "Down Air", "air"],
  ]],
  ["Throws", [
    ["grab", "Grab", "throw"],
    ["pummel", "Pummel", "throw"],
    ["forward_throw", "Forward Throw", "throw"],
    ["back_throw", "Back Throw", "throw"],
    ["up_throw", "Up Throw", "throw"],
    ["down_throw", "Down Throw", "throw"],
  ]],
  ["Other", [
    ["skins", "Skins", "other"],
    ["mechanic_notes", "Mechanic Notes", "other"],
    ["misc_notes", "Misc Notes", "other"],
  ]],
];

const CONCEPT_SECTION_KEY = "concept_gallery";
const RELEASE_ORDER = ["unassigned", "OG", "Unlockables", "DLC", "Undecided"];
const RELEASE_ASSIGNMENTS = ["OG", "Unlockables", "DLC", "Undecided"];
const RELEASE_LABELS = {
  unassigned: "Unassigned",
  OG: "OGs",
  Unlockables: "Unlockables",
  DLC: "DLC",
  Undecided: "Undecided",
};
const RELEASE_BUTTON_CLASS = {
  OG: "bucket-og",
  Unlockables: "bucket-unlockables",
  DLC: "bucket-dlc",
  Undecided: "bucket-undecided",
};
const APPROVAL_OPTIONS = ["approved", "unapproved"];
const APPROVAL_LABELS = {
  approved: "Approved",
  unapproved: "Unapproved",
};
const APPROVAL_BUTTON_CLASS = {
  approved: "approval-approved",
  unapproved: "approval-unapproved",
};
const SECTION_TYPE_BY_KEY = Object.fromEntries(
  SECTION_GROUPS.flatMap(([, sections]) => sections.map(([sectionKey, , sectionType]) => [sectionKey, sectionType])),
);
const COMPLETION_TYPE_ORDER = ["attack", "special", "air", "throw", "other"];
const TOTAL_SECTION_COUNT = Object.keys(SECTION_TYPE_BY_KEY).length;

const state = {
  snapshot: null,
  selectedSlug: null,
  query: "",
  selectedExportFormat: "text",
  saveTimers: new Map(),
  metaSaveTimers: new Map(),
  dirty: new Set(),
  saving: new Set(),
  errors: new Map(),
  contextSlug: null,
  deleteTargetSlug: null,
  deleteCountdown: null,
  deleteCountdownValue: 0,
};

const characterSearch = document.getElementById("characterSearch");
const characterList = document.getElementById("characterList");
const rosterSummary = document.getElementById("rosterSummary");
const characterMeta = document.getElementById("characterMeta");
const characterTitle = document.getElementById("characterTitle");
const notesContent = document.getElementById("notesContent");
const saveState = document.getElementById("saveState");
const loadArchiveBtn = document.getElementById("loadArchiveBtn");
const exportArchiveBtn = document.getElementById("exportArchiveBtn");
const contextMenu = document.getElementById("characterContextMenu");
const contextRenameBtn = document.getElementById("contextRenameBtn");
const contextDeleteBtn = document.getElementById("contextDeleteBtn");
const renameModal = document.getElementById("renameModal");
const renameModalMeta = document.getElementById("renameModalMeta");
const renameInput = document.getElementById("renameInput");
const renameCancelBtn = document.getElementById("renameCancelBtn");
const renameSaveBtn = document.getElementById("renameSaveBtn");
const createModal = document.getElementById("createModal");
const createInput = document.getElementById("createInput");
const createCancelBtn = document.getElementById("createCancelBtn");
const createSaveBtn = document.getElementById("createSaveBtn");
const addCharacterBtn = document.getElementById("addCharacterBtn");
const deleteModal = document.getElementById("deleteModal");
const deleteModalMeta = document.getElementById("deleteModalMeta");
const deleteCancelBtn = document.getElementById("deleteCancelBtn");
const deleteConfirmBtn = document.getElementById("deleteConfirmBtn");
const exportModal = document.getElementById("exportModal");
const exportOptionGrid = document.getElementById("exportOptionGrid");
const exportModalStatus = document.getElementById("exportModalStatus");
const exportCancelBtn = document.getElementById("exportCancelBtn");
const exportRunBtn = document.getElementById("exportRunBtn");

function desktopBridge() {
  return window.notesDesktop || null;
}

function escapeHtml(value) {
  return String(value ?? "")
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

async function apiRequest(url, options = {}) {
  const response = await fetch(url, options);
  const payload = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null;
  if (!response.ok || (payload && payload.ok === false)) {
    throw new Error(payload?.error || `Request failed: ${response.status}`);
  }
  return payload;
}

function allCharacters() {
  return state.snapshot?.characters || [];
}

function allEntries() {
  return state.snapshot?.entries || [];
}

function allMedia() {
  return state.snapshot?.media || [];
}

function currentCharacterRow(slug) {
  return allCharacters().find((row) => row.slug === slug) || null;
}

function parseTags(tags) {
  const seen = new Set();
  const result = [];
  for (const raw of String(tags || "").split(",")) {
    const value = raw.trim();
    if (!value) continue;
    const key = value.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(value);
  }
  return result;
}

function joinTags(tags) {
  return parseTags(tags.join(", ")).join(", ");
}

function filteredCharacters() {
  const q = state.query.trim().toLowerCase();
  const rows = [...allCharacters()].sort((a, b) => {
    const aBucket = RELEASE_ORDER.indexOf(a.release_bucket || "unassigned");
    const bBucket = RELEASE_ORDER.indexOf(b.release_bucket || "unassigned");
    if (aBucket !== bBucket) return aBucket - bBucket;
    return String(a.display_name || a.slug).localeCompare(String(b.display_name || b.slug));
  });
  if (!q) return rows;
  return rows.filter((row) => `${row.display_name || ""} ${row.slug || ""} ${row.tags || ""}`.toLowerCase().includes(q));
}

function selectedCharacter() {
  const rows = filteredCharacters();
  if (!rows.length) return null;
  if (!rows.some((row) => row.slug === state.selectedSlug)) {
    state.selectedSlug = rows[0].slug;
  }
  return rows.find((row) => row.slug === state.selectedSlug) || rows[0];
}

function getEntry(characterSlug, sectionKey) {
  return allEntries().find((row) => row.character_slug === characterSlug && row.section_key === sectionKey) || null;
}

function getMedia(characterSlug, sectionKey) {
  return allMedia().filter((row) => row.character_slug === characterSlug && row.section_key === sectionKey);
}

function upsertEntrySnapshot(row) {
  if (!state.snapshot) return;
  const index = allEntries().findIndex((entry) => entry.character_slug === row.character_slug && entry.section_key === row.section_key);
  if (index >= 0) {
    state.snapshot.entries[index] = {
      ...state.snapshot.entries[index],
      ...row,
    };
  } else {
    state.snapshot.entries.push(row);
  }
}

function characterCompletion(characterSlug) {
  const counts = {
    attack: 0,
    special: 0,
    air: 0,
    throw: 0,
    other: 0,
  };
  const seenKeys = new Set();
  for (const entry of allEntries()) {
    if (entry.character_slug !== characterSlug) continue;
    if (!String(entry.notes || "").trim()) continue;
    if (seenKeys.has(entry.section_key)) continue;
    const sectionType = SECTION_TYPE_BY_KEY[entry.section_key];
    if (!sectionType || !(sectionType in counts)) continue;
    counts[sectionType] += 1;
    seenKeys.add(entry.section_key);
  }

  const completedCount = Object.values(counts).reduce((sum, value) => sum + value, 0);
  const percent = TOTAL_SECTION_COUNT ? Math.round((completedCount / TOTAL_SECTION_COUNT) * 100) : 0;
  const segments = COMPLETION_TYPE_ORDER
    .filter((type) => counts[type] > 0)
    .map((type) => ({
      type,
      width: (counts[type] / TOTAL_SECTION_COUNT) * 100,
    }));

  return {
    completedCount,
    percent,
    segments,
  };
}

function statusKey(characterSlug, sectionKey) {
  return `${characterSlug}:${sectionKey}`;
}

function setGlobalSaveState(text, kind = "") {
  saveState.textContent = text;
  saveState.className = `save-state${kind ? ` ${kind}` : ""}`;
}

function setInlineStatus(characterSlug, sectionKey) {
  const key = statusKey(characterSlug, sectionKey);
  const node = document.querySelector(`[data-move-status="${CSS.escape(key)}"]`);
  if (!node) return;

  let text = "Saved";
  let kind = "saved";
  if (state.errors.has(key)) {
    text = state.errors.get(key);
    kind = "error";
  } else if (state.saving.has(key)) {
    text = "Saving...";
    kind = "saving";
  } else if (state.dirty.has(key)) {
    text = "Unsaved";
    kind = "dirty";
  } else if (!getEntry(characterSlug, sectionKey)?.notes?.trim()) {
    text = "Empty";
    kind = "";
  }

  node.textContent = text;
  node.className = `move-status${kind ? ` ${kind}` : ""}`;
}

function sectionTheme(sectionType) {
  if (sectionType === "attack") return "theme-a";
  if (sectionType === "special") return "theme-b";
  if (sectionType === "air") return "theme-air";
  if (sectionType === "throw") return "theme-throw";
  return "theme-other";
}

function groupThemeClass(sections) {
  return `${sectionTheme(sections[0]?.[2] || "other")}-group`;
}

function groupTitleThemeClass(sections) {
  return `${sectionTheme(sections[0]?.[2] || "other")}-text`;
}

function hideContextMenu() {
  contextMenu.classList.add("hidden");
  document.querySelectorAll(".character-item.context-open").forEach((node) => node.classList.remove("context-open"));
}

function showContextMenu(slug, x, y) {
  state.contextSlug = slug;
  hideContextMenu();
  const item = document.querySelector(`[data-character-select="${CSS.escape(slug)}"]`);
  item?.classList.add("context-open");
  contextMenu.style.left = `${x}px`;
  contextMenu.style.top = `${y}px`;
  contextMenu.classList.remove("hidden");
}

function openRenameModal(slug) {
  const character = currentCharacterRow(slug);
  if (!character) return;
  state.contextSlug = slug;
  renameModalMeta.textContent = `Rename ${character.display_name || character.slug}`;
  renameInput.value = character.display_name || character.slug;
  renameModal.classList.remove("hidden");
  renameInput.focus();
  renameInput.select();
}

function closeRenameModal() {
  renameModal.classList.add("hidden");
}

function openCreateModal() {
  createInput.value = "";
  createModal.classList.remove("hidden");
  createInput.focus();
}

function closeCreateModal() {
  createModal.classList.add("hidden");
}

function clearDeleteCountdown() {
  if (state.deleteCountdown) {
    window.clearInterval(state.deleteCountdown);
    state.deleteCountdown = null;
  }
}

function updateDeleteButton() {
  if (state.deleteCountdownValue > 0) {
    deleteConfirmBtn.disabled = true;
    deleteConfirmBtn.textContent = `Delete in ${state.deleteCountdownValue}`;
  } else {
    deleteConfirmBtn.disabled = false;
    deleteConfirmBtn.textContent = "Delete character";
  }
}

function openDeleteModal(slug) {
  const character = currentCharacterRow(slug);
  if (!character) return;
  state.deleteTargetSlug = slug;
  state.deleteCountdownValue = 3;
  deleteModalMeta.textContent = `Are you sure you want to delete ${character.display_name || character.slug}?`;
  updateDeleteButton();
  deleteModal.classList.remove("hidden");
  clearDeleteCountdown();
  state.deleteCountdown = window.setInterval(() => {
    state.deleteCountdownValue -= 1;
    updateDeleteButton();
    if (state.deleteCountdownValue <= 0) {
      clearDeleteCountdown();
    }
  }, 1000);
}

function closeDeleteModal() {
  clearDeleteCountdown();
  state.deleteTargetSlug = null;
  deleteModal.classList.add("hidden");
}

function syncExportOptions() {
  document.querySelectorAll("[data-export-format]").forEach((button) => {
    button.classList.toggle("active", button.dataset.exportFormat === state.selectedExportFormat);
  });
}

function setExportStatus(text) {
  exportModalStatus.textContent = text;
}

function openExportModal() {
  state.selectedExportFormat = "text";
  syncExportOptions();
  setExportStatus("Choose a format, then export. Finder will open when it finishes.");
  exportRunBtn.disabled = false;
  exportModal.classList.remove("hidden");
}

function closeExportModal() {
  exportModal.classList.add("hidden");
}

function renderSidebar() {
  const rows = filteredCharacters();
  const groups = new Map(RELEASE_ORDER.map((bucket) => [bucket, []]));
  for (const row of rows) {
    const bucket = groups.has(row.release_bucket) ? row.release_bucket : "unassigned";
    groups.get(bucket).push(row);
  }

  const totalCount = allCharacters().length;
  const officialCount = allCharacters().filter((row) => !["unassigned", "Undecided"].includes(row.release_bucket || "unassigned")).length;
  const filteredCount = rows.length;
  rosterSummary.innerHTML = `
    <span class="roster-stat">${totalCount} total</span>
    <span class="roster-separator">·</span>
    <span class="roster-stat">${officialCount} official</span>
    ${filteredCount !== totalCount ? `<span class="roster-separator">·</span><span class="roster-stat">${filteredCount} shown</span>` : ""}
  `;

  characterList.innerHTML = RELEASE_ORDER.map((bucket) => {
    const rowsForBucket = groups.get(bucket) || [];
    return `
      <section class="character-group bucket-${bucket.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")} ${rowsForBucket.length ? "" : "is-empty"}">
        <div class="group-head">
          <div class="group-label">${escapeHtml(RELEASE_LABELS[bucket] || bucket)}</div>
          <div class="group-count">${rowsForBucket.length}</div>
        </div>
        ${rowsForBucket.map((row) => `
          ${(() => {
            const completion = characterCompletion(row.slug);
            return `
              <button class="character-item ${row.slug === state.selectedSlug ? "active" : ""}" data-character-select="${escapeHtml(row.slug)}" type="button">
                ${completion.completedCount > 0 ? `
                  <span class="character-progress" aria-hidden="true">
                    ${completion.segments.map((segment) => `
                      <span class="character-progress-segment progress-${segment.type}" style="width:${segment.width.toFixed(3)}%"></span>
                    `).join("")}
                  </span>
                ` : ``}
                <span class="character-name">${escapeHtml(row.display_name || row.slug)}</span>
                <span class="character-item-meta">
                  ${row.approval_status === "approved" ? `<span class="character-approval-check" aria-label="Approved" title="Approved">✓</span>` : ``}
                  ${completion.percent > 0 ? `<span class="character-progress-value">${completion.percent}%</span>` : ``}
                </span>
              </button>
            `;
          })()}
        `).join("") || `<div class="group-empty">No characters</div>`}
      </section>
    `;
  }).join("") || `<div class="empty-state">No characters found.</div>`;
}

function renderMediaCard(row) {
  const preview = row.mime_type?.startsWith("image/")
    ? `<img src="${row.file_url}" alt="${escapeHtml(row.label || row.original_filename)}" />`
    : row.mime_type?.startsWith("video/")
      ? `<video src="${row.file_url}" controls preload="metadata"></video>`
      : `<a class="media-link" href="${row.file_url}" target="_blank" rel="noreferrer">Open</a>`;
  const copyAction = row.mime_type?.startsWith("image/")
    ? `<button class="media-copy" data-copy-media="${row.id}" type="button">Copy</button>`
    : "";

  return `
    <article class="media-card">
      <div class="media-preview">${preview}</div>
      <div class="media-name">${escapeHtml(row.label || row.original_filename)}</div>
      <div class="media-meta">${escapeHtml(row.mime_type || "file")}</div>
      <div class="media-actions">
        <a class="media-link" href="${row.file_url}" target="_blank" rel="noreferrer">Open</a>
        ${copyAction}
        <button class="media-delete" data-delete-media="${row.id}" type="button">Remove</button>
      </div>
    </article>
  `;
}

function renderConceptGallery(character) {
  const media = getMedia(character.slug, CONCEPT_SECTION_KEY);
  return `
    <details class="concept-gallery" ${media.length ? "open" : ""}>
      <summary class="concept-summary">
        <span>Concept Art Gallery</span>
        <span class="move-help">${media.length} item${media.length === 1 ? "" : "s"}</span>
      </summary>
      <div class="concept-body">
        <div class="upload-row">
          <label class="upload-label">
            Add concept art
            <input class="upload-input" data-upload-input="${CONCEPT_SECTION_KEY}" type="file" accept="image/*,video/*,.gif" />
          </label>
          <span class="move-help">Use this for main concept art and reference images.</span>
        </div>
        ${media.length ? `<div class="media-list">${media.map(renderMediaCard).join("")}</div>` : `<div class="media-empty">No concept art uploaded.</div>`}
      </div>
    </details>
  `;
}

function renderSection(character, sectionKey, sectionLabel, sectionType, openByDefault = false) {
  const entry = getEntry(character.slug, sectionKey);
  const media = getMedia(character.slug, sectionKey);
  const key = statusKey(character.slug, sectionKey);
  const shouldOpen = openByDefault || Boolean(entry?.notes?.trim()) || media.length > 0;

  return `
    <details class="move-section ${sectionTheme(sectionType)}" ${shouldOpen ? "open" : ""}>
      <summary class="move-summary">
        <span class="move-title">${escapeHtml(sectionLabel)}</span>
        <span class="move-status" data-move-status="${escapeHtml(key)}">${entry?.notes?.trim() ? "Saved" : "Empty"}</span>
      </summary>
      <div class="move-body">
        <div class="move-toolbar">
          <div class="move-help">${escapeHtml(sectionType)}</div>
          <div class="move-help">Autosaves after typing stops</div>
        </div>
        <textarea class="move-notes" data-section-input="${escapeHtml(sectionKey)}" data-section-label="${escapeHtml(sectionLabel)}" data-section-type="${escapeHtml(sectionType)}" placeholder="Type notes here...">${escapeHtml(entry?.notes || "")}</textarea>
        <div class="upload-row">
          <label class="upload-label">
            Upload preview
            <input class="upload-input" data-upload-input="${escapeHtml(sectionKey)}" type="file" accept="image/*,video/*,.gif" />
          </label>
          <span class="move-help">Images, GIFs, and videos supported.</span>
        </div>
        ${media.length ? `<div class="media-list">${media.map(renderMediaCard).join("")}</div>` : `<div class="media-empty">No previews uploaded.</div>`}
      </div>
    </details>
  `;
}

function renderCharacter() {
  const current = selectedCharacter();
  if (!current) {
    characterMeta.textContent = "No character selected";
    characterTitle.textContent = "Moveset Notes";
    notesContent.innerHTML = `<div class="empty-state">No character selected.</div>`;
    return;
  }

  characterMeta.textContent = `${RELEASE_LABELS[current.release_bucket || "unassigned"] || current.release_bucket || "unassigned"} · ${current.roster_group || "No group"}`;
  characterTitle.textContent = current.display_name || current.slug;
  const tags = parseTags(current.tags);

  notesContent.innerHTML = `
    <div class="character-top">
      <section class="character-main-card">
        <div class="character-control-row">
          <div class="control-group">
            <div class="control-group-title">Roster</div>
            <div class="bucket-actions">
              ${RELEASE_ASSIGNMENTS.map((bucket) => `
                <button class="bucket-button ${RELEASE_BUTTON_CLASS[bucket] || ""} ${current.release_bucket === bucket ? "active" : ""}" data-release-bucket="${bucket}" type="button">${bucket}</button>
              `).join("")}
            </div>
            <label class="control-group-title" for="unlockRuleInput">Unlock Rule</label>
            <textarea id="unlockRuleInput" class="unlock-rule-input" data-unlock-rule-input="true" rows="2" placeholder="How this character unlocks (requirements, conditions, milestones).">${escapeHtml(current.unlock_notes || "")}</textarea>
          </div>
          <div class="control-group control-group-right">
            <div class="control-group-title">Owner Approval</div>
            <div class="bucket-actions">
              ${APPROVAL_OPTIONS.map((status) => `
                <button class="bucket-button ${APPROVAL_BUTTON_CLASS[status] || ""} ${current.approval_status === status ? "active" : ""}" data-approval-status="${status}" type="button">${APPROVAL_LABELS[status]}</button>
              `).join("")}
            </div>
          </div>
        </div>
        <div class="move-help">Set roster bucket on the left and owner approval on the right. Nothing is approved unless you explicitly change it.</div>
      </section>
      <aside class="character-side-card">
        <div class="side-title">Character</div>
        <div class="meta-list">
          <div class="meta-row">
            <span class="meta-label">Slug</span>
            <span class="meta-value">${escapeHtml(current.slug)}</span>
          </div>
          <div class="meta-row">
            <span class="meta-label">Approval</span>
            <span class="meta-value">${escapeHtml(APPROVAL_LABELS[current.approval_status || "unapproved"] || "Unapproved")}</span>
          </div>
          <div class="meta-row">
            <span class="meta-label">Source</span>
            <span class="meta-value">${escapeHtml(current.source_type || "manual")}</span>
          </div>
        </div>
        <div class="side-title">Tags</div>
        <div class="tag-list">
          ${tags.length
            ? tags.map((tag) => `
              <span class="tag-chip">
                ${escapeHtml(tag)}
                <button class="tag-chip-remove" data-remove-tag="${escapeHtml(tag)}" type="button">x</button>
              </span>
            `).join("")
            : `<span class="move-help">No tags yet.</span>`}
        </div>
        <div class="tag-input-row">
          <input id="tagInput" class="tag-input" type="text" placeholder="Add tag" />
          <button id="tagAddBtn" class="tag-add-button" type="button">Add</button>
        </div>
        <div class="move-help">Press Enter or Add. Search on the left matches tags too.</div>
      </aside>
    </div>
    ${renderConceptGallery(current)}
    <div class="sections-stack">
      ${SECTION_GROUPS.map(([groupLabel, sections]) => `
        <section class="section-group-shell ${groupThemeClass(sections)}">
          <div class="section-group-title ${groupTitleThemeClass(sections)}">${escapeHtml(groupLabel)}</div>
          ${sections.map((section, index) => renderSection(current, section[0], section[1], section[2], index === 0)).join("")}
        </section>
      `).join("")}
    </div>
  `;

  for (const [, sections] of SECTION_GROUPS) {
    for (const [sectionKey] of sections) {
      setInlineStatus(current.slug, sectionKey);
    }
  }
}

function renderAll() {
  renderSidebar();
  renderCharacter();
  bindActions();
}

async function loadSnapshot({ preserveSelection = true } = {}) {
  const previous = state.selectedSlug;
  const snapshot = await apiRequest("/api/notes/bootstrap");
  state.snapshot = snapshot;
  if (preserveSelection && previous && snapshot.characters.some((row) => row.slug === previous)) {
    state.selectedSlug = previous;
  } else {
    state.selectedSlug = snapshot.characters[0]?.slug || null;
  }
  renderAll();
  setGlobalSaveState("Ready", "saved");
}

async function persistEntry(characterSlug, sectionKey, sectionLabel, sectionType, notes) {
  const key = statusKey(characterSlug, sectionKey);
  state.saving.add(key);
  state.errors.delete(key);
  setGlobalSaveState("Saving...", "saving");
  setInlineStatus(characterSlug, sectionKey);

  try {
    const result = await apiRequest("/api/notes/entries", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        character_slug: characterSlug,
        section_key: sectionKey,
        section_label: sectionLabel,
        section_type: sectionType,
        notes,
      }),
    });
    upsertEntrySnapshot(result.row);
    state.dirty.delete(key);
    renderSidebar();
    bindActions();
    setGlobalSaveState("Saved", "saved");
  } catch (error) {
    state.errors.set(key, error.message);
    setGlobalSaveState(error.message, "error");
  } finally {
    state.saving.delete(key);
    setInlineStatus(characterSlug, sectionKey);
  }
}

function queueSave(characterSlug, sectionKey, sectionLabel, sectionType, notes) {
  const key = statusKey(characterSlug, sectionKey);
  upsertEntrySnapshot({
    character_slug: characterSlug,
    section_key: sectionKey,
    section_label: sectionLabel,
    section_type: sectionType,
    notes,
  });
  state.dirty.add(key);
  state.errors.delete(key);
  renderSidebar();
  bindActions();
  setGlobalSaveState("Unsaved changes", "saving");
  setInlineStatus(characterSlug, sectionKey);

  const existingTimer = state.saveTimers.get(key);
  if (existingTimer) clearTimeout(existingTimer);
  const timer = window.setTimeout(() => {
    state.saveTimers.delete(key);
    persistEntry(characterSlug, sectionKey, sectionLabel, sectionType, notes);
  }, 450);
  state.saveTimers.set(key, timer);
}

function queueCharacterMetaSave(slug, fieldName, value) {
  const key = `meta:${slug}:${fieldName}`;
  const current = currentCharacterRow(slug);
  if (current) {
    current[fieldName] = value;
  }
  setGlobalSaveState("Unsaved changes", "saving");

  const existingTimer = state.metaSaveTimers.get(key);
  if (existingTimer) clearTimeout(existingTimer);
  const timer = window.setTimeout(async () => {
    state.metaSaveTimers.delete(key);
    try {
      await updateCharacterMeta(slug, {
        [fieldName]: value,
      });
    } catch (error) {
      setGlobalSaveState(error.message, "error");
    }
  }, 450);
  state.metaSaveTimers.set(key, timer);
}

async function updateCharacterMeta(slug, payload) {
  setGlobalSaveState("Updating character...", "saving");
  const current = currentCharacterRow(slug);
  const merged = {
    display_name: payload.display_name ?? current?.display_name ?? slug,
    approval_status: payload.approval_status ?? current?.approval_status ?? "unapproved",
    release_bucket: payload.release_bucket ?? current?.release_bucket ?? "unassigned",
    tags: payload.tags ?? current?.tags ?? "",
    unlock_notes: payload.unlock_notes ?? current?.unlock_notes ?? "",
  };
  const result = await apiRequest(`/api/notes/characters/${encodeURIComponent(slug)}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(merged),
  });
  const index = allCharacters().findIndex((row) => row.slug === slug);
  if (index >= 0) {
    state.snapshot.characters[index] = {
      ...state.snapshot.characters[index],
      display_name: result.row.display_name,
      tags: result.row.tags || "",
      approval_status: result.row.approval_status || "unapproved",
      release_bucket: result.row.release_bucket,
      unlock_notes: result.row.unlock_notes || "",
      review_status: result.row.review_status,
      roster_group: result.row.roster_group,
      source_type: result.row.source_type,
    };
  }
  renderAll();
  setGlobalSaveState("Saved", "saved");
}

async function createCharacter(displayName) {
  setGlobalSaveState("Creating character...", "saving");
  const result = await apiRequest("/api/notes/characters", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      display_name: displayName,
      release_bucket: "unassigned",
    }),
  });
  state.snapshot.characters.push({
    slug: result.row.slug,
    display_name: result.row.display_name,
    tags: result.row.tags || "",
    roster_group: result.row.roster_group || "",
    approval_status: result.row.approval_status || "unapproved",
    release_bucket: result.row.release_bucket || "unassigned",
    source_type: result.row.source_type || "manual",
    review_status: result.row.review_status || "approved",
    unlock_notes: result.row.unlock_notes || "",
    is_public: result.row.is_public,
    sort_order: result.row.sort_order || 0,
  });
  state.selectedSlug = result.row.slug;
  renderAll();
  setGlobalSaveState("Saved", "saved");
}

async function uploadMedia(characterSlug, sectionKey, file) {
  const formData = new FormData();
  formData.set("character_slug", characterSlug);
  formData.set("section_key", sectionKey);
  formData.set("label", file.name);
  formData.set("file", file);
  setGlobalSaveState(`Uploading ${file.name}...`, "saving");
  try {
    const result = await apiRequest("/api/notes/media", { method: "POST", body: formData });
    state.snapshot.media.unshift(result.asset);
    renderCharacter();
    bindActions();
    setGlobalSaveState("Upload complete", "saved");
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
}

async function removeMedia(id) {
  setGlobalSaveState("Removing preview...", "saving");
  try {
    await apiRequest(`/api/notes/media/${id}`, { method: "DELETE" });
    state.snapshot.media = allMedia().filter((row) => String(row.id) !== String(id));
    renderCharacter();
    bindActions();
    setGlobalSaveState("Removed", "saved");
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
}

async function blobToPng(blob) {
  const objectUrl = URL.createObjectURL(blob);
  try {
    const image = await new Promise((resolve, reject) => {
      const node = new Image();
      node.onload = () => resolve(node);
      node.onerror = () => reject(new Error("Couldn't read the image for clipboard copy."));
      node.src = objectUrl;
    });
    const canvas = document.createElement("canvas");
    canvas.width = image.naturalWidth || image.width;
    canvas.height = image.naturalHeight || image.height;
    const context = canvas.getContext("2d");
    if (!context) {
      throw new Error("Canvas copy support isn't available here.");
    }
    context.drawImage(image, 0, 0);
    const pngBlob = await new Promise((resolve, reject) => {
      canvas.toBlob((value) => {
        if (value) {
          resolve(value);
        } else {
          reject(new Error("Couldn't convert the image for clipboard copy."));
        }
      }, "image/png");
    });
    return pngBlob;
  } finally {
    URL.revokeObjectURL(objectUrl);
  }
}

async function copyMedia(id) {
  const row = allMedia().find((media) => String(media.id) === String(id));
  if (!row || !row.mime_type?.startsWith("image/")) {
    setGlobalSaveState("Only uploaded images can be copied.", "error");
    return;
  }
  if (!navigator.clipboard?.write || typeof window.ClipboardItem === "undefined") {
    setGlobalSaveState("Image copy isn't supported in this browser/app.", "error");
    return;
  }

  setGlobalSaveState("Copying image...", "saving");
  try {
    const response = await fetch(row.file_url);
    if (!response.ok) {
      throw new Error(`Couldn't load the image (${response.status}).`);
    }
    let blob = await response.blob();
    let mimeType = blob.type || row.mime_type || "image/png";

    try {
      await navigator.clipboard.write([
        new window.ClipboardItem({
          [mimeType]: blob,
        }),
      ]);
    } catch (_error) {
      blob = await blobToPng(blob);
      mimeType = "image/png";
      await navigator.clipboard.write([
        new window.ClipboardItem({
          [mimeType]: blob,
        }),
      ]);
    }

    setGlobalSaveState("Copied image", "saved");
  } catch (error) {
    setGlobalSaveState(error.message || "Couldn't copy the image.", "error");
  }
}

async function deleteCharacterConfirmed() {
  if (!state.deleteTargetSlug) return;
  const slug = state.deleteTargetSlug;
  setGlobalSaveState("Deleting character...", "saving");
  try {
    await apiRequest(`/api/notes/characters/${encodeURIComponent(slug)}`, { method: "DELETE" });
    state.snapshot.characters = allCharacters().filter((row) => row.slug !== slug);
    state.snapshot.entries = allEntries().filter((row) => row.character_slug !== slug);
    state.snapshot.media = allMedia().filter((row) => row.character_slug !== slug);
    closeDeleteModal();
    hideContextMenu();
    state.selectedSlug = filteredCharacters()[0]?.slug || state.snapshot.characters[0]?.slug || null;
    renderAll();
    setGlobalSaveState("Character deleted", "saved");
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
}

function bindActions() {
  document.querySelectorAll("[data-character-select]").forEach((button) => {
    button.onclick = () => {
      state.selectedSlug = button.dataset.characterSelect;
      hideContextMenu();
      renderAll();
    };

    button.oncontextmenu = (event) => {
      event.preventDefault();
      state.selectedSlug = button.dataset.characterSelect;
      renderAll();
      showContextMenu(button.dataset.characterSelect, event.clientX, event.clientY);
    };
  });

  document.querySelectorAll("[data-release-bucket]").forEach((button) => {
    button.onclick = async () => {
      const current = selectedCharacter();
      if (!current) return;
      try {
        await updateCharacterMeta(current.slug, {
          release_bucket: button.dataset.releaseBucket,
        });
      } catch (error) {
        setGlobalSaveState(error.message, "error");
      }
    };
  });

  document.querySelectorAll("[data-approval-status]").forEach((button) => {
    button.onclick = async () => {
      const current = selectedCharacter();
      if (!current) return;
      try {
        await updateCharacterMeta(current.slug, {
          approval_status: button.dataset.approvalStatus,
        });
      } catch (error) {
        setGlobalSaveState(error.message, "error");
      }
    };
  });

  document.querySelectorAll("[data-section-input]").forEach((textarea) => {
    textarea.oninput = () => {
      const current = selectedCharacter();
      if (!current) return;
      queueSave(
        current.slug,
        textarea.dataset.sectionInput,
        textarea.dataset.sectionLabel,
        textarea.dataset.sectionType,
        textarea.value
      );
    };
  });

  document.querySelectorAll("[data-upload-input]").forEach((input) => {
    input.onchange = () => {
      const current = selectedCharacter();
      const [file] = input.files || [];
      if (!current || !file) return;
      uploadMedia(current.slug, input.dataset.uploadInput, file);
      input.value = "";
    };
  });

  const unlockRuleInput = document.querySelector("[data-unlock-rule-input]");
  if (unlockRuleInput) {
    unlockRuleInput.oninput = () => {
      const current = selectedCharacter();
      if (!current) return;
      queueCharacterMetaSave(current.slug, "unlock_notes", unlockRuleInput.value);
    };
  }

  document.querySelectorAll("[data-delete-media]").forEach((button) => {
    button.onclick = () => removeMedia(button.dataset.deleteMedia);
  });

  document.querySelectorAll("[data-copy-media]").forEach((button) => {
    button.onclick = async () => {
      button.disabled = true;
      try {
        await copyMedia(button.dataset.copyMedia);
      } finally {
        button.disabled = false;
      }
    };
  });

  document.querySelectorAll("[data-remove-tag]").forEach((button) => {
    button.onclick = async () => {
      const current = selectedCharacter();
      if (!current) return;
      const nextTags = parseTags(current.tags).filter((tag) => tag.toLowerCase() !== button.dataset.removeTag.toLowerCase());
      try {
        await updateCharacterMeta(current.slug, { tags: joinTags(nextTags) });
      } catch (error) {
        setGlobalSaveState(error.message, "error");
      }
    };
  });

  const tagInput = document.getElementById("tagInput");
  const tagAddBtn = document.getElementById("tagAddBtn");
  const commitAddTag = async () => {
    const current = selectedCharacter();
    if (!current || !tagInput) return;
    const value = tagInput.value.trim().replace(/,+$/g, "");
    if (!value) return;
    const nextTags = parseTags(`${current.tags || ""}, ${value}`);
    try {
      await updateCharacterMeta(current.slug, { tags: joinTags(nextTags) });
      tagInput.value = "";
    } catch (error) {
      setGlobalSaveState(error.message, "error");
    }
  };

  if (tagAddBtn) {
    tagAddBtn.onclick = commitAddTag;
  }
  if (tagInput) {
    tagInput.onkeydown = (event) => {
      if (event.key === "Enter" || event.key === ",") {
        event.preventDefault();
        commitAddTag();
      }
    };
  }

  document.querySelectorAll("[data-export-format]").forEach((button) => {
    button.onclick = () => {
      state.selectedExportFormat = button.dataset.exportFormat || "text";
      syncExportOptions();
    };
  });
}

contextRenameBtn.onclick = () => {
  hideContextMenu();
  if (state.contextSlug) {
    openRenameModal(state.contextSlug);
  }
};

contextDeleteBtn.onclick = () => {
  hideContextMenu();
  if (state.contextSlug) {
    openDeleteModal(state.contextSlug);
  }
};

renameCancelBtn.onclick = closeRenameModal;
renameSaveBtn.onclick = async () => {
  const slug = state.contextSlug;
  const value = renameInput.value.trim();
  if (!slug || !value) return;
  try {
    await updateCharacterMeta(slug, { display_name: value });
    closeRenameModal();
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
};

createCancelBtn.onclick = closeCreateModal;
createSaveBtn.onclick = async () => {
  const value = createInput.value.trim();
  if (!value) return;
  try {
    await createCharacter(value);
    closeCreateModal();
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
};

addCharacterBtn.onclick = openCreateModal;
exportArchiveBtn.onclick = () => {
  openExportModal();
};
loadArchiveBtn.onclick = async () => {
  if (!desktopBridge()?.importArchive) {
    setGlobalSaveState("Load is available in the Mac app only.", "error");
    return;
  }
  try {
    setGlobalSaveState("Loading archive...", "saving");
    const result = await desktopBridge().importArchive();
    if (result?.canceled) {
      setGlobalSaveState("Ready", "saved");
      return;
    }
    await loadSnapshot({ preserveSelection: true });
    setGlobalSaveState(`Loaded ${result.imported.characters} characters`, "saved");
  } catch (error) {
    setGlobalSaveState(error.message, "error");
  }
};
exportCancelBtn.onclick = closeExportModal;
exportRunBtn.onclick = async () => {
  if (!desktopBridge()?.exportArchive) {
    setExportStatus("Export is available in the Mac app only.");
    return;
  }
  try {
    exportRunBtn.disabled = true;
    setExportStatus("Exporting...");
    const result = await desktopBridge().exportArchive(state.selectedExportFormat);
    setExportStatus(`Done. Opened in Finder: ${result.output_path}`);
    setGlobalSaveState("Export complete", "saved");
    exportRunBtn.disabled = false;
  } catch (error) {
    setExportStatus(error.message);
    setGlobalSaveState(error.message, "error");
    exportRunBtn.disabled = false;
  }
};

renameInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault();
    renameSaveBtn.click();
  }
});

createInput.addEventListener("keydown", (event) => {
  if (event.key === "Enter") {
    event.preventDefault();
    createSaveBtn.click();
  }
});

deleteCancelBtn.onclick = closeDeleteModal;
deleteConfirmBtn.onclick = () => {
  if (!deleteConfirmBtn.disabled) {
    deleteCharacterConfirmed();
  }
};

characterSearch.addEventListener("input", () => {
  state.query = characterSearch.value || "";
  renderAll();
});

document.addEventListener("click", (event) => {
  if (!contextMenu.classList.contains("hidden") && !contextMenu.contains(event.target)) {
    hideContextMenu();
  }
});

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    hideContextMenu();
    if (!exportModal.classList.contains("hidden")) {
      closeExportModal();
    }
  }
});

loadSnapshot().catch((error) => {
  notesContent.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
  setGlobalSaveState(error.message, "error");
});
