/**
 * Message list component — renders paginated message rows.
 * Reads from message-state; operations mutate state, list reacts.
 */

import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, abortMessageListRequests, updateFlags, getThreadMessages, moveMessages as apiMoveMessages, getPriorityLists, onEvent } from "../lib/api-client.js";
import * as state from "../lib/message-state.js";
import type { ListMessage } from "../lib/message-state.js";
import { showMessage as viewerShow, clearViewer as viewerClear } from "./message-viewer.js";
import { showContextMenu, type MenuItem } from "./context-menu.js";
import { pickFolder } from "./folder-picker.js";
import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";

type MessageSelectHandler = (accountId: string, uid: number, folderId: number) => void;

let onMessageSelect: MessageSelectHandler;
let currentAccountId: string;
let currentFolderId: number;
let currentSpecialUse = ""; // Cached for reloads — an empty value on reload used to reset Sent/Drafts/Outbox to "From"
let lastClickedRow: HTMLElement | null = null;
let currentPage: number;
let totalMessages: number;
let loading = false;
let unifiedMode = false;
let searchMode = false;

let currentSearchQuery = "";
// Remember the pre-search view mode so clearSearchMode() can restore it.
// Without this, searching from "All Inboxes" loses the unified-mode flag
// (loadSearchResults sets unifiedMode=false), and clearing the search
// drops into reloadCurrentFolder() which finds no folder and no unified
// mode and renders nothing — the empty-list / spurious-timeout symptom
// the user saw 2026-05-05.
let wasUnifiedBeforeSearch = false;
let showToInsteadOfFrom = false;
let touchWasScroll = false;
// Current sort column/direction — cycled by clicking the ml-header columns.
// "date desc" is the default (newest first). Clicking a column flips direction
// if it's already active, or switches to that column with its own default dir
// (text columns default asc, date defaults desc).
let currentSort = "date";
let currentSortDir: "asc" | "desc" = "desc";

/** Generation counter — incremented on every load* call (loadMessages,
 *  loadUnifiedInbox, loadSearchResults). Each load captures the current
 *  value at the top, then checks before rendering — if the captured gen
 *  no longer matches `loadGen`, the user has switched to a different
 *  view in the meantime and this stale response should be silently
 *  dropped instead of overwriting the new view. Bob 2026-05-08:
 *  rapid-fire folder clicks were producing "list shows folder X but
 *  preview shows folder Y" because folder X's getMessages eventually
 *  resolved AFTER folder Y's render and clobbered it. */
let loadGen = 0;

/** In-memory message cache for instant folder switches. Keyed by view —
 *  `unified`, `account:folder:flagged?`, `search:query`. The first click
 *  on a folder paints from this cache (~1 ms) BEFORE the IPC round-trip
 *  even fires; the IPC runs in background and only re-renders if the
 *  result diverges from the cached version. Bob 2026-05-09: clicking
 *  "All Inboxes" felt slow even though IPC returned in 18 ms because
 *  the round-trip + DOM build was the user-visible pause. With the
 *  cache, second-and-later clicks are instant. */
interface CachedListResult {
    items: any[];
    total: number;
    timestamp: number;
}
const listCache = new Map<string, CachedListResult>();
const CACHE_KEY_UNIFIED = "unified";
function cacheKey(mode: "folder" | "search", a?: string, f?: number, flagged?: boolean, q?: string): string {
    if (mode === "folder") return `folder:${a}:${f}:${flagged ? "flag" : ""}`;
    return `search:${q}`;
}

/** Per-view position memory — remembered selected UID + scroll position
 *  per folder / unified inbox / saved search. Keyed by the same string
 *  `cacheKey()` uses so each view has its own slot.
 *
 *  Restore rule (Bob 2026-05-12): on return to a view, focus the saved
 *  uid if it still exists; otherwise focus the first row whose uid is
 *  less than the saved uid (next-older entry in a date-desc list — uid
 *  is roughly monotonic with arrival time on most IMAP servers and on
 *  Gmail's hash-derived ids). If no smaller uid exists either, fall
 *  back to whatever row sits at the same numeric index. Survives a
 *  session reload via sessionStorage. */
interface ViewPosition { uid: number; uuid?: string; scroll: number; }
const positionMemory = new Map<string, ViewPosition>();
const POSITION_STORAGE_KEY = "mailx-list-positions";
try {
    const raw = sessionStorage.getItem(POSITION_STORAGE_KEY);
    if (raw) {
        const parsed = JSON.parse(raw) as Record<string, ViewPosition>;
        for (const [k, v] of Object.entries(parsed || {})) {
            if (typeof v?.uid === "number") positionMemory.set(k, v);
        }
    }
} catch { /* */ }
function persistPositions(): void {
    try {
        const obj: Record<string, ViewPosition> = {};
        for (const [k, v] of positionMemory) obj[k] = v;
        sessionStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(obj));
    } catch { /* */ }
}
function currentViewKey(): string | null {
    if (searchMode) return cacheKey("search", undefined, undefined, undefined, currentSearchQuery);
    if (unifiedMode) return CACHE_KEY_UNIFIED;
    if (!currentAccountId || currentFolderId == null) return null;
    const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
    return cacheKey("folder", currentAccountId, currentFolderId, flaggedOnly);
}
function rememberPosition(): void {
    const key = currentViewKey();
    if (!key) return;
    const body = document.getElementById("ml-body");
    if (!body) return;
    const sel = body.querySelector(".ml-row.selected") as HTMLElement | null;
    if (!sel) return;
    const uid = Number(sel.dataset.uid);
    if (!Number.isFinite(uid)) return;
    // Remember the stable `uuid` as the primary identity — bare `uid` is only
    // unique within (account, folder), so restoring by it can rebind the
    // viewer to a different message that happens to share the uid number.
    // `uid` is kept only for the next-older-neighbor fallback when the
    // remembered message has since been deleted.
    positionMemory.set(key, { uid, uuid: sel.dataset.uuid || "", scroll: body.scrollTop });
    persistPositions();
}
/** Choose the row to focus when re-entering a view with saved position.
 *  Returns the uid to focus, or null to fall back to selectFirst. */
function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
    if (!items.length) return null;
    // Exact restore by stable uuid — globally unique, so this can never
    // rebind the viewer to a same-uid-number message in another folder
    // (the bug: viewer silently jumped to a different letter mid-cleanup).
    if (saved.uuid) {
        const exact = items.find(m => m.uuid && m.uuid === saved.uuid);
        if (exact?.uuid) return exact.uuid;
    }
    // Saved message is gone (deleted). Pick the next-older entry by uid —
    // uid is roughly monotonic with arrival on IMAP/Gmail — and return ITS
    // uuid so the restore stays uuid-keyed end to end.
    let best: any = null;
    for (const m of items) {
        if (typeof m.uid !== "number" || !m.uuid) continue;
        if (m.uid < saved.uid && (!best || m.uid > best.uid)) best = m;
    }
    if (best) return best.uuid;
    // No older entry — saved was the bottom. Snap to the top of the list.
    return items[0]?.uuid || null;
}

/** Quick equality check — same UID set, same flag pattern, same total.
 *  Skip re-render when this returns true to avoid DOM churn / scroll-
 *  jump on a refresh that didn't change anything. */
function listResultsEqual(a: any[] | undefined, b: any[]): boolean {
    if (!a || a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
        if (a[i].uid !== b[i].uid) return false;
        if (a[i].accountId !== b[i].accountId) return false;
        // Flags-changed → must re-render (read/unread, flagged).
        if ((a[i].flags || []).join(",") !== (b[i].flags || []).join(",")) return false;
    }
    return true;
}

/** Single source of truth for "which row is focused" in the list.
 *
 *  Each rendered row is a `MessageRow` instance owning its DOM element,
 *  its message envelope, and its event handlers. `focusRow(row)` runs
 *  the atomic transition: unfocus the previous row, mark this one
 *  `.selected`, drive the viewer with the row's envelope, dispatch
 *  `mailx-focus-changed`. There is no "select state" anywhere else; the
 *  viewer has no subscriptions. If `focusRow` isn't called, the preview
 *  pane doesn't update — drift between the highlighted row and the
 *  preview is structurally impossible.
 *
 *  When the focused row's data leaves the list (delete, move, search
 *  reload, folder switch), the controller hands focus to a survivor
 *  via `focusByIdentity` or, if no survivor exists, calls
 *  `releaseFocus()` which clears highlight + viewer in the same call. */
let focusedRow: MessageRow | null = null;
const rowByKey = new Map<string, MessageRow>();

/** Priority sender / domain index, populated on app load and refreshed when
 *  the user marks/unmarks a sender. Module-scoped so MessageRow can hit it
 *  during construction without a service round-trip per row. */
let prioritySenders: Set<string> = new Set();
let priorityDomains: Set<string> = new Set();
function isPriorityAddr(addr: string): boolean {
    const lower = (addr || "").trim().toLowerCase();
    if (!lower) return false;
    if (prioritySenders.has(lower)) return true;
    const at = lower.indexOf("@");
    const domain = at >= 0 ? lower.slice(at + 1) : "";
    return !!domain && priorityDomains.has(domain);
}
/** Refresh the priority index from the service. Call on app load, after
 *  any user action that changes priority (mark/unmark), or whenever
 *  contacts.jsonc reloads. After refresh, every visible row's `.priority`
 *  class is recomputed without a full re-render. */
export async function refreshPriorityIndex(): Promise<void> {
    try {
        const r = await getPriorityLists();
        prioritySenders = new Set((r?.senders || []).map(s => (s || "").toLowerCase()));
        priorityDomains = new Set((r?.domains || []).map(d => (d || "").toLowerCase()));
        // Re-tag any currently rendered rows.
        for (const row of rowByKey.values()) {
            row.el.classList.toggle("priority", isPriorityAddr(row.msg?.from?.address || ""));
        }
    } catch { /* service may not be ready yet */ }
}

function rowKey(accountId: string, uid: number | string): string {
    return `${accountId}:${uid}`;
}

function focusRow(row: MessageRow, opts: { scroll?: boolean } = {}): void {
    // Single-select invariant: this row is the ONLY highlight, and it is the
    // row the viewer shows. Sweep any stray .selected so the list can never
    // present a highlighted row that doesn't match the viewer (Bob
    // 2026-05-18: "the message display and the highlight are supposed to be
    // tied together"). Multi-select keeps its own highlights — focusRow is
    // also called there for the just-toggled row, so skip the sweep then.
    const body = row.el.parentElement as HTMLElement | null;
    if (!body?.classList.contains("multi-select-on")) {
        body?.querySelectorAll(".ml-row.selected").forEach(r => {
            if (r !== row.el) r.classList.remove("selected");
        });
    }
    if (focusedRow && focusedRow !== row) focusedRow.setSelected(false);
    row.setSelected(true);
    focusedRow = row;
    // Bring the focused row into view ONLY for user-initiated focus changes
    // (click, keyboard nav, post-delete auto-advance). Programmatic restores
    // (restoreSelection after sync rerender) explicitly opt out via
    // {scroll:false}: otherwise the user's scroll-away-from-focused-row gets
    // yanked back every time folderCountsChanged triggers a list reload.
    // Bob 2026-05-14: "it's not letting me scroll down the list" — symptom
    // of repeated scrollIntoView during sync churn.
    if (opts.scroll !== false) {
        row.el.scrollIntoView({ block: "nearest" });
    }
    // Drive the viewer with the row's own envelope. Pass currentSpecialUse
    // so the viewer can recognize Drafts/Outbox rows and auto-open them in
    // compose instead of the read-only preview. Without this the draft was
    // showing in the viewer pane as if it were any received message —
    // wrong font (browser default Times), no edit-mode handoff (Bob
    // 2026-05-12: "when I open the draft it is not in edit mode as it
    // should be").
    viewerShow(row.accountId, row.msg.uid, row.msg.folderId, currentSpecialUse || undefined, false, row.msg);
    onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);
    document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: row.msg }));
    // Persist position so returning to this view re-focuses this row.
    rememberPosition();
}

/** Read the currently-focused message envelope. Used by app-level
 *  features (flag toggle, mark unread, status bar) that need to know
 *  what's open in the viewer. */
export function getCurrentFocused(): ListMessage | null {
    return focusedRow ? focusedRow.msg : null;
}

/** Programmatic focus by identity. Used for thread-popup clicks,
 *  keyboard nav, post-delete handoff. Returns true if a row was found
 *  and focused. */
function focusByIdentity(accountId: string, uid: number, opts: { scroll?: boolean } = {}): boolean {
    const row = rowByKey.get(rowKey(accountId, uid));
    if (!row) return false;
    focusRow(row, opts);
    return true;
}

/** Update the flagged class + star on a specific row by identity. Used by
 *  the toolbar Flag button so the visible star tracks the data instead of
 *  drifting until the next list rebuild. Calls into the row object's own
 *  setFlaggedClass — atomic class+text update, can't desync. */
export function setRowFlagged(accountId: string, uid: number, yes: boolean): void {
    const row = rowByKey.get(rowKey(accountId, uid));
    if (row) row.setFlaggedClass(yes);
}

/** Update the unread class on a specific row by identity. Used by
 *  message-viewer's auto-mark-as-read so the visual state flips off
 *  ONLY after the dwell timer fires + the IMAP STORE \Seen succeeds —
 *  not on raw click. Previous click-time `setUnreadClass(false)` meant
 *  a right-click during the 2-s dwell window read the row as "read"
 *  and offered "Mark unread" on a message that wasn't actually marked
 *  yet. */
export function setRowSeen(accountId: string, uid: number, yes: boolean): void {
    const row = rowByKey.get(rowKey(accountId, uid));
    if (row) row.setUnreadClass(!yes);
}

/** Scroll the focused row into view. Wired to a keyboard shortcut so the
 *  user can recover after scrolling the list away from the preview. */
export function scrollFocusedIntoView(): void {
    if (focusedRow) focusedRow.el.scrollIntoView({ block: "center" });
}

/** Release the focus slot and clear the preview pane in one call. */
export function releaseFocus(): void {
    focusedRow = null;
    // Strip EVERY .selected row, not just the focused one. A stray highlight
    // (a multi-select remnant, or a row that kept the class across an
    // incremental rerender) was left behind as a blue row with an empty
    // viewer. The highlight and the preview are one thing: no focus ⟹ no
    // highlight anywhere.
    const body = document.getElementById("ml-body");
    body?.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
    viewerClear();
    document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
}

/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
 *  Called from the bodyCached service event — covers both background prefetch
 *  and on-demand fetch. No-op for rows not currently rendered. */
export function markBodiesCached(items: { accountId: string; uid: number }[]): void {
    const body = document.getElementById("ml-body");
    if (!body || items.length === 0) return;
    for (const { accountId, uid } of items) {
        const row = body.querySelector(`.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(accountId)}"]`)
            || body.querySelector(`.ml-row[data-uid="${uid}"]`);
        if (row) {
            row.classList.remove("not-downloaded");
        }
    }
}

/** Get all selected message rows */
export function getSelectedMessages(): { accountId: string; uid: number; folderId: number }[] {
    const body = document.getElementById("ml-body");
    if (!body) return [];
    const rows = body.querySelectorAll(".ml-row.selected");
    return Array.from(rows).map(r => ({
        accountId: (r as HTMLElement).dataset.accountId || "",
        uid: Number((r as HTMLElement).dataset.uid),
        folderId: Number((r as HTMLElement).dataset.folderId),
    }));
}

function clearSelection(): void {
    const body = document.getElementById("ml-body");
    if (body) body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
    // The focused-row invariant is "the Row whose .selected is currently
    // mine". clearSelection wipes all .selected, so the invariant breaks
    // unless we drop the focused-row reference too.
    if (focusedRow) {
        focusedRow = null;
        viewerClear();
        document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: null }));
    }
}

/** Deterministic sender-avatar color from a seed string (typically the
 *  email address). Hash → hue at 12 evenly-spaced positions on the wheel.
 *  Saturation + lightness fixed so all colors carry the same visual weight
 *  regardless of hue, and so light/dark themes don't have to override. */
function senderColor(seed: string): string {
    let h = 0;
    for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
    const hue = ((Math.abs(h) % 12) * 30) + 15;  // 15, 45, 75, …, 345
    return `oklch(0.62 0.14 ${hue})`;
}

/** Render the attachment / replied / forwarded icon strip into a container.
 *  Mirrors classic Outlook's date-column status glyphs: paperclip for
 *  attachments, leftwards-hook arrow for replied (In-Reply-To linkage in
 *  the local DB; \Answered flag as fallback), rightwards-hook arrow for
 *  forwarded ($Forwarded keyword). Empty strip when none apply — no leading
 *  space, so the date sits flush right as before. */
function renderStatusIcons(host: HTMLElement, msg: any): void {
    host.textContent = "";
    if (msg.hasAttachments) {
        const a = document.createElement("span");
        a.className = "ml-icon ml-icon-attach";
        a.textContent = "📎";
        a.title = "Has attachments";
        host.appendChild(a);
    }
    const flags: string[] = msg.flags || [];
    // Primary signal: local DB knows the user (or anyone in this account)
    // replied — derived from In-Reply-To header linkage, instant on send,
    // works cross-account and across servers that drop \Answered. Plan B:
    // the server-side \Answered flag (e.g. another client replied from a
    // different machine before mailx synced the reply back).
    if (msg.isReplied || flags.includes("\\Answered")) {
        const r = document.createElement("span");
        r.className = "ml-icon ml-icon-replied";
        r.textContent = "↩";
        r.title = "Replied";
        host.appendChild(r);
    }
    if (flags.includes("$Forwarded")) {
        const f = document.createElement("span");
        f.className = "ml-icon ml-icon-forwarded";
        f.textContent = "↪";
        f.title = "Forwarded";
        host.appendChild(f);
    }
}

/** Exit multi-select mode (entered via touch long-press). Clears selection
 *  and the sticky body flag so subsequent taps open messages again. */
function exitMultiSelect(): void {
    const body = document.getElementById("ml-body");
    if (!body?.classList.contains("multi-select-on")) return;
    body.classList.remove("multi-select-on");
    clearSelection();
    updateBulkBar();
}

/** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
 *  toolbar now and every other bulk op (mark-read, flag, move) is one
 *  right-click away. Kept as a no-op stub so existing call sites
 *  (avatar tap, row click, long-press) don't need to be touched. */
function updateBulkBar(): void { /* bar removed; nothing to render */ }

// Escape key + click-outside-list exit multi-select mode. Attached once
// (idempotent because document only has one listener scope per handler).
if (!(window as any).__mailxMultiSelectWired) {
    (window as any).__mailxMultiSelectWired = true;
    document.addEventListener("keydown", (e) => {
        if (e.key === "Escape") exitMultiSelect();
    });
    document.addEventListener("pointerdown", (e) => {
        const body = document.getElementById("ml-body");
        if (!body?.classList.contains("multi-select-on")) return;
        const target = e.target as HTMLElement;
        // A tap on a row is handled by the row's own click listener.
        // The toolbar must also be exempt: its trash / spam / etc.
        // buttons operate ON the current multi-selection, so a tap on
        // them should NOT clear selection before the button's click
        // handler runs (otherwise getSelectedMessages returns empty
        // and the action no-ops — Android-reported 2026-04-30: "press
        // multiple circles, press trashcan, checks vanish, nothing
        // deleted"). Same logic for the folder-tree (drop targets,
        // future: bulk move). Exit only on a tap to genuine neutral
        // ground.
        // Exempt: rows (handled by their own listener), toolbar buttons
        // (delete/spam/etc. operate ON the selection — clearing it here
        // empties the selection before the click runs), folder-tree
        // (drop targets / future bulk move), and the context menu
        // (right-click → "mark read" / "move to" / etc. all need the
        // selection intact when the menu item runs).
        if (target.closest(".ml-row, .toolbar, .folder-tree, .ctx-menu, #btn-tb-delete, #btn-tb-spam")) return;
        exitMultiSelect();
    }, true);
}

function selectRange(from: HTMLElement, to: HTMLElement): void {
    const body = document.getElementById("ml-body");
    if (!body) return;
    const rows = Array.from(body.querySelectorAll(".ml-row"));
    const fromIdx = rows.indexOf(from);
    const toIdx = rows.indexOf(to);
    if (fromIdx < 0 || toIdx < 0) return;
    const lo = Math.min(fromIdx, toIdx);
    const hi = Math.max(fromIdx, toIdx);
    // A shift-click range IS a multi-selection — flag the body so focusRow()
    // (called right after, to drive the viewer off the clicked row) skips
    // its single-select sweep. Without this the sweep wiped every row in the
    // range except the one clicked (Bob 2026-05-19: "shift click should
    // extend a selection but it does not" — regression from the v1.1.88
    // highlight-desync fix).
    if (hi > lo) body.classList.add("multi-select-on");
    for (let i = lo; i <= hi; i++) rows[i].classList.add("selected");
}

/** The row to anchor a shift-click range against. `lastClickedRow` is the
 *  primary anchor, but it can become a detached DOM node after a list
 *  re-render (folder switch, sort, search reload, paging) — `selectRange`
 *  would then no-op. Fall back to whichever live row is `.selected` (the
 *  one in the viewer) before giving up. */
function resolveShiftAnchor(): HTMLElement | null {
    if (lastClickedRow?.isConnected) return lastClickedRow;
    const body = document.getElementById("ml-body");
    if (!body) return null;
    return body.querySelector(".ml-row.selected") as HTMLElement | null;
}

const timeFmt: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", hour12: false };
const dateFmt: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
const dateFmtSameYear: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };

export function initMessageList(handler: MessageSelectHandler): void {
    onMessageSelect = handler;

    // Infinite scroll
    const body = document.getElementById("ml-body");
    if (body) {
        // Touch scroll vs tap: the WebView occasionally synthesizes a click on
        // touchend even when the user clearly scrolled, which opened a message
        // just from swiping the list. Multi-signal detection so a scroll is
        // reliably classified:
        //   1. touchmove movement ≥ TAP_SLOP — the primary signal
        //   2. actual scrollTop change between touchstart and touchend — always
        //      set the flag when the container moved, even if touchmove never
        //      fired (some Android builds coalesce events under momentum)
        //   3. longer TAP_SLOP (15px) — fingers are wide; 10px was too twitchy
        let touchStartY = 0;
        let touchStartX = 0;
        let touchStartScrollTop = 0;
        const TAP_SLOP = 15;
        body.addEventListener("touchstart", (e) => {
            const t = e.touches[0];
            touchStartY = t.clientY;
            touchStartX = t.clientX;
            touchStartScrollTop = body.scrollTop;
            touchWasScroll = false;
        }, { passive: true });
        body.addEventListener("touchmove", (e) => {
            const t = e.touches[0];
            if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {
                touchWasScroll = true;
            }
        }, { passive: true });
        body.addEventListener("touchend", () => {
            // If the container actually scrolled during this touch, the user
            // was scrolling regardless of how small their finger movement was.
            if (body.scrollTop !== touchStartScrollTop) {
                touchWasScroll = true;
            }
        }, { passive: true });

        body.addEventListener("scroll", () => {
            if (loading) return;
            const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 200;
            if (nearBottom) {
                if (currentPage * 50 < totalMessages) {
                    loadMoreMessages();
                } else {
                    // Diagnostic: at bottom but not loading more. Log why
                    // so the user can see whether totalMessages is wrong,
                    // a filter is on, or we've truly reached the end.
                    // Only log once per "stuck at bottom" episode.
                    if (!(body as any)._mlScrollEndLogged) {
                        (body as any)._mlScrollEndLogged = true;
                        const rows = body.querySelectorAll(".ml-row").length;
                        console.log(`  [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
                    }
                }
            } else {
                // Reset the once-flag so next time we hit bottom we log again.
                (body as any)._mlScrollEndLogged = false;
            }
        });
    }

    // Viewer signals "this row is gone server-side" via mailx-remove-stale.
    // The list owns row lifecycle, so it runs the removal here — filtering
    // state, removing the DOM row, and handing focus to a survivor (or
    // clearing the pane) in one transaction.
    document.addEventListener("mailx-remove-stale", (e: any) => {
        const { accountId, uid } = e.detail || {};
        if (typeof uid === "number" && typeof accountId === "string") {
            removeMessagesAndReconcile([{ accountId, uid }]);
        }
    });

    // Sort column headers — click to cycle. Date defaults desc (newest first);
    // From/Subject default asc on first click so alphabetical order reads
    // naturally. Clicking the currently-active column flips direction.
    const header = document.getElementById("ml-header");
    if (header) {
        header.addEventListener("click", (e) => {
            const col = (e.target as HTMLElement).closest<HTMLElement>(".ml-col-sortable");
            if (!col) return;
            const key = col.dataset.sort;
            if (!key) return;
            if (currentSort === key) {
                currentSortDir = currentSortDir === "asc" ? "desc" : "asc";
            } else {
                currentSort = key;
                currentSortDir = key === "date" ? "desc" : "asc";
            }
            // Only per-folder lists support server-side sort today; unified and
            // search paths sort client-side on the fetched page. Reload if we
            // have an active per-folder context.
            if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {
                loadMessages(currentAccountId, currentFolderId, 1, "", false);
            } else {
                applyClientSideSort();
                updateSortIndicators();
            }
        });
    }
    updateSortIndicators();
}

/** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.
 *  Used for unified-inbox and search results where the server can't re-sort
 *  a single page on our behalf. */
function applyClientSideSort(): void {
    const items = [...state.getMessages()];
    const sign = currentSortDir === "asc" ? 1 : -1;
    items.sort((a: any, b: any) => {
        if (currentSort === "from") {
            const av = (a.from?.name || a.from?.address || "").toLowerCase();
            const bv = (b.from?.name || b.from?.address || "").toLowerCase();
            return av < bv ? -sign : av > bv ? sign : 0;
        }
        if (currentSort === "subject") {
            const av = (a.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
            const bv = (b.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
            return av < bv ? -sign : av > bv ? sign : 0;
        }
        // date
        return ((a.date || 0) - (b.date || 0)) * sign;
    });
    state.setMessages(items as any);
}

function updateSortIndicators(): void {
    const header = document.getElementById("ml-header");
    if (!header) return;
    header.querySelectorAll<HTMLElement>(".ml-col-sortable").forEach(c => {
        c.classList.remove("ml-col-sort-asc", "ml-col-sort-desc");
        if (c.dataset.sort === currentSort) {
            c.classList.add(currentSortDir === "asc" ? "ml-col-sort-asc" : "ml-col-sort-desc");
        }
    });
}

/**
 * Remove the named messages from the list and reconcile DOM + focus.
 *
 * Single-transaction transition: filters the underlying state, deletes
 * the DOM rows, and either re-focuses a surviving row or releases focus
 * (clearing the viewer). Replaces the old subscribe-and-sync model where
 * state.removeMessages broadcast a "removed" event to two independent
 * subscribers — that path could leave the highlight and preview out of
 * sync if the list and viewer noticed the change in different orders.
 *
 * Call this whenever local rows need to disappear (delete, move,
 * server-side stale removal, undo). Pass identities; the function
 * decides what to focus next.
 */
export function removeMessagesAndReconcile(
    uids: { accountId: string; uid: number }[],
): void {
    const focusedIdent = focusedRow
        ? { accountId: focusedRow.accountId, uid: focusedRow.msg.uid }
        : null;
    const outcome = state.removeMessages(uids, focusedIdent);

    // Invalidate the in-memory list cache so a window-switch or folder
    // re-visit doesn't re-paint the deleted rows from a stale snapshot.
    // Bob 2026-05-09: "I deleted entries, switched windows, came back —
    // the deleted entries reappeared." Cache is per-folder; without
    // knowing which folders were affected we drop the lot. Cheap (just
    // a Map clear) and the next IPC repopulates each on demand.
    listCache.clear();

    const body = document.getElementById("ml-body");
    if (body) {
        const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));
        for (const row of Array.from(body.querySelectorAll(".ml-row"))) {
            const el = row as HTMLElement;
            const key = `${el.dataset.accountId}:${el.dataset.uid}`;
            if (!stateUids.has(key)) {
                // Use the row object's detach() not el.remove() so rowByKey
                // stays clean. Stale entries there broke the auto-advance
                // path: focusByIdentity(survivor) could match a deleted row
                // whose .el was already gone from the DOM, then focusRow
                // would call setSelected on a detached node — visually
                // nothing happened, viewer cleared. Single ownership =
                // rowByKey is in sync with the DOM.
                const dead = rowByKey.get(key);
                if (dead) dead.detach();
                else el.remove();
            }
        }
        if (state.getMessages().length === 0) {
            body.innerHTML = `<div class="ml-empty">No messages</div>`;
        }
    }

    if (outcome.focusedWasRemoved) {
        const survivor = outcome.nextSurvivor;
        if (survivor && focusByIdentity(survivor.accountId, survivor.uid)) {
            // focusByIdentity handled the transition (DOM class + viewer).
            return;
        }
        // Survivor identity didn't resolve to a rendered row. Most common
        // case: state computed a survivor that's still loading or got
        // filtered. Fall back to the first row currently in state — there
        // IS a list, so showing "Select a message" while rows are visible
        // is the architectural fuckup (Bob 2026-05-14). Walk state in
        // order; the first row that has a live DOM/rowByKey entry wins.
        const remaining = state.getMessages();
        for (const m of remaining) {
            if (focusByIdentity(m.accountId, m.uid)) return;
        }
        // Truly empty folder (state.length === 0). Now releasing focus is
        // honest — there's no row to show.
        releaseFocus();
    }
}

/** Reload the currently displayed folder (preserves current selection).
 *  No-op when in search mode — search is a query snapshot, not a live
 *  feed. Sync / IDLE arrivals re-running the search would yank focus
 *  away from whatever the user is reading and reshuffle the result
 *  list every few seconds. The user can re-trigger search explicitly
 *  by pressing Enter or modifying the query. */
export function reloadCurrentFolder(): void {
    if (searchMode) return;
    if (unifiedMode) {
        loadUnifiedInbox(false);
    } else if (currentAccountId && currentFolderId) {
        loadMessages(currentAccountId, currentFolderId, 1, "", false);
    }
}

/** Exit search mode without triggering a reload — caller decides what to load
 *  next. Restores the pre-search view mode (unified vs folder) so the
 *  subsequent reloadCurrentFolder() call lands on the right branch.
 *  Without the unifiedMode restore, clearing a search that was started
 *  from "All Inboxes" left the list permanently empty. */
export function clearSearchMode(): void {
    searchMode = false;
    currentSearchQuery = "";
    if (wasUnifiedBeforeSearch) unifiedMode = true;
    wasUnifiedBeforeSearch = false;
}

/** Load unified inbox (all accounts) */
export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
    const myGen = ++loadGen;
    unifiedMode = true;
    searchMode = false;
    currentSpecialUse = "";
    showToInsteadOfFrom = false; // Unified inbox always shows From, not To
    currentPage = 1;
    totalMessages = 0;

    const body = document.getElementById("ml-body");
    if (!body) return;
    const fromHeader = document.querySelector(".ml-col-from");
    if (fromHeader) fromHeader.textContent = "From";

    // Saved per-view position wins over current DOM selection — same
    // rationale as loadMessages.
    const remembered = positionMemory.get(CACHE_KEY_UNIFIED);
    const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);

    // Instant first paint from cache. The IPC fires in background below
    // and only triggers a re-render if the result diverges from the
    // cached snapshot.
    const cached = listCache.get(CACHE_KEY_UNIFIED);
    if (cached) {
        totalMessages = cached.total;
        state.setMessages(cached.items);
        renderMessages(body, "", cached.items);
        const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
        if (targetUuid) {
            body.scrollTop = savedScroll;
            restoreSelection(body, targetUuid);
        } else if (autoSelect) {
            selectFirst(body);
        }
    } else if (autoSelect) {
        body.innerHTML = `<div class="ml-empty">Loading...</div>`;
    }

    try {
        const result = await apiGetUnifiedInbox(1);
        if (myGen !== loadGen) return;       // user moved on; drop stale response
        totalMessages = result.total;
        listCache.set(CACHE_KEY_UNIFIED, { items: result.items, total: result.total, timestamp: Date.now() });

        // Skip the re-render entirely if the IPC result matches what
        // the cache already painted. Avoids DOM churn / scroll-jump
        // on the common no-change refresh.
        if (cached && listResultsEqual(cached.items, result.items)) return;

        if (result.items.length === 0) {
            state.setMessages([]);
            body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
            return;
        }

        state.setMessages(result.items);
        renderMessages(body, "", result.items);

        const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
        if (targetUuid) {
            body.scrollTop = savedScroll;
            restoreSelection(body, targetUuid);
        } else if (autoSelect) {
            selectFirst(body);
        }
    } catch (e: any) {
        if (e.name === "AbortError") return;
        if (myGen !== loadGen) return;
        body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
    }
}

/** Load search results.
 *  `includeTrashSpam` only matters for global ("all") and "server" scopes —
 *  per-folder search ALWAYS returns matches in that folder, including when
 *  the folder itself is trash/junk. */
export async function loadSearchResults(query: string, scope = "all", accountId = "", folderId = 0, includeTrashSpam = false): Promise<void> {
    const myGen = ++loadGen;
    // Capture the pre-search mode on the first transition only — repeated
    // searches (typing more characters) shouldn't overwrite it with the
    // intermediate `unifiedMode = false` state.
    if (!searchMode) wasUnifiedBeforeSearch = unifiedMode;
    searchMode = true;
    unifiedMode = false;
    currentSearchQuery = query;
    currentPage = 1;
    totalMessages = 0;

    const body = document.getElementById("ml-body");
    if (!body) return;

    // Search reload tears down the current row set — focus must release
    // along with the rows. releaseFocus clears the preview pane in the
    // same call frame, so no orphan preview lingers behind a list that
    // no longer contains the previously-shown row.
    releaseFocus();

    // Only wipe to "Searching..." when there are no rows already showing.
    // Otherwise the list blanks every time sync fires `reloadCurrentFolder`
    // (which re-runs loadSearchResults), making the user see results
    // appear then disappear when an IDLE/sync event lands mid-typing.
    // The gen counter still guarantees only the latest query's rows
    // render — stale results don't sneak through.
    const hasExistingRows = body.querySelector(".ml-row") !== null;
    if (!hasExistingRows) {
        body.innerHTML = `<div class="ml-empty">Searching...</div>`;
    }

    const _searchT0 = performance.now();
    const _stamp = (label: string): void => {
        const line = `[search ${(performance.now() - _searchT0).toFixed(0).padStart(5)} ms] ${label} q=${JSON.stringify(query)}`;
        console.log("  " + line);
        try { (window as any).mailxapi?.logClientEvent?.(line); } catch { /* */ }
    };
    _stamp("start");
    try {
        // Regex search: filter client-side
        if (query.startsWith("/") && query.endsWith("/") && query.length > 2) {
            const pattern = query.slice(1, -1);
            let regex: RegExp;
            try { regex = new RegExp(pattern, "i"); } catch { body.innerHTML = `<div class="ml-empty">Invalid regex</div>`; return; }

            const source = scope === "current" && accountId
                ? await apiGetMessages(accountId, folderId, 1, 10000)
                : await apiGetUnifiedInbox(1, 10000);
            if (myGen !== loadGen) return;
            const matches = source.items.filter((m: any) =>
                regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || "")
            );
            totalMessages = matches.length;
            state.setMessages(matches);
            if (matches.length === 0) { body.innerHTML = `<div class="ml-empty">No regex matches</div>`; return; }
            body.innerHTML = "";
            appendMessages(body, "", matches);
            selectFirst(body);
            return;
        }

        _stamp("IPC begin");
        const result = await searchMessages(query, 1, 50, scope, accountId, folderId, includeTrashSpam);
        _stamp(`IPC done (${result.items.length}/${result.total})`);
        if (myGen !== loadGen) return;
        totalMessages = result.total;

        // Server search may have failed in some folders. Surfacing it matters
        // most for the zero-results case: "No results" + 5 folders that never
        // got searched is NOT the same as "definitely not on the server".
        const partial = !!(result as any).partial;
        const foldersFailed = (result as any).foldersFailed || 0;
        const foldersSearched = (result as any).foldersSearched || 0;
        const partialNote = partial
            ? `<div class="ml-search-warning">⚠ Searched ${foldersSearched} folder(s); ${foldersFailed} could not be searched (timeout / connection). Results may be incomplete — retry to search them again.</div>`
            : "";

        if (result.items.length === 0) {
            state.setMessages([]);
            body.innerHTML = partialNote +
                `<div class="ml-empty">No results for "${query}"${partial ? " in the folders that responded" : ""}</div>`;
            return;
        }

        state.setMessages(result.items);
        body.innerHTML = partialNote;
        appendMessages(body, "", result.items);
        // Auto-focus the first result so the preview shows immediately —
        // matching the regex-search path and the loadMessages default.
        selectFirst(body);
    } catch (e: any) {
        body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
    }
}

export async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = "", autoSelect = true): Promise<void> {
    const myGen = ++loadGen;
    searchMode = false;
    unifiedMode = false;
    // Folder switch clears any in-progress multi-select — carrying a "3
    // selected" state across folders would lie about what rows the bulk
    // buttons would act on.
    exitMultiSelect();
    // specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
    // folder path lowercased (folder-tree fallback when tag is missing — common
    // on Dovecot which doesn't advertise \Sent). Match both cases.
    // Empty specialUse on reload means "keep what we had" — otherwise a
    // folderCountsChanged event or sort-header click resets Sent/Drafts/Outbox
    // back to showing From (user-reported regression 2026-04-24).
    if (specialUse) currentSpecialUse = specialUse;
    const su = currentSpecialUse.toLowerCase();
    showToInsteadOfFrom = su === "sent" || su === "drafts" || su === "outbox"
        || su.endsWith("sent") || su.endsWith("drafts") || su.endsWith("outbox")
        || su === "sent items" || su === "sent mail" || su.endsWith("/sent items") || su.endsWith(".sent items");
    currentAccountId = accountId;
    currentFolderId = folderId;
    currentPage = 1;
    totalMessages = 0;

    const body = document.getElementById("ml-body");
    if (!body) return;

    // Update header label
    const fromHeader = document.querySelector(".ml-col-from");
    if (fromHeader) fromHeader.textContent = showToInsteadOfFrom ? "To" : "From";

    const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
    const cKey = cacheKey("folder", accountId, folderId, flaggedOnly);
    // Saved per-view position takes priority over the DOM's current
    // selection — when switching from folder A to B and back, the DOM
    // shows B's selected row but we want to land on whatever was last
    // focused in A.
    const remembered = positionMemory.get(cKey);
    const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);
    const cached = listCache.get(cKey);
    if (cached) {
        totalMessages = cached.total;
        state.setMessages(cached.items);
        renderMessages(body, accountId, cached.items);
        const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
        if (targetUuid) {
            requestAnimationFrame(() => {
                body.scrollTop = savedScroll;
                restoreSelection(body, targetUuid);
            });
        } else if (autoSelect) {
            selectFirst(body);
        }
    } else if (autoSelect) {
        body.innerHTML = `<div class="ml-empty">Loading...</div>`;
    }

    try {
        const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
        // Stale-response guard: a newer load* fired while we were
        // awaiting; the new view already painted. Drop this result
        // silently rather than overwriting.
        if (myGen !== loadGen) return;
        totalMessages = result.total;
        listCache.set(cKey, { items: result.items, total: result.total, timestamp: Date.now() });
        updateSortIndicators();
        // Skip re-render if data unchanged from the painted cache.
        if (cached && listResultsEqual(cached.items, result.items)) return;

        if (result.items.length === 0) {
            state.setMessages([]);
            body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
            // List/viewer fate-sharing: if the list is now empty, the
            // viewer must clear too — leaving the last-clicked message
            // visible while the list says "nothing here" is exactly the
            // confusion Bob hit on the flagged-only filter (2026-05-23).
            viewerClear();
            focusedRow = null;
            return;
        }

        state.setMessages(result.items);
        renderMessages(body, accountId, result.items);
        // Same fate-sharing rule for the non-empty branch: if the focused
        // viewer message isn't in the freshly-loaded set, clear it.
        if (focusedRow) {
            const stillThere = result.items.some((m: ListMessage) =>
                m.accountId === focusedRow!.accountId && m.uid === focusedRow!.msg.uid);
            if (!stillThere) {
                viewerClear();
                focusedRow = null;
            }
        }

        // Prefer saved position; otherwise default by autoSelect.
        const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
        if (targetUuid) {
            requestAnimationFrame(() => {
                if (myGen !== loadGen) return;
                body.scrollTop = savedScroll;
                restoreSelection(body, targetUuid);
            });
        } else if (autoSelect) {
            selectFirst(body);
        }
    } catch (e: any) {
        if (e.name === "AbortError") return;
        if (myGen !== loadGen) return;       // user moved on; suppress the error message
        body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
    }
}

async function loadMoreMessages(): Promise<void> {
    const body = document.getElementById("ml-body");
    if (!body) return;

    loading = true;
    currentPage++;

    try {
        const flaggedOnly = body.classList.contains("flagged-only");
        const result = searchMode
            ? await searchMessages(currentSearchQuery, currentPage)
            : unifiedMode
                ? await apiGetUnifiedInbox(currentPage)
                : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
        // Append to state
        const current = state.getMessages();
        state.setMessages([...current, ...result.items]);
        appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
    } catch (e: any) {
        console.error(`Load more error: ${e.message}`);
    } finally {
        loading = false;
    }
}

/** Replace body contents with rendered rows */
function renderMessages(body: HTMLElement, accountId: string, items: any[]): void {
    const fragment = document.createDocumentFragment();
    const tempDiv = document.createElement("div");
    appendMessages(tempDiv, accountId, items);
    while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
    body.replaceChildren(fragment);
}

function selectFirst(body: HTMLElement): void {
    // Narrow viewports (Android, phone-sized): don't auto-select. The
    // click handler in app.ts switches the layout to "narrow-active" on
    // any list-row click, which on a phone means the message viewer takes
    // over the screen and hides the list. Auto-selecting at startup
    // therefore lands the user in the LAST letter they read instead of
    // the inbox summary they wanted. Desktop unchanged — auto-select
    // remains useful when the list and viewer are side-by-side.
    if (window.innerWidth <= 768) return;
    const firstRow = body.querySelector(".ml-row") as HTMLElement;
    if (firstRow) firstRow.click();
}

function restoreSelection(body: HTMLElement, savedUuid: string | null | undefined): void {
    if (!savedUuid) return;
    // Locate the row by stable uuid — unique, so this is the exact message
    // the user had selected, never a uid-number collision in another folder.
    const row = body.querySelector(`.ml-row[data-uuid="${CSS.escape(savedUuid)}"]`) as HTMLElement | null;
    if (!row) return;
    const accountId = row.dataset.accountId;
    const uid = Number(row.dataset.uid);
    if (accountId && Number.isFinite(uid)) {
        // {scroll:false} — programmatic restore after a sync-driven reload.
        // The user might be reading rows ABOVE the focused one; do NOT yank
        // their scroll back to the focused row every time the list rebuilds.
        focusByIdentity(accountId, uid, { scroll: false });
    }
}

/** Show a floating list of all messages in a thread when the pill is clicked.
 *  Each entry in the popup selects that message in the viewer when clicked.
 *  This is simpler than inline expansion and avoids duplicating the row builder. */
export async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promise<void> {
    // Remove any existing popup
    document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
    let thread: any[] = [];
    try { thread = await getThreadMessages(headMsg.accountId, headMsg.threadId); } catch { /* ignore */ }
    if (!thread || thread.length === 0) return;
    thread.sort((a, b) => (a.date || 0) - (b.date || 0));
    const popup = document.createElement("div");
    popup.className = "ml-thread-popup";
    for (const msg of thread) {
        const item = document.createElement("div");
        item.className = "ml-thread-popup-item";
        if (!seenOf(msg)) item.classList.add("unread");
        const from = document.createElement("span");
        from.className = "ml-thread-popup-from";
        from.textContent = msg.from?.name || msg.from?.address || "?";
        const date = document.createElement("span");
        date.className = "ml-thread-popup-date";
        date.textContent = formatDate(msg.date);
        const subject = document.createElement("span");
        subject.className = "ml-thread-popup-subject";
        subject.textContent = msg.subject || "(no subject)";
        item.appendChild(from);
        item.appendChild(date);
        item.appendChild(subject);
        item.addEventListener("click", async () => {
            // Thread popup → viewer-only update. The list keeps showing the
            // thread head highlighted (it's the row in the actual list);
            // the viewer pivots to the clicked thread member. Single path
            // in: the viewer's show() call. No state, no event, no drift.
            const envelope = {
                accountId: msg.accountId,
                uid: msg.uid,
                folderId: msg.folderId,
                subject: msg.subject,
                from: msg.from,
                to: msg.to,
                cc: msg.cc,
                date: msg.date,
                flags: msg.flags,
                size: msg.size,
                preview: msg.preview,
                hasAttachments: msg.hasAttachments,
            };
            viewerShow(msg.accountId, msg.uid, msg.folderId, currentSpecialUse || undefined, false, envelope as ListMessage);
            onMessageSelect(msg.accountId, msg.uid, msg.folderId);
            popup.remove();
        });
        popup.appendChild(item);
    }
    document.body.appendChild(popup);
    const rect = pillEl.getBoundingClientRect();
    popup.style.left = `${rect.left}px`;
    popup.style.top = `${rect.bottom + 4}px`;
    // Dismiss on outside click
    setTimeout(() => {
        const dismiss = (e: MouseEvent) => {
            if (!popup.contains(e.target as Node)) {
                popup.remove();
                document.removeEventListener("mousedown", dismiss, true);
            }
        };
        document.addEventListener("mousedown", dismiss, true);
    }, 0);
}

/** A rendered row in the message list.
 *
 *  Owns its DOM element, the message envelope it represents, and all of
 *  its event handlers (click, dblclick, drag, contextmenu, touch
 *  long-press, plus the avatar / flag / thread-pill child handlers).
 *  State changes that affect appearance (selected, unread, flagged,
 *  body-cached) go through methods so the OO layer is the single point
 *  of mutation — no `el.classList.toggle("selected")` scattered through
 *  the codebase.
 *
 *  Lifecycle: constructor builds DOM and wires handlers; `attach(body)`
 *  inserts into the list; `detach()` removes from DOM and from the
 *  module-level rowByKey map. The list controller diff-updates rows on
 *  list mutations.
 *
 *  Multi-select still uses `.selected` class queries — that's a single
 *  DOM-level concept where drift isn't an issue (no separate render
 *  surface to drift against). The Row class wraps the *focus* concept
 *  (which couples to the viewer) as a fate-shared unit. */
class MessageRow {
    el: HTMLElement;
    private flagEl: HTMLSpanElement;

    constructor(
        public msg: any,
        public accountId: string,
        threadHead: boolean,
        threadCount: number,
        showAccountTag: boolean,
    ) {
        const row = document.createElement("div");
        this.el = row;
        row.className = "ml-row";
        row.draggable = true;
        if (!seenOf(msg)) row.classList.add("unread");
        if (flaggedOf(msg)) row.classList.add("flagged");
        if (!msg.bodyPath) row.classList.add("not-downloaded");
        if (msg.pending) row.classList.add("pending-reconcile");
        if (msg.inReplyTo) row.classList.add("is-reply");
        if (isPriorityAddr(msg.from?.address || "")) row.classList.add("priority");
        row.dataset.uid = String(msg.uid);
        row.dataset.accountId = accountId;
        row.dataset.folderId = String(msg.folderId);
        // Stable local identity. `uid` is only unique within (account, folder)
        // — in a unified / multi-folder list two messages can share a uid
        // NUMBER, so selection memory keyed on bare uid can rebind the viewer
        // to the wrong letter after a re-render. `uuid` is globally unique
        // and never changes; selection restore keys on it.
        if (msg.uuid) row.dataset.uuid = msg.uuid;
        if (msg.threadId) row.dataset.threadId = msg.threadId;
        if (threadHead) row.classList.add("thread-head");

        // ── Avatar (sender circle, doubles as multi-select affordance) ──
        const fromName = (showToInsteadOfFrom && msg.to?.length)
            ? (msg.to[0].name || msg.to[0].address || "?")
            : (msg.from?.name || msg.from?.address || "?");
        const seedAddr = (msg.from?.address || msg.from?.name || "?").toLowerCase();
        const initial = (fromName.replace(/^[\W_]+/, "") || "?").charAt(0).toUpperCase();
        const avatar = document.createElement("span");
        avatar.className = "ml-avatar";
        avatar.textContent = initial;
        avatar.style.background = senderColor(seedAddr);
        avatar.title = msg.from?.address || "";
        avatar.addEventListener("click", (e) => this.onAvatarClick(e));
        avatar.addEventListener("contextmenu", (e) => this.onAvatarContextMenu(e));

        // ── Flag star (toggle on click) ──
        const flag = document.createElement("span");
        flag.className = "ml-flag";
        flag.textContent = flaggedOf(msg) ? "★" : "☆";
        flag.title = "Toggle flag";
        flag.addEventListener("click", (e) => this.onFlagClick(e));
        this.flagEl = flag;

        // ── From column ──
        const from = document.createElement("span");
        from.className = "ml-from";
        if (showToInsteadOfFrom && msg.to?.length) {
            from.textContent = msg.to.map((a: any) => a.name || a.address).join(", ");
        } else {
            from.textContent = msg.from.name || msg.from.address;
        }
        if (showAccountTag && accountId) {
            const tag = document.createElement("span");
            tag.className = "ml-account-tag";
            tag.textContent = accountId.charAt(0).toUpperCase();
            tag.title = accountId;
            from.prepend(tag);
        }
        if (msg.folderName) {
            const folderTag = document.createElement("span");
            folderTag.className = "ml-folder-tag";
            folderTag.textContent = msg.folderName;
            folderTag.title = `In folder: ${msg.folderName}`;
            from.prepend(folderTag);
        }
        if ((msg.dupeCount as number) >= 2) {
            const dupe = document.createElement("span");
            dupe.className = "ml-dupe-tag";
            dupe.textContent = "⇆";
            dupe.title = `Same message on ${msg.dupeCount} accounts`;
            from.prepend(dupe);
        }

        // ── Subject (with optional thread pill + preview snippet) ──
        const subject = document.createElement("span");
        subject.className = "ml-subject";
        subject.innerHTML = escapeHtml(msg.subject);
        if (threadHead && threadCount > 1 && msg.threadId) {
            const threadPill = document.createElement("span");
            threadPill.className = "ml-thread-pill";
            threadPill.textContent = String(threadCount);
            threadPill.title = `${threadCount} messages in this thread — click to see list`;
            threadPill.addEventListener("click", async (e) => {
                e.stopPropagation();
                await showThreadPopup(threadPill, msg);
            });
            subject.prepend(threadPill);
        }
        if (msg.preview) {
            const preview = document.createElement("span");
            preview.className = "ml-preview";
            preview.textContent = ` — ${msg.preview}`;
            subject.appendChild(preview);
        }

        // ── Date column (with status icons: attachment / replied / forwarded) ──
        const date = document.createElement("span");
        date.className = "ml-date";
        const icons = document.createElement("span");
        icons.className = "ml-status-icons";
        renderStatusIcons(icons, msg);
        const dateText = document.createElement("span");
        dateText.className = "ml-date-text";
        dateText.textContent = formatDate(msg.date);
        date.appendChild(icons);
        date.appendChild(dateText);

        row.appendChild(avatar);
        row.appendChild(flag);
        row.appendChild(from);
        row.appendChild(date);
        row.appendChild(subject);

        row.addEventListener("click", (e) => this.onRowClick(e));
        row.addEventListener("dblclick", (e) => this.onRowDoubleClick(e));
        row.addEventListener("dragstart", (e) => this.onDragStart(e));
        row.addEventListener("dragend", () => row.classList.remove("dragging"));
        row.addEventListener("contextmenu", (e) => this.onRowContextMenu(e));
        this.wireLongPress(row);
    }

    /** Insert this row into a list body and register it in the lookup map. */
    attach(body: HTMLElement): void {
        body.appendChild(this.el);
        rowByKey.set(rowKey(this.accountId, this.msg.uid), this);
    }

    /** Remove this row from the DOM and the lookup map. If it was the
     *  focused row, the controller is responsible for releasing focus
     *  (this method doesn't auto-clear the viewer because the controller
     *  may want to hand focus to a sibling instead). */
    detach(): void {
        this.el.remove();
        rowByKey.delete(rowKey(this.accountId, this.msg.uid));
    }

    setSelected(yes: boolean): void { this.el.classList.toggle("selected", yes); }
    get isSelected(): boolean { return this.el.classList.contains("selected"); }
    setUnreadClass(yes: boolean): void { this.el.classList.toggle("unread", yes); }
    setFlaggedClass(yes: boolean): void {
        this.el.classList.toggle("flagged", yes);
        this.flagEl.textContent = yes ? "★" : "☆";
    }
    markBodyCached(): void { this.el.classList.remove("not-downloaded"); }
    /** Update the row's date column. Used by the draft-saved listener so
     *  Ctrl+S in compose bumps the time in the Drafts list immediately
     *  rather than waiting for the next IMAP APPEND + sync round-trip. */
    setDate(epochMs: number): void {
        this.msg.date = epochMs;
        const el = this.el.querySelector(".ml-date-text") as HTMLElement | null;
        if (el) el.textContent = formatDate(epochMs);
    }

    /** Re-render the attachment / replied / forwarded glyphs in the date
     *  cell. Called when a flag-change event (Answered, $Forwarded) lands
     *  for this row so the icon strip reflects current state without a
     *  full row rebuild. */
    refreshStatusIcons(): void {
        const icons = this.el.querySelector(".ml-status-icons") as HTMLElement | null;
        if (icons) renderStatusIcons(icons, this.msg);
    }

    // Visual-state accessors for flag toggles. Read the *visual* state
    // (CSS class), not `this.msg.flags`, because the flag array can lag
    // the rendered class — auto-mark-as-read removes the `unread` class
    // immediately on click but `\Seen` doesn't land in `msg.flags` until
    // the auto-mark timer fires (default 2 s). Right-click menus and
    // keyboard shortcuts ask "what does the user see?" — that's what
    // these getters answer. Setters keep both visual + flag-array in
    // sync so the next read by either side agrees.
    get isSeen(): boolean { return !this.el.classList.contains("unread"); }
    set isSeen(yes: boolean) {
        this.setUnreadClass(!yes);
        setSeen(this.msg, yes);
    }
    get isFlagged(): boolean { return this.el.classList.contains("flagged"); }
    set isFlagged(yes: boolean) {
        this.setFlaggedClass(yes);
        setFlagged(this.msg, yes);
    }

    private onAvatarClick(e: MouseEvent): void {
        e.stopPropagation();
        const body = document.getElementById("ml-body");
        if (!body) return;
        // Desktop: clicking the avatar selects the message like a normal row
        // click — it does NOT drop into multi-select mode. The avatar as a
        // multi-select trigger is a TOUCH affordance (no modifier keys on a
        // phone); on desktop it just entered multi-select, which blanks every
        // avatar circle (.multi-select-on .ml-avatar::before) and confused
        // the user (Bob 2026-05-22: "if I select a letter all the markings go
        // away"). Ctrl/Shift are the desktop multi-select mechanism.
        const isTouch = !!window.matchMedia?.("(pointer: coarse)")?.matches;
        if (!isTouch && !body.classList.contains("multi-select-on")) {
            clearSelection();
            focusRow(this);
            lastClickedRow = this.el;
            return;
        }
        if (body.classList.contains("multi-select-on")) {
            this.setSelected(!this.isSelected);
        } else {
            clearSelection();
            this.setSelected(true);
            body.classList.add("multi-select-on");
        }
        lastClickedRow = this.el;
        updateBulkBar();
    }

    private async onAvatarContextMenu(e: MouseEvent): Promise<void> {
        e.preventDefault();
        e.stopPropagation();
        const { showContextMenu: showMenu } = await import("./context-menu.js");
        const body = document.getElementById("ml-body");
        const visibleRows = body
            ? Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
            : [];
        const selectedCount = body
            ? body.querySelectorAll(".ml-row.selected").length
            : 0;
        showMenu(e.clientX, e.clientY, [
            {
                label: `Select all (${visibleRows.length})`,
                action: () => {
                    if (!body) return;
                    body.classList.add("multi-select-on");
                    for (const r of visibleRows) r.classList.add("selected");
                    lastClickedRow = visibleRows[visibleRows.length - 1] || null;
                    updateBulkBar();
                },
                disabled: visibleRows.length === 0,
            },
            {
                label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
                action: () => exitMultiSelect(),
                disabled: selectedCount === 0,
            },
            {
                label: "Invert selection",
                action: () => {
                    if (!body) return;
                    body.classList.add("multi-select-on");
                    for (const r of visibleRows) r.classList.toggle("selected");
                    lastClickedRow = visibleRows[visibleRows.length - 1] || null;
                    updateBulkBar();
                },
                disabled: visibleRows.length === 0,
            },
        ]);
    }

    private onFlagClick(e: MouseEvent): void {
        e.stopPropagation();
        // Local-first: flip the visual class + in-memory flag array NOW so the
        // star toggles under the cursor with zero latency, then mirror to the
        // server in the background. The earlier `await updateFlags(...)`
        // pattern (flip-after-roundtrip) made the click feel sluggish and
        // left the viewer's header star out of sync until the IPC returned
        // (Bob 2026-05-25: "clicked * and it wasn't immediately reflected").
        // On server-side failure, revert both visual + flags and surface
        // nothing — the row remains in its pre-click state, identical to a
        // never-clicked outcome.
        const newFlaggedState = !this.el.classList.contains("flagged");
        const prevFlags = [...(this.msg.flags || [])];
        this.isFlagged = newFlaggedState;
        updateFlags(this.accountId, this.msg.uid, this.msg.flags).catch(() => {
            this.msg.flags = prevFlags;
            this.setFlaggedClass(!newFlaggedState);
        });
    }

    private onRowClick(e: MouseEvent): void {
        if (touchWasScroll) { touchWasScroll = false; return; }
        const body = this.el.parentElement as HTMLElement | null;

        // Shift-click ALWAYS extends a range from the anchor — handled first,
        // before the multi-select-on branch. The first shift-click range
        // works, but selectRange() then sets `multi-select-on`; previously
        // the multi-select-on branch below swallowed every later shift-click
        // (toggled one row, returned) so extending a range a second time
        // failed (Bob 2026-05-22: "shift click ... sometimes it does work").
        if (e.shiftKey) {
            const anchor = resolveShiftAnchor();
            clearSelection();
            if (anchor) selectRange(anchor, this.el);
            lastClickedRow = this.el;
            focusRow(this);
            updateBulkBar();
            return;
        }

        if (body?.classList.contains("multi-select-on")) {
            // Desktop plain click (no Ctrl) COLLAPSES the multi-selection back
            // to a single select and exits multi-select mode. A shift-click
            // range sets `multi-select-on`, which blanks every avatar (they
            // become checkbox circles); without a plain-click exit the user
            // was stuck with blank circles and no obvious way out (Bob
            // 2026-05-22). Touch keeps tap-to-toggle — long-press is the
            // touch multi-select gesture and taps add/remove within it.
            const isTouch = !!window.matchMedia?.("(pointer: coarse)")?.matches;
            if (!isTouch && !e.ctrlKey && !e.metaKey) {
                exitMultiSelect();
                focusRow(this);
                lastClickedRow = this.el;
                updateBulkBar();
                return;
            }
            const willBeSelected = !this.isSelected;
            this.setSelected(willBeSelected);
            lastClickedRow = this.el;
            // 2026-05-13: clicking a row in multi-select mode wasn't showing
            // it in the viewer — Bob saw a blue-selected row with the empty
            // "Select a message to read" pane. Drive the viewer off the
            // last-toggled-on row so the message you just clicked is the
            // one you see. Deselecting doesn't change the focus.
            if (willBeSelected) focusRow(this);
            updateBulkBar();
            return;
        }
        // Note: no `setUnreadClass(false)` on click. The unread→read flip
        // is the auto-mark-as-read path's job (showMessage runs an STORE
        // \Seen after the dwell delay; on success it calls setRowSeen).
        // Pre-2026-05-13 we flipped the class immediately on click which
        // produced the "menu says 'Mark unread' on a row whose body never
        // loaded" complaint.
        if (e.ctrlKey || e.metaKey) {
            this.setSelected(!this.isSelected);
            lastClickedRow = this.el;
        } else {
            clearSelection();
            focusRow(this);
            lastClickedRow = this.el;
        }
        updateBulkBar();
    }

    private onRowDoubleClick(e: MouseEvent): void {
        e.preventDefault();
        e.stopPropagation();
        document.dispatchEvent(new CustomEvent("mailx-popout-message", {
            detail: { accountId: this.accountId, uid: this.msg.uid, folderId: this.msg.folderId, subject: this.msg.subject },
        }));
    }

    private onDragStart(e: DragEvent): void {
        if (!this.isSelected) {
            clearSelection();
            this.setSelected(true);
            lastClickedRow = this.el;
        }
        const selected = getSelectedMessages();
        e.dataTransfer!.setData("application/x-mailx-messages", JSON.stringify(selected));
        e.dataTransfer!.setData("application/x-mailx-message", JSON.stringify({
            accountId: this.accountId,
            uid: this.msg.uid,
            folderId: this.msg.folderId,
            subject: this.msg.subject,
        }));
        e.dataTransfer!.effectAllowed = "copyMove";
        this.el.classList.add("dragging");
        if (selected.length > 1) {
            const badge = document.createElement("div");
            badge.textContent = `${selected.length} messages`;
            badge.style.cssText = "position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px";
            document.body.appendChild(badge);
            e.dataTransfer!.setDragImage(badge, 0, 0);
            setTimeout(() => badge.remove(), 0);
        }
    }

    private wireLongPress(row: HTMLElement): void {
        let longPressTimer: ReturnType<typeof setTimeout> | null = null;
        const LONG_PRESS_MS = 550;
        row.addEventListener("touchstart", (_e: TouchEvent) => {
            if (longPressTimer) clearTimeout(longPressTimer);
            longPressTimer = setTimeout(() => {
                longPressTimer = null;
                const body = row.parentElement as HTMLElement | null;
                const alreadyMulti = body?.classList.contains("multi-select-on");
                if (alreadyMulti) {
                    this.setSelected(!this.isSelected);
                } else {
                    clearSelection();
                    this.setSelected(true);
                    body?.classList.add("multi-select-on");
                }
                lastClickedRow = this.el;
                updateBulkBar();
                try { (navigator as any).vibrate?.(20); } catch { /* */ }
            }, LONG_PRESS_MS);
        }, { passive: true });
        const cancelLongPress = () => {
            if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
        };
        row.addEventListener("touchmove", cancelLongPress, { passive: true });
        row.addEventListener("touchend", cancelLongPress, { passive: true });
        row.addEventListener("touchcancel", cancelLongPress, { passive: true });
    }

    private onRowContextMenu(e: MouseEvent): void {
        e.preventDefault();
        const body = this.el.parentElement as HTMLElement | null;
        const inMulti = !!body?.classList.contains("multi-select-on");
        if (!this.isSelected) {
            if (inMulti) {
                this.setSelected(true);
                lastClickedRow = this.el;
            } else {
                clearSelection();
                lastClickedRow = this.el;
                focusRow(this);
            }
        }

        // Read state for the menu label tracks the VISUAL state, not the
        // flag array. On click we remove the `unread` class immediately
        // (optimistic) but `msg.flags` doesn't pick up `\Seen` until the
        // auto-mark-as-read timer fires (default 2 s). Bob 2026-05-13:
        // right-clicking a freshly-clicked row offered "Mark read" while
        // the row already looked read. Reading the class keeps the label
        // honest with what the user sees.
        const isSeen = !this.el.classList.contains("unread");
        const isFlagged = this.el.classList.contains("flagged");
        const accountId = this.accountId;
        const msg = this.msg;
        const self = this;

        const items: MenuItem[] = [
            {
                label: isSeen ? "Mark unread" : "Mark read",
                action: async () => {
                    // Target state = inverse of what the menu showed,
                    // matching what the user clicked. Visual-state-based
                    // labels stay coherent with the auto-mark-as-read
                    // debounce; the new flag array is computed via the
                    // typed setter and sent to the server.
                    const probe = { flags: [...(msg.flags || [])] };
                    setSeen(probe, !isSeen);
                    try {
                        await updateFlags(accountId, msg.uid, probe.flags);
                        msg.flags = probe.flags;
                        state.updateMessageFlags(accountId, msg.uid, probe.flags);
                        self.setUnreadClass(isSeen);
                    } catch { /* ignore */ }
                },
            },
            {
                label: isFlagged ? "Unflag" : "Flag",
                action: async () => {
                    const probe = { flags: [...(msg.flags || [])] };
                    setFlagged(probe, !isFlagged);
                    try {
                        await updateFlags(accountId, msg.uid, probe.flags);
                        msg.flags = probe.flags;
                        self.setFlaggedClass(!isFlagged);
                    } catch { /* ignore */ }
                },
            },
            { label: "", action: () => {}, separator: true },
            // Drafts get an explicit "Edit draft" entry as the primary action —
            // double-click already opens the row in compose, but the context
            // menu had no equivalent (Bob 2026-05-13). Detect via \Draft flag
            // or the Drafts folder context. Edit triggers the same path as
            // double-click: dispatching popout-message lets app.ts's draft
            // detection route through to compose.
            ...((() => {
                const isDraftRow = draftOf(msg) || currentSpecialUse.toLowerCase() === "drafts";
                if (!isDraftRow) return [] as MenuItem[];
                return [
                    {
                        label: "Edit draft",
                        action: () => document.dispatchEvent(new CustomEvent("mailx-popout-message", {
                            detail: { accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject },
                        })),
                    },
                    { label: "", action: () => {}, separator: true },
                ];
            })()),
            { label: "Reply", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })) },
            { label: "Reply All", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })) },
            { label: "Forward", action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })) },
            { label: "", action: () => {}, separator: true },
            {
                label: "Move to folder…",
                action: async () => {
                    const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
                    const uids = selectedRows.length > 0
                        ? selectedRows.map((r: Element) => Number((r as HTMLElement).dataset.uid)).filter(u => !isNaN(u))
                        : [msg.uid];
                    const pick = await pickFolder(accountId, { excludeFolderIds: [msg.folderId] });
                    if (!pick) return;
                    // Local-first: optimistic remove first, fire-and-forget IPC.
                    // The IPC response may stall behind a long simpleParser
                    // (event-loop block) and time out at 120s, but the server-
                    // side move IS already queued at that point. Awaiting +
                    // alerting on timeout misreports a success as a failure.
                    removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
                    const statusSync = document.getElementById("status-sync");
                    if (statusSync) statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? "s" : ""} to ${pick.folderName}…`;
                    apiMoveMessages(accountId, uids, pick.folderId)
                        .then(() => { if (statusSync) statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })
                        .catch((err: any) => {
                            console.error(`Move failed: ${err?.message || err}`);
                            if (statusSync) statusSync.textContent = `Move sync issue: ${err?.message || err}`;
                        });
                },
            },
            { label: "Delete", action: () => document.dispatchEvent(new CustomEvent("mailx-delete")) },
            { label: "", action: () => {}, separator: true },
            { label: "⚠ Mark as spam", action: () => document.getElementById("btn-spam")?.click() },
            { label: "", action: () => {}, separator: true },
            {
                label: "Copy Message-ID",
                action: async () => {
                    if (!msg.messageId) { alert("No Message-ID on this row."); return; }
                    try { await navigator.clipboard.writeText(msg.messageId); } catch { /* */ }
                },
            },
        ];

        showContextMenu(e.clientX, e.clientY, items);
    }
}

function appendMessages(body: HTMLElement, accountId: string, items: any[]): void {
    // Thread grouping: when the list has the "threaded" class, collapse
    // messages sharing the same threadId to a single row showing the most
    // recent message, with a small pill indicating the thread size.
    const threaded = body.classList.contains("threaded");
    let rowsToRender: any[] = items;
    let threadSize: Map<any, number> | null = null;
    if (threaded) {
        const threadMap = new Map<string, any>();
        threadSize = new Map<any, number>();
        for (const msg of items) {
            const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
            const existing = threadMap.get(key);
            if (!existing || (msg.date || 0) > (existing.date || 0)) {
                threadMap.set(key, msg);
            }
        }
        for (const msg of items) {
            const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
            const head = threadMap.get(key);
            if (head) threadSize.set(head, (threadSize.get(head) || 0) + 1);
        }
        rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));
    }
    for (const msg of rowsToRender) {
        const msgAccountId = msg.accountId || accountId;
        const threadCount = threadSize ? (threadSize.get(msg) || 1) : 1;
        const isThreadHead = threadCount > 1 && !!msg.threadId;
        // showAccountTag: true when rendering the unified inbox (no
        // single accountId for the page; each row carries its own and
        // gets a one-letter account chip).
        const showAccountTag = !accountId && !!msgAccountId;
        const row = new MessageRow(msg, msgAccountId, isThreadHead, threadCount, showAccountTag);
        row.attach(body);
    }
}


function formatDate(epochMs: number): string {
    const d = new Date(epochMs);
    const now = new Date();
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());

    if (msgDay.getTime() === today.getTime())
        return d.toLocaleTimeString(undefined, timeFmt);

    if (d.getFullYear() === now.getFullYear())
        return d.toLocaleString(undefined, dateFmtSameYear);

    return d.toLocaleString(undefined, dateFmt);
}

function escapeHtml(s: string): string {
    const div = document.createElement("div");
    div.textContent = s;
    return div.innerHTML;
}

// `draftSaved` from the service tells us a Ctrl+S / autosave wrote new
// content to disk for the draft identified by `previousDraftUid`. Bump
// the time in the row immediately so the Drafts list reflects the save
// instead of waiting for the IMAP APPEND of the new copy + the next sync
// of the Drafts folder. The new UID is unknown at this point; we update
// the existing row's date so the user sees something happen on save.
// Once sync brings the new UID in, the row gets replaced naturally by
// the list rebuild and the date is whatever the server says.
onEvent((ev: any) => {
    if (!ev || ev.type !== "draftSaved") return;
    if (!ev.accountId || ev.previousDraftUid == null) return;
    const row = rowByKey.get(rowKey(ev.accountId, ev.previousDraftUid));
    if (row) row.setDate(typeof ev.savedAt === "number" ? ev.savedAt : Date.now());
});
