/**
 * 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 } 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";

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 = "";
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";

/** 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>();

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

function focusRow(row: MessageRow): void {
    if (focusedRow && focusedRow !== row) focusedRow.setSelected(false);
    row.setSelected(true);
    focusedRow = row;
    // Drive the viewer with the row's own envelope. Single call site;
    // the viewer paints headers immediately, fetches body in background.
    viewerShow(row.accountId, row.msg.uid, row.msg.folderId, undefined, false, row.msg);
    onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);
    document.dispatchEvent(new CustomEvent("mailx-focus-changed", { detail: row.msg }));
}

/** 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): boolean {
    const row = rowByKey.get(rowKey(accountId, uid));
    if (!row) return false;
    focusRow(row);
    return true;
}

/** Release the focus slot and clear the preview pane in one call. */
export function releaseFocus(): void {
    if (focusedRow) focusedRow.setSelected(false);
    focusedRow = null;
    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})`;
}

/** 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);
    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 && currentPage * 50 < totalMessages) {
                loadMoreMessages();
            }
        });
    }

    // 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);

    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)) el.remove();
        }
        if (state.getMessages().length === 0) {
            body.innerHTML = `<div class="ml-empty">No messages</div>`;
        }
    }

    if (outcome.focusedWasRemoved) {
        if (outcome.nextSurvivor && focusByIdentity(outcome.nextSurvivor.accountId, outcome.nextSurvivor.uid)) {
            // focusByIdentity handled the transition (DOM class + viewer).
        } else {
            // No survivor (or its row didn't render) — release focus and
            // clear the pane so there's no orphaned preview.
            releaseFocus();
        }
    }
}

/** Reload the currently displayed folder (preserves current selection) */
export function reloadCurrentFolder(): void {
    if (searchMode) {
        loadSearchResults(currentSearchQuery);
    } else 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. Used by the search-input "cleared to empty" handler so the next
 *  reloadCurrentFolder() call doesn't re-run the stale search query. */
export function clearSearchMode(): void {
    searchMode = false;
    currentSearchQuery = "";
}

/** Load unified inbox (all accounts) */
export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
    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";

    const savedScroll = !autoSelect ? body.scrollTop : 0;
    const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;

    if (autoSelect) {
        body.innerHTML = `<div class="ml-empty">Loading...</div>`;
    }

    try {
        const result = await apiGetUnifiedInbox(1);
        totalMessages = result.total;

        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);

        if (autoSelect) {
            selectFirst(body);
        } else {
            body.scrollTop = savedScroll;
            restoreSelection(body, savedUid);
        }
    } catch (e: any) {
        if (e.name === "AbortError") return;
        body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
    }
}

/** Load search results */
export async function loadSearchResults(query: string, scope = "all", accountId = "", folderId = 0): Promise<void> {
    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();

    body.innerHTML = `<div class="ml-empty">Searching...</div>`;

    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);
            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;
        }

        const result = await searchMessages(query, 1, 50, scope, accountId, folderId);
        totalMessages = result.total;

        if (result.items.length === 0) {
            state.setMessages([]);
            body.innerHTML = `<div class="ml-empty">No results for "${query}"</div>`;
            return;
        }

        state.setMessages(result.items);
        body.innerHTML = "";
        appendMessages(body, "", result.items);
    } 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> {
    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 savedScroll = !autoSelect ? body.scrollTop : 0;
    const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;

    if (autoSelect) {
        body.innerHTML = `<div class="ml-empty">Loading...</div>`;
    }

    try {
        const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
        const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
        totalMessages = result.total;
        updateSortIndicators();

        if (result.items.length === 0) {
            state.setMessages([]);
            body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
            return;
        }

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

        if (autoSelect) {
            selectFirst(body);
        } else {
            requestAnimationFrame(() => {
                body.scrollTop = savedScroll;
                restoreSelection(body, savedUid);
            });
        }
    } catch (e: any) {
        if (e.name === "AbortError") return;
        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, savedUid: string | null | undefined): void {
    if (savedUid) {
        const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`) as HTMLElement;
        if (row) row.classList.add("selected");
    }
}

/** 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 (!msg.flags.includes("\\Seen")) 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, 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 (!msg.flags.includes("\\Seen")) row.classList.add("unread");
        if (msg.flags.includes("\\Flagged")) 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");
        row.dataset.uid = String(msg.uid);
        row.dataset.accountId = accountId;
        row.dataset.folderId = String(msg.folderId);
        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 = msg.flags.includes("\\Flagged") ? "★" : "☆";
        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 ──
        const date = document.createElement("span");
        date.className = "ml-date";
        date.textContent = formatDate(msg.date);

        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"); }

    private onAvatarClick(e: MouseEvent): void {
        e.stopPropagation();
        const body = document.getElementById("ml-body");
        if (!body) 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 async onFlagClick(e: MouseEvent): Promise<void> {
        e.stopPropagation();
        const isFlagged = this.el.classList.contains("flagged");
        const currentFlags: string[] = this.msg.flags || [];
        const newFlags = isFlagged
            ? currentFlags.filter((f: string) => f !== "\\Flagged")
            : [...currentFlags, "\\Flagged"];
        try {
            await updateFlags(this.accountId, this.msg.uid, newFlags);
            this.msg.flags = newFlags;
            this.setFlaggedClass(!isFlagged);
        } catch { /* ignore */ }
    }

    private onRowClick(e: MouseEvent): void {
        if (touchWasScroll) { touchWasScroll = false; return; }
        const body = this.el.parentElement as HTMLElement | null;
        if (body?.classList.contains("multi-select-on")) {
            this.setSelected(!this.isSelected);
            lastClickedRow = this.el;
            updateBulkBar();
            return;
        }
        if (e.shiftKey) {
            const anchor = resolveShiftAnchor();
            if (anchor) {
                clearSelection();
                selectRange(anchor, this.el);
                lastClickedRow = this.el;
                this.setUnreadClass(false);
                focusRow(this);
            } else {
                clearSelection();
                focusRow(this);
                lastClickedRow = this.el;
                this.setUnreadClass(false);
            }
        } else if (e.ctrlKey || e.metaKey) {
            this.setSelected(!this.isSelected);
            lastClickedRow = this.el;
        } else {
            clearSelection();
            focusRow(this);
            lastClickedRow = this.el;
            this.setUnreadClass(false);
        }
        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);
            }
        }

        const isSeen = this.msg.flags.includes("\\Seen");
        const isFlagged = this.msg.flags.includes("\\Flagged");
        const accountId = this.accountId;
        const msg = this.msg;
        const self = this;

        const items: MenuItem[] = [
            {
                label: isSeen ? "Mark unread" : "Mark read",
                action: async () => {
                    const newFlags = isSeen
                        ? msg.flags.filter((f: string) => f !== "\\Seen")
                        : [...msg.flags, "\\Seen"];
                    try {
                        await updateFlags(accountId, msg.uid, newFlags);
                        msg.flags = newFlags;
                        state.updateMessageFlags(accountId, msg.uid, newFlags);
                        self.setUnreadClass(!newFlags.includes("\\Seen"));
                    } catch { /* ignore */ }
                },
            },
            {
                label: isFlagged ? "Unflag" : "Flag",
                action: async () => {
                    const newFlags = isFlagged
                        ? msg.flags.filter((f: string) => f !== "\\Flagged")
                        : [...msg.flags, "\\Flagged"];
                    try {
                        await updateFlags(accountId, msg.uid, newFlags);
                        msg.flags = newFlags;
                        self.setFlaggedClass(newFlags.includes("\\Flagged"));
                    } catch { /* ignore */ }
                },
            },
            { 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;
                    try {
                        await apiMoveMessages(accountId, uids, pick.folderId);
                        removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
                    } catch (err: any) {
                        alert(`Move failed: ${err.message}`);
                    }
                },
            },
            { 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;
}
