import { writable, derived, get } from 'svelte/store';
import Dexie from 'dexie';
import { Remote } from '../utils/remote';
import { Local, Session, Accounts } from '../utils/storage';
import { db } from '../utils/db';
import { mailboxStore } from './mailboxStore';
import { searchStore } from './searchStore';
import {
  syncSettings,
  clearSettings,
  applySettings,
  createLabel as createSettingsLabel,
  updateLabel as updateSettingsLabel,
  deleteLabel as deleteSettingsLabel,
  settingsActions,
  bodyIndexing,
  loadProfileName,
  loadProfileImage,
  settingsLabels,
  fetchLabels,
  fetchAccountData,
  effectiveLayoutMode,
  setSettingValue,
  getEffectiveSettingValue,
} from './settingsStore';
import { resolveSpamReportAddress, buildSpamReportEmail } from '../utils/spam-report.js';
import { normalizeLayoutMode } from './settingsRegistry';
import { getMessageApiId } from '../utils/sync-helpers';
import {
  getSafeFilename,
  looksLikeHtml,
  normalizeHeaders,
  buildOriginalViewerPage,
  pickOriginalContent,
} from './mailbox-actions-helpers';
import { startInitialSync, queueBodiesForFolder } from '../utils/sync-controller';
import { resetSyncWorkerReady, revokeSyncWorkerAuth } from '../utils/sync-worker-client.js';
import { isDemoBlockedError, isDemoMode } from '../utils/demo-mode';
import { clearMailServiceState } from './mailService';
import { normalizeEmail, dedupeAddresses, extractAddressList } from '../utils/address.ts';
import { validateLabelName } from '../utils/label-validation.ts';
import { resolveSearchBodyIndexing } from '../utils/search-body-indexing.js';
import { LABEL_PALETTE, canonicalizeLabelKeyword } from '../utils/labels.js';
import { isHiddenLabel } from '../utils/label-filters';
import { queueMutation } from '../utils/mutation-queue';
import { config } from '../config';
import { createInboxUpdater } from '../utils/websocket-updater';
import { getWebSocketManager } from '../utils/websocket-manager.js';
import { i18n } from '../utils/i18n';
import { isTauri, isTauriDesktop, isTauriMobile, swReadyWithTimeout } from '../utils/platform.js';
import { downloadFile } from '../utils/download';
import { warn } from '../utils/logger.ts';
import { resetTabs } from './tabStore';
import { parseReferences } from '../utils/threading';
import { isOnline } from '../utils/network-status';

/**
 * Additional mailbox actions that were in MailboxView
 * These complement the core actions in mailboxStore
 */

// Additional state stores
export const storageUsed = writable(0);
export const storageTotal = writable(0);
export const localUsage = writable(0);
export const localQuota = writable(0);
export const indexCount = writable(0);
export const indexSize = writable(0);
export const syncPending = writable(0);

// Sync progress state
export const syncProgress = writable({
  active: false,
  stage: '', // 'metadata' | 'bodies'
  folder: '',
  current: 0,
  total: 0,
  message: '',
});
export const initialSyncStarted = writable(false);

// Index progress state
export const indexProgress = writable({
  active: false,
  current: 0,
  total: 0,
  message: '',
});

export const bodyIndexingEnabled = writable(resolveSearchBodyIndexing());
export const accounts = writable([]);
export const currentAccount = writable(Local.get('email') || '');
export const accountMenuOpen = writable(false);
export const mobileReader = writable(false);
export const bulkMoveOpen = writable(false);
export const availableMoveTargets = writable([]);
export const availableLabels = writable([]);
export const selectedConversation = writable(null);
export const starredOnly = mailboxStore.state.starredOnly;
export const layoutMode = derived(effectiveLayoutMode, ($mode) => normalizeLayoutMode($mode));

// Navigation reference (set by main.js)
let navigateRef = null;
export const setNavigate = (fn) => {
  navigateRef = fn;
};

// Toast reference (set by main.js)
let toastsRef = null;
export const setToasts = (toasts) => {
  toastsRef = toasts;
};
let missingMessageIdToastShown = false;
const showMissingMessageIdToast = (action) => {
  if (missingMessageIdToastShown) return;
  toastsRef?.show?.(`Unable to ${action}: missing server message id.`, 'error');
  missingMessageIdToastShown = true;
};

let loadPromise = null;
let loadAccount = null;
let loadGeneration = 0;
let inboxUpdater = null;

// Compose modal reference (set by main.js)
let composeModalRef = null;
export const setComposeModal = (modal) => {
  composeModalRef = modal;
};

const getUserEmails = () => {
  const emails = new Set();
  const primary = Local.get('email');
  if (primary) emails.add(normalizeEmail(primary));

  const aliasAuth = Local.get('alias_auth');
  if (aliasAuth && aliasAuth.includes(':')) {
    emails.add(normalizeEmail(aliasAuth.split(':')[0]));
  }

  const acctList = get(accounts) || [];
  acctList.forEach((acct) => {
    if (acct?.email) emails.add(normalizeEmail(acct.email));
  });

  return emails;
};

export const computeReplyTargets = (msg = {}, options = {}) => {
  const { replyAll = false } = options;
  const selfEmails = getUserEmails();
  const isSelf = (addr) => selfEmails.has(normalizeEmail(addr));

  const replyToList = extractAddressList(msg, 'replyTo').length
    ? extractAddressList(msg, 'replyTo')
    : extractAddressList(msg, 'reply_to');
  const fromList = extractAddressList(msg, 'from');
  const toList = extractAddressList(msg, 'to').length
    ? extractAddressList(msg, 'to')
    : extractAddressList(msg, 'recipients');
  const ccList = extractAddressList(msg, 'cc');

  const nonMeReplyTo = replyToList.filter((a) => !isSelf(a));
  const nonMeFrom = fromList.filter((a) => !isSelf(a));
  const nonMeTo = toList.filter((a) => !isSelf(a));
  const nonMeCc = ccList.filter((a) => !isSelf(a));

  // Check if the message was sent by the current user
  const isOwnMessage = fromList.length > 0 && fromList.every((a) => isSelf(a));

  // For simple reply, prioritize the sender — but if the message is from
  // yourself (sent copy), reply to the original TO recipients instead.
  const pickReplyAnchor = () => {
    if (isOwnMessage) {
      return nonMeTo[0] || toList[0] || nonMeReplyTo[0] || replyToList[0];
    }
    return (
      nonMeReplyTo[0] || replyToList[0] || nonMeFrom[0] || fromList[0] || nonMeTo[0] || toList[0]
    );
  };

  let toRecipients = [];
  if (replyAll) {
    if (isOwnMessage) {
      // Replying to own message: TO recipients are the primary targets
      toRecipients.push(...nonMeTo);
      if (!toRecipients.length) toRecipients.push(...toList);
    } else {
      // Step 1: Add the sender (prefer reply-to, fallback to from)
      if (nonMeReplyTo.length) {
        toRecipients.push(...nonMeReplyTo);
      } else if (nonMeFrom.length) {
        toRecipients.push(...nonMeFrom);
      }
      // Step 2: Add all non-self TO recipients
      toRecipients.push(...nonMeTo);
    }
  } else {
    // For simple reply, always include the anchor (even if it's yourself)
    const anchor = pickReplyAnchor();
    if (anchor) toRecipients = [anchor];
  }

  // For reply all, include non-self CC only
  let ccRecipients = replyAll ? dedupeAddresses(nonMeCc) : [];

  return {
    to: dedupeAddresses(toRecipients),
    cc: ccRecipients,
    anchor: pickReplyAnchor() || '',
  };
};

export const setLayoutMode = (mode) => {
  return setSettingValue('layout_mode', mode, { account: Local.get('email') || 'default' });
};

/**
 * Load initial mailbox data
 */
export const load = async () => {
  const nextAccount = Local.get('email') || 'default';
  initialSyncStarted.set(true);
  if (loadPromise && loadAccount === nextAccount) {
    return loadPromise;
  }
  const thisGeneration = ++loadGeneration;
  loadAccount = nextAccount;
  loadPromise = (async () => {
    await loadAccounts();
    if (thisGeneration !== loadGeneration) return;

    // Sync settings in background — don't block the load chain on network.
    // Cache is applied synchronously by fetchSettings(); the network refresh
    // updates stores when it arrives but doesn't gate folder/message display.
    const settingsPromise = syncSettings().catch((err) => {
      warn('syncSettings background refresh failed', err);
    });

    // loadFolders must complete before loadMessages (sets selectedFolder)
    const foldersResult = await mailboxStore.actions.loadFolders().then(
      () => ({ status: 'fulfilled' }),
      (err) => ({ status: 'rejected', reason: err }),
    );
    if (thisGeneration !== loadGeneration) return;

    // If loadFolders() failed and we have no folders, bail with error state
    if (foldersResult.status === 'rejected') {
      const hasFolders = get(mailboxStore.state.folders)?.length > 0;
      if (!hasFolders) {
        mailboxStore.state.loading.set(false);
        mailboxStore.state.error.set('Failed to load folders. Please try again.');
        warn('load() bailing: loadFolders failed with no cached folders', foldersResult.reason);
        return;
      }
      warn('loadFolders failed but using cached folders', foldersResult.reason);
    }

    const bodyIndexingValue = get(bodyIndexing);
    bodyIndexingEnabled.set(Boolean(bodyIndexingValue));
    searchStore.actions.setIncludeBody(Boolean(bodyIndexingValue)).catch(() => {});

    // Run loadLabels and loadMessages in parallel — labels only affect the
    // filter dropdown, not message rendering. No reason to serialize them.
    const [labelsResult, messagesResult] = await Promise.allSettled([
      loadLabels(),
      mailboxStore.actions.loadMessages(),
    ]);
    if (labelsResult.status === 'rejected') {
      warn('[load] loadLabels failed, continuing without labels', labelsResult.reason);
    }
    if (messagesResult.status === 'rejected') {
      warn('[load] loadMessages failed', messagesResult.reason);
    }
    if (thisGeneration !== loadGeneration) return;

    // Incrementally index any new labels from the freshly loaded messages
    const loadedMsgs = get(mailboxStore.state.messages) || [];
    mergeNewLabels(loadedMsgs, nextAccount).catch(() => {});

    const folders = get(mailboxStore.state.folders) || [];
    // Demo mode is a sandboxed, fully local experience — its folders/messages
    // come from the in-memory demo interceptor, not a real backend. Running the
    // background metadata/body sync there spins the sync worker against fake
    // data and, worse, re-renders the message list mid-interaction (the
    // "Syncing <folder>" progress churn). That churn is a real source of
    // flakiness in interaction e2e on slow runners (selection/toast races) and
    // serves no purpose in demo, so skip it entirely.
    if (folders.length && !isDemoMode()) {
      startInitialSync(nextAccount, folders, { wantBodies: false });
      queueBodiesForFolder('INBOX', nextAccount, { bodyLimit: 100 });
    }

    // Start the multi-account WebSocket updater if not already running.
    // Unlike the old single-account model, we do NOT destroy and recreate
    // on every load() — the manager keeps connections for all accounts alive.
    if (!inboxUpdater) {
      inboxUpdater = createInboxUpdater();
      inboxUpdater.start();
    } else {
      // Reconcile connections in case accounts changed
      const mgr = getWebSocketManager();
      mgr.reconcile();
    }

    // Show this account's last-known storage immediately, then refresh live.
    seedStorageStats(nextAccount);
    updateStorageStats(nextAccount);

    // Prefetch adjacent accounts' INBOX in the SW background
    prefetchAdjacentAccounts(nextAccount);

    // Ensure settings network refresh finishes (best effort, non-blocking for UI)
    await settingsPromise;

    // Retry storage stats with a forced fetch if the first attempt yielded no total.
    // This handles the case where the initial fetchAccountData returned cached/stale
    // data that lacked quota fields, or the request was still in-flight from
    // syncSettings and resolved with partial data.
    if (!get(storageTotal)) {
      updateStorageStats(nextAccount, { force: true });
    }
  })();
  try {
    return await loadPromise;
  } finally {
    if (loadPromise && loadAccount === nextAccount) {
      loadPromise = null;
      loadAccount = null;
    }
  }
};

/**
 * Prefetch adjacent accounts' INBOX via the service worker.
 * Runs after the active account finishes loading so the SW can
 * cache the first page of metadata for instant account switching.
 */
function prefetchAdjacentAccounts(activeAccount) {
  if (!isOnline() || !('serviceWorker' in navigator)) return;
  const allAccounts = Accounts.getAll();
  if (allAccounts.length <= 1) return;

  // Delay prefetch to avoid competing with the active account's initial sync
  setTimeout(async () => {
    try {
      // Use a timeout so this doesn't hang forever on platforms where the
      // SW never activates (Chrome OS Flex, storage quota issues, etc.)
      const reg = await swReadyWithTimeout(5000);
      if (!reg?.active) return;

      for (const acct of allAccounts) {
        if (acct.email === activeAccount) continue;
        const authToken = acct.aliasAuth || (acct.apiKey ? `${acct.apiKey}:` : '');
        if (!authToken) continue;
        reg.active.postMessage({
          type: 'startSync',
          accountId: acct.email,
          folderId: 'INBOX',
          apiBase: config.apiBase,
          authToken,
          pageSize: 50,
          maxMessages: 50,
          fetchBodies: false,
        });
      }
    } catch (err) {
      warn('[prefetchAdjacentAccounts] failed', err);
    }
  }, 5000);
}

/**
 * Toggle read/unread status for a message
 */
export const toggleRead = async (msg) => {
  if (!msg?.id) return;

  const isUnread = msg.is_unread;
  const newFlags = isUnread
    ? [...(msg.flags || []), '\\Seen']
    : (msg.flags || []).filter((f) => f !== '\\Seen');

  // Optimistic update
  const messages = get(mailboxStore.state.messages);
  const nextUnread = !isUnread;
  mailboxStore.state.messages.set(
    messages.map((m) =>
      m.id === msg.id
        ? { ...m, is_unread: nextUnread, is_unread_index: nextUnread ? 1 : 0, flags: newFlags }
        : m,
    ),
  );
  mailboxStore.actions.addPendingFlagMutation(msg.id, {
    is_unread: nextUnread,
    is_unread_index: nextUnread ? 1 : 0,
    flags: newFlags,
  });

  // Invalidate in-memory folder cache so stale flags don't survive into
  // the next loadMessages() cycle triggered by a WebSocket sync refresh.
  const account = Local.get('email') || 'default';
  mailboxStore.actions.invalidateFolderInMemCache?.(account, msg.folder);

  const apiId = getMessageApiId(msg);
  if (!apiId) {
    warn('toggleRead failed: missing message id');
    mailboxStore.state.messages.set(messages);
    showMissingMessageIdToast('update read status');
    return;
  }

  // Update IDB cache immediately (optimistic)
  await db.messages
    .where('[account+id]')
    .equals([account, msg.id])
    .modify({ is_unread: nextUnread, is_unread_index: nextUnread ? 1 : 0, flags: newFlags })
    .catch(() => {});

  mailboxStore.actions.updateFolderUnreadCounts();

  // Sync to server or queue for later
  const mutationPayload = {
    messageId: apiId,
    isUnread: isUnread,
    flags: msg.flags || [],
    folder: msg.folder,
  };

  if (!isOnline()) {
    await queueMutation('toggleRead', mutationPayload);
    return;
  }

  try {
    await Remote.request(
      'MessageUpdate',
      { flags: newFlags, folder: msg.folder },
      { method: 'PUT', pathOverride: `/v1/messages/${encodeURIComponent(apiId)}` },
    );
  } catch (err) {
    warn('toggleRead failed, queuing for retry', err);
    await queueMutation('toggleRead', mutationPayload);
  }
};

/**
 * Toggle star/flag on a message
 */
export const toggleStar = async (msg) => {
  if (!msg?.id) return;

  const isStarred = msg.is_starred || (msg.flags || []).includes('\\Flagged');
  const newFlags = new Set(msg.flags || []);

  if (isStarred) {
    newFlags.delete('\\Flagged');
  } else {
    newFlags.add('\\Flagged');
  }

  // Optimistic update in message list and selected message
  const messages = get(mailboxStore.state.messages);
  const newFlagsArr = Array.from(newFlags);
  mailboxStore.state.messages.set(
    messages.map((m) =>
      m.id === msg.id ? { ...m, is_starred: !isStarred, flags: newFlagsArr } : m,
    ),
  );
  mailboxStore.actions.addPendingFlagMutation(msg.id, {
    is_starred: !isStarred,
    flags: newFlagsArr,
  });

  // Invalidate in-memory folder cache so stale flags don't survive into
  // the next loadMessages() cycle triggered by a WebSocket sync refresh.
  const account = Local.get('email') || 'default';
  mailboxStore.actions.invalidateFolderInMemCache?.(account, msg.folder);

  const currentSelected = get(mailboxStore.state.selectedMessage);
  if (currentSelected?.id === msg.id) {
    mailboxStore.state.selectedMessage.set({
      ...currentSelected,
      is_starred: !isStarred,
      flags: newFlagsArr,
    });
  }

  const apiId = getMessageApiId(msg);
  if (!apiId) {
    warn('toggleStar failed: missing message id');
    mailboxStore.state.messages.set(messages);
    if (currentSelected?.id === msg.id) {
      mailboxStore.state.selectedMessage.set(currentSelected);
    }
    showMissingMessageIdToast('update star');
    return;
  }

  // Update IDB cache immediately (optimistic)
  await db.messages
    .where('[account+id]')
    .equals([account, msg.id])
    .modify({ flags: Array.from(newFlags), is_starred: !isStarred })
    .catch(() => {});

  // Sync to server or queue for later
  const mutationPayload = {
    messageId: apiId,
    isStarred: isStarred,
    flags: msg.flags || [],
    folder: msg.folder,
  };

  if (!isOnline()) {
    await queueMutation('toggleStar', mutationPayload);
    return;
  }

  try {
    await Remote.request(
      'MessageUpdate',
      { flags: Array.from(newFlags), folder: msg.folder },
      { method: 'PUT', pathOverride: `/v1/messages/${encodeURIComponent(apiId)}` },
    );
  } catch (err) {
    warn('toggleStar failed, queuing for retry', err);
    await queueMutation('toggleStar', mutationPayload);
  }
};

/**
 * Archive a message
 */
export const archiveMessage = async (msg) => {
  return mailboxStore.actions.archiveMessage(msg);
};

/**
 * Delete a message
 */
export const deleteMessage = async (msg, options = {}) => {
  return mailboxStore.actions.deleteMessage(msg, options);
};

/**
 * Validate and sanitize cached body content
 * Returns sanitized content or empty string if content is corrupted
 */
const validateCachedBody = (content) => {
  if (!content || typeof content !== 'string') return '';

  // Only "repair" content that is genuinely double-encoded — i.e. the HTML tags
  // themselves were entity-escaped, so the body is escaped source like
  // "&lt;div&gt;...&lt;/div&gt;" with no real tags. In that case decoding the
  // entities recovers the original markup.
  //
  // We must NOT touch real HTML that merely *contains* entities. A normal email
  // body routinely has "&gt;" (from a ">" in the visible text after HTML
  // serialization), "&lt;email@addr&gt;", "&nbsp;", etc. Flattening such a body
  // via textContent would strip every tag, line break, and inline image —
  // exactly the "quoted reply collapses into a wall of text" bug.
  const hasRealHtmlTags = /<[a-zA-Z!/]/.test(content);
  const looksDoubleEncoded = /&lt;[a-zA-Z/!]|&amp;lt;/.test(content);

  if (!hasRealHtmlTags && looksDoubleEncoded) {
    warn('[validateCachedBody] Detected double-encoded HTML in cached body, decoding');
    try {
      // Use DOMParser instead of textarea.innerHTML to safely decode HTML entities
      // textarea.innerHTML is vulnerable to XSS if content contains script-like patterns
      if (typeof DOMParser !== 'undefined') {
        const doc = new DOMParser().parseFromString(content, 'text/html');
        const unescaped = doc.body?.textContent || '';
        // Only accept the decode if it actually revealed real HTML markup
        if (unescaped && /<[a-zA-Z!/]/.test(unescaped)) {
          return unescaped;
        }
      }
    } catch {
      // Fall through and return the content untouched
    }
    // Could not decode into real HTML — return as-is rather than destroying it
    warn('[validateCachedBody] Could not decode double-encoded cache, leaving as-is');
    return content;
  }

  return content;
};

/**
 * Strip quote-collapse markup (fe-quote-wrapper, fe-quote-toggle) that
 * processQuotedContent adds for the viewing UI. The store's messageBody
 * contains this processed HTML, but replies/forwards need the raw body.
 */
export const stripQuoteCollapseMarkup = (html: string) => {
  if (!html || !html.includes('fe-quote-wrapper')) return html;
  try {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    // Remove all toggle buttons
    doc.querySelectorAll('.fe-quote-toggle').forEach((el) => el.remove());
    // Unwrap fe-quote-content divs (move children up)
    doc.querySelectorAll('.fe-quote-content').forEach((el) => {
      while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
      el.remove();
    });
    // Unwrap fe-quote-wrapper divs (move children up)
    doc.querySelectorAll('.fe-quote-wrapper').forEach((el) => {
      while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
      el.remove();
    });
    return doc.body.innerHTML;
  } catch {
    return html;
  }
};

/**
 * Get the body for a message - checks store first, then fetches from cache
 */
export const getMessageBodyForReply = async (msg) => {
  const msgId = getMessageApiId(msg);
  if (!msgId) return '';

  // Check if this is the currently selected message - use store value if so
  const selectedMessage = get(mailboxStore.state.selectedMessage);
  const selectedId = getMessageApiId(selectedMessage);
  const storeBody = get(mailboxStore.state.messageBody);

  if (selectedId && selectedId === msgId && storeBody) {
    // Store body has processQuotedContent markup — strip it for reply/forward
    return validateCachedBody(stripQuoteCollapseMarkup(storeBody));
  }

  // Otherwise fetch from the database cache
  const account = Local.get('email') || 'default';
  try {
    const cached = await db.messageBodies.get([account, msgId]);
    if (cached?.body) {
      const validated = validateCachedBody(cached.body);
      if (!validated && cached.body) {
        // Cache was corrupted, delete it so it gets re-fetched next time
        try {
          await db.messageBodies.delete([account, msgId]);
        } catch {
          // Ignore deletion errors
        }
      }
      return validated;
    }
    if (cached?.textContent) return validateCachedBody(cached.textContent);
  } catch (err) {
    warn('[getMessageBodyForReply] Failed to fetch message body from cache:', err);
  }

  // If not in cache, return empty string (caller will use snippet as fallback)
  // No network fetch to keep replies instant
  return '';
};

/**
 * Format a date for reply/forward attribution
 */
const formatAttributionDate = (date) => {
  if (!date) return '';
  const d = new Date(typeof date === 'number' ? date : date);
  if (isNaN(d.getTime())) return '';
  return d.toLocaleString(i18n.getFormattingLocale(), {
    weekday: 'short',
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
  });
};

/**
 * Encode HTML content as base64 for the RawHtmlQuote TipTap extension.
 * This preserves original email styling (fonts, colors, spacing) by avoiding
 * TipTap's standard HTML parsing which strips inline styles.
 */
const encodeRawHtml = (value: string) => {
  if (!value) return '';
  try {
    return btoa(unescape(encodeURIComponent(value)));
  } catch {
    return '';
  }
};

/**
 * Build quoted body HTML for reply
 */
export const buildReplyQuotedBody = (msg, originalBody) => {
  const fromName = msg?.from || 'Unknown';
  const dateStr = formatAttributionDate(msg?.date || msg?.dateMs);
  const attribution = dateStr ? `On ${dateStr}, ${fromName} wrote:` : `${fromName} wrote:`;
  const encoded = encodeRawHtml(originalBody);

  return `<p><br></p><p><br></p><p class="fe-reply-attribution">${attribution}</p><blockquote data-raw-html="${encoded}" data-raw-variant="reply"></blockquote>`;
};

/**
 * Build quoted body HTML for forward
 */
export const buildForwardQuotedBody = (msg, originalBody) => {
  const from = msg?.from || '';
  const to = Array.isArray(msg?.to) ? msg.to.join(', ') : msg?.to || '';
  const dateStr = formatAttributionDate(msg?.date || msg?.dateMs);
  const subject = msg?.subject || '';

  const headerLines = [
    '---------- Forwarded message ---------',
    `From: ${from}`,
    `Date: ${dateStr}`,
    `Subject: ${subject}`,
    `To: ${to}`,
  ].join('<br>');

  const encoded = encodeRawHtml(originalBody);

  return `<p><br></p><p><br></p><p><br></p><p class="fe-reply-attribution">${headerLines}</p><blockquote data-raw-html="${encoded}" data-raw-variant="forward"></blockquote>`;
};

/**
 * Add RE: prefix if not already present
 */
export const addReplyPrefix = (subject) => {
  if (!subject) return 'Re: ';
  const trimmed = subject.trim();
  if (/^re:\s*/i.test(trimmed)) return trimmed;
  return `Re: ${trimmed}`;
};

/**
 * Add Fwd: prefix if not already present
 */
export const addForwardPrefix = (subject) => {
  if (!subject) return 'Fwd: ';
  const trimmed = subject.trim();
  if (/^fwd?:\s*/i.test(trimmed)) return trimmed;
  return `Fwd: ${trimmed}`;
};

/**
 * Reply to a message
 * Opens compose immediately and loads body asynchronously
 */
export const replyTo = async (msg, options = {}) => {
  if (!composeModalRef) return;
  const targets = computeReplyTargets(msg, options);
  const headerRecord =
    msg?.nodemailer?.headers?.['message-id'] || msg?.nodemailer?.headers?.['Message-ID'];
  const headerValue = headerRecord?.value || headerRecord?.text || headerRecord;
  const headerId = Array.isArray(headerValue) ? headerValue[0] : headerValue;
  const inReplyTo =
    msg?.message_id ||
    msg?.messageId ||
    msg?.header_message_id ||
    msg?.headerMessageId ||
    msg?.['Message-ID'] ||
    msg?.['message-id'] ||
    msg?.nodemailer?.messageId ||
    msg?.nodemailer?.message_id ||
    headerId ||
    '';

  // Build References header per RFC 2822:
  // New References = original message's References + original message's Message-ID
  const parentRefs = parseReferences(
    msg?.references ||
      msg?.References ||
      msg?.nodemailer?.references ||
      msg?.nodemailer?.headers?.references ||
      msg?.nodemailer?.headers?.References,
  );
  const normalizedInReplyTo = inReplyTo
    ? inReplyTo.startsWith('<')
      ? inReplyTo
      : `<${inReplyTo}>`
    : '';
  // Append the parent's Message-ID if not already in the chain
  const refsArray = [...parentRefs];
  if (normalizedInReplyTo && !refsArray.includes(normalizedInReplyTo)) {
    refsArray.push(normalizedInReplyTo);
  }
  const references = refsArray.join(' ');

  // Determine which of the user's own accounts received this message.
  // The `from` field in the prefill must be the user's address (the one
  // that appeared in To/CC of the original message), NOT the sender's
  // address.  Using the sender's address causes the SMTP API to reject
  // with "From header must be equal to ..." when replying from a
  // secondary account.
  const selfEmails = getUserEmails();
  const toList = extractAddressList(msg, 'to');
  const ccList = extractAddressList(msg, 'cc');
  const allRecipients = [...toList, ...ccList];
  let replyFromAddress = '';
  for (const addr of allRecipients) {
    const email = normalizeEmail(addr);
    if (email && selfEmails.has(email)) {
      replyFromAddress = email;
      break;
    }
  }
  // Fallback: use the current account email
  if (!replyFromAddress) {
    replyFromAddress = get(currentAccount) || Local.get('email') || '';
  }

  // Open compose immediately with loading state
  const replyToApiId = getMessageApiId(msg);
  composeModalRef.reply?.({
    subject: addReplyPrefix(msg?.subject),
    from: replyFromAddress,
    to: targets.to,
    cc: targets.cc,
    date: msg?.date || msg?.dateMs,
    html: '<p><br></p>', // Start with empty paragraph for cursor position
    bodyLoading: true, // Signal that body is loading
    inReplyTo,
    references,
    replyToMessageId: replyToApiId || null,
    replyToMessageFolder: msg?.folder || null,
  });

  // Fetch body from cache (no network request)
  try {
    const messageBodyValue = await getMessageBodyForReply(msg);

    // If we got a cached body, use it; otherwise use snippet (no error shown)
    const bodyToUse = messageBodyValue || msg?.snippet || msg?.textContent || '';
    const quotedBody = buildReplyQuotedBody(msg, bodyToUse);
    composeModalRef.updateReplyBody?.(quotedBody, { inReplyTo, focusTop: true });
  } catch (err) {
    warn('[replyTo] Failed to load body:', err);
    // Use snippet as fallback
    const fallbackBody = msg?.snippet || msg?.textContent || '';
    const quotedBody = buildReplyQuotedBody(msg, fallbackBody);
    composeModalRef.updateReplyBody?.(quotedBody, { inReplyTo, focusTop: true });
  }
};

export const replyAll = (msg) => replyTo(msg, { replyAll: true });

export const forwardMessage = async (msg) => {
  if (!composeModalRef) return;

  // Try to get cached body first
  const messageBodyValue = await getMessageBodyForReply(msg);

  // Use fallback values if body is not available (same as replyTo)
  const bodyToUse = messageBodyValue || msg?.snippet || msg?.textContent || '';
  const quotedBody = buildForwardQuotedBody(msg, bodyToUse);

  // Determine which of the user's own accounts received this message.
  // Same logic as replyTo — the `from` field must be the user's address
  // that appeared in To/CC of the original message, not the sender's address.
  const selfEmails = getUserEmails();
  const toList = extractAddressList(msg, 'to');
  const ccList = extractAddressList(msg, 'cc');
  const allRecipients = [...toList, ...ccList];
  let forwardFromAddress = '';
  for (const addr of allRecipients) {
    const email = normalizeEmail(addr);
    if (email && selfEmails.has(email)) {
      forwardFromAddress = email;
      break;
    }
  }
  if (!forwardFromAddress) {
    forwardFromAddress = get(currentAccount) || Local.get('email') || '';
  }

  composeModalRef.forward?.({
    subject: addForwardPrefix(msg?.subject),
    from: forwardFromAddress,
    html: quotedBody,
  });
};

/**
 * Search handler
 */
export const onSearch = async (term) => {
  try {
    await mailboxStore.actions.searchMessages(term);
  } catch (err) {
    warn('[mailboxActions] search failed', err);
    mailboxStore.state.query.set(term || '');
  }
};

/**
 * Load messages (wrapper)
 */
export const loadMessages = () => {
  return mailboxStore.actions.loadMessages();
};

/**
 * Get selected conversations
 */
export const getSelectedConversations = () => {
  const selectedIds = get(mailboxStore.state.selectedConversationIds);
  const conversations = get(mailboxStore.state.filteredConversations);
  return conversations.filter((c) => selectedIds.includes(c.id));
};

/**
 * Get selected messages from conversations
 */
export const getSelectedMessagesFromConversations = () => {
  const convs = getSelectedConversations();
  return convs.flatMap((c) => c.messages || []);
};

/**
 * Bulk move selected conversations
 */
export const bulkMoveTo = async (target) => {
  const messages = getSelectedMessagesFromConversations();
  if (!messages.length || !target) return;

  let successCount = 0;
  for (const msg of messages) {
    const result = await mailboxStore.actions.moveMessage(msg, target, { stayInFolder: true });
    if (result.success) successCount++;
  }

  mailboxStore.state.selectedConversationIds.set([]);
  bulkMoveOpen.set(false);

  if (successCount > 0) {
    toastsRef?.show?.(`Moved ${successCount} message(s)`, 'success');
    await mailboxStore.actions.loadMessages();
  } else {
    toastsRef?.show?.('Failed to move messages', 'error');
  }
};

/**
 * Context menu: move message
 */
export const contextMoveTo = async (msg, target) => {
  if (!msg || !target) return;
  const result = await mailboxStore.actions.moveMessage(msg, target, { stayInFolder: true });
  if (result.success) {
    toastsRef?.show?.('Message moved', 'success');
    await mailboxStore.actions.loadMessages();
  } else {
    toastsRef?.show?.('Failed to move message', 'error');
  }
};

/**
 * Context menu: label message (placeholder)
 */
export const contextLabel = async (msgOrLabel, labelMaybe, options = {}) => {
  const msg = labelMaybe
    ? msgOrLabel
    : msgOrLabel && msgOrLabel.id
      ? msgOrLabel
      : get(mailboxStore.state.selectedMessage);
  const label = labelMaybe || msgOrLabel;
  const { action = 'toggle', silent = false } = options || {};
  if (!msg || !label) return;
  const msgId = msg?.id;
  const messageList = get(mailboxStore.state.messages) || [];
  const latest = msgId != null ? messageList.find((entry) => entry?.id === msgId) : null;
  const targetMsg = latest || msg;
  // Canonicalize to the backend's stored form (lowercase) so the optimistic
  // value equals what the server persists — otherwise a "Work" tag shows now
  // but the server returns "work" on refresh and the chip mismatches/vanishes.
  const labelId = canonicalizeLabelKeyword(
    typeof label === 'object' ? label.id || label.keyword || label.value || label.name : label,
  );
  const account = Local.get('email') || 'default';
  const currentLabels = Array.isArray(targetMsg.labels)
    ? targetMsg.labels.map(canonicalizeLabelKeyword)
    : [];
  const hasLabel = currentLabels.includes(labelId);
  let nextLabels = currentLabels;
  if (action === 'add') {
    if (hasLabel) return;
    nextLabels = currentLabels.concat(labelId);
  } else if (action === 'remove') {
    if (!hasLabel) return;
    nextLabels = currentLabels.filter((l) => l !== labelId);
  } else {
    nextLabels = hasLabel
      ? currentLabels.filter((l) => l !== labelId)
      : currentLabels.concat(labelId);
  }

  // Optimistic update in memory
  const msgs = get(mailboxStore.state.messages);
  mailboxStore.state.messages.set(
    msgs.map((m) => (m.id === msgId ? { ...m, labels: nextLabels } : m)),
  );
  const selectedMsg = get(mailboxStore.state.selectedMessage);
  if (selectedMsg?.id === msgId) {
    mailboxStore.state.selectedMessage.set({ ...selectedMsg, labels: nextLabels });
  }

  const apiId = getMessageApiId(targetMsg);
  if (!apiId) {
    warn('contextLabel failed: missing message id');
    mailboxStore.state.messages.set(msgs);
    if (selectedMsg?.id === msgId) {
      mailboxStore.state.selectedMessage.set(selectedMsg);
    }
    if (!silent) {
      showMissingMessageIdToast('update labels');
    }
    return;
  }

  // Update IDB cache immediately (optimistic)
  const candidateIds = new Set();
  const addCandidate = (value) => {
    if (value === undefined || value === null || value === '') return;
    candidateIds.add(value);
    if (typeof value === 'string' && /^\d+$/.test(value)) {
      candidateIds.add(Number(value));
    }
  };
  addCandidate(msgId);
  addCandidate(targetMsg?.uid);
  addCandidate(targetMsg?.message_id);
  addCandidate(targetMsg?.header_message_id);

  for (const candidate of candidateIds) {
    await db.messages
      .where('[account+id]')
      .equals([account, candidate])
      .modify({ labels: nextLabels })
      .catch(() => {});
  }
  await searchStore.actions.indexMessages([{ ...targetMsg, labels: nextLabels }]).catch(() => {});

  const labelList = get(availableLabels);
  const lbl = labelList.find(
    (l) => canonicalizeLabelKeyword(l.id || l.keyword || l.value || l.name) === labelId,
  );
  const labelName = lbl?.name || lbl?.label || lbl?.value || labelId;

  // Sync to server or queue for later
  const mutationPayload = {
    messageId: apiId,
    labels: nextLabels,
  };

  if (!isOnline()) {
    await queueMutation('label', mutationPayload);
    if (!silent) {
      const verb =
        action === 'add'
          ? 'Applied'
          : action === 'remove'
            ? 'Removed'
            : hasLabel
              ? 'Removed'
              : 'Applied';
      toastsRef?.show?.(`${verb} label ${labelName}`, 'success');
    }
    return;
  }

  try {
    await Remote.request(
      'MessageUpdate',
      { labels: nextLabels },
      { method: 'PUT', pathOverride: `/v1/messages/${encodeURIComponent(apiId)}` },
    );
    if (!silent) {
      const verb =
        action === 'add'
          ? 'Applied'
          : action === 'remove'
            ? 'Removed'
            : hasLabel
              ? 'Removed'
              : 'Applied';
      toastsRef?.show?.(`${verb} label ${labelName}`, 'success');
    }
  } catch (err) {
    warn('contextLabel failed, queuing for retry', err);
    await queueMutation('label', mutationPayload);
    if (!silent) {
      const verb =
        action === 'add'
          ? 'Applied'
          : action === 'remove'
            ? 'Removed'
            : hasLabel
              ? 'Removed'
              : 'Applied';
      toastsRef?.show?.(`${verb} label ${labelName} (will sync when online)`, 'success');
    }
  }
};

// Max concurrent /v1/folders/:id detail requests when discovering label flags.
// Bounds the fan-out so label load doesn't serialize one round-trip per folder,
// while staying polite to the API.
export const FOLDER_FLAG_FETCH_CONCURRENCY = 6;

/**
 * Load labels for the current account
 */
export const loadLabels = async (options = {}) => {
  const { includeFolderFlags = false } = options;
  const account = Local.get('email') || 'default';
  const initialAccount = account;
  const labelMap = new Map();
  const palette = LABEL_PALETTE;
  const colorFor = (name = '') => {
    let hash = 0;
    for (let i = 0; i < name.length; i++) {
      hash = (hash << 5) - hash + name.charCodeAt(i);
      hash |= 0;
    }
    const idx = Math.abs(hash) % palette.length;
    return palette[idx];
  };
  const normalizeLabel = (flag = '', folderPath = '') => {
    const id = String(flag || '').trim();
    if (isHiddenLabel(id)) return null;
    const normalized = id.replace(/^\$?label/i, '').replace(/^[\s-_]+/, '');
    const readable = normalized || id.replace(/^[\\$]+/, '');
    const name = readable.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim() || id;
    return {
      id,
      name,
      color: colorFor(id),
      account,
      folder: folderPath,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };
  };
  try {
    // Load any cached labels table first
    const cached = await db.labels
      .where('account')
      .equals(account)
      .toArray()
      .catch(() => []);
    (cached || []).forEach((l) => {
      if (!l?.id || isHiddenLabel(l.id)) return;
      labelMap.set(l.id, l);
    });
  } catch (err) {
    warn('loadLabels cache failed', err);
  }

  try {
    const settingsLabelsList =
      (await fetchLabels(true, { force: true }).catch(() => get(settingsLabels) || [])) || [];
    if ((Local.get('email') || 'default') !== initialAccount) return;
    (settingsLabelsList || []).forEach((lbl) => {
      const id = lbl.keyword || lbl.id || lbl.name;
      if (!id || isHiddenLabel(id)) return;
      const existing = labelMap.get(id);
      labelMap.set(id, {
        ...existing,
        id,
        name: lbl.name || existing?.name || id,
        color: lbl.color || existing?.color || colorFor(id),
        account,
        createdAt: existing?.createdAt || Date.now(),
        updatedAt: Date.now(),
      });
    });
  } catch (err) {
    warn('loadLabels settings labels failed', err);
  }

  // Only scan messages for label discovery on first load (no cached labels).
  // On subsequent loads the labels table already has all previously discovered labels.
  if (labelMap.size === 0) {
    try {
      const allMessages = await db.messages
        .where('account')
        .equals(account)
        .toArray()
        .catch(() => []);
      if (!allMessages?.length) return;
      for (const msg of allMessages) {
        if ((Local.get('email') || 'default') !== initialAccount) break;
        for (const lbl of msg.labels || []) {
          const key = String(lbl);
          if (!labelMap.has(key) && !isHiddenLabel(key)) {
            labelMap.set(key, {
              id: key,
              name: key,
              color: colorFor(key),
              account,
              createdAt: Date.now(),
              updatedAt: Date.now(),
            });
          }
        }
      }
    } catch (err) {
      warn('loadLabels from messages failed', err);
    }
  }

  if (includeFolderFlags) {
    try {
      let folderList = get(mailboxStore.state.folders) || [];
      if (!folderList.length) {
        await mailboxStore.actions.loadFolders();
        folderList = get(mailboxStore.state.folders) || [];
      }
      if ((Local.get('email') || 'default') !== initialAccount) return;
      // Fetch folder details with bounded concurrency instead of one
      // sequential round-trip per folder. Results are kept in folder order and
      // merged afterwards so the first-write-wins behaviour is unchanged.
      const details: Array<{ folder: (typeof folderList)[number]; flags: string[] } | null> =
        new Array(folderList.length).fill(null);
      let cursor = 0;
      const fetchNext = async () => {
        while (true) {
          const idx = cursor++;
          if (idx >= folderList.length) return;
          const folder = folderList[idx];
          if (!folder?.id) continue;
          try {
            const detail = await Remote.request(
              'FolderGet',
              {},
              { method: 'GET', pathOverride: `/v1/folders/${encodeURIComponent(folder.id)}` },
            );
            details[idx] = { folder, flags: detail?.flags || detail?.Flags || [] };
          } catch (err) {
            warn('loadLabels folder detail failed', err);
          }
        }
      };
      const workers = Math.min(FOLDER_FLAG_FETCH_CONCURRENCY, folderList.length);
      await Promise.all(Array.from({ length: workers }, fetchNext));
      // Merge in original folder order to preserve first-write-wins per label id.
      for (const entry of details) {
        if (!entry) continue;
        (entry.flags || []).forEach((flag) => {
          const label = normalizeLabel(flag, entry.folder.path);
          if (label && !labelMap.has(label.id)) {
            labelMap.set(label.id, label);
          }
        });
      }
    } catch (err) {
      warn('loadLabels from folders failed', err);
    }
  }

  if ((Local.get('email') || 'default') !== initialAccount) return;

  const normalized = Array.from(labelMap.values()).filter(
    (l) => l.id && l.name && !isHiddenLabel(l.id),
  );
  availableLabels.set(normalized);
  try {
    await db.labels
      .where('account')
      .equals(account)
      .delete()
      .catch(() => {});
    if (normalized.length) {
      await db.labels.bulkPut(
        normalized.map((l) => ({
          ...l,
          account,
        })),
      );
    }
  } catch (err) {
    warn('loadLabels persist failed', err);
  }
};

/**
 * Incrementally merge labels discovered in a batch of messages
 * into the availableLabels store and db.labels table.
 * Called after messages are loaded/synced to avoid a full table scan.
 */
export const mergeNewLabels = async (messages, account) => {
  if (!messages?.length) return;
  const existing = get(availableLabels) || [];
  const existingIds = new Set(existing.map((l) => l.id));
  const palette = LABEL_PALETTE;
  const colorFor = (name = '') => {
    let hash = 0;
    for (let i = 0; i < name.length; i++) {
      hash = (hash << 5) - hash + name.charCodeAt(i);
      hash |= 0;
    }
    return palette[Math.abs(hash) % palette.length];
  };
  const newLabels = [];
  for (const msg of messages) {
    for (const lbl of msg.labels || []) {
      const key = String(lbl);
      if (!key || existingIds.has(key)) continue;
      if (isHiddenLabel(key)) continue;
      existingIds.add(key);
      newLabels.push({
        id: key,
        name: key.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim() || key,
        color: colorFor(key),
        account,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      });
    }
  }
  if (!newLabels.length) return;
  const activeAccount = Local.get('email') || 'default';
  if (activeAccount !== account) return;
  availableLabels.set([...existing, ...newLabels]);
  try {
    await db.labels.bulkPut(newLabels);
  } catch {
    // Best effort
  }
};

/**
 * Create a label and refresh available labels
 */
export const createLabel = async (name, color = '') => {
  const validation = validateLabelName(name);
  if (!validation.ok) {
    toastsRef?.show?.(validation.error, 'error');
    return { success: false, error: validation.error };
  }
  const trimmed = validation.value;
  try {
    const res = await createSettingsLabel({
      keyword: trimmed,
      name: trimmed,
      color: color || undefined,
    });
    if (!res?.success) {
      if (res?.blocked) return { success: false, blocked: true };
      const msg = res?.error || 'Failed to create label';
      toastsRef?.show?.(msg, 'error');
      return { success: false, error: msg };
    }
    await loadLabels();
    toastsRef?.show?.(`Created label ${trimmed}`, 'success');
    return { success: true, label: res.label };
  } catch (err) {
    warn('createLabel failed', err);
    if (isDemoBlockedError(err)) return { success: false, blocked: true };
    const msg = err?.message || 'Failed to create label';
    toastsRef?.show?.(msg, 'error');
    return { success: false, error: msg };
  }
};

/**
 * Update a label and refresh available labels
 */
export const updateLabel = async (keyword, updates = {}) => {
  const name = updates?.name;
  if (name) {
    const validation = validateLabelName(name);
    if (!validation.ok) {
      toastsRef?.show?.(validation.error, 'error');
      return { success: false, error: validation.error };
    }
  }
  try {
    const res = await updateSettingsLabel(keyword, updates);
    if (!res?.success) {
      if (res?.blocked) return { success: false, blocked: true };
      const msg = res?.error || 'Failed to update label';
      toastsRef?.show?.(msg, 'error');
      return { success: false, error: msg };
    }
    await loadLabels();
    toastsRef?.show?.('Label updated', 'success');
    return { success: true };
  } catch (err) {
    warn('updateLabel failed', err);
    if (isDemoBlockedError(err)) return { success: false, blocked: true };
    const msg = err?.message || 'Failed to update label';
    toastsRef?.show?.(msg, 'error');
    return { success: false, error: msg };
  }
};

/**
 * Delete a label and refresh available labels
 */
export const deleteLabel = async (keyword) => {
  try {
    const res = await deleteSettingsLabel(keyword);
    if (!res?.success) {
      if (res?.blocked) return { success: false, blocked: true };
      const msg = res?.error || 'Failed to delete label';
      toastsRef?.show?.(msg, 'error');
      return { success: false, error: msg };
    }
    await loadLabels();
    toastsRef?.show?.('Label deleted', 'success');
    return { success: true };
  } catch (err) {
    warn('deleteLabel failed', err);
    if (isDemoBlockedError(err)) return { success: false, blocked: true };
    const msg = err?.message || 'Failed to delete label';
    toastsRef?.show?.(msg, 'error');
    return { success: false, error: msg };
  }
};

/**
 * Update storage statistics
 */
// Last-known server storage figures are cached per account so the display is
// available immediately on load/switch and never blanks while the live
// /v1/account fetch is in flight (or if it transiently returns no quota).
const STORAGE_STATS_PREFIX = 'storage_stats_';

export const seedStorageStats = (requestedAccount?: string | null) => {
  const account = requestedAccount || Local.get('email') || 'default';
  try {
    const raw = Local.get(`${STORAGE_STATS_PREFIX}${account}`);
    if (raw) {
      const parsed = JSON.parse(raw);
      if (parsed?.total > 0) {
        storageUsed.set(parsed.used || 0);
        storageTotal.set(parsed.total);
        return;
      }
    }
  } catch {
    // ignore malformed cache
  }
  // No cached figure for this account, so clear it: we never want to show the
  // previous account's usage while the live fetch runs.
  storageUsed.set(0);
  storageTotal.set(0);
};

export const updateStorageStats = async (requestedAccount, { force = false } = {}) => {
  const account = requestedAccount || Local.get('email') || 'default';
  try {
    // Get storage from Account endpoint (same as old MailboxView)
    const res = await fetchAccountData({ force });
    // Verify account hasn't switched while the request was in-flight
    const activeNow = Local.get('email') || 'default';
    if (activeNow !== account) return;
    const used = res?.storage_used || res?.storage_used_by_aliases || 0;
    const total =
      res?.storage_quota ||
      res?.max_quota_per_alias ||
      res?.max_quota ||
      res?.max_quota_per_aliases ||
      0;
    if (total) {
      storageUsed.set(used);
      storageTotal.set(total);
      try {
        Local.set(`${STORAGE_STATS_PREFIX}${account}`, JSON.stringify({ used, total }));
      } catch {
        // cache write is best-effort
      }
    }
  } catch (err) {
    warn('Account storage stats failed (non-blocking)', err);
  }

  try {
    // Get local storage stats
    if (navigator.storage && navigator.storage.estimate) {
      const estimate = await navigator.storage.estimate();
      localUsage.set(estimate.usage || 0);
      localQuota.set(estimate.quota || 0);
    }
  } catch (err) {
    warn('Local storage estimate failed', err);
  }

  try {
    // Get index stats from DB
    const messageCount = await db.messages.count().catch(() => 0);
    indexCount.set(messageCount || 0);

    // Rough estimate of index size (not precise)
    const allMessages = await db.messages
      .limit(100)
      .toArray()
      .catch(() => []);
    if (allMessages?.length) {
      const avgSize =
        allMessages.reduce((sum, m) => sum + JSON.stringify(m).length, 0) / allMessages.length;
      indexSize.set(Math.round(((messageCount || 0) * avgSize) / 1024)); // KB
    }
  } catch (err) {
    warn('Index stats failed', err);
  }
};

/**
 * Toggle body indexing
 */
export const toggleBodyIndexing = () => {
  const current = get(bodyIndexingEnabled);
  const next = !current;
  bodyIndexingEnabled.set(next);
  settingsActions.setBodyIndexing(next);
  searchStore.actions.setIncludeBody(next).catch(() => {
    // Search worker re-init is best-effort.
  });
  toastsRef?.show?.(`Body indexing ${next ? 'enabled' : 'disabled'}`, 'info');
};

/**
 * Rebuild search index (placeholder)
 */
export const rebuildFullSearchIndex = async () => {
  toastsRef?.show?.('Rebuilding search index...', 'info');
  try {
    await searchStore.actions.rebuildFromCache();
    await updateStorageStats();
    toastsRef?.show?.('Index rebuild complete', 'success');
  } catch (err) {
    warn('rebuildFullSearchIndex failed', err);
    toastsRef?.show?.('Failed to rebuild index', 'error');
  }
};

/**
 * Rebuild search from cache (placeholder)
 */
export const rebuildSearchFromCache = async () => {
  return rebuildFullSearchIndex();
};

/**
 * Load accounts list
 */
export const loadAccounts = async () => {
  try {
    // Initialize the accounts system (migrates from old single-account if needed)
    Accounts.init();

    // Load accounts from local storage
    const list = Accounts.getAll();

    // Format accounts for display
    const formattedAccounts = list.map((acct) => ({
      email: acct.email,
      addedAt: acct.addedAt,
      lastActive: acct.lastActive,
    }));

    accounts.set(formattedAccounts);

    // Set current account
    const activeEmail = Accounts.getActive() || Local.get('email');
    if (activeEmail) {
      currentAccount.set(activeEmail);
      loadProfileName(activeEmail);
      loadProfileImage(activeEmail);
    }
  } catch (err) {
    warn('loadAccounts failed', err);
  }
};

/**
 * Toggle account menu
 */
export const toggleAccountMenu = () => {
  accountMenuOpen.update((open) => !open);
};

/**
 * Toggle bulk move dropdown
 */
export const toggleBulkMove = () => {
  bulkMoveOpen.update((open) => !open);
};

/**
 * Start add-account flow by sending the user back to login
 * Keeps existing session data so multiple accounts can co-exist.
 */
export const addAccount = () => {
  accountMenuOpen.set(false);
  // Root renders the login view with add_account parameter to prevent auto-redirect
  if (navigateRef) navigateRef('/?add_account=true');
  else window.location.href = '/?add_account=true';
};

export const resetSessionState = () => {
  resetMailboxStateForAccount();
  mailboxStore.actions.resetForAccount?.();
  searchStore.actions.resetSearchConnection();
  resetSyncWorkerReady();
};

const clearSensitiveClientStorage = (account?: string) => {
  // Clear per-account PGP material before removing the email key
  if (account) {
    Local.remove(`pgp_passphrases_${account}`);
    Local.remove(`pgp_keys_${account}`);
  }

  Local.remove('alias_auth');
  Local.remove('api_key');
  Local.remove('authToken');
  Local.remove('email');
  try {
    sessionStorage.clear();
  } catch (err) {
    warn('Failed to clear sessionStorage during sign out', err);
  }
};

/**
 * Sign out of the current account.
 * If other accounts exist, switch to another account.
 * Otherwise, return to login and clear all data.
 */
export const signOut = async () => {
  const currentEmail = Local.get('email');
  accountMenuOpen.set(false);

  // Remove only THIS account's WebSocket connection (other accounts keep theirs).
  // If this is the LAST account, destroy the entire updater.
  if (currentEmail) {
    const mgr = getWebSocketManager();
    mgr.removeAccount(currentEmail);
  }
  const remainingWsAccounts = Accounts.getAll().filter((a) => a.email !== currentEmail);
  if (remainingWsAccounts.length === 0) {
    // Last account — full teardown
    if (inboxUpdater) inboxUpdater.destroy();
    inboxUpdater = null;
  }

  // Clean up custom event listeners from main.ts
  try {
    const { cleanupCustomEventListeners } = await import('../main');
    cleanupCustomEventListeners();
  } catch {
    // main module may not expose cleanup yet
  }

  // Deactivate demo mode and clear its localStorage key
  try {
    const { deactivateDemoMode } = await import('../utils/demo-mode.js');
    deactivateDemoMode();
  } catch {
    // demo-mode module may not be loaded
  }

  // Remove only THIS account's push registration (other accounts keep theirs).
  // If this is the LAST account, cleanupPushNotifications() tears down everything.
  if (isTauriMobile && currentEmail) {
    try {
      const remainingBeforeRemove = Accounts.getAll().filter((a) => a.email !== currentEmail);
      if (remainingBeforeRemove.length > 0) {
        // Other accounts remain — only deregister this one account
        const currentAccount = Accounts.getAll().find((a) => a.email === currentEmail);
        const { deregisterAccountPush } = await import('../utils/push-notifications.js');
        await deregisterAccountPush(currentEmail, currentAccount?.aliasAuth);
      } else {
        // Last account — full cleanup (native listeners, all registrations)
        const { cleanupPushNotifications } = await import('../utils/push-notifications.js');
        await cleanupPushNotifications();
      }
    } catch (err) {
      warn('Failed to clean up push registration during sign out', err);
    }
  }

  clearSensitiveClientStorage(currentEmail);

  if (currentEmail) {
    // The sync worker holds credentials per account so in-flight syncs can
    // finish under their own identity; a signed-out account's header must be
    // dropped explicitly or it stays usable for the worker's lifetime.
    revokeSyncWorkerAuth(currentEmail);
    try {
      await Accounts.remove(currentEmail, true);
    } catch (err) {
      warn('Failed to remove account cache during sign out', err);
    }
  }

  // Check if there are other accounts remaining
  const remainingAccounts = Accounts.getAll();

  if (remainingAccounts.length > 0) {
    // Switch to the first remaining account using cache-first atomic swap
    const nextAccount = remainingAccounts[0];

    if (toastsRef) {
      toastsRef.show(`Signed out of ${currentEmail}. Switched to ${nextAccount.email}`, 'success');
    }

    if (navigateRef) navigateRef('/mailbox');
    else window.location.href = '/mailbox';

    await performAccountSwitch(nextAccount.email);
  } else {
    // Show toast before clearing storage/navigation
    if (toastsRef && currentEmail) {
      toastsRef.show(`Signed out of ${currentEmail}`, 'success');
    }

    // No other accounts, clear everything and go to login
    // Close the Dexie instance inside the worker, then terminate the worker
    // so no connection holds the IndexedDB open during deletion
    try {
      const { closeDatabase, terminateDbWorker } = await import('../utils/db-worker-client.js');
      await closeDatabase();
      terminateDbWorker();
    } catch (err) {
      warn('Failed to close database', err);
    }

    // Set a flag for the fallback-recovery.js to delete IndexedDB on next page load
    // (the database may still be blocked by open connections on this page)
    localStorage.setItem('webmail_pending_idb_cleanup', '1');
    // Clear only webmail-prefixed keys (preserves third-party localStorage on same origin)
    Local.clear();
    Session.clear();
    // Re-set cleanup flag after clear (Local.clear removes it)
    localStorage.setItem('webmail_pending_idb_cleanup', '1');
    accounts.set([]);
    currentAccount.set('');

    // Clear all service worker caches on full logout (static assets only, no API data)
    try {
      const { clearAllSWCaches } = await import('../utils/sw-cache.js');
      await clearAllSWCaches();
    } catch (err) {
      warn('Failed to clear SW caches', err);
    }

    // Clear all IndexedDB databases (Dexie mail cache, etc.)
    try {
      const { forceDeleteAllDatabases } = await import('../utils/db-recovery.js');
      await forceDeleteAllDatabases();
    } catch (err) {
      warn('Failed to clear IndexedDB', err);
    }

    mailboxStore.state.selectedConversationIds.set([]);
    mailboxStore.state.selectedMessage.set(null);
    mailboxStore.state.messages.set([]);
    mailboxStore.state.folders.set([]);

    // Clear settings on full logout
    clearSettings();

    // Dispatch event to clear login form fields
    window.dispatchEvent(new CustomEvent('login-clear-fields'));

    // Hard navigation with replace to guarantee all in-memory state is cleared
    // and the current history entry is replaced (no back to stale mailbox).
    window.location.replace('/');
  }
};

/**
 * Switch account (leading-edge debounce)
 *
 * Executes immediately on first call (no delay), then suppresses rapid
 * follow-up calls within 300ms. Only the last call in a burst wins.
 */
let switchCooldownTimer = null;
let switchPendingEmail = null;
let switchPendingResolve = null;

export const switchAccount = (accountObj) => {
  const email = accountObj?.email || accountObj;
  if (!email) return Promise.resolve();

  // Ignore if the requested account is already active
  const active = get(currentAccount) || Local.get('email') || '';
  if (active === email) return Promise.resolve();

  // If a cooldown is active, replace the pending target (trailing call wins)
  if (switchCooldownTimer) {
    switchPendingEmail = email;
    return new Promise((resolve) => {
      // Resolve the previous pending caller so it doesn't hang
      if (switchPendingResolve) switchPendingResolve();
      switchPendingResolve = resolve;
    });
  }

  // Leading edge: execute immediately
  switchCooldownTimer = setTimeout(async () => {
    const pending = switchPendingEmail;
    const pendingResolve = switchPendingResolve;
    switchCooldownTimer = null;
    switchPendingEmail = null;
    switchPendingResolve = null;
    // If a different account was requested during cooldown, execute it now
    if (pending && pending !== email) {
      try {
        await performAccountSwitch(pending);
      } finally {
        if (pendingResolve) pendingResolve();
      }
    } else if (pendingResolve) {
      pendingResolve();
    }
  }, 300);

  return performAccountSwitch(email);
};

/**
 * Pre-read Account B's cached data from IndexedDB before resetting stores.
 * Returns { folders, messages, settings, labels } ready for atomic swap.
 */
const preloadAccountCache = async (account) => {
  const [cachedFolders, cachedSettings, cachedLabels] = await Promise.all([
    db.folders
      .where('account')
      .equals(account)
      .toArray()
      .catch(() => []),
    db.settings.get(account).catch(() => null),
    db.settingsLabels.get(account).catch(() => null),
  ]);

  // Build the folder list and find INBOX
  const folderList = cachedFolders?.length
    ? mailboxStore.actions.buildFolderList?.(cachedFolders) || cachedFolders
    : [];
  const inbox = folderList.find((f) => f.path?.toUpperCase?.() === 'INBOX');
  const defaultFolder = inbox?.path || folderList[0]?.path || 'INBOX';

  // Read first page of messages for the default folder
  let cachedMessages = [];
  try {
    const range = db.messages
      .where('[account+folder+date]')
      .between(
        [account, defaultFolder, Dexie.minKey],
        [account, defaultFolder, Dexie.maxKey],
        true,
        true,
      );
    cachedMessages = await range.reverse().limit(50).toArray();
  } catch {
    // Fall back to unordered read
    try {
      cachedMessages = await db.messages
        .where('[account+folder]')
        .equals([account, defaultFolder])
        .limit(50)
        .toArray();
    } catch {
      // No cached messages available
    }
  }

  // Read cached labels
  let labelList = [];
  try {
    labelList = await db.labels.where('account').equals(account).toArray();
  } catch {
    // No cached labels
  }

  return {
    folders: folderList,
    defaultFolder,
    messages: cachedMessages,
    settings: cachedSettings?.settings || null,
    settingsLabels: cachedLabels?.labels || [],
    labels: labelList,
  };
};

const performAccountSwitch = async (email) => {
  // Per-account push: NO teardown on switch. All accounts keep their
  // registrations alive so push notifications arrive for every account.

  // Use Accounts.setActive to properly switch account credentials
  const switched = Accounts.setActive(email);

  if (!switched) {
    toastsRef?.show?.(`Failed to switch to ${email}`, 'error');
    return;
  }

  currentAccount.set(email);
  accountMenuOpen.set(false);

  // PHASE 1: Pre-read Account B's cached data from IndexedDB BEFORE blanking the UI.
  // This allows an atomic swap from Account A → Account B with no blank state.
  const cached = await preloadAccountCache(email);

  // Multi-account WebSocket: do NOT destroy the updater on account switch.
  // All accounts keep their WebSocket connections alive. Just reconcile
  // in case the account list changed.
  if (inboxUpdater) {
    const mgr = getWebSocketManager();
    mgr.reconcile();
  }

  // PHASE 2: Reset workers and internal tracking (no visible effect)
  resetSyncWorkerReady();
  clearMailServiceState();
  mailboxStore.actions.resetForAccount?.();
  mailboxStore.actions.clearFolderMessageCache?.();

  // PHASE 3: Atomic swap — replace stores with cached data instead of blanking them
  initialSyncStarted.set(false);
  // Swap storage figures to the new account's last-known immediately (or clear
  // if none) so the sidebar never shows the previous account's usage; load()
  // below refreshes it live.
  seedStorageStats(email);
  availableMoveTargets.set([]);
  syncProgress.set({ active: false, stage: '', folder: '', current: 0, total: 0, message: '' });
  indexProgress.set({ active: false, current: 0, total: 0, message: '' });
  mailboxStore.state.selectedConversationIds.set([]);
  mailboxStore.state.selectedMessage.set(null);
  mailboxStore.state.messageBody.set('');
  mailboxStore.state.attachments.set([]);
  mailboxStore.state.page.set(1);
  mailboxStore.state.query.set('');
  mailboxStore.state.searchResults.set([]);
  mailboxStore.state.searchActive.set(false);
  mailboxStore.state.messageLoading.set(false);
  mailboxStore.state.error.set('');
  mailboxStore.state.hasNextPage.set(false);
  mailboxStore.state.unreadOnly.set(false);
  mailboxStore.state.hasAttachmentsOnly.set(false);
  mailboxStore.state.starredOnly.set(false);

  // Swap in cached data (or empty if no cache — first time for this account)
  if (cached.folders.length) {
    mailboxStore.state.folders.set(cached.folders);
    mailboxStore.state.selectedFolder.set(cached.defaultFolder);
    mailboxStore.state.loading.set(false);
  } else {
    mailboxStore.state.folders.set([]);
    mailboxStore.state.selectedFolder.set('');
    mailboxStore.state.loading.set(true);
  }
  if (cached.messages.length) {
    mailboxStore.state.messages.set(cached.messages);
  } else {
    mailboxStore.state.messages.set([]);
  }
  if (cached.labels.length) {
    availableLabels.set(cached.labels);
  } else {
    availableLabels.set([]);
  }

  // Apply cached settings immediately (theme, layout, etc.)
  if (cached.settings) {
    applySettings(cached.settings);
  }
  // Always set settingsLabels (even if empty) to prevent the previous
  // account's labels leaking into the new account's view. Without this,
  // switching A → B (B has no labels) would keep showing A's labels.
  settingsLabels.set(cached.settingsLabels);

  // Reset tabs for new account (desktop only)
  if (isTauriDesktop) {
    resetTabs(cached.defaultFolder || 'INBOX');
  }

  toastsRef?.show?.(`Switched to ${email}`, 'info');

  // PHASE 4: Deferred non-blocking work
  // Reset search store — don't await, init lazily on first search
  searchStore.actions.resetSearchConnection();
  searchStore.actions.ensureInitialized(email).catch(() => {});

  // PHASE 5: Full load with network refresh (non-blocking for cached accounts)
  // Note: we don't clearSettings() here because cached settings were already applied
  // above and load() -> syncSettings() will refresh from network in the background.
  await load();

  // Per-account push: syncPushNotifications reconciles ALL accounts,
  // so after switching we just ensure the new active account is covered.
  // This is a no-op if the account was already registered with the current token.
  if (isTauriMobile) {
    try {
      const { syncPushNotifications } = await import('../utils/push-notifications.js');
      await syncPushNotifications();
    } catch (err) {
      warn('Failed to sync push after account switch', err);
    }
  }
};

/**
 * Fetch cached message content for downloads/views
 */
async function getMessageContent(msg) {
  if (!msg?.id) {
    return null;
  }
  const account = Local.get('email') || 'default';
  try {
    const body = await db.messageBodies.get([account, msg.id]);
    if (body?.body || body?.textContent || body?.raw) {
      return body;
    }
  } catch (err) {
    warn('getMessageContent failed', err);
  }
  return null;
}

function openInNewTab(content, mime = 'text/html') {
  if (isTauri) {
    openInNewTabTauri(content, mime).catch((err) => {
      console.warn('[openInNewTab] Tauri open failed:', err);
      toastsRef?.show?.('Unable to open original message', 'error');
    });
    return true;
  }

  try {
    const blob = new Blob([content], { type: mime });
    const url = URL.createObjectURL(blob);
    window.open(url, '_blank', 'noopener,noreferrer');
    setTimeout(() => URL.revokeObjectURL(url), 10_000);
    return true;
  } catch (err) {
    console.error('openInNewTab failed', err);
    return false;
  }
}

async function openInNewTabTauri(content, mime) {
  const { writeFile } = await import('@tauri-apps/plugin-fs');
  const { tempDir, join } = await import('@tauri-apps/api/path');
  const { openPath } = await import('@tauri-apps/plugin-opener');

  // Filename pattern must stay in sync with the opener scope in
  // src-tauri/capabilities/default.json — the Tauri opener plugin only
  // allows opening files under $TEMP/forwardemail-original-*.{html,txt,eml}.
  const ext = mime === 'text/html' ? 'html' : mime === 'message/rfc822' ? 'eml' : 'txt';
  const tmp = await tempDir();
  // Use `join` rather than string concatenation — on Windows, `tempDir()`
  // does not always return a trailing separator, which previously yielded
  // a malformed path like `C:\Users\...\Tempforwardemail-original-…`.
  const filePath = await join(tmp, `forwardemail-original-${Date.now()}.${ext}`);

  const data =
    typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content);
  await writeFile(filePath, data);
  await openPath(filePath);
}

const resetMailboxStateForAccount = () => {
  mailboxStore.actions.clearFolderMessageCache?.();
  initialSyncStarted.set(false);
  availableLabels.set([]);
  availableMoveTargets.set([]);
  syncProgress.set({
    active: false,
    stage: '',
    folder: '',
    current: 0,
    total: 0,
    message: '',
  });
  indexProgress.set({
    active: false,
    current: 0,
    total: 0,
    message: '',
  });
  mailboxStore.state.selectedConversationIds.set([]);
  mailboxStore.state.selectedMessage.set(null);
  mailboxStore.state.messageBody.set('');
  mailboxStore.state.messages.set([]);
  mailboxStore.state.folders.set([]);
  mailboxStore.state.attachments.set([]);
  mailboxStore.state.selectedFolder.set('');
  mailboxStore.state.page.set(1);
  mailboxStore.state.query.set('');
  mailboxStore.state.searchResults.set([]);
  mailboxStore.state.searchActive.set(false);
  mailboxStore.state.loading.set(true);
  mailboxStore.state.messageLoading.set(false);
  mailboxStore.state.error.set('');
  mailboxStore.state.hasNextPage.set(false);
  mailboxStore.state.unreadOnly.set(false);
  mailboxStore.state.hasAttachmentsOnly.set(false);
  mailboxStore.state.starredOnly.set(false);
};

const fetchRawOriginal = async (msg) => {
  const apiId = getMessageApiId(msg);
  if (!apiId) return null;
  const folder = msg?.folder_path || msg?.folder || msg?.path || '';
  try {
    const res = await Remote.request(
      'Message',
      {},
      {
        method: 'GET',
        pathOverride: `/v1/messages/${encodeURIComponent(apiId)}?folder=${encodeURIComponent(folder || '')}&raw=true`,
        // Server returns raw RFC822 text (message/rfc822), not JSON
        responseAs: 'text',
      },
    );
    return res?.Result || res || null;
  } catch (err) {
    warn('[viewOriginal] raw fetch failed', err);
    return null;
  }
};

const fetchEmlOriginal = async (msg) => {
  const apiId = getMessageApiId(msg);
  if (!apiId) return null;
  const folder = msg?.folder_path || msg?.folder || msg?.path || '';
  try {
    const res = await Remote.request(
      'Message',
      {},
      {
        method: 'GET',
        pathOverride: `/v1/messages/${encodeURIComponent(apiId)}?folder=${encodeURIComponent(folder || '')}&eml=true`,
        // Server returns raw RFC822 text (message/rfc822), not JSON
        responseAs: 'text',
      },
    );
    return res?.Result || res || null;
  } catch (err) {
    warn('[downloadOriginal] eml fetch failed', err);
    return null;
  }
};

export const downloadOriginal = async (msg) => {
  const target = msg || get(mailboxStore.state.selectedMessage);
  const content = await getMessageContent(target);
  if (!content) {
    toastsRef?.show?.('Original message not available to download', 'error');
    return;
  }
  const meta = content?.meta || {};
  let emlPayload =
    meta.eml ||
    meta.rawEml ||
    meta.raw ||
    meta.Raw ||
    meta.rawBody ||
    meta.original ||
    meta.originalMessage ||
    meta.rawMessage ||
    pickOriginalContent(content);

  // Prefer server-provided RFC822 if not present or looks rendered HTML.
  if (!emlPayload || looksLikeHtml(emlPayload)) {
    const emlRes = await fetchEmlOriginal(target);
    if (typeof emlRes === 'string') {
      emlPayload = emlRes;
    } else if (emlRes?.eml || emlRes?.raw) {
      emlPayload = emlRes.eml || emlRes.raw;
      // Cache for reuse
      try {
        const account = Local.get('email') || 'default';
        const cached = await db.messageBodies
          .where('[account+id]')
          .equals([account, target.id])
          .first();
        if (cached) {
          await db.messageBodies.put({
            ...cached,
            meta: { ...(cached.meta || {}), eml: emlPayload },
          });
        }
      } catch (err) {
        warn('[downloadOriginal] failed to persist eml to cache', err);
      }
    }
  }

  // If the eml is still PGP-armored, fall back to a decrypted-text EML shell.
  if (typeof emlPayload === 'string' && /-----BEGIN PGP MESSAGE-----/.test(emlPayload)) {
    const decryptedText =
      content?.textContent ||
      (typeof content?.body === 'string' && !looksLikeHtml(content.body) ? content.body : '');
    if (decryptedText) {
      const headersText = normalizeHeaders(
        meta.headers || meta.Headers || meta.rawHeaders || meta.RawHeaders,
        emlPayload,
      );
      const headerBlock = headersText
        ? headersText.replace(/\r\n/g, '\n').split('\n').join('\r\n')
        : `Subject: ${target?.subject || ''}\r\n`;
      emlPayload = `${headerBlock}\r\n\r\n${decryptedText}`;
    }
  }

  const success = downloadFile(
    emlPayload || '',
    getSafeFilename(target?.subject, 'eml'),
    'message/rfc822',
  );
  if (!success) {
    toastsRef?.show?.('Unable to download original message', 'error');
  }
};

export const getSpamReportAddress = () =>
  resolveSpamReportAddress(getEffectiveSettingValue('spam_report_address'));

/**
 * Forwards a message to the configured spam report address as an .eml
 * attachment and queues it through the outbox. The caller is responsible
 * for deleting the message afterwards so it can reuse the existing delete
 * flow (selection advance, toasts, reload).
 *
 * Returns { address } on success, throws when the report cannot be built.
 */
export const reportSpamMessage = async (msg) => {
  const target = msg || get(mailboxStore.state.selectedMessage);
  if (!target) return null;

  const address = getSpamReportAddress();

  // Prefer the raw RFC822 source so the report keeps full headers.
  const content = await getMessageContent(target).catch(() => null);
  const meta = content?.meta || {};
  let emlPayload =
    meta.eml ||
    meta.rawEml ||
    meta.raw ||
    meta.Raw ||
    meta.rawBody ||
    meta.original ||
    meta.originalMessage ||
    meta.rawMessage ||
    null;
  if (!emlPayload || looksLikeHtml(emlPayload)) {
    const emlRes = await fetchEmlOriginal(target);
    if (typeof emlRes === 'string' && emlRes) emlPayload = emlRes;
    else if (emlRes?.eml || emlRes?.raw) emlPayload = emlRes.eml || emlRes.raw;
  }

  const fallbackText =
    content?.textContent ||
    (typeof content?.body === 'string' && !looksLikeHtml(content.body) ? content.body : '') ||
    pickOriginalContent(content) ||
    target?.preview ||
    '';

  if (!emlPayload && !fallbackText) {
    throw new Error('Original message is not available to report');
  }

  const payload = buildSpamReportEmail({
    reportAddress: address,
    fromAddress: Local.get('email') || '',
    message: target,
    eml: typeof emlPayload === 'string' ? emlPayload : null,
    fallbackText,
  });

  const { queueEmail } = await import('../utils/outbox-service.js');
  await queueEmail(payload);
  return { address };
};

export const viewOriginal = async (msg) => {
  const target = msg || get(mailboxStore.state.selectedMessage);
  const content = await getMessageContent(target);
  if (!content) {
    toastsRef?.show?.('Original message not available', 'error');
    return;
  }
  const meta = content?.meta || {};
  let payload =
    meta.raw ||
    meta.Raw ||
    meta.rawBody ||
    meta.original ||
    meta.originalMessage ||
    meta.rawMessage ||
    pickOriginalContent(content);
  let headersText = normalizeHeaders(
    meta.headers || meta.Headers || meta.rawHeaders || meta.RawHeaders,
    payload,
  );

  // If the cached meta/body doesn't have raw RFC822 (or looks like rendered HTML), fetch it explicitly.
  if ((!payload || looksLikeHtml(payload) || !headersText) && target) {
    const rawRes = await fetchRawOriginal(target);
    if (rawRes) {
      // Handle both string responses and object responses
      const rawContent = typeof rawRes === 'string' ? rawRes : rawRes.raw;
      const rawHeaders =
        typeof rawRes === 'object'
          ? rawRes.headers || rawRes.Headers || rawRes.rawHeaders || rawRes.RawHeaders
          : null;

      if (rawContent) {
        payload = rawContent;
        headersText = normalizeHeaders(rawHeaders, payload);

        // Persist raw + headers for next time.
        try {
          const account = Local.get('email') || 'default';
          const cached = await db.messageBodies
            .where('[account+id]')
            .equals([account, target.id])
            .first();
          if (cached) {
            await db.messageBodies.put({
              ...cached,
              meta: {
                ...(cached.meta || {}),
                raw: rawContent,
                headers: rawHeaders,
              },
            });
          }
        } catch (err) {
          warn('[viewOriginal] failed to persist raw to cache', err);
        }
      }
    }
  }

  // Get decrypted text/html body for encrypted messages
  const decryptedText =
    content?.textContent || (typeof content?.body === 'string' ? content.body : '');

  // Detect current theme from parent window
  const isLightMode =
    typeof document !== 'undefined' && document.body.classList.contains('light-mode');

  const viewerPage = buildOriginalViewerPage({
    raw: payload,
    headers: headersText,
    decrypted: decryptedText,
    subject: target?.subject || '',
    isLightMode,
  });
  const ok = openInNewTab(viewerPage, 'text/html');
  if (!ok) {
    toastsRef?.show?.('Unable to open original message', 'error');
  }
};

// Export everything as a single object for convenience
export const mailboxActions = {
  load,
  toggleRead,
  archiveMessage,
  deleteMessage,
  replyTo,
  forwardMessage,
  onSearch,
  loadMessages,
  toggleStar,
  getSelectedConversations,
  getSelectedMessagesFromConversations,
  bulkMoveTo,
  contextMoveTo,
  contextLabel,
  computeReplyTargets,
  replyAll,
  updateStorageStats,
  toggleBodyIndexing,
  rebuildFullSearchIndex,
  rebuildSearchFromCache,
  loadAccounts,
  addAccount,
  switchAccount,
  signOut,
  toggleAccountMenu,
  toggleBulkMove,
  setNavigate,
  setToasts,
  setComposeModal,
  setLayoutMode,
  layoutMode,
  starredOnly,
  downloadOriginal,
  viewOriginal,
};
