/**
 * @bobfrankston/mailx-types
 * Shared type definitions for the mailx email client.
 * This is the contract between client and server.
 */

// Generated rule data — both desktop store and Android store import via
// this barrel so a single source-of-truth (contact-rules.jsonc) drives
// junk-contact filtering on every platform.
export { CONTACT_RULES } from "./contact-rules.js";
export type { MailxApi } from "./mailx-api.js";
// Shared contacts.jsonc mutations — one implementation for desktop + Android.
export {
    addContactsDenylistEntry, addContactsPreferredEntry,
    type PreferredContactEntry, type CloudReadFn, type CloudWriteFn,
} from "./contacts-config.js";

// Group-name expansion for recipient fields. Lets users type a group name
// (e.g. "family") in To/Cc/Bcc and have it expand to the address list at
// send time. Both desktop and Android send paths consume this expander
// against contacts.jsonc → groups.
export {
    expandRecipients, splitRecipients, isAddressToken, extractAddress,
} from "./groups.js";
export type { GroupMap, RecipientToken, ExpansionResult } from "./groups.js";

// Reminder dismissed/snoozed cross-device state — one merge implementation
// for the desktop service (mailx-settings) and Android/web (mailx-store-web).
export {
    REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual,
} from "./reminder-state.js";
export type { ReminderState } from "./reminder-state.js";

// Outgoing-mail inline images: data: URI → cid: extraction shared by both
// MIME assemblers (desktop send + Android/web send).
export { extractInlineImages } from "./mime-inline-images.js";
export { buildMimeMessage } from "./mime-build.js";
export type { BuildMimeOptions, BuiltMime, MimeAttachment } from "./mime-build.js";
export type { InlineImagePart } from "./mime-inline-images.js";

// ── Account Configuration ──

/** Supported authentication methods */
export type AuthMethod = "password" | "oauth2";

/** Mail account configuration */
export interface AccountConfig {
    id: string;                     /** Unique account identifier (e.g., "iecc", "gmail-bob") */
    name: string;                   /** Sender name for From header (e.g., "Bob Frankston") */
    label?: string;                 /** UI label for account list (e.g., "Gmail"). Falls back to name if not set */
    email: string;                  /** Email address */
    imap: {
        host: string;
        port: number;
        tls: boolean;
        auth: AuthMethod;
        user: string;
        password?: string;          /** For password auth */
        oauthClientId?: string;     /** For OAuth2 */
    };
    smtp: {
        host: string;
        port: number;
        tls: boolean;
        auth: AuthMethod;
        user: string;
        password?: string;
    };
    enabled: boolean;
    primary?: boolean;              /** Catch-all "this is my main account" — default source for Calendar / Tasks / Contacts when no per-feature override set. */
    primaryCalendar?: boolean;      /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */
    primaryTasks?: boolean;         /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */
    primaryContacts?: boolean;      /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */
    defaultSend?: boolean;          /** Use this account's SMTP when From doesn't match any account */
    syncContacts?: boolean;         /** Sync contacts even when account is disabled (contacts-only Gmail) */
    relayDomains?: string[];        /** Domains to skip in Delivered-To chain (e.g., ["relay.aaz.lt"]) */
    identityDomains?: string[];    /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
    spam?: string;                  /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
    signature?: string;             /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
    sig?: AccountSignature;         /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
}

/** Signature configuration in accounts.jsonc. Initial shape carries `text`
 *  only; `html: true` reserved for future support of raw HTML signatures. */
export interface AccountSignature {
    text: string;                   /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
    html?: boolean;                 /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
}

// ── Folder Types ──

/** Standard IMAP special-use folder types */
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";

/** A mailbox folder */
export interface Folder {
    id: number;
    accountId: string;
    path: string;                   /** IMAP path e.g., "INBOX", "INBOX/Projects" */
    name: string;                   /** Display name */
    specialUse: SpecialUse;         /** Standard folder type, or null for custom */
    delimiter: string;              /** IMAP hierarchy delimiter */
    totalCount: number;
    unreadCount: number;
    children: Folder[];             /** Nested subfolders */
}

// ── Message flag state ──
//
// External API surface for the IMAP system flags. The literal strings
// (`"\\Seen"`, `"\\Flagged"`, etc.) live ONLY inside this module — every
// caller speaks in terms of named predicates (`seenOf(msg)`) and verbs
// (`setSeen(msg, true)`, `toggleSeen(msg)`). That keeps a typo like
// `"\Seen"` (single backslash) from silently bypassing flag checks
// elsewhere, and removes the "two-API leak" of having both `FLAG.SEEN`
// constants AND verb helpers exposed simultaneously.
//
// These work against any object with a mutable `flags: string[]`
// property — `MessageEnvelope`, `Message`, and the row's `msg` reference
// all qualify. The verbs return `void` and mutate `msg.flags` in place
// (replacing the array with a fresh one so listeners observing
// reference identity, e.g. message-state, still re-render). Pure
// predicate calls (`seenOf`, `flaggedOf`) don't mutate.

const _SEEN     = "\\Seen";
const _FLAGGED  = "\\Flagged";
const _ANSWERED = "\\Answered";
const _DRAFT    = "\\Draft";
const _DELETED  = "\\Deleted";

interface FlagBearing { flags: string[]; }

function _has(msg: FlagBearing | { flags?: readonly string[] }, flag: string): boolean {
    return !!msg.flags && msg.flags.includes(flag);
}
function _set(msg: FlagBearing, flag: string, state: boolean): void {
    const present = (msg.flags || []).includes(flag);
    if (state === present) return;
    const next = state
        ? [...(msg.flags || []), flag]
        : (msg.flags || []).filter(f => f !== flag);
    msg.flags = next;
}

export const seenOf     = (m: { flags?: readonly string[] }): boolean => _has(m, _SEEN);
export const flaggedOf  = (m: { flags?: readonly string[] }): boolean => _has(m, _FLAGGED);
export const answeredOf = (m: { flags?: readonly string[] }): boolean => _has(m, _ANSWERED);
export const draftOf    = (m: { flags?: readonly string[] }): boolean => _has(m, _DRAFT);
export const deletedOf  = (m: { flags?: readonly string[] }): boolean => _has(m, _DELETED);

export const setSeen     = (m: FlagBearing, state: boolean): void => _set(m, _SEEN, state);
export const setFlagged  = (m: FlagBearing, state: boolean): void => _set(m, _FLAGGED, state);
export const setAnswered = (m: FlagBearing, state: boolean): void => _set(m, _ANSWERED, state);
export const setDraft    = (m: FlagBearing, state: boolean): void => _set(m, _DRAFT, state);

export const toggleSeen    = (m: FlagBearing): void => _set(m, _SEEN, !_has(m, _SEEN));
export const toggleFlagged = (m: FlagBearing): void => _set(m, _FLAGGED, !_has(m, _FLAGGED));

// ── Message Types ──

/** Email address with optional display name */
export interface EmailAddress {
    name: string;                   /** Display name, may be empty */
    address: string;                /** Email address */
}

/** Message envelope (headers only, for list display) */
export interface MessageEnvelope {
    id: number;                     /** Local store ID */
    accountId: string;
    folderId: number;
    folderName?: string;            /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
    uid: number;                    /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */
    uuid?: string;                  /** Stable local identity, minted once at first-sight; never changes */
    messageId: string;              /** RFC Message-ID header */
    inReplyTo: string;              /** For threading */
    references: string[];           /** For threading */
    threadId?: string;              /** Computed thread id (root Message-ID of the conversation) */
    date: number;                   /** Epoch ms — per-account arrival time (IMAP INTERNALDATE / Gmail internalDate) */
    sentDate?: number;              /** Epoch ms — the message's own Date: header (send time); falls back to date */
    subject: string;
    from: EmailAddress;
    to: EmailAddress[];
    cc: EmailAddress[];
    flags: string[];                /** IMAP flags: ["\\Seen", "\\Flagged"] */
    size: number;
    hasAttachments: boolean;
    isReplied?: boolean;            /** Local DB knows the user (or anyone in this account) replied — derived from In-Reply-To linkage, independent of \Answered */
    preview: string;                /** First ~200 chars of body text */
    bodyPath?: string;              /** Local body location: "idb:..." or "gmail:<id>" */
    providerId?: string;            /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
    pending?: boolean;              /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet — UI renders pink */
}

/** Full message with body content */
export interface Message extends MessageEnvelope {
    bodyHtml: string;               /** HTML body */
    bodyText: string;               /** Plain text body */
    attachments: Attachment[];
}

/** File attachment metadata */
export interface Attachment {
    id: number;
    filename: string;
    mimeType: string;
    size: number;
    contentId: string;              /** For inline attachments (cid:) */
}

// ── API Types ──

/** Paginated list response */
export interface PagedResult<T> {
    items: T[];
    total: number;
    page: number;
    pageSize: number;
}

/** Message list query parameters */
export interface MessageQuery {
    accountId: string;
    folderId: number;
    page?: number;
    pageSize?: number;
    sort?: "date" | "from" | "subject";
    sortDir?: "asc" | "desc";
    /** Which date drives the "date" sort + the date column: the Date: header
     *  (sent, default) or the per-account arrival time (received). */
    dateBasis?: "sent" | "received";
    search?: string;
    /** Restrict to messages with the \Flagged flag set (whole-folder, not
     *  just the currently-rendered page — lets the "show flagged" filter
     *  find stars on messages that haven't been paged in yet). */
    flaggedOnly?: boolean;
}

/** Compose/send a message */
export interface ComposeMessage {
    from: string;                   /** Account ID to send from */
    to: EmailAddress[];
    cc?: EmailAddress[];
    bcc?: EmailAddress[];
    subject: string;
    bodyHtml: string;
    bodyText?: string;
    inReplyTo?: string;             /** Message-ID of message being replied to */
    references?: string[];
    attachments?: { filename: string; data: string; mimeType: string }[];
}

// ── Queue Types ──

export type QueueStatus = "pending" | "sending" | "sent" | "failed";

export interface QueueItem {
    id: number;
    status: QueueStatus;
    createdAt: number;
    sendAfter: number;              /** Epoch ms, for deferred send */
    attempts: number;
    lastAttempt: number;
    error: string;
    subject: string;
    to: EmailAddress[];
}

// ── WebSocket Event Types ──

export type WsEvent =
    | { type: "newMessage"; accountId: string; folderId: number; message: MessageEnvelope }
    | { type: "messageDeleted"; accountId: string; folderId: number; uid: number }
    | { type: "messageMoved"; accountId: string; fromFolderId: number; toFolderId: number; uid: number }
    | { type: "folderCountsChanged"; accountId: string; counts: Record<number, { total: number; unread: number }> }
    | { type: "folderSynced"; accountId: string; entries: { folderId: number; syncedAt: number }[] }
    | { type: "syncProgress"; accountId: string; phase: string; progress: number }
    | { type: "queueUpdate"; pending: number; sending: number; failed: number }
    | { type: "connected" }
    | { type: "reload" }
    | { type: "error"; message: string }
    | { type: "accountError"; accountId: string; error: string; hint: string; isOAuth: boolean };

// ── Settings Types ──

export interface MailxSettings {
    accounts: AccountConfig[];
    ui: {
        theme: "system" | "dark" | "light";
        editor: "quill" | "tiptap";
        folderWidth: number;
        listViewerSplit: number;    /** Percentage for message list height */
        fontSize: number;
    };
    sync: {
        intervalMinutes: number;
        historyDays: number;        /** 0 = all history */
        prefetch: boolean;          /** Download message bodies during sync (default true) */
    };
    store: {
        basePath: string;           /** Where message bodies are stored */
        compressionBoundaryDays: number;  /** Messages older than this get compressed */
    };
    autocomplete?: AutocompleteSettings;
}

// ── Autocomplete Types ──

export interface AutocompleteSettings {
    enabled: boolean;
    provider: "ollama" | "claude" | "openai" | "off";
    ollamaUrl: string;
    ollamaModel: string;
    cloudApiKey: string;
    cloudModel: string;
    debounceMs: number;
    maxTokens: number;
    /** Per-feature opt-in for non-autocomplete AI helpers. All default false
     *  per user preference (2026-04-21): AI features should be controlled by
     *  a flag, initially OFF in settings. Provider config is shared with
     *  autocomplete (provider, cloudApiKey, cloudModel, etc.). */
    translateEnabled?: boolean;
    proofreadEnabled?: boolean;
}

/** AI provider API keys. Lives at the top of `accounts.jsonc` alongside
 *  `accounts:` so all secrets are co-located in one cloud-synced file. The
 *  keys field replaces `preferences.jsonc.autocomplete.cloudApiKey` (which
 *  was tied to the *active* provider) so a user can set both Anthropic and
 *  OpenAI keys once and switch providers without re-entering anything. */
export interface AiKeys {
    anthropic?: string;
    openai?: string;
}

export interface AutocompleteRequest {
    subject: string;
    to: string;
    bodyText: string;
    cursorOffset: number;
}

export interface AutocompleteResponse {
    suggestion: string;
}

// ── AI Transform (translate / proofread / summarize) ──

export interface AiTransformRequest {
    /** translate = render in `targetLang`; proofread = corrected version
     *  with grammar/spelling fixes; summarize = short paragraph summary;
     *  extractEvent = parse natural-language event description into a
     *  structured calendar event (title/start/end/location/notes). */
    action: "translate" | "proofread" | "summarize" | "extractEvent";
    text: string;
    /** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
    targetLang?: string;
    /** Caller's "now" hint, ISO 8601 (e.g. 2026-05-05T18:30:00). Used by
     *  extractEvent to resolve relative dates ("tomorrow at 3pm"). The
     *  service can't trust its own clock — the user might be on a phone
     *  in a different timezone — so the caller supplies it. */
    nowISO?: string;
}

/** Structured calendar event fields parsed from a natural-language
 *  description by aiTransform({ action: "extractEvent" }). All times are
 *  ISO 8601 in the caller's local timezone (no `Z` suffix); the client
 *  shapes them into Google Calendar's `dates=` URL parameter. */
export interface ExtractedEvent {
    title: string;
    startISO?: string;
    endISO?: string;
    allDay?: boolean;
    /** IANA zone the times are wall-clock in, when the source text stated one
     *  (e.g. an invitation with "(Malaysia Time - Kuala Lumpur)"). Client
     *  passes it to Google Calendar as `ctz=`. Absent = user's local zone. */
    timeZone?: string;
    location?: string;
    notes?: string;
}

export interface AiTransformResponse {
    /** Transformed text. Empty when AI is disabled / provider error / feature
     *  not enabled — caller should treat empty as "no result". For
     *  extractEvent this holds the JSON-stringified ExtractedEvent so the
     *  response shape stays single-field across all actions. */
    text: string;
    /** Parsed ExtractedEvent, populated only for extractEvent. Lets the
     *  client skip the JSON.parse step (the service already validated).
     *  First event when the text described several. */
    event?: ExtractedEvent;
    /** All extracted events — the text may describe more than one. */
    events?: ExtractedEvent[];
    /** Optional reason for empty result, surfaced to UI status bar. */
    reason?: string;
}

// ── Store Interface ──

/** Body storage backend interface -- implementations are swappable.
 *  Uses Uint8Array, not Node's Buffer: mailx-types is a zero-dep, browser-
 *  capable package, and the browser client (DOM lib, no @types/node) imports
 *  it. Buffer extends Uint8Array, so Node implementations (FileMessageStore)
 *  still satisfy this; the browser side gets a real, node-free type. */
export interface MessageStore {
    putMessage(accountId: string, folderId: number, uid: number, raw: Uint8Array): Promise<string>;
    getMessage(accountId: string, folderId: number, uid: number): Promise<Uint8Array>;
    deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
    hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;
}

// ── Shared Utilities ──
// Pure functions used by both desktop (mailx-service) and Android (web-service).
// Kept here to avoid duplication — both platforms import from mailx-types.

/** Sanitize HTML for safe display — strips scripts, inline handlers, remote images, forms, iframes. */
export function sanitizeHtml(html: string): { html: string; hasRemoteContent: boolean } {
    let hasRemoteContent = false;
    let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
    clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
    clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
        const url = src.slice(1, -1);
        if (url.startsWith("data:") || url.startsWith("cid:")) return match;
        hasRemoteContent = true;
        return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
    });
    clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
        hasRemoteContent = true;
        return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
    });
    clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
        hasRemoteContent = true;
        return `url("") /* blocked: ${url} */`;
    });
    clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
    // Strip every interactive form element. Even without the surrounding
    // <form>, an <input type="password"> in an email body is dangerous —
    // WebView2's password manager offers to autofill it, and the user
    // might type a real password into what looks like an in-email control.
    // Same for <textarea>, <select>/<option>, and <button>. Bob 2026-05-14:
    // observed a phishing-shaped "CHANGE YOUR PASSWORD" email rendering
    // with working input fields + the OS autofill chip. Replace with a
    // visible "[blocked: input]" marker so the user sees something was
    // stripped (a silent removal would hide the phishing attempt entirely
    // — they'd think "this email is just text" when it actually tried to
    // capture credentials).
    clean = clean.replace(/<input\b[^>]*\/?>/gi, "[blocked: form input]");
    clean = clean.replace(/<textarea\b[^>]*>[\s\S]*?<\/textarea>/gi, "[blocked: textarea]");
    clean = clean.replace(/<select\b[^>]*>[\s\S]*?<\/select>/gi, "[blocked: select]");
    clean = clean.replace(/<button\b[^>]*>([\s\S]*?)<\/button>/gi, "$1");
    clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
    return { html: clean, hasRemoteContent };
}

/**
 * Encode a header VALUE as RFC 2047 encoded-words when it contains non-ASCII.
 *
 * Why this has to exist: a header carrying raw 8-bit UTF-8 makes the whole
 * message an EAI message (RFC 6530), which can only be relayed over SMTPUTF8
 * (RFC 6531). Plenty of receivers still don't advertise that extension, and a
 * sending MTA that can't downgrade has no choice but to bounce. mailx built
 * every outgoing header with bare interpolation — `Subject: ${msg.subject}` —
 * so ONE curly apostrophe was enough to make a message undeliverable:
 *
 *   Subject: Re: … One of Canada’s Fastest Fiber Networks     (U+2019, raw UTF-8)
 *   → <alland@soundbytesradio.com>: EAI message but server 17.57.152.5
 *     does not support SMTPUTF8 … This is a permanent error
 *
 * (Bob 2026-08-08, qmail bounce from gal.iecc.com — iCloud, Zoho and
 * land-com all refused it; herot.com, gmail and bob.ma took it, so it looked
 * like a partial failure.) Reply subjects inherit whatever punctuation the
 * original used, and Word/paste smart quotes do the rest, so this fires
 * constantly and silently.
 *
 * Pure-ASCII values are returned untouched — encoding them would be legal but
 * needlessly unreadable in every mail client's raw-source view.
 *
 * Base64 rather than Q-encoding: it is uniform (no per-character escaping
 * rules that differ between phrase and unstructured context) and never
 * produces a token needing further escaping. Chunks are split so each
 * encoded-word stays inside RFC 2047's 75-character limit, split on CHARACTER
 * boundaries so a multi-byte sequence is never cut in half.
 */
export function encodeHeaderWord(text: string): string {
    if (!text) return "";
    // eslint-disable-next-line no-control-regex
    if (!/[^\x00-\x7F]/.test(text)) return text;
    const encoder = new TextEncoder();
    // "=?UTF-8?B?" + payload + "?=" must be ≤ 75 chars, so the base64 payload
    // gets 75 - 12 = 63 chars, and base64 only splits cleanly every 4 chars →
    // 60 chars of payload = 45 bytes of input per encoded-word.
    const MAX_BYTES_PER_WORD = 45;
    const words: number[][] = [];
    let chunk: number[] = [];
    for (const ch of text) {                       // iterate by code point
        const bytes = Array.from(encoder.encode(ch));
        if (chunk.length + bytes.length > MAX_BYTES_PER_WORD) {
            words.push(chunk);
            chunk = [];
        }
        chunk.push(...bytes);
    }
    if (chunk.length) words.push(chunk);
    // Continuation lines are joined with CRLF + space: consecutive
    // encoded-words separated by folding whitespace are concatenated by the
    // receiver with the whitespace REMOVED (RFC 2047 §6.2), which is what we
    // want — the original text had no space there.
    return words
        .map(b => `=?UTF-8?B?${bytesToBase64(Uint8Array.from(b))}?=`)
        .join("\r\n ");
}

/** Base64 without assuming Buffer (this package is browser-capable). */
function bytesToBase64(bytes: Uint8Array): string {
    let bin = "";
    for (const b of bytes) bin += String.fromCharCode(b);
    // btoa exists in browsers and in Node ≥16.
    return btoa(bin);
}

/**
 * Encode an address header value (From/To/Cc/Bcc/Reply-To), encoding only the
 * DISPLAY NAME of each address. The addr-spec must stay literal: encoding it
 * would corrupt the address, and a non-ASCII addr-spec is genuinely EAI and
 * cannot be represented any other way.
 *
 * Splits on commas outside angle brackets and quotes so a display name
 * containing a comma ("Frankston, Bob" <x@y>) survives.
 */
export function encodeAddressHeader(value: string): string {
    if (!value) return "";
    // eslint-disable-next-line no-control-regex
    if (!/[^\x00-\x7F]/.test(value)) return value;
    const parts: string[] = [];
    let cur = "";
    let inAngle = false;
    let inQuote = false;
    for (const ch of value) {
        if (ch === '"') inQuote = !inQuote;
        else if (ch === "<" && !inQuote) inAngle = true;
        else if (ch === ">" && !inQuote) inAngle = false;
        if (ch === "," && !inAngle && !inQuote) { parts.push(cur); cur = ""; continue; }
        cur += ch;
    }
    if (cur.trim()) parts.push(cur);
    return parts.map(part => {
        const p = part.trim();
        const m = /^(.*?)\s*(<[^>]*>)\s*$/.exec(p);
        if (!m) return p;                       // bare addr-spec, nothing to encode
        const display = m[1].replace(/^"(.*)"$/s, "$1");
        if (!display) return m[2];
        // eslint-disable-next-line no-control-regex
        if (!/[^\x00-\x7F]/.test(display)) return p;   // ASCII name — leave the quoting alone
        return `${encodeHeaderWord(display)} ${m[2]}`;
    }).join(", ");
}

/** Encode text as RFC 2045 quoted-printable. */
export function encodeQuotedPrintable(text: string): string {
    const encoder = new TextEncoder();
    const bytes = encoder.encode(text);
    let line = "";
    let result = "";
    for (let i = 0; i < bytes.length; i++) {
        const b = bytes[i];
        let encoded: string;
        if (b === 0x0D && bytes[i + 1] === 0x0A) {
            result += line + "\r\n";
            line = "";
            i++;
            continue;
        } else if (b === 0x0A) {
            result += line + "\r\n";
            line = "";
            continue;
        } else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
            encoded = String.fromCharCode(b);
        } else {
            encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
        }
        if (line.length + encoded.length > 75) {
            result += line + "=\r\n";
            line = "";
        }
        line += encoded;
    }
    result += line;
    return result;
}

/** Render an HTML document as a plain-text approximation suitable for the
 *  text/plain alternative part of a multipart/alternative outgoing MIME
 *  message. Not a full HTML-to-text engine — just enough to give non-HTML
 *  clients (plain-text readers, spam filters scoring on text/plain, people
 *  who turned HTML off) a readable fallback. Preserves line breaks for
 *  `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the
 *  common HTML entities, and collapses runs of whitespace.
 *
 *  Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;
 *  shipping a real text part typically drops the score by 1–2 points. Also
 *  matches the behaviour of every other mainstream mail client — sending a
 *  text/html part alone marks mailx as an outlier in mail logs. */
export function htmlToPlainText(html: string): string {
    if (!html) return "";
    let s = html;
    // Drop <style> / <script> entirely (their contents aren't readable text).
    s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "");
    s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
    // Block-level breaks — treat closing tags as line terminators so
    // paragraphs don't run together.
    s = s.replace(/<br\s*\/?\s*>/gi, "\n");
    s = s.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\s*>/gi, "\n");
    // List-item leading bullet (rough but readable).
    s = s.replace(/<li\b[^>]*>/gi, "  • ");
    // Anchor: keep href in parens after the text so URLs survive.
    s = s.replace(/<a\b[^>]*href\s*=\s*(['"])([^'"]*)\1[^>]*>([\s\S]*?)<\/a>/gi,
        (_m, _q, href, text) => {
            const t = text.replace(/<[^>]+>/g, "").trim();
            return t && t !== href ? `${t} (${href})` : href;
        });
    // Strip remaining tags.
    s = s.replace(/<[^>]+>/g, "");
    // Decode a pragmatic set of HTML entities — the rare ones survive as-is.
    s = s.replace(/&nbsp;/gi, " ")
         .replace(/&amp;/gi, "&")
         .replace(/&lt;/gi, "<")
         .replace(/&gt;/gi, ">")
         .replace(/&quot;/gi, "\"")
         .replace(/&#39;/gi, "'")
         .replace(/&apos;/gi, "'")
         .replace(/&mdash;/gi, "—")
         .replace(/&ndash;/gi, "–")
         .replace(/&hellip;/gi, "…")
         .replace(/&#(\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))
         .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));
    // Normalise whitespace: collapse runs of spaces/tabs, trim per-line,
    // cap consecutive blank lines at 2.
    s = s.replace(/[ \t]+/g, " ")
         .split("\n").map(l => l.replace(/^[ \t]+|[ \t]+$/g, "")).join("\n")
         .replace(/\n{3,}/g, "\n\n")
         .trim();
    return s;
}

/** Parse search query into structured conditions.
 *  Supports qualifiers: from:, to:, subject:, date:, has:attachment,
 *  is:flagged, is:unread, is:read. Unqualified terms search across subject /
 *  from / preview. Returns { conditions, params } for SQL WHERE clause with
 *  LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
 *  range comparisons).
 *
 *  Date syntax (matches Gmail-ish conventions):
 *    - date:2026-04-22         exact day
 *    - date:2026-04            month
 *    - date:>2026-04-01        after
 *    - date:<2026-04-01        before
 *    - date:2026-04-01..2026-04-30   range
 *    - date:today / yesterday / last7 / last30
 */
export function parseSearchQuery(query: string): { conditions: string[]; params: (string | number)[] } {
    const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
    const conditions: string[] = [];
    const params: (string | number)[] = [];

    const dayStart = (y: number, m: number, d: number) => new Date(y, m - 1, d).getTime();
    const parseDateSpec = (spec: string): { from?: number; to?: number } | null => {
        const now = new Date();
        const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
        if (spec === "today") return { from: today0, to: today0 + 86400_000 };
        if (spec === "yesterday") return { from: today0 - 86400_000, to: today0 };
        const lastN = spec.match(/^last(\d+)$/i);
        if (lastN) return { from: today0 - parseInt(lastN[1]) * 86400_000 };
        const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
        if (rangeMatch) return {
            from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
            to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
        };
        const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
        if (gtMatch) return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
        const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
        if (ltMatch) return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
        const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
        if (monthMatch) {
            const y = +monthMatch[1], m = +monthMatch[2];
            const from = dayStart(y, m, 1);
            const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
            return { from, to };
        }
        const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
        if (dayMatch) {
            const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
            return { from, to: from + 86400_000 };
        }
        return null;
    };

    for (const part of parts) {
        const fromMatch = part.match(/^from:(.+)$/i);
        const toMatch = part.match(/^to:(.+)$/i);
        const subjectMatch = part.match(/^subject:(.+)$/i);
        const hasMatch = part.match(/^has:(.+)$/i);
        const isMatch = part.match(/^is:(.+)$/i);
        const dateMatch = part.match(/^date:(.+)$/i);

        if (fromMatch) {
            const term = `%${fromMatch[1].replace(/"/g, "")}%`;
            conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
            params.push(term, term);
        } else if (toMatch) {
            const term = `%${toMatch[1].replace(/"/g, "")}%`;
            conditions.push("(to_json LIKE ? OR cc_json LIKE ?)");
            params.push(term, term);
        } else if (subjectMatch) {
            const term = `%${subjectMatch[1].replace(/"/g, "")}%`;
            conditions.push("subject LIKE ?");
            params.push(term);
        } else if (hasMatch) {
            const v = hasMatch[1].toLowerCase();
            if (v === "attachment" || v === "attachments") {
                conditions.push("has_attachments = 1");
            }
            // Unknown has: qualifier — silently drop; treating as a literal
            // search term would be confusing.
        } else if (isMatch) {
            const v = isMatch[1].toLowerCase();
            if (v === "flagged" || v === "starred") {
                conditions.push("flags_json LIKE ?"); params.push("%\\\\Flagged%");
            } else if (v === "unread") {
                conditions.push("flags_json NOT LIKE ?"); params.push("%\\\\Seen%");
            } else if (v === "read") {
                conditions.push("flags_json LIKE ?"); params.push("%\\\\Seen%");
            }
        } else if (dateMatch) {
            const spec = parseDateSpec(dateMatch[1]);
            if (spec) {
                if (spec.from !== undefined) {
                    conditions.push("date >= ?"); params.push(spec.from);
                }
                if (spec.to !== undefined) {
                    conditions.push("date < ?"); params.push(spec.to);
                }
            }
        } else {
            const term = `%${part}%`;
            conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");
            params.push(term, term, term, term);
        }
    }

    return { conditions, params };
}
