#!/usr/bin/env node
/**
 * Bundle client entry points so cold-start drops from ~43 s to ~2 s.
 *
 * mailx's msger custom protocol routes every fetch through one IPC channel
 * (Rust → daemon stdin → response → stdout), so an ES module import cascade
 * pays a multi-second roundtrip per file. With ~17 client-side modules each
 * pulling in transitive deps, the WebView is paying that cost N times in
 * series.
 *
 * esbuild collapses the import graph into one file per entry point. The
 * boot router in index.html stops cascading after one fetch.
 *
 * Dynamic `await import(...)` calls stay as runtime imports (rmf-tiny
 * adapter for the TinyMCE editor, android-bootstrap fallback). Those are
 * either tiny themselves, or live in a code path the desktop main flow
 * doesn't take.
 */
import * as esbuild from "esbuild";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";

const root = path.resolve(fileURLToPath(import.meta.url), "..", "..");
const watch = process.argv.includes("--watch");

/** Cache-bust the bundle references in the HTML entry points with a content
 *  hash. The msger custom protocol serves bundles with no cache headers, so
 *  WebView2 caches `app.bundle.js` and keeps serving the OLD bundle across
 *  daemon relaunches — every client-side fix silently failed to reach the user
 *  until the cache happened to evict (Bob 2026-06-26: flag/Ctrl+A fixes "still
 *  happening" while the daemon was already on the new version). Stamping a
 *  per-build `?v=<hash>` makes the URL change whenever the bundle changes, so
 *  the WebView is forced to fetch the new one. */
function shortHash(file) {
    try { return crypto.createHash("sha1").update(fs.readFileSync(file)).digest("hex").slice(0, 10); }
    catch { return String(Date.now()); }
}
function stampHtml(htmlFile, refs) {
    let html;
    try { html = fs.readFileSync(htmlFile, "utf8"); } catch { return; }
    let changed = false;
    for (const { name, file } of refs) {
        const v = shortHash(file);
        // Match the bundle name + optional existing ?v=… up to the closing
        // quote, so re-runs replace the prior hash instead of stacking.
        const re = new RegExp(`(${name.replace(/[.\\/]/g, m => "\\" + m)})(\\?v=[a-z0-9]+)?(")`, "g");
        const next = html.replace(re, `$1?v=${v}$3`);
        if (next !== html) { html = next; changed = true; }
    }
    if (changed) {
        fs.writeFileSync(htmlFile, html);
        console.log(`  stamped ${path.relative(root, htmlFile)}`);
    }
}
function stampAll() {
    stampHtml(path.join(root, "client", "index.html"), [
        { name: "./app.bundle.js", file: path.join(root, "client", "app.bundle.js") },
        { name: "./android-bootstrap.bundle.js", file: path.join(root, "client", "android-bootstrap.bundle.js") },
    ]);
    stampHtml(path.join(root, "client", "compose", "compose.html"), [
        { name: "compose.bundle.js", file: path.join(root, "client", "compose", "compose.bundle.js") },
    ]);
}

// Each entry has its own external list because the Android bootstrap bundle
// needs to INCLUDE @bobfrankston/mailx-store-web (entire purpose of the
// bundle), while app.bundle.js externalizes it so the desktop build doesn't
// pull a 1-MB blob into a path that only Android takes.
const entries = [
    {
        in:  path.join(root, "client", "app.ts"),
        out: path.join(root, "client", "app.bundle.js"),
        label: "app",
        external: [
            "@bobfrankston/rmf-tiny",
            "@bobfrankston/mailx-store-web/*",
            "tinymce",
            "tinymce/*",
            "quill",
            "quill/*",
            "jsonc-parser",
            "mammoth",
            "html-to-docx",
        ],
    },
    {
        in:  path.join(root, "client", "compose", "compose.ts"),
        out: path.join(root, "client", "compose", "compose.bundle.js"),
        label: "compose",
        external: [
            "@bobfrankston/rmf-tiny",
            "@bobfrankston/mailx-store-web/*",
            "tinymce",
            "tinymce/*",
            "quill",
            "quill/*",
            "jsonc-parser",
            "mammoth",
            "html-to-docx",
        ],
    },
    {
        // 2026-05-13: dedicated Android bootstrap bundle. The previous
        // arrangement leaned on a runtime import map to resolve
        // `@bobfrankston/mailx-store-web/android-bootstrap.js` →
        // `file:///android_asset/node_modules/@bobfrankston/...` at WebView
        // load time. That URL never resolved on real APKs ("Failed to
        // fetch dynamically imported module" 100% reproducible across
        // v695 → v702), apparently because MAUI's MauiAsset machinery
        // mangles deep `node_modules/@bobfrankston/...` paths in ways the
        // WebView can't undo. Bundling inlines every transitive dep
        // (mailx-types, sql.js, etc.) and ships ONE self-contained file at
        // `file:///android_asset/client/android-bootstrap.bundle.js` —
        // same asset tree as `client/index.html`, the path we already
        // know works.
        in:  path.join(root, "packages", "mailx-store-web", "android-bootstrap.ts"),
        out: path.join(root, "client", "android-bootstrap.bundle.js"),
        label: "android-bootstrap",
        external: [
            // sql.js's JS module IS bundled (sql-wasm-browser.js is just JS,
            // ~600KB; esbuild inlines it as part of the bootstrap bundle).
            // The .wasm binary stays out — sql.js fetches it at runtime via
            // the locateFile callback in db.ts, which resolves to the APK's
            // file:///android_asset/node_modules/sql.js/dist/sql-wasm.wasm.
            //
            // Externalizing sql.js entirely (which we did before this fix)
            // left a runtime `import initSqlJs from "sql.js"` in the bundle.
            // The WebView's import map then resolved it to sql-wasm-browser.js
            // — a UMD/CJS module with no `export default`, so the dynamic
            // import threw "module 'sql.js' does not provide an export named
            // 'default'" and initAndroid never ran. 100% boot failure since
            // forever. Found by reading jserv logit-mailx-android.log
            // 2026-05-14 18:45:56.
        ],
    },
];

const shared = {
    bundle:    true,
    format:    "esm",
    target:    "es2022",
    platform:  "browser",
    sourcemap: true,
    splitting: false,
    minify: false,
    logLevel: "info",
    // .wasm files are inlined as base64 data URLs so the runtime can
    // decode → Uint8Array → pass as initSqlJs({ wasmBinary }), bypassing
    // sql.js's fetch(...) of the .wasm. Android WebView refuses to
    // compile WASM fetched from file:// (even with AllowFileAccess /
    // AllowUniversalAccess set — the WebAssembly compiler requires
    // content-type:application/wasm which file:// doesn't supply, AND
    // ArrayBuffer-fallback fetch also fails). Bob 2026-05-14 v1.1.6.1
    // logit: "wasm streaming compile failed → fallback ArrayBuffer
    // instantiation → both async and sync fetching of the wasm failed".
    // Embedding makes the bundle larger (~700 KB → ~933 KB base64) but
    // eliminates the runtime fetch and works on every platform.
    loader: { ".wasm": "binary" },
};

async function buildAll() {
    const t0 = Date.now();
    const results = await Promise.all(entries.map(e =>
        esbuild.build({ ...shared, external: e.external, entryPoints: [e.in], outfile: e.out })
            .then(r => ({ ok: true, label: e.label, out: e.out }))
            .catch(err => ({ ok: false, label: e.label, err }))
    ));
    for (const r of results) {
        if (r.ok) console.log(`  bundle:${r.label} → ${path.relative(root, r.out)}`);
        else console.error(`  bundle:${r.label} FAILED: ${r.err?.message || r.err}`);
    }
    const failed = results.filter(r => !r.ok).length;
    if (failed) process.exit(1);
    // NOTE: do NOT stamp `?v=` cache-bust query strings onto the bundle URLs.
    // msger's custom protocol can't resolve `app.bundle.js?v=<hash>` — the
    // dynamic import() hangs forever (never resolves OR rejects), so the whole
    // app dies: no version, no interactivity, only the boot-snapshot paint
    // (Bob 2026-06-26 "very broken, no version"). Cache-busting must use a real
    // filename change (hashed filename) if revisited, never a query string.
    console.log(`build-bundles: done in ${Date.now() - t0} ms`);
}

if (watch) {
    const ctxs = await Promise.all(entries.map(e =>
        esbuild.context({ ...shared, external: e.external, entryPoints: [e.in], outfile: e.out })
    ));
    for (const c of ctxs) await c.watch();
    console.log("build-bundles: watch mode");
} else {
    await buildAll();
}
