/* Meetings page data binder.
 * Injected by web_shell.py after the page's static markup is parsed.
 * Calls lumo.requestMeetings() / lumo.requestMeeting(id) to replace
 * the design-time demo rows with real meeting data, and wires:
 *   - row click → load that meeting's detail
 *   - tab click → switch Summary / Transcript content
 *   - Start meeting / Stop and save → call into Python
 *   - Recording state (live timer, rec dot, btn-stop) driven by
 *     Python-side bridge pushes: recording-started, recording-tick,
 *     recording-stopped
 *   - Toolbar ★ Favorite → toggle favorite flag on the meeting
 *   - Toolbar ⋯ More → context menu w/ Archive (with confirm modal)
 *   - Open in Finder → reveal meeting folder in Finder
 *
 * Per GLOBAL RULE #1 in OPS/CLAUDE.md, no actual deletion happens. The
 * Archive action sets meta.archived = true; files stay on disk and the
 * meeting is just filtered out of the list. The confirm modal makes
 * the action intentional even though it's reversible.
 */

(function () {
  function esc(s) {
    return String(s == null ? "" : s)
      .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
  }

  function pad2(n) { n = Math.max(0, Math.floor(n)); return (n < 10 ? "0" : "") + n; }
  function fmtTimer(seconds) {
    seconds = Math.max(0, Math.floor(seconds || 0));
    var m = Math.floor(seconds / 60);
    var s = seconds - m * 60;
    return pad2(m) + ":" + pad2(s);
  }

  var STAR_FILLED =
    '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" '
    + 'style="vertical-align:-1px;margin-left:6px;color:var(--accent,#ebb638);">'
    + '<path d="M12 3l2.6 5.6 6 .7-4.4 4.1 1.2 6L12 16.8 6.6 19.4l1.2-6L3.4 9.3l6-.7z"/></svg>';

  function buildRow(m) {
    var row = document.createElement("div");
    row.className = "mrow";
    row.dataset.id = m.id;
    row.dataset.title = m.title || "";
    row.dataset.favorite = m.favorite ? "1" : "0";
    var html = "";
    html += '<span class="mrow-dot ' + esc(m.status) + '"></span>';
    html += '<div class="mrow-body">';
    html += '<div class="mrow-title">' + esc(m.title)
         + (m.favorite ? STAR_FILLED : "") + "</div>";
    html += '<div class="mrow-meta">'
         + '<span>' + esc(m.date) + '</span>'
         + '<span style="color: var(--ink-dim);">·</span>'
         + '<span>' + esc(m.dur) + '</span>'
         + '</div>';
    if (m.preview) {
      html += '<div class="mrow-preview">' + esc(m.preview) + "</div>";
    }
    html += "</div>";
    row.innerHTML = html;
    row.addEventListener("click", function () { selectMeeting(m.id); });
    return row;
  }

  function setActive(id) {
    document.querySelectorAll(".mrow").forEach(function (r) {
      r.classList.toggle("sel", r.dataset.id === id);
    });
  }

  function getActiveTab() {
    var t = document.querySelector(".tabs .tab.active");
    return t ? t.textContent.trim().toLowerCase() : "summary";
  }

  function renderDetail(m) {
    if (!m) return;
    var crumb = document.querySelector(".crumbs .here");
    if (crumb) crumb.textContent = m.title;
    var titleEl = document.querySelector(".detail-title");
    if (titleEl) {
      titleEl.innerHTML = esc(m.title) + (m.favorite ? STAR_FILLED : "");
    }
    var metaEl = document.querySelector(".detail-meta");
    if (metaEl) {
      metaEl.innerHTML =
        '<span>' + esc(m.date) + '</span>'
      + '<span class="dot">·</span>'
      + '<span>' + esc(m.dur) + '</span>';
    }
    var tab = getActiveTab();
    var body = document.querySelector(".reading .md, .reading .turns");
    if (body) {
      var html = tab === "transcript" ? m.transcript_html : m.summary_html;
      if (!html) {
        html = tab === "transcript"
          ? '<p class="lead">Transcript not available yet.</p>'
          : '<p class="lead">Summary will appear here once the LLM finishes.</p>';
      }
      if (tab === "transcript") {
        body.classList.remove("md"); body.classList.add("turns");
      } else {
        body.classList.remove("turns"); body.classList.add("md");
      }
      body.innerHTML = html;
    }
    document.body.dataset.currentMeeting = m.id;
    document.body.dataset.currentMeetingFav = m.favorite ? "1" : "0";
    document.body.dataset.currentMeetingTitle = m.title || "";
    updateFavoriteButtonState(m.favorite);
    wireSummaryRetryButton(m.id);
    wireConsentJumpLink();
    wireTranscriptRetryButton(m.id);
    wireTitleEdit(m.id);
  }

  function wireTranscriptRetryButton(meetingId) {
    // Surface in placeholder transcript HTML when no real turns exist.
    var btn = document.querySelector('[data-action="retry-transcribe"]');
    if (!btn || btn.dataset.wired === "1") return;
    btn.dataset.wired = "1";
    btn.addEventListener("click", function () {
      btn.disabled = true;
      btn.textContent = "Generating…";
      window.lumo.call("retryMeetingTranscribe", { id: meetingId })
        .then(function (res) {
          if (res && res.ok) {
            btn.textContent = "Generating… (this can take 30-120s)";
            // Poll the meeting detail every 5s until transcript lands.
            var poll = setInterval(function () {
              window.lumo.call("requestMeeting", { id: meetingId })
                .then(function (m2) {
                  // Detect "transcript actually populated" by looking
                  // for the .turn marker in transcript_html.
                  if (m2 && m2.transcript_html
                          && m2.transcript_html.indexOf('class="turn"') >= 0) {
                    clearInterval(poll);
                    selectMeeting(meetingId);
                  }
                });
            }, 5000);
            // Stop polling after 5 minutes regardless.
            setTimeout(function () { clearInterval(poll); }, 5 * 60 * 1000);
          } else {
            btn.disabled = false;
            btn.textContent = "Generate transcript now";
            alert("Couldn't start: " + ((res && res.error) || "unknown"));
          }
        })
        .catch(function (err) {
          btn.disabled = false;
          btn.textContent = "Generate transcript now";
          console.error("retryMeetingTranscribe threw:", err);
        });
    });
  }

  function wireTitleEdit(meetingId) {
    var titleEl = document.querySelector(".detail-title");
    if (!titleEl || titleEl.dataset.editableWired === "1") return;
    titleEl.dataset.editableWired = "1";
    titleEl.style.cursor = "text";
    titleEl.title = "Double-click to rename";
    titleEl.addEventListener("dblclick", function () {
      var currentText = titleEl.textContent.trim();
      // Strip trailing star glyph (visual favorite indicator).
      var input = document.createElement("input");
      input.value = currentText;
      input.style.cssText = [
        "background: transparent",
        "border: 1px solid var(--accent,#ebb638)",
        "border-radius: 6px",
        "padding: 4px 8px",
        "color: var(--text,#efeae4)",
        "font: inherit",
        "font-size: 22px",
        "font-weight: 600",
        "width: 100%",
        "outline: none",
      ].join(";");
      titleEl.innerHTML = "";
      titleEl.appendChild(input);
      input.focus();
      input.select();
      var committed = false;
      function commit() {
        if (committed) return;
        committed = true;
        var newTitle = input.value.trim();
        if (newTitle && newTitle !== currentText) {
          window.lumo.call("updateMeetingTitle",
                           { id: meetingId, title: newTitle })
            .then(function (res) {
              if (res && res.ok) {
                // Refresh selection + list so the new title is visible.
                loadMeetings({ selectId: meetingId });
              } else {
                titleEl.textContent = currentText;
                alert("Couldn't rename: " + ((res && res.error) || "unknown"));
              }
            })
            .catch(function (err) {
              titleEl.textContent = currentText;
              console.error("updateMeetingTitle threw:", err);
            });
        } else {
          titleEl.textContent = currentText;
        }
      }
      input.addEventListener("blur", commit);
      input.addEventListener("keydown", function (e) {
        if (e.key === "Enter") { e.preventDefault(); commit(); }
        if (e.key === "Escape") {
          committed = true;
          titleEl.textContent = currentText;
        }
      });
    });
  }

  function wireSummaryRetryButton(meetingId) {
    // CR #14 — the placeholder summary HTML from web_handlers may include
    // <button data-action="retry-summary">. Wire it after each render so a
    // single click re-kicks summarize_meeting_async on the backend.
    var btn = document.querySelector('[data-action="retry-summary"]');
    if (!btn || btn.dataset.wired === "1") return;
    btn.dataset.wired = "1";
    btn.addEventListener("click", function () {
      btn.disabled = true;
      btn.textContent = "Retrying…";
      window.lumo.call("retryMeetingSummary", { id: meetingId })
        .then(function (res) {
          if (res && res.ok) {
            btn.textContent = "Retrying… (refresh shortly)";
            setTimeout(function () { selectMeeting(meetingId); }, 4000);
          } else {
            btn.disabled = false;
            btn.textContent = "Retry summary";
            alert("Retry failed: " + ((res && res.error) || "unknown"));
          }
        })
        .catch(function (err) {
          btn.disabled = false;
          btn.textContent = "Retry summary";
          console.error("retryMeetingSummary threw:", err);
        });
    });
  }

  function wireConsentJumpLink() {
    // Audit #5 placeholder includes a "Settings" link to jump to the
    // cloud-summary toggle. Catch the click and switch sections.
    var a = document.querySelector('[data-jump-settings]');
    if (!a || a.dataset.wired === "1") return;
    a.dataset.wired = "1";
    a.addEventListener("click", function (e) {
      e.preventDefault();
      window.lumo.call("switchSection", { id: "settings" })
        .catch(function (err) { console.error(err); });
    });
  }

  function updateFavoriteButtonState(isFav) {
    // The Favorite toolbar button starts as an outline star. When the
    // meeting is favorited, we (a) fill the star path with the accent
    // color and (b) tint the button itself so the active state is
    // obvious without needing a tooltip.
    var favBtn = document.querySelector('.detail-toolbar .tool-btn[data-act="favorite"]');
    if (!favBtn) return;
    favBtn.style.color = isFav ? "var(--accent,#ebb638)" : "";
    favBtn.title = isFav ? "Remove from favorites" : "Add to favorites";
    var path = favBtn.querySelector("svg path");
    if (path) {
      path.setAttribute("fill", isFav ? "currentColor" : "none");
    }
  }

  async function selectMeeting(id) {
    setActive(id);
    // Re-show toolbar buttons that renderEmptyDetail hid. No-op if they were
    // never hidden.
    document.querySelectorAll(".detail-toolbar .tool-btn").forEach(function (el) {
      el.style.display = "";
    });
    try {
      var m = await window.lumo.call("requestMeeting", { id: id });
      renderDetail(m);
    } catch (err) {
      console.error("requestMeeting failed:", err);
    }
  }

  // Replaces the right pane with the design's "no meetings yet" placeholder.
  // Used when loadMeetings finds an empty list — without this, the static
  // designer mockup (Q3 roadmap sync etc.) from Meetings.html stays visible
  // and looks like a real selected meeting on a fresh install.
  function renderEmptyDetail() {
    var crumbsHere = document.querySelector(".detail-toolbar .crumbs .here");
    if (crumbsHere) crumbsHere.textContent = "Meetings";
    // Hide non-breadcrumb toolbar items (Open in Finder / favorite / more)
    // since there's nothing selected to act on.
    document.querySelectorAll(".detail-toolbar .tool-btn").forEach(function (el) {
      el.style.display = "none";
    });
    var scroll = document.querySelector(".detail-scroll");
    if (!scroll) return;
    scroll.innerHTML =
      '<div class="placeholder">'
    + '<div class="ph-orb"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden="true">'
    + '<path d="M15.5 8.5a3.5 3.5 0 1 0-7 0v3a3.5 3.5 0 0 0 7 0zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M8.5 21h7" '
    + 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"></path>'
    + '</svg></div>'
    + '<h3>No meetings yet</h3>'
    + '<p>Your meeting notes will appear here. Press Start meeting above, or hold ⌥ and start talking — the first recording is the tour.</p>'
    + '</div>';
  }

  async function loadMeetings(opts) {
    opts = opts || {};
    var scroll = document.querySelector(".list-scroll");
    if (!scroll) return;
    var meetings;
    try {
      meetings = await window.lumo.call("requestMeetings");
    } catch (err) {
      console.error("requestMeetings failed:", err);
      return;
    }
    var prevSel = document.body.dataset.currentMeeting || "";
    scroll.innerHTML = "";
    if (!meetings || meetings.length === 0) {
      // Left: simple empty hint
      scroll.innerHTML =
        '<div style="padding: 40px 28px; text-align: center; '
      + 'font-family: var(--mono); font-size: 12px; '
      + 'color: var(--ink-dim); line-height: 1.7;">'
      + "No meetings yet.<br>Your recordings will land here."
      + "</div>";
      // Right: clear the designer mockup (Q3 roadmap etc.) and show
      // the empty-state placeholder from Meetings - Empty.html. Without
      // this, the static stub data from Meetings.html stays visible when
      // the list is empty — a confusing fake "selected meeting".
      renderEmptyDetail();
      document.body.dataset.currentMeeting = "";
      return;
    }
    var frag = document.createDocumentFragment();
    meetings.forEach(function (m) { frag.appendChild(buildRow(m)); });
    scroll.appendChild(frag);
    // Preserve current selection across refreshes (e.g. after archive
    // or favorite toggle). Otherwise jump to the newest.
    var keep = meetings.find(function (m) { return m.id === prevSel; });
    var pickId = (opts.selectId)
        || (keep ? prevSel : meetings[0].id);
    selectMeeting(pickId);
  }

  function wireTabs() {
    document.querySelectorAll(".tabs .tab").forEach(function (tab) {
      tab.addEventListener("click", function () {
        document.querySelectorAll(".tabs .tab").forEach(function (t) {
          t.classList.toggle("active", t === tab);
        });
        var id = document.body.dataset.currentMeeting;
        if (id) selectMeeting(id);
      });
    });
  }

  // ── Recording state ────────────────────────────────────────────────

  // Cached references so we can transform back to idle reliably.
  // The design's normal-state markup is captured on init so the
  // recording UI can be reverted exactly when the recording ends.
  var idleRhStatusHTML = null;
  var idleBtnClass = null;
  var idleBtnHTML = null;

  function captureIdleMarkup() {
    var rh = document.querySelector(".record-head .rh-status");
    if (rh && idleRhStatusHTML === null) idleRhStatusHTML = rh.innerHTML;
    var btn = document.querySelector(".record-head button.btn");
    if (btn) {
      if (idleBtnClass === null) idleBtnClass = btn.className;
      if (idleBtnHTML === null) idleBtnHTML = btn.innerHTML;
    }
  }

  function enterRecordingUI(meetingId, initialSeconds) {
    captureIdleMarkup();
    document.body.dataset.recording = "1";
    if (meetingId) document.body.dataset.recordingId = meetingId;
    var rh = document.querySelector(".record-head .rh-status");
    if (rh) {
      rh.className = "rh-status rec";
      rh.innerHTML =
        '<span class="rec-dot"></span> Recording '
        + '<span class="timer">' + fmtTimer(initialSeconds || 0) + "</span>";
    }
    var help = document.querySelector(".record-head .rh-help");
    if (help) {
      help.textContent = "Capturing your mic + system audio. "
        + "Click stop when the call ends.";
    }
    var btn = document.querySelector(".record-head button.btn");
    if (btn) {
      btn.className = "btn btn-stop full";
      btn.innerHTML = "Stop and save";
    }
    // Live-transcript banner pinned above the detail area.
    if (!document.querySelector(".live-banner")) {
      var banner = document.createElement("div");
      banner.className = "live-banner";
      banner.innerHTML = '<span class="rec-dot"></span> '
        + "Recording in progress — transcript will appear after stop.";
      var detail = document.querySelector(".detail-col");
      if (detail) detail.insertBefore(banner, detail.firstChild);
    }
  }

  function exitRecordingUI() {
    document.body.dataset.recording = "0";
    delete document.body.dataset.recordingId;
    var rh = document.querySelector(".record-head .rh-status");
    if (rh && idleRhStatusHTML !== null) {
      rh.className = "rh-status";
      rh.innerHTML = idleRhStatusHTML;
    }
    var btn = document.querySelector(".record-head button.btn");
    if (btn && idleBtnHTML !== null && idleBtnClass !== null) {
      btn.className = idleBtnClass;
      btn.innerHTML = idleBtnHTML;
      // Wire-up bug fix (2026-06-17): wireRecordingButton sets
      // btn.dataset.pending = "1" + btn.disabled = true on Stop click
      // (so the user gets immediate visual feedback while recorder.stop()
      // drains helpers, 500-2000 ms). The button is rebuilt visually here
      // when the `recording-stopped` push arrives, but the dataset and
      // disabled attribute survive innerHTML replacement → next Start
      // click hits the `if (btn.dataset.pending === "1") return;` guard
      // and silently does nothing. Switching tabs worked around it only
      // because loadMeetings() rebuilds the whole .list-col DOM. Clear
      // both here so the button is genuinely click-ready on the spot.
      btn.dataset.pending = "0";
      btn.disabled = false;
    }
    var banner = document.querySelector(".live-banner");
    if (banner) banner.parentNode.removeChild(banner);
  }

  function updateTimer(seconds) {
    var t = document.querySelector(".record-head .rh-status .timer");
    if (t) t.textContent = fmtTimer(seconds);
  }

  function wireRecordingButton() {
    // Use event delegation on the .record-head container so the same
    // listener applies whether the inner button is .btn-primary or
    // .btn-stop. The dataset.recording flag tells us which call to make.
    var head = document.querySelector(".record-head");
    if (!head) return;
    head.addEventListener("click", function (e) {
      var btn = e.target.closest && e.target.closest("button.btn");
      if (!btn) return;
      // Guard against double-clicks while a stop is in flight. Without
      // this, the user clicks Stop → request goes to bg thread, capsule
      // / button don't change for 500-2000ms (recorder.stop() drains
      // helpers + finalizes WAV), user thinks it didn't work, clicks
      // again. We give immediate visual feedback by disabling the
      // button + relabeling to "Stopping…" until the recording-stopped
      // push fires (or 5s lapse) and exitRecordingUI rebuilds the
      // button.
      if (btn.dataset.pending === "1") return;
      btn.dataset.pending = "1";
      var isRecording = document.body.dataset.recording === "1";
      var fn = isRecording ? "stopMeeting" : "startMeeting";
      if (fn === "stopMeeting") {
        btn.disabled = true;
        btn.innerHTML = '<span class="rec-dot" style="opacity:.4"></span> Stopping…';
        // Safety net: if recording-stopped never arrives (e.g. helper
        // crashes), un-stick the button after 5 seconds.
        setTimeout(function () { btn.dataset.pending = "0"; }, 5000);
      }
      if (fn === "startMeeting") {
        // Audit #5 — require off-device-summary consent before the first
        // start. Recording itself happens on-device; only the summary
        // step would send the transcript out. We still ask before any
        // recording starts so the user makes the decision once,
        // up-front, and there's no surprise later when the transcript
        // is auto-shipped to the LLM.
        ensureSummaryConsent(function (granted) {
          if (granted === null) {
            btn.dataset.pending = "0";
            return;
          }
          // CR (2026-06-26) — meetings ALWAYS transcribe locally
          // (cloud_dictator is dictation-only). On a fresh Mac the
          // user has no idea Whisper needs to be on disk, and the
          // meeting would silently record with no transcript.
          // Surface the download flow upfront so they know what's
          // about to happen and see real progress.
          ensureLocalModelForMeeting(function (ready) {
            if (!ready) {
              btn.dataset.pending = "0";
              return;
            }
            window.lumo.call(fn).catch(function (err) {
              console.error(fn, "failed:", err);
            });
          });
        });
      } else {
        window.lumo.call(fn).catch(function (err) {
          console.error(fn, "failed:", err);
        });
      }
    });
  }

  // CR (2026-06-26) — pre-flight Whisper-model check before a meeting
  // starts. If the local model is already on disk, calls cb(true) right
  // away. Otherwise pops a modal explaining why the download is needed,
  // kicks downloadOfflineModel, polls progress, and calls cb(true) when
  // ready or cb(false) if the user cancels.
  function ensureLocalModelForMeeting(cb) {
    window.lumo.call("requestTranscriberStatus").then(function (st) {
      if (st && st.local_ready) { cb(true); return; }
      showMeetingModelOverlay(cb);
    }).catch(function () {
      // Status probe failed — proceed anyway, the recorder will
      // surface its own error if Whisper truly missing.
      cb(true);
    });
  }

  function fmtBytes(n) {
    n = Number(n || 0);
    if (n <= 0) return "—";
    var mb = n / 1048576;
    if (mb < 1024) return mb.toFixed(0) + " MB";
    return (mb / 1024).toFixed(2) + " GB";
  }

  function showMeetingModelOverlay(cb) {
    var existing = document.querySelector(".lumo-meeting-model-overlay");
    if (existing) { existing.parentNode.removeChild(existing); }
    var overlay = document.createElement("div");
    overlay.className = "lumo-meeting-model-overlay";
    overlay.style.cssText =
      "position:fixed;inset:0;background:rgba(5,5,5,.88);"
      + "z-index:99999;display:flex;align-items:center;justify-content:center;"
      + "backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);";
    overlay.innerHTML =
      '<div style="background:var(--card,#141414);'
      +   'border:1px solid rgba(255,255,255,.08);border-radius:16px;'
      +   'padding:28px 30px;max-width:500px;width:calc(100% - 40px);'
      +   'box-shadow:0 30px 80px rgba(0,0,0,.6);">'
      +   '<div style="font-family:var(--display);font-size:22px;'
      +     'color:var(--text,#efeae4);margin-bottom:10px;">'
      +     'Whisper model needed'
      +   '</div>'
      +   '<div style="font-family:var(--mono);font-size:12.5px;'
      +     'color:var(--ink,#888f8a);line-height:1.55;margin-bottom:18px;">'
      +     'Meeting transcription always runs locally — the audio from '
      +     'your calls never leaves your Mac. The Whisper model '
      +     '(~1.5 GB) needs to be downloaded once before Lirelle can '
      +     'transcribe a meeting. After it\'s on disk, everything '
      +     'works offline forever.'
      +   '</div>'
      +   '<div data-mm-stage="ask" style="display:flex;justify-content:flex-end;gap:10px;">'
      +     '<button data-mm-cancel style="appearance:none;-webkit-appearance:none;'
      +       'cursor:pointer;background:transparent;color:var(--ink,#888f8a);'
      +       'border:1px solid rgba(255,255,255,.12);padding:8px 14px;'
      +       'border-radius:8px;font-family:var(--mono);font-size:12px;">'
      +       'Cancel'
      +     '</button>'
      +     '<button data-mm-go style="appearance:none;-webkit-appearance:none;'
      +       'cursor:pointer;background:var(--accent,#ebb638);color:#050505;'
      +       'border:none;padding:8px 16px;border-radius:8px;'
      +       'font-family:var(--mono);font-size:12px;font-weight:600;">'
      +       'Download Whisper'
      +     '</button>'
      +   '</div>'
      +   '<div data-mm-stage="download" style="display:none;">'
      +     '<div data-mm-status style="font-family:var(--mono);font-size:12px;'
      +       'color:var(--text,#efeae4);margin-bottom:8px;">Starting…</div>'
      +     '<div style="height:8px;background:rgba(255,255,255,.06);'
      +       'border-radius:4px;overflow:hidden;margin-bottom:8px;">'
      +       '<div data-mm-bar style="height:100%;width:0%;'
      +         'background:var(--accent,#ebb638);transition:width .3s ease;">'
      +       '</div>'
      +     '</div>'
      +     '<div data-mm-bytes style="font-family:var(--mono);font-size:11px;'
      +       'color:var(--ink,#888f8a);text-align:right;">— / —</div>'
      +   '</div>'
      + '</div>';
    document.body.appendChild(overlay);

    function close() {
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
    }
    overlay.querySelector("[data-mm-cancel]").addEventListener("click", function () {
      close();
      cb(false);
    });
    overlay.querySelector("[data-mm-go]").addEventListener("click", function () {
      overlay.querySelector('[data-mm-stage="ask"]').style.display = "none";
      overlay.querySelector('[data-mm-stage="download"]').style.display = "";
      window.lumo.call("downloadOfflineModel").then(function (res) {
        if (!res || !res.ok) {
          overlay.querySelector("[data-mm-status]").textContent =
            "Couldn't start: " + ((res && res.error) || "unknown");
        }
      });
      pollMeetingModelProgress(overlay, cb);
    });
  }

  async function pollMeetingModelProgress(overlay, cb) {
    while (overlay.parentNode) {
      var st;
      try { st = await window.lumo.call("requestTranscriberStatus"); }
      catch (err) { st = null; }
      if (st) {
        var prog = st.download_progress || {};
        var statusEl = overlay.querySelector("[data-mm-status]");
        var barEl    = overlay.querySelector("[data-mm-bar]");
        var bytesEl  = overlay.querySelector("[data-mm-bytes]");
        if (prog.bytes_total > 0) {
          var pct = Math.min(100,
            Math.round(100 * prog.bytes_done / prog.bytes_total));
          barEl.style.width = pct + "%";
          bytesEl.textContent =
            fmtBytes(prog.bytes_done) + " / " + fmtBytes(prog.bytes_total)
            + "  (" + pct + "%)";
        } else if (prog.bytes_done > 0) {
          bytesEl.textContent = fmtBytes(prog.bytes_done);
        }
        if (prog.msg && statusEl) statusEl.textContent = prog.msg;
        if (st.local_ready || prog.done) {
          if (barEl) barEl.style.width = "100%";
          if (statusEl) statusEl.textContent = "Model ready — starting meeting…";
          await new Promise(function (r) { setTimeout(r, 600); });
          if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
          cb(true);
          return;
        }
        if (prog.error) {
          if (statusEl) statusEl.textContent = "Download failed: " + prog.error;
          var ask = overlay.querySelector('[data-mm-stage="ask"]');
          if (ask) ask.style.display = "";
          return;
        }
      }
      await new Promise(function (r) { setTimeout(r, 1200); });
    }
  }

  function ensureSummaryConsent(cb) {
    window.lumo.call("requestSummaryConsent").then(function (state) {
      if (state && state.granted === true) { cb(true); return; }
      if (state && state.granted === false) { cb(false); return; }
      // Never asked — show the modal.
      openSummaryConsentModal(function (granted) {
        if (granted === null) { cb(null); return; }
        window.lumo.call("setSummaryConsent", { granted: granted })
          .then(function () { cb(granted); })
          .catch(function (err) {
            console.error("setSummaryConsent failed:", err);
            cb(null);
          });
      });
    }).catch(function (err) {
      console.error("requestSummaryConsent failed:", err);
      cb(null);
    });
  }

  function openSummaryConsentModal(onChoice) {
    var existing = document.querySelector(".lumo-consent-overlay");
    if (existing) existing.parentNode.removeChild(existing);
    var overlay = document.createElement("div");
    overlay.className = "lumo-consent-overlay";
    overlay.style.cssText =
      "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.55);"
      + "display:flex;align-items:center;justify-content:center;";
    var modal = document.createElement("div");
    modal.style.cssText =
      "background:var(--card,#141414);border:1px solid rgba(255,255,255,.08);"
      + "border-radius:14px;padding:28px;min-width:460px;max-width:560px;"
      + "box-shadow:0 24px 64px rgba(0,0,0,.7);";
    modal.innerHTML =
      '<h3 style="margin:0 0 14px;font-size:18px;font-weight:600;'
      + 'color:var(--text,#efeae4);">Allow cloud meeting summaries?</h3>'
      + '<p style="margin:0 0 12px;font-family:var(--mono);font-size:12.5px;'
      + 'color:var(--text,#efeae4);line-height:1.65;">'
      + "<strong>Dictation</strong> always stays on this Mac. Whisper "
      + "runs locally and never sends your voice anywhere.</p>"
      + '<p style="margin:0 0 12px;font-family:var(--mono);font-size:12.5px;'
      + 'color:var(--text,#efeae4);line-height:1.65;">'
      + "<strong>Meeting summaries</strong> are different. To turn the "
      + "transcript into bullet points, Lirelle sends the transcript to our "
      + "server, which forwards it to Google Gemini via OpenRouter. The "
      + "audio never leaves your Mac — only the text.</p>"
      + '<p style="margin:0 0 22px;font-family:var(--mono);font-size:11.5px;'
      + 'color:var(--ink-dim,#888f8a);line-height:1.55;">'
      + "If you decline, recording + transcription still work fully — "
      + "you just won't get an auto-summary. You can change this any "
      + "time in Settings.</p>"
      + '<div style="display:flex;justify-content:flex-end;gap:8px;">'
      + '<button class="btn lumo-decline" style="padding:9px 18px;'
      + 'background:transparent;border:1px solid rgba(255,255,255,.12);'
      + 'color:var(--text,#efeae4);border-radius:8px;cursor:pointer;'
      + 'font-family:var(--mono);font-size:12.5px;">Transcript only</button>'
      + '<button class="btn lumo-allow" style="padding:9px 18px;'
      + 'background:var(--accent,#ebb638);border:1px solid var(--accent,#ebb638);'
      + 'color:#0a0a0a;border-radius:8px;cursor:pointer;'
      + 'font-family:var(--mono);font-size:12.5px;font-weight:600;">'
      + "Allow cloud summary</button>"
      + "</div>";
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    function close(answer) {
      if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
      onChoice(answer);
    }
    modal.querySelector(".lumo-allow").addEventListener("click",
      function () { close(true); });
    modal.querySelector(".lumo-decline").addEventListener("click",
      function () { close(false); });
    overlay.addEventListener("click", function (e) {
      if (e.target === overlay) close(null);
    });
  }

  async function syncRecordingStateFromPython() {
    try {
      var st = await window.lumo.call("requestRecordingState");
      if (st && st.recording) {
        enterRecordingUI(st.id || "", st.seconds || 0);
      } else {
        // Make sure idle markup is captured on first idle load.
        captureIdleMarkup();
      }
    } catch (err) {
      console.warn("requestRecordingState failed:", err);
      captureIdleMarkup();
    }
  }

  function wireRecordingPushes() {
    if (!window.lumo || !window.lumo.on) return;
    window.lumo.on("recording-started", function (payload) {
      enterRecordingUI((payload || {}).id || "", 0);
    });
    window.lumo.on("recording-tick", function (payload) {
      updateTimer((payload || {}).seconds || 0);
    });
    window.lumo.on("recording-stopped", function (payload) {
      exitRecordingUI();
      // Refresh the list and select the just-saved meeting if we got an id.
      var newId = (payload || {}).id || "";
      loadMeetings(newId ? { selectId: newId } : undefined);
    });
    // CR (2026-06-23) — fired by MeetingRecorder.on_transcribed when
    // transcript.md lands. Without this, leaving the Meetings tab
    // open while a transcription ran in the background showed "no
    // transcript" until the user switched tabs.
    window.lumo.on("meeting-transcribed", function (payload) {
      var refreshId = (payload || {}).id
        || document.body.dataset.currentMeeting
        || "";
      loadMeetings(refreshId ? { selectId: refreshId } : undefined);
    });
  }

  // ── Toolbar buttons (Favorite + More) ──────────────────────────────

  function tagToolbarButtons() {
    // The design's toolbar has icon-only buttons identified only by
    // their tooltip (title attribute). Tag them with data-act so the
    // delegated click handler can find them after innerHTML rewrites.
    document.querySelectorAll(".detail-toolbar .tool-btn").forEach(function (btn) {
      var t = (btn.title || "").toLowerCase();
      if (t.indexOf("favorite") >= 0) btn.dataset.act = "favorite";
      else if (t === "more") btn.dataset.act = "more";
      else if (t.indexOf("finder") >= 0
               || ((btn.querySelector(".lbl") || {}).textContent || "")
                    .toLowerCase().indexOf("finder") >= 0) {
        btn.dataset.act = "finder";
      }
    });
  }

  function wireToolbarActions() {
    tagToolbarButtons();
    var toolbar = document.querySelector(".detail-toolbar");
    if (!toolbar) return;
    toolbar.addEventListener("click", function (e) {
      var btn = e.target.closest && e.target.closest(".tool-btn[data-act]");
      if (!btn) return;
      var id = document.body.dataset.currentMeeting;
      if (!id) return;
      var act = btn.dataset.act;
      if (act === "finder") {
        window.lumo.call("revealMeeting", { id: id }).catch(console.error);
      } else if (act === "favorite") {
        window.lumo.call("toggleFavoriteMeeting", { id: id })
          .then(function (res) {
            if (res && res.ok) loadMeetings({ selectId: id });
          })
          .catch(console.error);
      } else if (act === "more") {
        openMoreMenu(btn, id);
      }
    });
  }

  // ── More menu + Archive confirm modal ──────────────────────────────

  function closeMoreMenu() {
    var existing = document.querySelector(".lumo-context-menu");
    if (existing) existing.parentNode.removeChild(existing);
  }

  function openMoreMenu(anchorBtn, meetingId) {
    closeMoreMenu();
    var menu = document.createElement("div");
    menu.className = "lumo-context-menu";
    menu.style.cssText =
      "position:fixed;z-index:9999;background:var(--card,#141414);"
      + "border:1px solid rgba(255,255,255,.08);border-radius:10px;"
      + "padding:6px;min-width:200px;box-shadow:0 12px 32px rgba(0,0,0,.6);";
    var rect = anchorBtn.getBoundingClientRect();
    menu.style.top = (rect.bottom + 6) + "px";
    menu.style.left = Math.max(8, rect.right - 200) + "px";

    var title = document.body.dataset.currentMeetingTitle || "this meeting";

    // Favorite is intentionally NOT in this menu — the ★ button in the
    // toolbar already toggles it. Keep More for destructive-ish actions.
    var archiveItem = makeMenuItem(
      "Archive…",
      function () {
        closeMoreMenu();
        openArchiveConfirm(meetingId, title);
      },
      { danger: true }
    );

    menu.appendChild(archiveItem);
    document.body.appendChild(menu);

    // Close on outside click / Escape.
    setTimeout(function () {
      document.addEventListener("click", outsideClickClose, true);
      document.addEventListener("keydown", escClose, true);
    }, 0);
  }

  function makeMenuItem(label, onClick, opts) {
    opts = opts || {};
    var item = document.createElement("div");
    item.textContent = label;
    item.style.cssText =
      "padding:9px 12px;border-radius:6px;cursor:pointer;"
      + "font-family:var(--mono,monospace);font-size:12.5px;"
      + "color:" + (opts.danger ? "#e87171" : "var(--text,#efeae4)") + ";";
    item.addEventListener("mouseenter", function () {
      item.style.background = "rgba(255,255,255,.05)";
    });
    item.addEventListener("mouseleave", function () {
      item.style.background = "";
    });
    item.addEventListener("click", onClick);
    return item;
  }

  function outsideClickClose(e) {
    var menu = document.querySelector(".lumo-context-menu");
    if (menu && !menu.contains(e.target)) {
      closeMoreMenu();
      document.removeEventListener("click", outsideClickClose, true);
      document.removeEventListener("keydown", escClose, true);
    }
  }
  function escClose(e) {
    if (e.key === "Escape") {
      closeMoreMenu();
      document.removeEventListener("click", outsideClickClose, true);
      document.removeEventListener("keydown", escClose, true);
    }
  }

  function openArchiveConfirm(meetingId, title) {
    var existing = document.querySelector(".lumo-modal-overlay");
    if (existing) existing.parentNode.removeChild(existing);

    var overlay = document.createElement("div");
    overlay.className = "lumo-modal-overlay";
    overlay.style.cssText =
      "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.55);"
      + "display:flex;align-items:center;justify-content:center;";
    var modal = document.createElement("div");
    modal.style.cssText =
      "background:var(--card,#141414);border:1px solid rgba(255,255,255,.08);"
      + "border-radius:14px;padding:24px;min-width:380px;max-width:480px;"
      + "box-shadow:0 24px 64px rgba(0,0,0,.7);";
    modal.innerHTML =
      '<h3 style="margin:0 0 10px;font-size:16px;color:var(--text,#efeae4);">'
        + "Archive this meeting?</h3>"
      + '<p style="margin:0 0 6px;font-family:var(--mono);font-size:12.5px;'
        + 'color:var(--text,#efeae4);line-height:1.55;">'
        + esc(title) + "</p>"
      + '<p style="margin:0 0 18px;font-family:var(--mono);font-size:11.5px;'
        + 'color:var(--ink-dim,#888f8a);line-height:1.55;">'
        + "Files stay on disk — nothing is deleted. The meeting just stops "
        + "showing in this list. We don't have an unarchive UI yet, so undoing "
        + "means setting <code>archived: false</code> in meta.json manually."
        + "</p>"
      + '<div style="display:flex;justify-content:flex-end;gap:8px;">'
        + '<button class="btn lumo-cancel" style="padding:8px 16px;'
        + 'background:transparent;border:1px solid rgba(255,255,255,.12);'
        + 'color:var(--text,#efeae4);border-radius:8px;cursor:pointer;'
        + 'font-family:var(--mono);font-size:12.5px;">Cancel</button>'
        + '<button class="btn lumo-confirm" style="padding:8px 16px;'
        + 'background:#a02d2d;border:1px solid #a02d2d;color:white;'
        + 'border-radius:8px;cursor:pointer;font-family:var(--mono);'
        + 'font-size:12.5px;">Archive</button>'
      + "</div>";
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    var cancel = modal.querySelector(".lumo-cancel");
    var confirm = modal.querySelector(".lumo-confirm");
    function close() { overlay.parentNode && overlay.parentNode.removeChild(overlay); }
    cancel.addEventListener("click", close);
    overlay.addEventListener("click", function (e) {
      if (e.target === overlay) close();
    });
    confirm.addEventListener("click", function () {
      window.lumo.call("archiveMeeting", { id: meetingId, confirmed: true })
        .then(function (res) {
          close();
          if (res && res.ok) {
            // Clear the displayed selection — the archived meeting is
            // no longer in the list. loadMeetings will pick the newest.
            delete document.body.dataset.currentMeeting;
            loadMeetings();
          } else {
            alert("Archive failed: " + ((res && res.error) || "unknown"));
          }
        })
        .catch(function (err) {
          close();
          alert("Archive threw: " + err);
        });
    });
  }

  // ── Init ───────────────────────────────────────────────────────────

  async function init() {
    wireTabs();
    wireToolbarActions();
    wireRecordingButton();
    wireRecordingPushes();
    await syncRecordingStateFromPython();
    loadMeetings();
    wireListAutoRefresh();
  }

  function wireListAutoRefresh() {
    // Async pipelines (transcription + Gemini summary) finish minutes
    // after the recording stops. Until that lands, the meeting row's
    // title falls back to "Untitled meeting" because list_meetings
    // reads the heading from summary.md which doesn't exist yet.
    // Previously the list never refetched unless the user pressed
    // Stop again — so a 2-day-old meeting that got summarised today
    // still showed the old title in the rail. Now we refresh on:
    //   • window focus      (user comes back to Lirelle)
    //   • visibility-change (WKWebView regaining visibility)
    //   • a soft 30s poll while the page is visible — covers the
    //     "user keeps Lirelle focused waiting for the summary to land"
    //     scenario without hammering the bridge.
    function refresh() {
      var currentId = document.body.dataset.currentMeeting || "";
      loadMeetings(currentId ? { selectId: currentId } : undefined);
    }
    window.addEventListener("focus", refresh);
    document.addEventListener("visibilitychange", function () {
      if (!document.hidden) refresh();
    });
    setInterval(function () {
      if (!document.hidden) refresh();
    }, 30000);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
