/**
 * Assemble an RFC 5322 / MIME message. The ONE place that does this.
 *
 * Why it exists: four live code paths built messages by hand — desktop send
 * and draft (`mailx-service`), Android/web send and draft (`mailx-store-web`)
 * — 72% identical line-for-line, plus four more copies in packages nothing
 * could reach. Every fix to the shape had to be found and applied N times,
 * and the N-th was routinely missed:
 *
 *   - v1.2.166  envelope scraped quoted headers — "SAME shape in 3 files"
 *   - v1.2.179  inline images data: → cid: — had to land in BOTH send paths
 *   - v1.2.226  RFC 2047 encoded-words — 7 sites by hand, and the Android
 *               path still got only Subject: its From/To/Cc/Bcc were raw
 *               interpolation until this unification (2026-08-09), so an
 *               accented display name sent from Android was still an EAI
 *               message that non-SMTPUTF8 receivers bounce
 *
 * The shape produced is unchanged from what desktop send has emitted since
 * v1.2.179 — this is an extraction, verified byte-for-byte against the old
 * code over a matrix of body/attachment/inline-image/header combinations
 * (see `test/mime-build.test.mjs`), not a rewrite.
 *
 * Structure it emits, narrowest form that carries the content:
 *
 *   text only ............... text/plain
 *   html .................... multipart/alternative (plain derived + html)
 *   html + pasted images .... multipart/related [ alternative, image parts ]
 *   any of those + files .... multipart/mixed [ the above, attachments ]
 *   draft (simpleHtml) ...... a single text/html part, no alternative
 */

import { encodeQuotedPrintable, encodeHeaderWord, encodeAddressHeader, htmlToPlainText } from "./index.js";
import { extractInlineImages } from "./mime-inline-images.js";

export interface MimeAttachment {
    filename?: string;
    mimeType?: string;
    dataBase64?: string;
}

export interface BuildMimeOptions {
    /** Already-formatted address strings ("Name <a@b>", comma-separated). */
    from: string;
    to?: string;
    cc?: string;
    bcc?: string;
    subject: string;
    bodyHtml?: string;
    bodyText?: string;
    attachments?: MimeAttachment[];
    inReplyTo?: string;
    references?: string[];
    /** Supply to reuse an existing id; omit and one is generated from `domain`. */
    messageId?: string;
    /** Domain for the generated Message-ID and for inline-image cid: ids. */
    domain: string;
    /** Extra header lines inserted before MIME-Version (e.g. X-Mailx-Draft-ID). */
    extraHeaders?: string[];
    /** Emit no Message-ID header at all — the draft paths never carried one. */
    omitMessageId?: boolean;
    /** Draft mode: one text/html part, no alternative / related / mixed and no
     *  inline-image extraction. A draft is re-parsed by the compose window, not
     *  delivered, so the multipart machinery would only get in the way. */
    simpleHtml?: boolean;
    /** Injectable for tests; defaults to `new Date()`. */
    now?: Date;
    /** Injectable for tests so boundaries and ids are reproducible. */
    unique?: () => string;
}

export interface BuiltMime {
    /** The complete message, CRLF line endings, ready for SMTP or APPEND. */
    raw: string;
    /** The Message-ID actually used (empty when `omitMessageId`). */
    messageId: string;
    /** How many data: images became cid: parts — worth logging. */
    inlineImageCount: number;
}

/** Base64 must be folded at ≤76 chars per RFC 2045. */
function wrap76(s: string): string {
    return s.match(/.{1,76}/g)?.join("\r\n") || s;
}

export function buildMimeMessage(opts: BuildMimeOptions): BuiltMime {
    const now = opts.now || new Date();
    const unique = opts.unique
        || (() => `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`);
    const newBoundary = () => `mailx_${unique()}`;

    const messageId = opts.omitMessageId
        ? ""
        : (opts.messageId || `<${Date.now()}.${Math.random().toString(36).slice(2)}@${opts.domain}>`);

    const hasHtml = !!opts.bodyHtml;

    // ── headers ──
    // RFC 2047 on every header that can carry a display name or free text.
    // Raw 8-bit here makes the whole message EAI (RFC 6530), which a receiver
    // without SMTPUTF8 (RFC 6531) must refuse — one curly apostrophe in a
    // Subject cost three recipients on 2026-08-08.
    const headers: (string | null)[] = [
        `From: ${encodeAddressHeader(opts.from)}`,
        opts.to ? `To: ${encodeAddressHeader(opts.to)}` : null,
        opts.cc ? `Cc: ${encodeAddressHeader(opts.cc)}` : null,
        // Bcc belongs IN a draft — the SEND path strips it, so that a saved
        // draft reopened for editing still shows who was on Bcc.
        opts.bcc ? `Bcc: ${encodeAddressHeader(opts.bcc)}` : null,
        `Subject: ${encodeHeaderWord(opts.subject)}`,
        `Date: ${now.toUTCString()}`,
        messageId ? `Message-ID: ${messageId}` : null,
        opts.inReplyTo ? `In-Reply-To: ${opts.inReplyTo}` : null,
        opts.references?.length ? `References: ${opts.references.join(" ")}` : null,
        ...(opts.extraHeaders || []),
        `MIME-Version: 1.0`,
    ];
    const commonHeaders = headers.filter((h): h is string => h !== null);

    // ── draft: a single text/html part and nothing else ──
    if (opts.simpleHtml) {
        const body = opts.bodyHtml || opts.bodyText || "";
        const raw = [
            ...commonHeaders,
            `Content-Type: text/html; charset=UTF-8`,
            `Content-Transfer-Encoding: quoted-printable`,
        ].join("\r\n") + `\r\n\r\n` + encodeQuotedPrintable(body);
        return { raw, messageId, inlineImageCount: 0 };
    }

    // Pasted images live in the editor HTML as data: URIs — fine in a WebView,
    // broken for recipients on Outlook desktop (won't render data: images) and
    // Gmail web (sanitizer strips them). Extract to cid:-referenced parts.
    const extracted = hasHtml
        ? extractInlineImages(opts.bodyHtml!, opts.domain)
        : { html: "", images: [] as ReturnType<typeof extractInlineImages>["images"] };
    const htmlBody = extracted.html;
    const inlineImages = extracted.images;
    // HTML-bodied mail carries a text/plain alternative too: spam filters score
    // HTML-only mail 1–2 points worse, and plain-text readers still exist.
    const textBody = opts.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
    const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
    const textEncoded = encodeQuotedPrintable(textBody);

    /** The body part that carries the message text, without envelope headers,
     *  so it can stand alone OR become the first part of a multipart/mixed. */
    const makeInner = (): { headers: string[]; body: string } => {
        if (hasHtml) {
            const altBoundary = newBoundary();
            const body =
                `--${altBoundary}\r\n` +
                `Content-Type: text/plain; charset=UTF-8\r\n` +
                `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                `${textEncoded}\r\n` +
                `--${altBoundary}\r\n` +
                `Content-Type: text/html; charset=UTF-8\r\n` +
                `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
                `${htmlEncoded}\r\n` +
                `--${altBoundary}--\r\n`;
            const alt = {
                headers: [`Content-Type: multipart/alternative; boundary="${altBoundary}"`],
                body,
            };
            if (inlineImages.length === 0) return alt;
            const relBoundary = newBoundary();
            const relParts = [
                `--${relBoundary}\r\n` + alt.headers.join("\r\n") + `\r\n\r\n` + alt.body,
                ...inlineImages.map(im =>
                    `--${relBoundary}\r\n` +
                    `Content-Type: ${im.mime}\r\n` +
                    `Content-ID: <${im.contentId}>\r\n` +
                    `Content-Disposition: inline\r\n` +
                    `Content-Transfer-Encoding: base64\r\n\r\n` +
                    `${wrap76(im.base64)}\r\n`),
            ];
            return {
                headers: [`Content-Type: multipart/related; boundary="${relBoundary}"; type="multipart/alternative"`],
                body: relParts.join("") + `--${relBoundary}--\r\n`,
            };
        }
        return {
            headers: [
                `Content-Type: text/plain; charset=UTF-8`,
                `Content-Transfer-Encoding: quoted-printable`,
            ],
            body: textEncoded,
        };
    };

    const attachments = opts.attachments || [];
    let raw: string;
    if (attachments.length > 0) {
        const mixedBoundary = newBoundary();
        const inner = makeInner();
        const parts: string[] = [
            `--${mixedBoundary}\r\n` + inner.headers.join("\r\n") + `\r\n\r\n` + inner.body,
        ];
        for (const att of attachments) {
            // A quote or newline in a filename would break out of the header.
            const filename = (att.filename || "attachment").replace(/[\r\n"]/g, "_");
            const mime = att.mimeType || "application/octet-stream";
            parts.push(
                `--${mixedBoundary}\r\n` +
                `Content-Type: ${mime}; name="${filename}"\r\n` +
                `Content-Disposition: attachment; filename="${filename}"\r\n` +
                `Content-Transfer-Encoding: base64\r\n\r\n` +
                `${wrap76(att.dataBase64 || "")}\r\n`
            );
        }
        raw = [...commonHeaders, `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`].join("\r\n")
            + `\r\n\r\n` + parts.join("") + `--${mixedBoundary}--\r\n`;
    } else {
        const inner = makeInner();
        raw = [...commonHeaders, ...inner.headers].join("\r\n") + `\r\n\r\n` + inner.body;
    }

    return { raw, messageId, inlineImageCount: inlineImages.length };
}
