#!/usr/bin/env node
/**
 * rmfmail -- email client
 *
 * Usage:
 *   rmfmail                   Start service + open in msger (IPC, no TCP)
 *   rmfmail --server           Start Express HTTP server (dev/remote)
 *   rmfmail --no-browser       Start server only (headless)
 *   rmfmail --verbose          Show console output (default: log file only)
 *   rmfmail --email <addr>     First-time setup with email (skips prompt)
 *   rmfmail --import <file>    Import accounts.jsonc into GDrive and merge
 *   rmfmail -v / --version     Show version and exit
 *   rmfmail -kill              Kill running rmfmail processes
 *   rmfmail -setup             Interactive first-time setup (CLI)
 *   rmfmail -test              Test IMAP/SMTP connectivity
 *   rmfmail -rebuild           Wipe local cache, re-sync from IMAP
 *   rmfmail -repair            Re-sync metadata (fix corrupt subjects) keeping .eml files
 *   rmfmail -reauth            Clear cached OAuth tokens; next start re-consents
 *                             (use when new Google scopes have been added)
 */

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import net from "node:net";
import { ports } from "@bobfrankston/miscinfo";
import { showMessageBox, showService, setAppName, setAppIcon } from "@bobfrankston/mailx-host";

setAppName("rmfmail");
// Prefer the .ico (Windows Explorer / taskbar-pin shortcut uses the embedded
// icon resource of the pinned exe, or a Windows icon resource referenced
// via PKEY_AppUserModel_RelaunchIconResource — PNG can't play either role).
// Fall back to PNG for the in-window / tao-level icon on non-Windows.
{
    const icoPath = path.resolve(import.meta.dirname, "..", "client", "icon.ico");
    const pngPath = path.resolve(import.meta.dirname, "..", "client", "icon.png");
    setAppIcon(fs.existsSync(icoPath) ? icoPath : pngPath);
}
const PORT = ports.mailx;
const args = process.argv.slice(2);

// Normalize: accept both -flag and --flag
function hasFlag(name: string): boolean { return args.includes(`-${name}`) || args.includes(`--${name}`); }

const verbose = hasFlag("verbose");
const isDaemon = hasFlag("daemon"); // internal: re-spawned detached process

// Read our own version once — used for the instance file + upgrade check below.
const __selfRoot = path.join(import.meta.dirname, "..");
const __selfVersion: string = (() => {
    try { return JSON.parse(fs.readFileSync(path.join(__selfRoot, "package.json"), "utf-8")).version || "unknown"; }
    catch { return "unknown"; }
})();
const __instanceFile = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail", "instance.json");

function readInstanceFile(): { pid: number; version: string; startedAt: number } | null {
    try {
        const raw = fs.readFileSync(__instanceFile, "utf-8");
        const inst = JSON.parse(raw);
        if (typeof inst.pid === "number" && typeof inst.version === "string") return inst;
    } catch { /* missing or unreadable — treated as no instance */ }
    return null;
}
function writeInstanceFile(pid: number): void {
    try {
        fs.mkdirSync(path.dirname(__instanceFile), { recursive: true });
        fs.writeFileSync(__instanceFile, JSON.stringify({ pid, version: __selfVersion, startedAt: Date.now() }, null, 2));
    } catch { /* non-fatal */ }
}
function clearInstanceFile(): void {
    try { fs.unlinkSync(__instanceFile); } catch { /* ignore */ }
}
function pidAlive(pid: number): boolean {
    try { process.kill(pid, 0); return true; } catch { return false; }
}
/** True only if `pid` is alive AND its command line looks like a rmfmail node
 *  process. Windows reuses PIDs aggressively — a rmfmail that exited without
 *  running its cleanup handler can leave behind an instance.json whose PID
 *  has since been recycled by some other app (e.g. Creative Cloud UI Helper),
 *  making bare pidAlive() return true and wedging every subsequent `mailx`
 *  with "already running". Verify it's actually us. Non-Windows: fall back
 *  to pidAlive — POSIX PID reuse is rare enough that we don't bother. */
function pidIsMailx(pid: number): boolean {
    if (!pidAlive(pid)) return false;
    if (process.platform !== "win32") return true;
    try {
        const { execSync } = require("node:child_process");
        const out = execSync(
            `powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`,
            { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
        ).toString();
        return /(?:rmfmail|mailx)[\\\/](?:bin|packages|app)|mailx-server/i.test(out);
    } catch { return false; }
}

// Version-mismatch upgrade: if a daemon from an older version is running when
// the user types `mailx`, kill it so the new one can take over. Without this,
// a second invocation would silently no-op (daemon exists), leaving the user
// on an old UI with no indication that the install has been upgraded.
// Skip this logic for command-only flags (kill, rebuild, setup, ...) and for
// the internal --daemon respawn.
const __commandFlags = ["kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "import", "log", "reauth"];
const __isCommandInvocation = process.argv.slice(2).some(a => __commandFlags.includes(a.replace(/^--?/, "")));
if (!isDaemon && !__isCommandInvocation) {
    const inst = readInstanceFile();
    if (inst && pidIsMailx(inst.pid)) {
        if (inst.version !== __selfVersion) {
            console.log(`rmfmail: upgrading running daemon (PID ${inst.pid}) from v${inst.version} → v${__selfVersion}`);
            try { process.kill(inst.pid, "SIGTERM"); } catch { /* already gone */ }
            // Give it ~1.5s to exit gracefully, then verify
            const deadline = Date.now() + 2000;
            while (Date.now() < deadline && pidAlive(inst.pid)) {
                const sab = new SharedArrayBuffer(4);
                Atomics.wait(new Int32Array(sab), 0, 0, 100); // 100ms nap
            }
            if (pidAlive(inst.pid)) {
                try { process.kill(inst.pid, "SIGKILL"); } catch { /* */ }
            }
            clearInstanceFile();
        } else {
            // Same version already running — nothing to do. Print so the user
            // knows why `mailx` seems to have done nothing.
            console.log(`rmfmail v${__selfVersion} is already running (PID ${inst.pid}). Use rmfmail -kill to stop it.`);
            process.exit(0);
        }
    } else if (inst) {
        // Stale instance file — PID is dead. Clean up.
        clearInstanceFile();
    }
}

// Auto-detach: re-spawn as background process so terminal returns immediately
// Skip for: --verbose (want console), --daemon (already detached),
// and any command flags (setup, kill, test, etc.)
if (!verbose && !isDaemon && !process.argv.slice(2).some(a => /^-/.test(a))) {
    const { spawn } = await import("node:child_process");
    const child = spawn(process.execPath, [...process.argv.slice(1), "--daemon"], {
        // windowsHide on the spawn options below — prevents the brief
        // console-window flash when the daemon launches.
        detached: true,
        stdio: "ignore",
        windowsHide: true,
    });
    child.unref();
    process.exit(0);
}

const setupMode = hasFlag("setup");
const addMode = hasFlag("add");
const testMode = hasFlag("test");
const rebuildMode = hasFlag("rebuild");
const repairMode = hasFlag("repair");
const importMode = hasFlag("import");

// Validate arguments
const knownFlags = ["verbose", "kill", "v", "version", "setup", "add", "test", "rebuild", "repair", "log", "import", "email", "mail", "daemon"];
for (const arg of args) {
    const flag = arg.replace(/^--?/, "");
    if (arg.startsWith("-") && !knownFlags.includes(flag)) {
        console.error(`Unknown option: ${arg}`);
        console.error("Usage: rmfmail [-verbose] [-kill] [-rebuild] [-v] [-setup]");
        process.exit(1);
    }
}

function log(...msg: any[]): void { if (verbose) console.log("[rmfmail]", ...msg); }

/** Detect whether we're running with administrator / root privileges.
 *  Windows: `net session` requires admin — succeeds silently when elevated,
 *  errors "Access is denied" otherwise. Linux/Mac: check process uid.
 *  Returns true only when positively detected as elevated; on ambiguity
 *  (e.g. child_process spawn failed for non-privilege reasons), returns
 *  false so we don't block users on false positives. */
function isElevated(): boolean {
    try {
        if (process.platform === "win32") {
            const { execSync } = require("node:child_process");
            execSync("net session >nul 2>&1", { stdio: "ignore", windowsHide: true });
            return true;
        }
        if (typeof (process as any).getuid === "function") {
            return (process as any).getuid() === 0;
        }
    } catch { /* non-admin → net session fails */ }
    return false;
}

/** Put up a blocking warning dialog via showMessageBox. Returns the label
 *  the user clicked. The default (Quit) is first so Enter dismisses to
 *  safety. Caller decides what to do with "Continue anyway". */
async function warnElevated(): Promise<string> {
    const res = await showMessageBox({
        title: "rmfmail — elevated run not recommended",
        message:
            "rmfmail is running with Administrator privileges.\n\n" +
            "This can corrupt the per-user WebView2 profile at\n" +
            "%LOCALAPPDATA%\\msger\\webview2\\ and create admin-owned files\n" +
            "under ~/.rmfmail/ that later non-admin runs can't write to\n" +
            "(SQLite db, tokens, config).\n\n" +
            "Quit, relaunch from a normal shell, and only use admin if\n" +
            "you specifically know you need it. To bypass this warning\n" +
            "(for scripted admin use), pass --allow-elevated.",
        buttons: ["Quit", "Continue anyway"],
        size: { width: 540, height: 340 },
        escapeCloses: true,
    });
    return res.button;
}

// Kill any running rmfmail server
if (hasFlag("kill")) {
    log("Killing rmfmail processes...");
    const { execSync } = await import("node:child_process");
    let killed = 0;

    // Try graceful exit first
    try {
        execSync(`curl -s -m 2 http://localhost:${PORT}/api/exit`, { stdio: "pipe" });
        log("Sent graceful exit");
        execSync("timeout /t 1 /nobreak", { stdio: "pipe" });
    } catch { /* server may not be responding */ }

    if (process.platform === "win32") {
        // Kill by port
        try {
            const out = execSync(`netstat -ano | findstr :${PORT} | findstr LISTENING`, { encoding: "utf-8" }).trim();
            const pids = [...new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean))];
            for (const pid of pids) {
                try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (port ${PORT})`); killed++; } catch { /* */ }
            }
        } catch { /* no process on port */ }

        // Kill any node.exe running rmfmail (server or IPC service). Match
        // the legacy `mailx` path too — source tree dir is still
        // `Y:\dev\email\mailx\` and per-app exes from older installs are
        // under `%LOCALAPPDATA%\mailx\` until those get cleaned up.
        try {
            const ps = execSync(
                `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='node.exe'\\" | Where-Object { $_.CommandLine -match '(rmfmail|mailx)-server|(rmfmail|mailx)\\\\packages|(rmfmail|mailx)\\\\bin' } | Select-Object -ExpandProperty ProcessId"`,
                { encoding: "utf-8" }
            ).trim();
            for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
                try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (rmfmail node process)`); killed++; } catch { /* */ }
            }
        } catch { /* */ }

        // Kill orphaned msgernative.exe windows. When the node process dies
        // without cascade-killing its WebView child (old crash, forced
        // taskkill, etc.), the msgernative.exe stays on screen and looks
        // like a live rmfmail. rmfmail -kill should leave no trace.
        // Scoped to exes launched for rmfmail by filtering CommandLine —
        // don't touch msger windows started by other apps (msga, bbs, etc.).
        try {
            const ps = execSync(
                `powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name='msgernative.exe'\\" | Where-Object { $_.CommandLine -match 'rmfmail|mailx' -or $_.Path -match 'rmfmail|mailx' } | Select-Object -ExpandProperty ProcessId"`,
                { encoding: "utf-8" }
            ).trim();
            for (const pid of ps.split("\n").map(s => s.trim()).filter(s => /^\d+$/.test(s))) {
                try { execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" }); console.log(`Killed PID ${pid} (rmfmail msgernative/WebView)`); killed++; } catch { /* */ }
            }
        } catch { /* */ }
    } else {
        try { execSync(`fuser -k ${PORT}/tcp`, { stdio: "pipe" }); console.log(`Killed process on port ${PORT}`); killed++; } catch { /* */ }
    }

    // Clean up stale SQLite WAL/SHM files
    const mailxDir = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
    for (const ext of ["mailx.db-shm", "mailx.db-wal"]) {
        const p = path.join(mailxDir, ext);
        try { fs.unlinkSync(p); log(`Cleaned ${ext}`); } catch { /* */ }
    }

    // Always clear instance.json — the daemon's exit handler does this on a
    // clean shutdown, but if it crashed (or was force-killed before the
    // handler ran) the file lingers and wedges every future `mailx` with
    // "already running" because Windows recycles PIDs aggressively. -kill
    // is the user's escape hatch; leave no lock behind.
    clearInstanceFile();

    if (killed === 0) console.log("No rmfmail processes found");
    process.exit(0);
}

// Re-auth: clear cached OAuth tokens so the next start forces a fresh
// consent flow. Needed when scopes change (e.g. Google Tasks was added
// 2026-04-23 but existing tokens were issued against the older scope
// set, so tasks API calls 403ed with "insufficient authentication
// scopes"). Safe — tokens are only a cache; fresh consent re-issues.
if (hasFlag("reauth")) {
    const { getConfigDir } = await import("@bobfrankston/mailx-settings");
    const tokensDir = path.join(getConfigDir(), "tokens");
    if (!fs.existsSync(tokensDir)) {
        console.log("No tokens directory — nothing to clear.");
        process.exit(0);
    }
    let cleared = 0;
    for (const entry of fs.readdirSync(tokensDir)) {
        const userDir = path.join(tokensDir, entry);
        try {
            const stat = fs.statSync(userDir);
            if (!stat.isDirectory()) continue;
            const tokenFile = path.join(userDir, "oauth-token.json");
            if (fs.existsSync(tokenFile)) {
                fs.unlinkSync(tokenFile);
                console.log(`  Cleared token for ${entry}`);
                cleared++;
            }
        } catch { /* skip */ }
    }
    console.log(cleared === 0
        ? "No cached tokens found."
        : `Cleared ${cleared} cached token(s). Next 'rmfmail' start will open a browser OAuth consent so the new scopes (tasks, full contacts) get granted.`);
    process.exit(0);
}

// Rebuild: wipe DB + message store, keep accounts/settings
if (rebuildMode) {
    const { getConfigDir, getStorePath } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const storePath = getStorePath();

    console.log("Rebuilding rmfmail local cache...");
    console.log("  Accounts and settings will be preserved.");

    // Remove DB files
    for (const f of ["mailx.db", "mailx.db-wal", "mailx.db-shm"]) {
        const p = path.join(dbDir, f);
        if (fs.existsSync(p)) { fs.unlinkSync(p); console.log(`  Deleted ${f}`); }
    }

    // Remove message store
    if (fs.existsSync(storePath)) {
        fs.rmSync(storePath, { recursive: true });
        console.log(`  Deleted message store`);
    }

    console.log("  Rebuild complete. Run 'rmfmail' to start fresh.");
    process.exit(0);
}

// Repair: re-sync metadata (subjects, flags, envelopes) without deleting stored .eml files
if (repairMode) {
    const { getConfigDir } = await import("@bobfrankston/mailx-settings");
    const dbDir = getConfigDir();
    const dbPath = path.join(dbDir, "mailx.db");

    if (!fs.existsSync(dbPath)) {
        console.error("No database found. Run 'rmfmail' first to create one.");
        process.exit(1);
    }

    console.log("Repairing rmfmail metadata...");
    console.log("  Message bodies (.eml files) will be preserved.");
    console.log("  Clearing message metadata for re-sync...");

    // Dynamic require — better-sqlite3 is a native module, not typed in bin/
    const mod = "better-sqlite3";
    const Database = (await import(/* webpackIgnore: true */ mod) as any).default;
    const db = Database(dbPath);
    db.pragma("journal_mode = WAL");

    const count = (db.prepare("SELECT COUNT(*) as cnt FROM messages").get() as any).cnt;
    db.exec("DELETE FROM messages");
    db.exec("DELETE FROM messages_fts");
    // Reset folder sync state so IMAP re-syncs all envelopes
    db.exec("UPDATE folders SET total = 0, unread = 0");
    db.close();

    console.log(`  Cleared ${count} message entries. Folder sync state reset.`);
    console.log("  Run 'rmfmail' to re-sync from IMAP with correct encoding.");
    process.exit(0);
}

// Import accounts from a local file into GDrive
if (importMode) {
    const importPath = args.find(a => !a.startsWith("-"));
    if (!importPath) {
        console.error("Usage: rmfmail --import <path-to-accounts.jsonc>");
        console.error("  Reads accounts from a local file and saves to Google Drive.");
        console.error("  Example: rmfmail --import ~/OneDrive/home/.rmfmail/accounts.jsonc");
        process.exit(1);
    }
    const { parse: parseJsonc } = await import("jsonc-parser");
    const absPath = path.resolve(importPath);
    if (!fs.existsSync(absPath)) {
        console.error(`File not found: ${absPath}`);
        process.exit(1);
    }
    const content = fs.readFileSync(absPath, "utf-8").replace(/\r/g, "");
    const data = parseJsonc(content);
    const accounts = data?.accounts || (Array.isArray(data) ? data : null);
    if (!accounts || accounts.length === 0) {
        console.error("No accounts found in file. Expected { accounts: [...] } or [...]");
        process.exit(1);
    }
    console.log(`Found ${accounts.length} account(s) in ${absPath}`);

    // Initialize cloud config (GDrive) and save
    const { initCloudConfig, loadAccounts, saveAccounts } = await import("@bobfrankston/mailx-settings");
    await initCloudConfig("gdrive");

    // Merge: existing cloud accounts + imported, deduplicate by email
    const existing = loadAccounts();
    const merged = [...existing];
    for (const acct of accounts) {
        if (!merged.some(e => e.email === acct.email)) {
            merged.push(acct);
            console.log(`  + ${acct.label || acct.email}`);
        } else {
            console.log(`  = ${acct.label || acct.email} (already exists)`);
        }
    }
    // Wrap with name if the source had one
    const wrapper: any = { accounts: merged };
    if (data?.name) wrapper.name = data.name;
    await saveAccounts(merged);
    console.log(`Saved ${merged.length} account(s). Run 'rmfmail' to start.`);
    process.exit(0);
}

// Version
if (hasFlag("v") || hasFlag("version")) {
    const root = path.join(import.meta.dirname, "..");
    const ver = (pkg: string): string => {
        for (const dir of [`${root}/node_modules/${pkg}`, `${root}/node_modules/@bobfrankston/${pkg.replace("@bobfrankston/","")}`]) {
            try { return JSON.parse(fs.readFileSync(`${dir}/package.json`, "utf-8")).version; } catch { /* */ }
        }
        // Check workspace packages
        const short = pkg.replace("@bobfrankston/","");
        try { return JSON.parse(fs.readFileSync(`${root}/packages/${short}/package.json`, "utf-8")).version; } catch { /* */ }
        return "not found";
    };
    try {
        const pkg = JSON.parse(fs.readFileSync(`${root}/package.json`, "utf-8"));
        console.log(`\x1b[1;97;44m rmfmail v${pkg.version} \x1b[0m`);
    } catch { console.log("rmfmail (version unknown)"); }
    console.log(`  node      ${process.version}`);
    console.log(`  iflow-direct  ${ver("@bobfrankston/iflow-direct")}`);
    console.log(`  miscinfo  ${ver("@bobfrankston/miscinfo")}`);
    console.log(`  oauth     ${ver("@bobfrankston/oauthsupport")}`);
    console.log(`  store     ${ver("@bobfrankston/mailx-store")}`);
    console.log(`  server    ${ver("@bobfrankston/mailx-server")}`);
    console.log(`  api       ${ver("@bobfrankston/mailx-api")}`);
    console.log(`  platform  ${process.platform} ${process.arch}`);
    process.exit(0);
}

function isPortInUse(port: number): Promise<boolean> {
    return new Promise((resolve) => {
        const socket = net.createConnection({ port, host: "127.0.0.1" });
        socket.once("connect", () => { socket.destroy(); resolve(true); });
        socket.once("error", () => resolve(false));
    });
}

/** Launch msger pointing at the server URL */
function launchMsger(url: string): void {
    showMessageBox({ url, detach: true, size: { width: 1400, height: 900 } });
}

async function prompt(question: string): Promise<string> {
    const readline = await import("readline");
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    return new Promise(resolve => rl.question(question, (ans: string) => { rl.close(); resolve(ans.trim()); }));
}

interface DiscoveredServer {
    imap: { host: string; port: number; auth: string };
    smtp: { host: string; port: number; auth: string };
    source: string;
}

interface AccountInput {
    email: string;
    password?: string;
    imap?: { host: string; port?: number };
    smtp?: { host: string; port?: number };
}

/** Check if rmfmail is configured (has local config or accounts) */
function hasConfig(): boolean {
    const home = process.env.USERPROFILE || process.env.HOME || "";
    const mailxDir = path.join(home, ".rmfmail");
    for (const f of ["config.jsonc", "accounts.jsonc", "settings.jsonc"]) {
        if (fs.existsSync(path.join(mailxDir, f))) return true;
    }
    return false;
}

/** Try to auto-discover mail server settings from email domain */
async function autoDiscover(domain: string): Promise<DiscoveredServer | null> {
    // 1. Try Thunderbird ISPDB
    try {
        const res = await fetch(`https://autoconfig.thunderbird.net/v1.1/${domain}`, { signal: AbortSignal.timeout(5000) });
        if (res.ok) {
            const xml = await res.text();
            const imap = xml.match(/<incomingServer type="imap">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
            const smtp = xml.match(/<outgoingServer type="smtp">[\s\S]*?<hostname>(.*?)<\/hostname>[\s\S]*?<port>(\d+)<\/port>[\s\S]*?<authentication>(.*?)<\/authentication>/);
            if (imap && smtp) {
                return {
                    imap: { host: imap[1], port: parseInt(imap[2]), auth: imap[3].includes("OAuth2") ? "oauth2" : "password" },
                    smtp: { host: smtp[1], port: parseInt(smtp[2]), auth: smtp[3].includes("OAuth2") ? "oauth2" : "password" },
                    source: "ISPDB",
                };
            }
        }
    } catch { /* timeout or not found */ }

    // 2. Try DNS SRV records
    try {
        const dns = await import("node:dns/promises");
        const imapSrv = await dns.resolveSrv(`_imaps._tcp.${domain}`).catch((): null => null);
        const smtpSrv = await dns.resolveSrv(`_submission._tcp.${domain}`).catch((): null => null);
        if (imapSrv?.[0] && smtpSrv?.[0]) {
            return {
                imap: { host: imapSrv[0].name, port: imapSrv[0].port, auth: "password" },
                smtp: { host: smtpSrv[0].name, port: smtpSrv[0].port, auth: "password" },
                source: "DNS SRV",
            };
        }
    } catch { /* DNS failed */ }

    // 3. Try MX-based detection (Google Workspace, Microsoft 365)
    try {
        const dns = await import("node:dns/promises");
        const records = await dns.resolveMx(domain);
        for (const mx of records) {
            const host = mx.exchange.toLowerCase();
            if (host.endsWith(".google.com") || host.endsWith(".googlemail.com")) {
                return {
                    imap: { host: "imap.gmail.com", port: 993, auth: "oauth2" },
                    smtp: { host: "smtp.gmail.com", port: 587, auth: "oauth2" },
                    source: `MX → Google (${host})`,
                };
            }
            if (host.endsWith(".outlook.com") || host.endsWith(".protection.outlook.com")) {
                return {
                    imap: { host: "outlook.office365.com", port: 993, auth: "oauth2" },
                    smtp: { host: "smtp.office365.com", port: 587, auth: "oauth2" },
                    source: `MX → Microsoft (${host})`,
                };
            }
        }
    } catch { /* DNS failed */ }

    return null;
}

/** Prompt user for account details, auto-discover where possible */
async function promptForAccount(intro?: string): Promise<AccountInput | null> {
    if (intro) console.log(intro);
    const email = await prompt("Email address (or 'skip'): ");
    if (!email || email.toLowerCase() === "skip") return null;

    const domain = email.split("@")[1]?.toLowerCase() || "";
    const knownOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"];

    if (knownOAuth.includes(domain)) {
        console.log(`  ${domain}: OAuth2 — no password needed. Browser will prompt for authorization.`);
        return { email };
    }

    // Try auto-discovery
    console.log(`  Looking up mail servers for ${domain}...`);
    const discovered = await autoDiscover(domain);

    if (discovered) {
        console.log(`  Found via ${discovered.source}: IMAP ${discovered.imap.host}:${discovered.imap.port}, SMTP ${discovered.smtp.host}:${discovered.smtp.port}`);
        if (discovered.imap.auth === "oauth2") {
            console.log("  OAuth2 authentication — browser will prompt for authorization.");
            return { email };
        }
        const password = await prompt("Password: ");
        return {
            email, password,
            imap: { host: discovered.imap.host, port: discovered.imap.port },
            smtp: { host: discovered.smtp.host, port: discovered.smtp.port },
        };
    }

    // Manual fallback
    console.log("  Could not auto-detect servers. Enter manually:");
    const password = await prompt("Password: ");
    const imapHost = await prompt(`IMAP host [imap.${domain}]: `) || `imap.${domain}`;
    const smtpHost = await prompt(`SMTP host [smtp.${domain}]: `) || `smtp.${domain}`;
    return {
        email, password,
        imap: { host: imapHost },
        smtp: { host: smtpHost },
    };
}

/** Interactive first-time setup — GDrive API for cloud storage.
 *  Returns true if an account was added (caller should proceed to launch UI),
 *  false if the user skipped (UI's in-browser setup form will take over). */
async function runSetup(providedEmail?: string): Promise<boolean> {
    console.log("\nrmfmail— first-time setup\n");

    const home = process.env.USERPROFILE || process.env.HOME || "";
    const mailxDir = path.join(home, ".rmfmail");
    fs.mkdirSync(mailxDir, { recursive: true });

    // Use --email flag or prompt interactively
    const email = providedEmail || await prompt("Email address (Gmail recommended): ");
    if (!email || !email.includes("@")) {
        console.log(`\nNo account added. The UI will show a setup form.`);
        return false;
    }
    if (providedEmail) console.log(`Using email: ${email}`);

    const domain = email.split("@")[1]?.toLowerCase() || "";
    let isGoogle = ["gmail.com", "googlemail.com"].includes(domain);
    if (!isGoogle) {
        try {
            const dnsmod = await import("node:dns/promises");
            const records = await dnsmod.resolveMx(domain);
            isGoogle = records.some(mx => {
                const host = mx.exchange.toLowerCase();
                return host.endsWith(".google.com") || host.endsWith(".googlemail.com");
            });
            if (isGoogle) console.log(`  ${domain} is hosted on Google (detected via MX)`);
        } catch { /* DNS lookup failed */ }
    }

    // For Google-hosted accounts, check Drive for existing settings first
    let driveFolderId: string | null = null;
    if (isGoogle) {
        console.log("\nChecking Google Drive for existing rmfmail settings...");
        try {
            const { gDriveFindOrCreateFolder, getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
            driveFolderId = await gDriveFindOrCreateFolder();
            if (driveFolderId) {
                console.log(`  Drive folder: My Drive/rmfmail/ (${driveFolderId})`);
                const gdrive = getCloudProvider("gdrive", driveFolderId);
                if (gdrive) {
                    // Read accounts.jsonc (canonical) — ignore legacy settings.jsonc
                    const existing = await gdrive.read("accounts.jsonc");
                    if (existing) {
                        const { parse: parseJsonc } = await import("jsonc-parser");
                        const data = parseJsonc(existing);
                        const accts = data?.accounts || (Array.isArray(data) ? data : []);
                        if (accts.length > 0) {
                            console.log(`\nFound ${accts.length} existing account(s) on Google Drive (My Drive/rmfmail/accounts.jsonc):`);
                            for (const a of accts) console.log(`  • ${a.label || a.name || a.email}`);
                            // Save config pointing to Drive — no prompts needed
                            const config = { sharedDir: { provider: "gdrive", path: "rmfmail", folderId: driveFolderId } };
                            fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
                            console.log("Local config created. Starting rmfmail...\n");
                            return true;
                        }
                    }
                }
                // No existing accounts — save Drive config for later
                const config = { sharedDir: { provider: "gdrive", path: "rmfmail", folderId: driveFolderId } };
                fs.writeFileSync(path.join(mailxDir, "config.jsonc"), JSON.stringify(config, null, 2));
            } else {
                console.log("  Could not access Google Drive (OAuth not granted or token expired).");
                console.log("  Account will be saved locally; the UI will retry the cloud sync when you fix Drive access.");
            }
        } catch (e: any) {
            console.log(`  Drive check failed: ${e.message} — will save locally and retry from UI.`);
        }
    }

    // No existing accounts found — build a new account
    const account = { email } as any;
    const isOAuth = ["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com"].includes(domain);
    if (!isOAuth) {
        account.password = await prompt("Password (app password for Yahoo/AOL/iCloud): ");
    }

    // Display name: leave empty for Google accounts so MailxService.setupAccount
    // (or the next launch's IMAP auth) can resolve it from the People API once
    // the Gmail OAuth token exists. The Drive token alone doesn't have the
    // contacts.readonly scope, so we can't fetch it here at CLI-prompt time.
    const defaultName = email.split("@")[0];
    const name = await prompt(`Your name (for From: header) [auto-detect from Google, or '${defaultName}']: `) || (isGoogle ? "" : defaultName);

    // ALWAYS write a local copy first so the data is never lost. The cloud
    // write below is the sync, not the source of truth on this machine.
    const accountsData = { name, accounts: [account] };
    const localAccountsPath = path.join(mailxDir, "accounts.jsonc");
    fs.writeFileSync(localAccountsPath, JSON.stringify(accountsData, null, 2));

    if (isGoogle && driveFolderId) {
        // Save to Google Drive via API
        console.log("\nSaving account to Google Drive...");
        try {
            const { getCloudProvider } = await import("@bobfrankston/mailx-settings/cloud.js");
            const gdrive = getCloudProvider("gdrive", driveFolderId);
            if (!gdrive) throw new Error("getCloudProvider returned null");
            await gdrive.write("accounts.jsonc", JSON.stringify(accountsData, null, 2));
            console.log("  Account saved to Google Drive.");
        } catch (e: any) {
            console.log(`  Drive write failed: ${e.message}`);
            console.log(`  Local copy saved at ${localAccountsPath} — UI will retry cloud sync.`);
        }
    } else if (isGoogle && !driveFolderId) {
        console.log(`  Skipping Drive sync (no folder ID). Local copy at ${localAccountsPath}.`);
    }

    console.log("Setup complete. Starting rmfmail...\n");
    return true;
}

/** Test account connectivity — IMAP connect, SMTP send, sync round-trip */
async function runTest(): Promise<void> {
    console.log("\nrmfmail— connection test\n");

    // Start server in-process to access settings
    console.log("Loading settings...");
    const { loadSettings, getSharedDir, initLocalConfig } = await import("@bobfrankston/mailx-settings");
    initLocalConfig();
    const settings = loadSettings();

    if (settings.accounts.length === 0) {
        console.log("No accounts configured. Run: rmfmail -setup");
        process.exit(1);
    }

    console.log(`Shared dir: ${getSharedDir()}`);
    console.log(`Accounts: ${settings.accounts.map((a: any) => `${a.label || a.name} <${a.email}>`).join(", ")}\n`);

    for (const account of settings.accounts) {
        if (!account.enabled) { console.log(`  ${account.label || account.id}: SKIPPED (disabled)\n`); continue; }

        console.log(`Testing ${account.label || account.id} (${account.email}):`);

        // Test IMAP
        try {
            const { createAutoImapConfig, CompatImapClient } = await import("@bobfrankston/iflow-direct");
            const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
            const config = createAutoImapConfig({
                server: account.imap.host,
                port: account.imap.port,
                username: account.imap.user,
                password: account.imap.password
            });
            const client = new CompatImapClient(config, () => new NodeTcpTransport());
            const folders = await client.getFolderList();
            await client.logout();
            console.log(`  IMAP: OK (${folders.length} folders)`);
        } catch (e: any) {
            console.log(`  IMAP: FAILED — ${e.message}`);
        }

        // Test SMTP
        try {
            const { createTransport } = await import("nodemailer");
            let smtpAuth: any;
            if (account.smtp.auth === "password") {
                smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
            } else if (account.smtp.auth === "oauth2") {
                // Try to get OAuth token
                const { createAutoImapConfig } = await import("@bobfrankston/iflow-direct");
                const config = createAutoImapConfig({
                    server: account.imap.host,
                    port: account.imap.port,
                    username: account.imap.user,
                });
                if (config.tokenProvider) {
                    const accessToken = await config.tokenProvider();
                    smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
                }
            }
            const transport = createTransport({
                host: account.smtp.host,
                port: account.smtp.port,
                secure: account.smtp.port === 465,
                auth: smtpAuth,
                tls: { rejectUnauthorized: false },
            });
            await transport.verify();
            console.log(`  SMTP: OK`);

            // Send test message to self
            const testSubject = `rmfmail test — ${new Date().toLocaleString()}`;
            await transport.sendMail({
                from: `${account.name} <${account.email}>`,
                to: account.email,
                subject: testSubject,
                text: `This is a test message from rmfmail -test.\nSent: ${new Date().toISOString()}\nAccount: ${account.id}`,
            });
            console.log(`  SEND: OK — test message sent to ${account.email}`);
            console.log(`  Subject: "${testSubject}"`);
        } catch (e: any) {
            console.log(`  SMTP: FAILED — ${e.message}`);
        }

        console.log();
    }

    console.log("Test complete. Check your inbox for the test message(s).");
    process.exit(0);
}

/** Register this client on GDrive — writes/updates clients.jsonc with device info.
 *  Best-effort: silently skips when cloud isn't ready (fresh install, expired
 *  token). The scary "No cloud configured" banner must NOT fire for this. */
async function registerClient(settings: any): Promise<void> {
    const { cloudRead, cloudWrite, getStorageInfo } = await import("@bobfrankston/mailx-settings");
    // Check if cloud is actually ready before attempting a write — cloudWrite
    // calls setCloudError which pushes an error banner to the UI. We'd rather
    // silently skip than show "No cloud configured" at startup.
    const info = getStorageInfo();
    if (info.mode === "local" || !info.provider || info.provider === "local") return;

    // Device ID: stable per machine, stored locally
    const deviceIdPath = path.join(os.homedir(), ".rmfmail", "device-id");
    let deviceId: string;
    if (fs.existsSync(deviceIdPath)) {
        deviceId = fs.readFileSync(deviceIdPath, "utf-8").trim();
    } else {
        deviceId = `${os.hostname()}-${Date.now().toString(36)}`;
        fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true });
        fs.writeFileSync(deviceIdPath, deviceId);
    }

    // Get local IP
    let localIp = "";
    try {
        const nets = os.networkInterfaces();
        for (const addrs of Object.values(nets) as (os.NetworkInterfaceInfo[] | undefined)[]) {
            for (const addr of addrs || []) {
                if (addr.family === "IPv4" && !addr.internal) { localIp = addr.address; break; }
            }
            if (localIp) break;
        }
    } catch { /* ignore */ }

    // Read existing clients.jsonc from cloud (may not exist yet — that's fine)
    let clients: Record<string, any> = {};
    try {
        const content = await cloudRead("clients.jsonc");
        if (content) clients = JSON.parse(content);
    } catch { /* start fresh */ }

    // Update this device's entry
    clients[deviceId] = {
        hostname: os.hostname(),
        platform: `${process.platform} ${process.arch}`,
        accounts: settings.accounts.map((a: any) => a.id),
        lastSeen: new Date().toISOString(),
        ip: localIp,
        version: JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version,
    };

    // Write back. cloudWrite now throws on failure (and sets lastCloudError),
    // so swallow here — registerClient is fire-and-forget from the caller.
    try {
        await cloudWrite("clients.jsonc", JSON.stringify(clients, null, 2));
        console.log(`  [client] Registered device: ${deviceId}`);
    } catch (e: any) {
        console.error(`  [client] Failed to register device: ${e.message}`);
    }
}

async function main(): Promise<void> {
    log(`Platform: ${process.platform} ${process.arch}`);
    log(`Node: ${process.version}`);
    log(`Mode: ${setupMode ? "setup" : "auto"}`);

    // Refuse to run elevated unless explicitly opted in. An elevated mailx
    // can poison %LOCALAPPDATA%\msger\webview2\ (see msger/notes.md WebView2
    // profile playbook) and create admin-owned files under ~/.rmfmail/ that
    // later non-admin runs can't write to. `net session` requires admin on
    // Windows; succeeds → admin, fails → non-admin. Linux/Mac use process
    // uid (0 = root). --allow-elevated bypasses for scripted admin use.
    if (!hasFlag("allow-elevated") && !isDaemon && isElevated()) {
        const button = await warnElevated();
        if (button !== "Continue anyway") {
            log("User chose Quit on elevated-run warning. Exiting.");
            process.exit(0);
        }
        log("User chose Continue anyway on elevated-run warning. Proceeding (will likely poison local state).");
    }

    // Test connectivity
    if (testMode) {
        await runTest();
        return;
    }

    // Add account to existing config
    if (addMode) {
        const account = await promptForAccount();
        if (account) {
            const home = process.env.USERPROFILE || process.env.HOME || "";
            const mailxDir = path.join(home, ".rmfmail");
            const settingsPath = path.join(mailxDir, "settings.jsonc");
            let settings: any;
            try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8").replace(/\r/g, "").replace(/\/\/.*/g, "")); } catch { settings = { accounts: [] }; }
            if (!settings.accounts) settings.accounts = [];
            settings.accounts.push(account);
            fs.mkdirSync(mailxDir, { recursive: true });
            fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
            console.log(`Added ${account.email} to settings. Restart rmfmail to connect.`);
        }
        process.exit(0);
    }

    // Auto-detect first run — enter setup if no config exists.
    // Skip CLI prompts entirely when stdin isn't a TTY (auto-detached daemon
    // has stdio:"ignore", so prompt() returns "" instantly and the user never
    // gets to type their email — silent no-setup). The in-browser setup form
    // takes over in that case.
    const hasTty = setupMode ? !!process.stdin.isTTY : (process.stdin.isTTY === true);
    if (setupMode || !hasConfig()) {
        if (!setupMode) console.log("No rmfmail configuration found.");
        // -email or -mail flag skips the interactive prompt
        const emailFlag = args.findIndex(a => a === "-email" || a === "--email" || a === "-mail" || a === "--mail");
        const emailArg = args.find(a => a.startsWith("-email=") || a.startsWith("--email=") || a.startsWith("-mail=") || a.startsWith("--mail="))?.split("=")[1]
            || (emailFlag >= 0 ? args[emailFlag + 1] : undefined);
        if (hasTty || emailArg) {
            await runSetup(emailArg);
        } else {
            console.log("No TTY and no -email flag — skipping CLI setup; in-browser setup form will appear.");
            // Ensure the data dir exists so the UI can write its config.
            const home = process.env.USERPROFILE || process.env.HOME || "";
            fs.mkdirSync(path.join(home, ".rmfmail"), { recursive: true });
        }
    }

    // Redirect console to log file — keep terminal clean
    if (!verbose) {
        const home = process.env.USERPROFILE || process.env.HOME || ".";
        const logDir = path.join(home, ".rmfmail", "logs");
        fs.mkdirSync(logDir, { recursive: true });
        // Prune logs older than LOG_RETENTION_DAYS on startup. Keep it simple:
        // scan the dir, stat, delete. Cheap even with years of history.
        // Bumped 7 → 30 days so "where did my letter go?" reports can still
        // reach the `[reconcile-delete]` log entry weeks after the fact.
        const LOG_RETENTION_DAYS = 30;
        const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
        try {
            for (const name of fs.readdirSync(logDir)) {
                if (!/^mailx-\d{4}-\d{2}-\d{2}\.log$/.test(name)) continue;
                const full = path.join(logDir, name);
                try {
                    const st = fs.statSync(full);
                    if (st.mtimeMs < cutoff) fs.unlinkSync(full);
                } catch { /* ignore per-file error */ }
            }
        } catch { /* ignore — log pruning is best-effort */ }
        const logDate = new Date().toISOString().slice(0, 10);
        const logPath = path.join(logDir, `mailx-${logDate}.log`);
        const logStream = fs.createWriteStream(logPath, { flags: "a" });
        const ts = () => new Date().toISOString().slice(11, 23);
        console.log = (...a: any[]) => { logStream.write(`${ts()} ${a.join(" ")}\n`); };
        console.error = (...a: any[]) => { logStream.write(`${ts()} ERROR ${a.join(" ")}\n`); };
        // Redirect daemon's process.stderr to the log too. msger forwards its
        // Rust child's stderr to our process.stderr; in daemon mode that's
        // stdio:"ignore" → /dev/null, which buries diagnostics like
        // "Failed to load icon ..." that we need to debug taskbar-icon
        // problems. Wire it through here.
        const origStderrWrite = process.stderr.write.bind(process.stderr);
        process.stderr.write = ((chunk: any, ...rest: any[]): boolean => {
            try {
                const s = typeof chunk === "string" ? chunk : chunk.toString();
                logStream.write(`${ts()} STDERR ${s}${s.endsWith("\n") ? "" : "\n"}`);
            } catch { /* ignore — best-effort logging */ }
            return origStderrWrite(chunk, ...rest);
        }) as typeof process.stderr.write;
    }

    // IPC service mode — no HTTP server
    console.log("Starting rmfmail service...");
    const { MailxDB } = await import("@bobfrankston/mailx-store");
    const { ImapManager } = await import("@bobfrankston/mailx-imap");
    const { MailxService } = await import("@bobfrankston/mailx-service");
    const { dispatch } = await import("@bobfrankston/mailx-service/jsonrpc.js");
    const { loadSettings, loadAccountsAsync, getConfigDir, getStorageInfo } = await import("@bobfrankston/mailx-settings");

    let settings = loadSettings();
    if (settings.accounts.length === 0) {
        const cloudAccounts = await loadAccountsAsync();
        if (cloudAccounts.length > 0) {
            settings = { ...settings, accounts: cloudAccounts };
            console.log(`  Loaded ${cloudAccounts.length} account(s) from cloud`);
        }
    }

    const db = new MailxDB(getConfigDir());

    // Auto-create the sending/ recovery README on every startup. Stays in
    // sync with the running version of mailx; user can ignore once the
    // disk-staging fallback is no longer needed.
    try {
        const sendingDir = path.join(getConfigDir(), "sending");
        fs.mkdirSync(sendingDir, { recursive: true });
        const readmePath = path.join(sendingDir, "README.md");
        const readmeBody = `# \`~/.rmfmail/sending/\` and \`~/.rmfmail/outbox/\` — outgoing-mail staging

Auto-generated by rmfmail on startup. Manual recovery reference for when rmfmail is broken or you need to feed an outgoing message into another mail program.

## Layout

\`\`\`
~/.rmfmail/
├── outbox/<account>/
│   └── *.ltr                  ← THE QUEUE. Worker scans every 10s, sends, deletes on success.
└── sending/<account>/
    ├── editing/               ← Last 3 draft autosaves while composing.
    ├── queued/                ← Manual drop-in / crash-recovery copies.
    └── sent/                  ← Audit trail of successfully sent messages.
\`\`\`

In-flight files are atomically renamed to \`<file>.sending-<host>-<pid>\` while the worker is processing them — same-machine claim so two rmfmail instances don't double-send. Stale claims (dead PIDs on this host) are recovered on the next tick.

## Manual fallback

- **rmfmail is dead, need to send a draft** — most recent file in \`sending/<account>/editing/\` is a complete RFC 822 message; copy the body into another mail client and resend.
- **Feed a raw .eml to mailx** — drop into \`sending/<account>/queued/\`. Picked up within 10s.
- **rmfmail says queued but server doesn't have it** — look in \`outbox/<account>/\`. \`.ltr\` still there → worker hasn't sent yet (check \`~/.rmfmail/logs/\`). \`.sending-<host>-<pid>\` → in flight. Gone → success.

## Format

RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable in a text editor). Every message carries \`Message-ID:\` for cross-device dedup; \`X-Mailx-Retry\` marks retry attempts.
`;
        // Only rewrite if content drifted (avoids gratuitous mtime updates).
        let existing = "";
        try { existing = fs.readFileSync(readmePath, "utf-8"); } catch { /* missing */ }
        if (existing !== readmeBody) fs.writeFileSync(readmePath, readmeBody);
    } catch (e: any) {
        console.error(`  [readme] Could not write sending README: ${e?.message || e}`);
    }

    // `--server` mode — hand off to the Express HTTP server package (self-
    // contained; initializes its own DB / IMAP manager / MailxService). No
    // WebView2, no IPC bridge. Useful for remote access (phone before MAUI
    // was a thing) and for JSON-RPC debugging from devtools. Loopback by
    // default; passes --external through if the user asked for it. See C34.
    if (hasFlag("server")) {
        writeInstanceFile(process.pid);
        await import("@bobfrankston/mailx-server");
        // mailx-server's index.ts self-invokes `start()` — once imported,
        // Express owns the event loop. Don't fall through to showService.
        return;
    }

    const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
    const imapManager = new ImapManager(db, () => new NodeTcpTransport());
    // Native client is the only option (iflow-direct)
    const svc = new MailxService(db, imapManager);

    // Open msger in service mode — custom protocol serves files from client dir
    const clientDir = path.join(import.meta.dirname, "..", "client");
    const mailxapiPath = path.join(clientDir, "lib", "mailxapi.js");
    const mailxapiScript = fs.readFileSync(mailxapiPath, "utf-8");

    // Restore saved window geometry (position + size) from previous session
    const windowJsonPath = path.join(getConfigDir(), "window.json");
    interface WindowGeometry { x: number; y: number; width: number; height: number }
    let savedGeometry: WindowGeometry | null = null;
    try {
        const raw = JSON.parse(fs.readFileSync(windowJsonPath, "utf-8"));
        if (typeof raw.width === "number" && raw.width > 200 &&
            typeof raw.height === "number" && raw.height > 200) {
            savedGeometry = raw as WindowGeometry;
        }
    } catch { /* no saved geometry — use defaults */ }

    const rootPkgVersion = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8")).version;
    // Pass the .png as the window-decode icon: msger uses the `image` crate
    // to decode `options.icon` for the Tao window icon, and PNG-in-ICO files
    // round-trip unreliably through the image-0.24 ICO decoder, which leaves
    // the taskbar entry showing the empty msgernative.exe default. PNG always
    // decodes cleanly. The .ico path is forwarded separately as
    // `relaunchIcon`, which msger uses for `PKEY_AppUserModel_RelaunchIconResource`
    // (the path is consumed verbatim — no decode — so PNG-in-ICO is fine
    // there). Falls back if either file is missing.
    const __iconIco = path.join(clientDir, "icon.ico");
    const __iconPng = path.join(clientDir, "icon.png");
    const __iconPathRuntime = fs.existsSync(__iconPng) ? __iconPng : __iconIco;
    const __iconPathPin = fs.existsSync(__iconIco) ? __iconIco : undefined;
    console.log(`[icon] runtime=${__iconPathRuntime} (exists=${fs.existsSync(__iconPathRuntime)}) pin=${__iconPathPin ?? "none"} (exists=${__iconPathPin ? fs.existsSync(__iconPathPin) : false})`);
    // Pinned-shortcut launch command. Windows captures the running exe at
    // pin time by default, but the running exe is `mailx.exe` — actually
    // msgernative.exe expecting JSON options on stdin. Click that bare and
    // it dies on `serde_json::from_str("")`. Override with an explicit
    // node + script invocation so the pin re-enters through bin/mailx.js
    // the same way the `mailx` CLI does.
    const __mailxJs = path.join(import.meta.dirname, "mailx.js");
    const __relaunchCommand = `"${process.execPath}" "${__mailxJs}"`;
    const handle = showService({
        title: `rmfmail v${rootPkgVersion}`,
        url: "index.html",
        contentDir: clientDir,
        initScript: mailxapiScript,
        icon: __iconPathRuntime,
        relaunchIcon: __iconPathPin,
        relaunchCommand: __relaunchCommand,
        relaunchDisplayName: "rmfmail",
        aumid: "com.frankston.rmfmail",
        size: savedGeometry
            ? { width: savedGeometry.width, height: savedGeometry.height }
            : { width: 1400, height: 900 },
        pos: savedGeometry
            ? { x: savedGeometry.x, y: savedGeometry.y }
            : undefined,
        escapeCloses: false,
    });

    // Register ourselves as the live instance so subsequent `mailx` invocations
    // can detect version-mismatch and upgrade us (see top of file). Clear on
    // any of: SIGINT, SIGTERM, normal exit.
    //
    // Critical: the SIGTERM handler must *close the WebView child process*
    // (handle.close() → kills msgernative.exe) before Node exits. Without
    // this, the auto-upgrade leaves the old WebView orphaned on screen and
    // the user sees an apparently frozen "old mailx" while the new Node is
    // trying to spawn a second one. Cascade-killing the child makes the
    // version-mismatch auto-upgrade actually transparent to the user.
    writeInstanceFile(process.pid);
    const __cleanupInstance = () => {
        // Only clear if WE are still the registered instance. Prevents the
        // restart-daemon sequence (clear → spawn → new daemon writes its
        // own entry → we exit) from deleting the replacement's claim on
        // the way out.
        const inst = readInstanceFile();
        if (inst && inst.pid === process.pid) clearInstanceFile();
        try { handle.close(); } catch { /* already gone */ }
    };
    process.once("exit", __cleanupInstance);
    process.once("SIGINT", () => { __cleanupInstance(); process.exit(0); });
    process.once("SIGTERM", () => { __cleanupInstance(); process.exit(0); });

    // Handle requests from WebView → dispatch to MailxService
    // Pass server version to dispatch so getVersion returns it
    const rootPkg = JSON.parse(fs.readFileSync(path.join(import.meta.dirname, "..", "package.json"), "utf-8"));

    handle.onRequest(async (req) => {
        if (!req._action) {
            // msger sends {"button":"OK","closed":true} on navigation — ignore it, don't exit
            if (req.closed || req.button) {
                console.log(`[ipc] ← ignored close signal during navigation: ${JSON.stringify(req).substring(0, 100)}`);
                return;
            }
            console.log(`[ipc] ← ignored (no _action): ${JSON.stringify(req).substring(0, 100)}`);
            return;
        }
        console.log(`[ipc] ← ${req._action} (${req._cbid})`);
        // Save window position+size for next launch (handled here, not in MailxService)
        if (req._action === "saveWindowGeometry") {
            try {
                const geom: WindowGeometry = {
                    x: Number(req.x) || 0,
                    y: Number(req.y) || 0,
                    width: Math.max(400, Number(req.width) || 1400),
                    height: Math.max(300, Number(req.height) || 900),
                };
                fs.writeFileSync(windowJsonPath, JSON.stringify(geom, null, 2));
            } catch (e: any) {
                console.error(`[window] Failed to save geometry: ${e.message}`);
            }
            handle.send({ _cbid: req._cbid, result: { ok: true } });
            return;
        }
        // Restart the daemon in-place without npm install. Subtle: the new
        // mailx's startup-time instance check sees the instance.json we
        // wrote and bails with "already running" if versions match —
        // skipping the new process entirely. Clear the instance file
        // FIRST so the replacement can claim the slot, THEN spawn, THEN
        // gracefully shut this process down. The exit handler guards
        // against clobbering the replacement's entry (see __cleanupInstance
        // below — only clears if instance.json's PID still matches ours).
        if (req._action === "restartDaemon") {
            handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
            try {
                clearInstanceFile();
                const { spawn: spawnChild } = await import("child_process");
                const child = spawnChild("rmfmail", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
                child.unref();
                console.log("  [restart] Spawned fresh daemon; shutting down current");
                // Give the spawn a moment to take hold before we start
                // tearing things down — otherwise IMAP disconnects could
                // race with the new process's startup handshake.
                await new Promise(r => setTimeout(r, 800));
            } catch (e: any) {
                console.error(`  [restart] Spawn failed: ${e.message}`);
            }
            gracefulShutdown("User requested restart");
            return;
        }
        // Auto-update action: run npm install then restart
        if (req._action === "performUpdate") {
            handle.send({ _cbid: req._cbid, ok: true, status: "updating" });
            try {
                const { execSync, spawn: spawnChild } = await import("child_process");
                console.log("  [update] Installing latest version...");
                execSync("npm install -g @bobfrankston/rmfmail", { encoding: "utf-8", timeout: 120_000, stdio: "inherit", windowsHide: true });
                console.log("  [update] Install complete — relaunching");
                // Spawn the new version detached so it outlives this process
                const child = spawnChild("rmfmail", [], { detached: true, stdio: "ignore", shell: true, windowsHide: true });
                child.unref();
                gracefulShutdown("Update applied");
            } catch (e: any) {
                // Don't shut down on a failed install — the user is still in
                // the middle of a session, an offline-time "Update now" tap
                // shouldn't kill the running app. Surface the failure so the
                // banner can offer a retry instead of leaving "Updating..."
                // pinned forever.
                const msg = e?.message || String(e);
                console.error(`  [update] Install failed: ${msg}`);
                const offline = /ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|getaddrinfo|network|registry/i.test(msg);
                handle.send({ _event: "updateFailed", type: "updateFailed", offline, message: msg });
            }
            return;
        }
        // Per-action wall-clock timing so a "took N seconds" report tells us
        // where between Rust→stdin→dispatch→service the time actually went.
        const ipcT0 = Date.now();
        try {
            const response = await dispatch(svc as any, req);
            const elapsed = Date.now() - ipcT0;
            console.log(`[ipc] → ${req._action} (${req._cbid}) ok in ${elapsed}ms`);
            handle.send(response);
        } catch (e: any) {
            const elapsed = Date.now() - ipcT0;
            console.error(`[ipc] → ${req._action} (${req._cbid}) error in ${elapsed}ms: ${e.message}`);
            handle.send({ _cbid: req._cbid, error: e.message });
        }
    });

    // Wire IMAP events → push to WebView (throttled to avoid flooding stdin)
    let pendingSyncProgress: Record<string, { phase: string; progress: number }> = {};
    let syncProgressTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("syncProgress", (accountId: string, phase: string, progress: number) => {
        pendingSyncProgress[accountId] = { phase, progress };
        if (!syncProgressTimer) {
            syncProgressTimer = setTimeout(() => {
                syncProgressTimer = null;
                for (const [id, p] of Object.entries(pendingSyncProgress)) {
                    handle.send({ _event: "syncProgress", type: "syncProgress", accountId: id, phase: p.phase, progress: p.progress });
                }
                pendingSyncProgress = {};
            }, 500); // batch sync events every 500ms
        }
    });
    let pendingCounts: Record<string, any> = {};
    let countsTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("folderCountsChanged", (accountId: string, counts: any) => {
        pendingCounts[accountId] = counts;
        if (!countsTimer) {
            countsTimer = setTimeout(() => {
                countsTimer = null;
                for (const [id, c] of Object.entries(pendingCounts)) {
                    handle.send({ _event: "folderCountsChanged", type: "folderCountsChanged", accountId: id, ...c });
                }
                pendingCounts = {};
            }, 1000); // batch count updates every 1s
        }
    });
    // Batch folderSynced events (same cadence as counts) so a sync-all burst
    // doesn't flood the WebView with one stdin write per folder.
    let pendingSynced: Record<string, { folderId: number; syncedAt: number }[]> = {};
    let syncedTimer: ReturnType<typeof setTimeout> | null = null;
    imapManager.on("folderSynced", (accountId: string, folderId: number, syncedAt: number) => {
        (pendingSynced[accountId] ||= []).push({ folderId, syncedAt });
        if (!syncedTimer) {
            syncedTimer = setTimeout(() => {
                syncedTimer = null;
                for (const [id, entries] of Object.entries(pendingSynced)) {
                    handle.send({ _event: "folderSynced", type: "folderSynced", accountId: id, entries });
                }
                pendingSynced = {};
            }, 1000);
        }
    });
    imapManager.on("syncError", (accountId: string, error: string) => {
        handle.send({ _event: "error", type: "error", message: `${accountId}: ${error}` });
    });
    imapManager.on("accountError", (accountId: string, error: string, hint: string, isOAuth: boolean) => {
        handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
    });
    imapManager.on("configChanged", (filename: string) => {
        handle.send({ _event: "configChanged", type: "configChanged", filename });
    });
    imapManager.on("outboxStatus", (status: any) => {
        handle.send({ _event: "outboxStatus", type: "outboxStatus", ...status });
    });
    // syncComplete drives the folder-tree refresh that picks up newly-discovered
    // folders on first run (Gmail accounts have no folders in the DB until the
    // first sync fetches the labels). Without this forward, the UI shows the
    // account but no folders, and never auto-selects the inbox.
    imapManager.on("syncComplete", (accountId: string) => {
        handle.send({ _event: "syncComplete", type: "syncComplete", accountId });
    });
    imapManager.on("syncActionFailed", (accountId: string, action: string, uid: number, error: string) => {
        handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
    });
    // External-edit (Word) save events. The service watches the temp file
    // and emits this when Word writes; compose.ts listens and reloads Quill.
    imapManager.on("wordEditUpdated", (payload: { editId: string; html: string }) => {
        handle.send({ _event: "wordEditUpdated", type: "wordEditUpdated", ...payload });
    });
    // Cloud-write/read failures from mailx-settings → push to UI as a banner so
    // silent fall-back-to-local can no longer swallow Drive errors.
    const { onCloudError } = await import("@bobfrankston/mailx-settings");
    onCloudError((error, ctx) => {
        if (error) {
            handle.send({ _event: "cloudError", type: "cloudError", error, op: ctx?.op, filename: ctx?.filename });
        } else {
            handle.send({ _event: "cloudError", type: "cloudError", error: null });
        }
    });
    // Coalesce bodyCached into batches so a prefetch storm doesn't flood stdin
    // with one IPC write per message — lets the UI flip many rows at once.
    let pendingCached: { accountId: string; uid: number }[] = [];
    let cachedTimer: ReturnType<typeof setTimeout> | null = null;
    // Calendar/tasks refresh completion — service emits when a pull merged
    // new rows or reconciled a server-side delete. Client re-renders its
    // sidebar on receipt. No payload beyond accountId.
    imapManager.on("calendarUpdated", (payload: any) => {
        handle.send({ _event: "calendarUpdated", type: "calendarUpdated", ...payload });
    });
    imapManager.on("tasksUpdated", (payload: any) => {
        handle.send({ _event: "tasksUpdated", type: "tasksUpdated", ...payload });
    });
    imapManager.on("authScopeError", (payload: any) => {
        handle.send({ _event: "authScopeError", type: "authScopeError", ...payload });
    });
    imapManager.on("bodyCached", (accountId: string, uid: number) => {
        pendingCached.push({ accountId, uid });
        if (!cachedTimer) {
            cachedTimer = setTimeout(() => {
                cachedTimer = null;
                const batch = pendingCached;
                pendingCached = [];
                handle.send({ _event: "bodyCached", type: "bodyCached", items: batch });
            }, 500);
        }
    });

    // Brief pause for WebView2 to initialize before starting IMAP (avoids stdin writes during init)
    await new Promise(r => setTimeout(r, 500));

    // Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
    for (const account of settings.accounts) {
        if (!account.enabled) continue;
        try {
            await imapManager.addAccount(account);
            console.log(`  Account: ${account.label || account.name} (${account.id})`);
        } catch (e: any) {
            console.error(`  Failed: ${account.id}: ${e.message}`);
        }
    }
    // After OAuth has completed, resolve missing display names for Google
    // accounts via the People API (contacts.readonly is in the Gmail scope).
    // "Missing" = empty or matches the email local-part default.
    try {
        const { getGoogleProfile } = await import("@bobfrankston/mailx-settings/cloud.js");
        const { saveAccounts } = await import("@bobfrankston/mailx-settings");
        let updated = false;
        for (const acct of settings.accounts) {
            if (!acct.enabled) continue;
            const isGoogle = acct.email.endsWith("@gmail.com")
                          || acct.email.endsWith("@googlemail.com")
                          || acct.imap?.host?.includes("gmail");
            if (!isGoogle) continue;
            const local = acct.email.split("@")[0];
            const looksDefault = !acct.name || acct.name === local;
            if (!looksDefault) continue;
            try {
                const tok = await imapManager.getOAuthToken(acct.id);
                if (!tok) continue;
                const profile = await getGoogleProfile(tok);
                if (profile?.name && profile.name !== acct.name) {
                    console.log(`  [name-resolve] ${acct.id}: '${acct.name || "(empty)"}' → '${profile.name}'`);
                    acct.name = profile.name;
                    updated = true;
                }
            } catch (e: any) {
                console.error(`  [name-resolve] ${acct.id}: ${e.message}`);
            }
        }
        if (updated) {
            try { await saveAccounts(settings.accounts); } catch (e: any) {
                console.error(`  [name-resolve] saveAccounts failed: ${e.message}`);
            }
        }
    } catch (e: any) {
        console.error(`  [name-resolve] init failed: ${e.message}`);
    }
    // Register this client device on GDrive (fire-and-forget).
    // Skip when no accounts — cloud isn't configured yet on fresh installs,
    // so the write fails with "No cloud configured" and flashes a scary banner.
    if (settings.accounts.length > 0) {
        registerClient(settings).catch(() => {});
    }

    // Start sync in background — don't block. Kick off IDLE watchers once the
    // initial sync finishes so IMAP accounts get instant-push new-mail (the
    // 5-min STATUS poll is only a safety net).
    if (settings.accounts.some(a => a.enabled)) {
        // Fast-path: fire a quick INBOX check on every account IMMEDIATELY,
        // parallel to the full syncAll. quickInboxCheckAccount uses a fresh
        // client + a cached folder list from the DB, so it skips the
        // folder-list fetch that syncAll's step 1 does. On a cold Dovecot
        // session that folder LIST can take several seconds on big trees
        // (bobma = ~105 folders) — no reason the user should wait for it
        // before seeing mail that arrived overnight in INBOX.
        for (const acct of settings.accounts) {
            if (!acct.enabled) continue;
            imapManager.quickInboxCheckAccount(acct.id).catch(e =>
                console.error(`  [startup-check] ${acct.id}: ${e?.message || e}`)
            );
        }
        imapManager.syncAll()
            .then(() => imapManager.startWatching())
            .then(() => {
                // Seed the contacts table from received messages so address
                // autocomplete works on the first compose without waiting for
                // the user to manually trigger it. Cheap — one grouped SELECT
                // + one INSERT per new sender. No-op if contact already exists.
                const added = db.seedContactsFromMessages();
                if (added > 0) console.log(`  [contacts] seeded ${added} from message senders`);
            })
            .catch(e => console.error(`  Sync error: ${e.message}`));
    }
    imapManager.startPeriodicSync(settings.sync.intervalMinutes);
    imapManager.startOutboxWorker();
    imapManager.watchConfigFiles();
    // Re-seed contacts every 30 min so newly-received senders surface in
    // autocomplete without restarting mailx. Cheap; idempotent.
    setInterval(() => {
        try {
            const added = db.seedContactsFromMessages();
            if (added > 0) console.log(`  [contacts] periodic seed added ${added} new senders`);
        } catch (e: any) { console.error(`  [contacts] periodic seed error: ${e.message}`); }
    }, 30 * 60_000);
    // Drain store_sync (calendar / tasks / contacts two-way pushes) every
    // 30s. Local edits also drain immediately; this picks up rows that
    // failed their first attempt (network blip, token refresh, 5xx).
    setInterval(() => {
        svc.drainStoreSync().catch((e: any) =>
            console.error(`  [store_sync] periodic drain error: ${e?.message || e}`));
    }, 30_000);
    // Calendar + Tasks poll — pulls server-side changes so the sidebar
    // reflects events added/edited/deleted on another device without
    // needing a sidebar nav click. 5-minute cadence is well under the
    // per-user rate limit and the 1M/day project quota. Webhooks would
    // be cheaper in theory but need a public HTTPS endpoint; poll is
    // the pragmatic choice (confirmed 2026-04-23). getCalendarEvents /
    // getTasks emit the refresh event via imapManager, so the sidebar
    // re-renders automatically.
    const CAL_POLL_MS = 5 * 60_000;
    const horizonDays = 90;  // larger than sidebar's default so background
    // poll catches upcoming-week events the sidebar hasn't asked for yet.
    setInterval(() => {
        const now = Date.now();
        svc.getCalendarEvents(now, now + horizonDays * 86400_000)
            .catch((e: any) => console.error(`  [calendar] poll error: ${e?.message || e}`));
        svc.getTasks(false)
            .catch((e: any) => console.error(`  [tasks] poll error: ${e?.message || e}`));
    }, CAL_POLL_MS);

    // Contacts poll — incremental sync via People API syncToken (persisted
    // per-account in the kv table). Cheap after the first run because only
    // changed/deleted contacts come back. 15-minute cadence picks up new
    // contacts added on phone / web without restart while staying well
    // under the People API rate limit.
    const CONTACTS_POLL_MS = 15 * 60_000;
    setInterval(() => {
        svc.syncGoogleContacts()
            .catch((e: any) => console.error(`  [contacts] poll error: ${e?.message || e}`));
    }, CONTACTS_POLL_MS);

    // Auto-update: periodically check npm for a newer version and push a
    // notification to the WebView so the user can update with one click.
    const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
    async function checkForUpdate(): Promise<void> {
        try {
            // spawn with windowsHide:true — execSync briefly flashes a cmd
            // window on Windows every time the periodic check fires.
            const { spawn } = await import("child_process");
            const latest = await new Promise<string>((resolve, reject) => {
                const child = spawn("npm", ["view", "@bobfrankston/mailx", "version"], {
                    windowsHide: true,
                    shell: true,
                });
                let out = "";
                let err = "";
                child.stdout.on("data", (d: Buffer) => { out += d.toString(); });
                child.stderr.on("data", (d: Buffer) => { err += d.toString(); });
                const killer = setTimeout(() => { try { child.kill(); } catch { /* */ } reject(new Error("npm view timed out")); }, 15_000);
                child.on("error", (e) => { clearTimeout(killer); reject(e); });
                child.on("exit", (code) => {
                    clearTimeout(killer);
                    if (code === 0) resolve(out.trim());
                    else reject(new Error(err.trim() || `npm view exit ${code}`));
                });
            });
            const current = rootPkgVersion;
            if (latest && latest !== current) {
                console.log(`  [update] New version available: ${current} → ${latest}`);
                handle.send({ _event: "updateAvailable", type: "updateAvailable", current, latest });
            }
        } catch (e: any) {
            // Silent — network down, npm not reachable, etc.
            console.log(`  [update] Check failed: ${e.message}`);
        }
    }
    // First check after 2 minutes (don't slow down startup), then every 30 min
    setTimeout(() => {
        checkForUpdate();
        setInterval(checkForUpdate, UPDATE_CHECK_MS);
    }, 120_000);

    // Graceful shutdown — close IMAP connections, stop timers, close DB
    let shuttingDown = false;
    async function gracefulShutdown(reason: string): Promise<void> {
        if (shuttingDown) return;
        shuttingDown = true;
        console.log(`${reason} — shutting down`);
        imapManager.stopPeriodicSync();
        imapManager.stopOutboxWorker();
        // 3s hard timeout — don't hang on broken IMAP connections
        const forceExit = setTimeout(() => { console.log("Forced exit"); process.exit(0); }, 3000);
        try { await imapManager.shutdown(); } catch { /* proceed */ }
        clearTimeout(forceExit);
        db.close();
        process.exit(0);
    }

    process.on("SIGINT", () => gracefulShutdown("SIGINT"));
    process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
    process.on("uncaughtException", (err) => {
        console.error(`UNCAUGHT EXCEPTION: ${err.stack || err.message}`);
        gracefulShutdown("uncaughtException");
    });
    process.on("unhandledRejection", (reason: any) => {
        console.error(`UNHANDLED REJECTION: ${reason?.stack || reason?.message || reason}`);
    });
    process.on("exit", (code) => {
        console.log(`Process exit (code ${code})`);
        if (!shuttingDown) {
            imapManager.stopPeriodicSync();
            imapManager.stopOutboxWorker();
            db.close();
        }
    });

    // Wait for window close, then shut down
    await handle.closed;
    await gracefulShutdown("Window closed");
}

main().catch(console.error);
