/**
 * @bobfrankston/mailx-settings
 * Settings management with shared/local split.
 *
 * Files (shared — on OneDrive or similar):
 *   accounts.jsonc    — IMAP/SMTP account configs
 *   preferences.jsonc — UI, sync, font settings
 *   allowlist.jsonc   — remote content allow-list
 *
 * Local overrides (~/.mailx/):
 *   config.jsonc      — pointer to shared dir + local-only settings (storePath, historyDays)
 *   accounts.jsonc    — cached copy, fallback when shared unavailable
 *   preferences.jsonc — local overrides merged on top of shared
 *   allowlist.jsonc   — cached copy
 *
 * The old settings.jsonc is still supported for backward compatibility.
 */
import * as fs from "node:fs";
import * as path from "node:path";
import { parse as parseJsonc } from "jsonc-parser";
import { REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual } from "@bobfrankston/mailx-types";
import { getCloudProvider, gDriveFindOrCreateFolder, gDriveValidateCachedFolder } from "./cloud.js";
const __dirname = import.meta.dirname;
// ── Paths ──
// REMOVE 2026-06-15 — one-shot migration of legacy ~/.mailx → ~/.rmfmail.
// Five cases handled (idempotent — running twice is safe):
//   1. only ~/.mailx: rename to ~/.rmfmail (atomic single move).
//   2. ~/.mailx contains ONLY a `mailxstore/` subdir while ~/.rmfmail is
//      already the active config — move the body store across, rewrite
//      body_path rows in the DB, then remove the empty ~/.mailx. This is
//      the hybrid-state user (their config jumped to .rmfmail but the
//      stored body files lived under .mailx because storePath was an
//      absolute path).
//   3. both exist with other content in ~/.mailx: rename to ~/.mailx-old
//      so it's visibly cruft.
//   4. only ~/.rmfmail (or neither): do nothing.
// 5 covers config.jsonc with absolute storePath pointing at ~/.mailx —
// rewritten to a relative "mailxstore" so the path follows the config dir.
{
    const home = process.env.USERPROFILE || process.env.HOME || ".";
    const oldDir = path.join(home, ".mailx");
    const newDir = path.join(home, ".rmfmail");
    const asideDir = path.join(home, ".mailx-old");
    if (!fs.existsSync(newDir) && fs.existsSync(oldDir)) {
        fs.renameSync(oldDir, newDir);
        console.log("[migrate] ~/.mailx → ~/.rmfmail");
    }
    else if (fs.existsSync(newDir) && fs.existsSync(oldDir)) {
        // Hybrid: figure out whether `.mailx/` is just a leftover store or
        // has meaningful content.
        let entries = [];
        try {
            entries = fs.readdirSync(oldDir);
        }
        catch { /* */ }
        const onlyStore = entries.length === 1 && entries[0] === "mailxstore";
        if (onlyStore) {
            const oldStore = path.join(oldDir, "mailxstore");
            const newStore = path.join(newDir, "mailxstore");
            try {
                fs.mkdirSync(newStore, { recursive: true });
                // Recursive move (rename per top-level entry; rename across
                // dirs on the same volume is atomic, fall back to copy+unlink
                // if it fails — e.g. when the volumes differ).
                const moveRecursive = (src, dst) => {
                    const stat = fs.statSync(src);
                    if (stat.isDirectory()) {
                        fs.mkdirSync(dst, { recursive: true });
                        for (const child of fs.readdirSync(src))
                            moveRecursive(path.join(src, child), path.join(dst, child));
                        try {
                            fs.rmdirSync(src);
                        }
                        catch { /* */ }
                    }
                    else {
                        try {
                            fs.renameSync(src, dst);
                        }
                        catch {
                            fs.copyFileSync(src, dst);
                            fs.unlinkSync(src);
                        }
                    }
                };
                for (const child of fs.readdirSync(oldStore)) {
                    moveRecursive(path.join(oldStore, child), path.join(newStore, child));
                }
                try {
                    fs.rmdirSync(oldStore);
                }
                catch { /* */ }
                try {
                    fs.rmdirSync(oldDir);
                }
                catch { /* may still have files; let next case handle */ }
                console.log("[migrate] moved ~/.mailx/mailxstore/* → ~/.rmfmail/mailxstore/");
            }
            catch (e) {
                console.warn(`[migrate] mailxstore move failed: ${e.message}`);
            }
        }
        else if (!fs.existsSync(asideDir)) {
            fs.renameSync(oldDir, asideDir);
            console.log("[migrate] ~/.mailx → ~/.mailx-old (safe to delete)");
        }
    }
    // Body-path rewrite: any DB rows still pointing at the old absolute
    // store path get translated to the new one. Fire-and-forget — the DB
    // module exposes a method called from the daemon's startup; this
    // module-level migration just touches config.jsonc.
    const localCfg = path.join(newDir, "config.jsonc");
    if (fs.existsSync(localCfg)) {
        try {
            const raw = fs.readFileSync(localCfg, "utf-8");
            const stripped = raw.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
            const cfg = JSON.parse(stripped);
            // Drop storePath entirely when it points at the legacy dir or
            // is the default — config.jsonc shouldn't pin paths that follow
            // from where LOCAL_DIR is. Users who actually need a custom
            // store location keep that override; everyone else gets the
            // default (~/.rmfmail/mailxstore) without ceremony.
            if (typeof cfg.storePath === "string") {
                const p = cfg.storePath;
                const isLegacyAbs = /[\\/]\.mailx[\\/]mailxstore$/i.test(p);
                const isDefaultRel = p === "mailxstore";
                const isDefaultAbs = /[\\/]\.rmfmail[\\/]mailxstore$/i.test(p);
                if (isLegacyAbs || isDefaultRel || isDefaultAbs) {
                    delete cfg.storePath;
                    fs.writeFileSync(localCfg, JSON.stringify(cfg, null, 2));
                    console.log("[migrate] config.jsonc storePath dropped (using default ~/.rmfmail/mailxstore)");
                }
            }
            // Same for sharedDir.path label (cosmetic — folderId is what matters).
            if (cfg.sharedDir && typeof cfg.sharedDir === "object" && cfg.sharedDir.path === "home/.mailx") {
                cfg.sharedDir.path = "home/.rmfmail";
                fs.writeFileSync(localCfg, JSON.stringify(cfg, null, 2));
                console.log("[migrate] config.jsonc sharedDir.path → home/.rmfmail");
            }
        }
        catch (e) {
            console.warn(`[migrate] config.jsonc rewrite failed: ${e.message}`);
        }
    }
}
const LOCAL_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
const LOCAL_CONFIG_PATH = path.join(LOCAL_DIR, "config.jsonc");
const LEGACY_CONFIG_PATH = path.join(LOCAL_DIR, "config.json");
const DEFAULT_STORE_PATH = path.join(LOCAL_DIR, "mailxstore");
/** Provider-specific locator for getCloudProvider: gdrive wants the cached
 *  folder ID, onedrive wants the base path. One helper so the call sites
 *  can't drift apart. */
function cloudLocator(cfg) {
    return cfg.provider === "onedrive" ? cfg.path : cfg.folderId;
}
/** Resolve a path from config — relative to ~/.mailx/, ~ expands to home */
function resolvePath(p) {
    if (!p)
        return p;
    const home = process.env.USERPROFILE || process.env.HOME || ".";
    // Expand ~ to home directory
    if (p.startsWith("~/") || p.startsWith("~\\"))
        return path.join(home, p.slice(2));
    if (p === "~")
        return home;
    // Absolute path — use as-is
    if (path.isAbsolute(p))
        return p;
    // Relative — resolve from config directory (~/.mailx/)
    return path.resolve(LOCAL_DIR, p);
}
function readLocalConfig() {
    // Migrate config.json → config.jsonc
    if (!fs.existsSync(LOCAL_CONFIG_PATH) && fs.existsSync(LEGACY_CONFIG_PATH)) {
        fs.renameSync(LEGACY_CONFIG_PATH, LOCAL_CONFIG_PATH);
    }
    if (!fs.existsSync(LOCAL_CONFIG_PATH))
        return {};
    return readJsonc(LOCAL_CONFIG_PATH) || {};
}
/** Resolve provider config to a filesystem path (checks for local Google Drive mount) */
function resolveProvider(cfg) {
    // Cloud providers use API only — no filesystem mount scanning
    if (cfg.provider === "gdrive" || cfg.provider === "google" || cfg.provider === "onedrive")
        return undefined;
    if (cfg.provider === "local")
        return resolvePath(cfg.path);
    return undefined;
}
/** Pending cloud config for API fallback (set when mount not found) */
let pendingCloudConfig = null;
/** Last cloud API error (for UI display) */
let lastCloudError = null;
const cloudErrorListeners = [];
export function onCloudError(cb) {
    cloudErrorListeners.push(cb);
    return () => {
        const i = cloudErrorListeners.indexOf(cb);
        if (i >= 0)
            cloudErrorListeners.splice(i, 1);
    };
}
function setCloudError(error, context) {
    lastCloudError = error;
    for (const cb of cloudErrorListeners) {
        try {
            cb(error, context);
        }
        catch { /* listener faults shouldn't break writes */ }
    }
}
function resolveSharedEntry(entry) {
    if (typeof entry === "string") {
        const p = resolvePath(entry);
        return fs.existsSync(p) ? p : undefined;
    }
    return resolveProvider(entry);
}
function getSharedDir() {
    const config = readLocalConfig();
    if (config.sharedDir) {
        const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
        for (const entry of entries) {
            const resolved = resolveSharedEntry(entry);
            if (resolved)
                return resolved;
        }
        // Set pending cloud config for API access
        if (!pendingCloudConfig) {
            const lastProvider = [...entries].reverse().find(e => typeof e !== "string");
            if (lastProvider)
                pendingCloudConfig = lastProvider;
        }
    }
    // Legacy settingsPath no longer used for shared dir — use loadLegacySettings() for reading only.
    return LOCAL_DIR;
}
/** Once-per-process validation flag — verify the cached folderId points at
 *  an `.rmfmail` folder before trusting it. Catches the cross-version-cache
 *  corruption case where 1.0.504 cached a wrong ID and never re-discovered. */
let folderIdValidated = false;
/** Read a file via cloud API (when filesystem mount not available) */
export async function cloudRead(filename) {
    if (!pendingCloudConfig)
        return null;
    // Validate cached folderId once per process. If it points somewhere
    // other than an `.rmfmail` folder (e.g. an orphan from a buggy version),
    // drop it and re-discover via gDriveFindOrCreateFolder.
    if (pendingCloudConfig.folderId && !folderIdValidated) {
        folderIdValidated = true;
        const ok = await gDriveValidateCachedFolder(pendingCloudConfig.folderId);
        if (!ok) {
            pendingCloudConfig.folderId = undefined;
            clearFolderIdInConfig();
        }
    }
    // Ensure we have a folder ID (gdrive only — onedrive is path-addressed)
    if (pendingCloudConfig.provider !== "onedrive" && !pendingCloudConfig.folderId) {
        pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
        if (pendingCloudConfig.folderId)
            saveFolderIdToConfig(pendingCloudConfig.folderId);
        if (!pendingCloudConfig.folderId) {
            setCloudError(`Cannot read ${filename}: Google Drive folder unavailable (OAuth not granted?)`, { op: "read", filename });
            return null;
        }
    }
    const provider = getCloudProvider(pendingCloudConfig.provider, cloudLocator(pendingCloudConfig));
    if (!provider) {
        setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename });
        return null;
    }
    console.log(`  [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
    // Wall-clock timeout: a hung gdrive fetch used to block every other IPC
    // (the service main thread serialised everything behind whatever was
    // in flight). One stuck `getUserDict` once held the dispatcher for 101 s
    // — saveDraft / getAccounts / searchContacts all timed out client-side
    // before the gdrive call eventually errored with "fetch failed". 15 s is
    // plenty for a healthy round-trip; anything longer means the network /
    // OAuth refresh / Google side is wedged and we should fall back to the
    // local cache instead of holding up the UI.
    const content = await Promise.race([
        provider.read(filename),
        new Promise((_, reject) => setTimeout(() => reject(new Error(`cloudRead ${filename}: 15s timeout`)), 15_000)),
    ]);
    if (content) {
        setCloudError(null);
        // Cache locally — stashing the outgoing version in prev/ first so a
        // remote clobber landing here leaves a local trail to recover from.
        try {
            fs.mkdirSync(LOCAL_DIR, { recursive: true });
            stashPrev(filename, content);
            fs.writeFileSync(path.join(LOCAL_DIR, filename), content);
        }
        catch { /* ignore cache write failure */ }
    }
    // Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
    return content;
}
/** Read a file from the shared cloud config folder as raw bytes, base64-encoded.
 *  Binary-safe sibling of cloudRead — used for the optional custom reminder
 *  sound that lives next to the JSONC config on Drive. Returns null if cloud
 *  isn't configured, the file is missing, or the read errors (caller falls back
 *  to the built-in chime). */
export async function cloudReadBinary(filename) {
    if (!pendingCloudConfig)
        return null;
    if (pendingCloudConfig.provider !== "onedrive" && !pendingCloudConfig.folderId) {
        pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
        if (pendingCloudConfig.folderId)
            saveFolderIdToConfig(pendingCloudConfig.folderId);
        if (!pendingCloudConfig.folderId)
            return null;
    }
    const provider = getCloudProvider(pendingCloudConfig.provider, cloudLocator(pendingCloudConfig));
    if (!provider?.readBinary)
        return null;
    try {
        return await Promise.race([
            provider.readBinary(filename),
            new Promise((_, reject) => setTimeout(() => reject(new Error(`cloudReadBinary ${filename}: 15s timeout`)), 15_000)),
        ]);
    }
    catch (e) {
        console.error(`  [cloud] readBinary ${filename}: ${e?.message || e}`);
        return null;
    }
}
/** Keep the last few versions of shared config files in ~/.rmfmail/prev/
 *  (Bob's standard prev/ convention). Written whenever a .jsonc config is
 *  about to CHANGE — from a local save (cloudWrite) or a remote edit landing
 *  via cloudRead's cache update. Exists for exactly the 2026-07-23 incident
 *  class: the shared allowlist got clobbered and Drive's revision history
 *  only reaches back ~30 days — a local prev/ trail would have held every
 *  state. Never blocks the save; keeps the newest PREV_KEEP per file. */
const PREV_KEEP = 10;
function stashPrev(filename, aboutToBecome) {
    if (!filename.endsWith(".jsonc"))
        return;
    try {
        const cur = path.join(LOCAL_DIR, filename);
        if (!fs.existsSync(cur))
            return;
        const old = fs.readFileSync(cur, "utf-8");
        if (old === aboutToBecome)
            return;
        const prevDir = path.join(LOCAL_DIR, "prev");
        fs.mkdirSync(prevDir, { recursive: true });
        const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
        const base = filename.replace(/[\\/]/g, "_").replace(/\.jsonc$/, "");
        fs.writeFileSync(path.join(prevDir, `${base}-${stamp}.jsonc`), old);
        const mine = fs.readdirSync(prevDir).filter(f => f.startsWith(base + "-")).sort();
        for (const f of mine.slice(0, Math.max(0, mine.length - PREV_KEEP))) {
            fs.unlinkSync(path.join(prevDir, f));
        }
    }
    catch { /* archival must never block a save */ }
}
/** Write a file via cloud API. Throws on failure with a descriptive error,
 *  and updates lastCloudError so UI banners pick it up via getStorageInfo()
 *  and the onCloudError listener. */
export async function cloudWrite(filename, content) {
    if (!pendingCloudConfig) {
        const err = `Cloud not initialized yet — cannot save ${filename} (pendingCloudConfig is null; loadSettings may not have run, or config.jsonc has no sharedDir)`;
        console.error(`  [cloud] cloudWrite: ${err}`);
        setCloudError(err, { op: "write", filename });
        throw new Error(err);
    }
    // Ensure we have a folder ID (gdrive only — onedrive is path-addressed)
    if (pendingCloudConfig.provider !== "onedrive" && !pendingCloudConfig.folderId) {
        pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
        if (pendingCloudConfig.folderId)
            saveFolderIdToConfig(pendingCloudConfig.folderId);
        if (!pendingCloudConfig.folderId) {
            const err = `Cannot save ${filename}: Google Drive folder unavailable (OAuth not granted or token expired)`;
            setCloudError(err, { op: "write", filename });
            throw new Error(err);
        }
    }
    const provider = getCloudProvider(pendingCloudConfig.provider, cloudLocator(pendingCloudConfig));
    if (!provider) {
        const err = `No cloud provider for ${pendingCloudConfig.provider}`;
        setCloudError(err, { op: "write", filename });
        throw new Error(err);
    }
    // Local-save path: stash what the file looked like before this change.
    stashPrev(filename, content);
    try {
        // 30 s timeout — uploads run slightly longer than reads (round-trip
        // for resumable upload init + content body), so the cap is bigger
        // than cloudRead's 15 s, but still bounded. Same reason: a wedged
        // gdrive call must not freeze the IPC dispatcher.
        await Promise.race([
            provider.write(filename, content),
            new Promise((_, reject) => setTimeout(() => reject(new Error(`cloudWrite ${filename}: 30s timeout`)), 30_000)),
        ]);
        setCloudError(null);
    }
    catch (e) {
        setCloudError(e.message, { op: "write", filename });
        throw e;
    }
}
/** Persist the discovered folder ID back to config.jsonc so we don't search again */
function saveFolderIdToConfig(folderId) {
    try {
        const config = readLocalConfig();
        if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
            config.sharedDir.folderId = folderId;
            atomicWrite(LOCAL_CONFIG_PATH, config);
        }
    }
    catch { /* non-critical */ }
}
/** Drop a stale folder ID from config.jsonc so the next cloudRead/cloudWrite
 *  re-discovers via gDriveFindOrCreateFolder. */
function clearFolderIdInConfig() {
    try {
        const config = readLocalConfig();
        if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
            delete config.sharedDir.folderId;
            atomicWrite(LOCAL_CONFIG_PATH, config);
        }
    }
    catch { /* non-critical */ }
}
/** Get storage provider info for display */
export function getStorageInfo() {
    const configDir = LOCAL_DIR;
    const config = readLocalConfig();
    if (config.sharedDir) {
        const entries = Array.isArray(config.sharedDir) ? config.sharedDir : [config.sharedDir];
        for (const entry of entries) {
            if (typeof entry === "string") {
                const resolved = resolveSharedEntry(entry);
                if (resolved && resolved !== LOCAL_DIR) {
                    return { provider: "local", mode: "local", cloudPath: resolved, configDir };
                }
                continue;
            }
            // Provider-based entry — check for API mode (folderId) first, then mount
            const name = (entry.provider === "gdrive" || entry.provider === "google") ? "gdrive" : entry.provider;
            if (entry.folderId || entry.provider === "onedrive") {
                // Has folder ID (or is path-addressed onedrive) → API mode (don't scan filesystem for mounts)
                return { provider: name, mode: "api", cloudPath: entry.path, folderName: entry.path, folderId: entry.folderId, configDir, cloudError: lastCloudError || undefined };
            }
            const resolved = resolveSharedEntry(entry);
            if (resolved && resolved !== LOCAL_DIR) {
                return { provider: name, mode: "mount", cloudPath: entry.path, folderName: entry.path, configDir };
            }
        }
        // Not mounted and no folderId — check pendingCloudConfig from initCloudConfig()
        if (pendingCloudConfig) {
            const name = (pendingCloudConfig.provider === "gdrive" || pendingCloudConfig.provider === "google") ? "gdrive" : pendingCloudConfig.provider;
            return { provider: name, mode: "api", cloudPath: pendingCloudConfig.path, folderName: pendingCloudConfig.path, configDir, cloudError: lastCloudError || undefined };
        }
    }
    return { provider: "local", mode: "local", configDir };
}
// ── File helpers ──
/** Read JSON or JSONC file. If exact path not found, tries .json/.jsonc variant. */
function readJsonc(filePath) {
    let actual = filePath;
    if (!fs.existsSync(actual)) {
        // Try alternate extension
        if (actual.endsWith(".jsonc"))
            actual = actual.replace(/\.jsonc$/, ".json");
        else if (actual.endsWith(".json"))
            actual = actual.replace(/\.json$/, ".jsonc");
        if (!fs.existsSync(actual))
            return null;
    }
    try {
        return parseJsonc(fs.readFileSync(actual, "utf-8").replace(/\r/g, ""));
    }
    catch (e) {
        console.error(`Failed to read ${actual}: ${e.message}`);
        return null;
    }
}
function atomicWrite(filePath, data) {
    const dir = path.dirname(filePath);
    fs.mkdirSync(dir, { recursive: true });
    const tmp = filePath + ".tmp";
    fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
    fs.renameSync(tmp, filePath);
}
/** Read a config file: shared first, local fallback, merge local overrides */
function loadFile(filename, defaults) {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, filename);
    const localPath = path.join(LOCAL_DIR, filename);
    // Read shared version
    let data = readJsonc(sharedPath);
    // If no shared, try local
    if (!data)
        data = readJsonc(localPath);
    // If neither exists, use defaults
    if (!data)
        return defaults;
    // Cache shared → local (for offline fallback)
    if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
        try {
            const shared = fs.readFileSync(sharedPath, "utf-8");
            fs.mkdirSync(LOCAL_DIR, { recursive: true });
            fs.writeFileSync(localPath, shared);
        }
        catch { /* ignore cache failures */ }
    }
    return { ...defaults, ...data };
}
/** Save a config file to the shared directory (and cloud API if active).
 *  Always writes the local copy first so the data is never lost.
 *  If a cloud provider is configured, also writes there — failures set
 *  lastCloudError and notify onCloudError listeners so the UI can show
 *  a banner. The cloud write is fire-and-forget at this layer; callers
 *  who need the result should use the typed save* helpers below. */
function saveFile(filename, data) {
    const sharedDir = getSharedDir();
    atomicWrite(path.join(sharedDir, filename), data);
    // Also update local cache
    if (sharedDir !== LOCAL_DIR) {
        try {
            atomicWrite(path.join(LOCAL_DIR, filename), data);
        }
        catch { /* ignore */ }
    }
    // Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
    if (pendingCloudConfig) {
        cloudWrite(filename, JSON.stringify(data, null, 2))
            .then(() => console.log(`  [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`))
            .catch(e => console.error(`  [cloud] Failed to save ${filename}: ${e.message}`));
        // Note: we don't await — saveFile is sync. cloudWrite() already calls
        // setCloudError(), which fires onCloudError listeners synchronously
        // when the promise rejects, so the UI gets the failure even though
        // we don't propagate it back through the call chain here.
    }
}
const PROVIDERS = {
    "gmail.com": {
        label: "Gmail",
        imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
        smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
    },
    "googlemail.com": {
        label: "Gmail",
        imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" },
        smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" },
    },
    "outlook.com": {
        label: "Outlook",
        imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
        smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
    },
    "hotmail.com": {
        label: "Hotmail",
        imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" },
        smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" },
    },
    "yahoo.com": {
        label: "Yahoo",
        imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" },
        smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" },
    },
    "aol.com": {
        label: "AOL",
        imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
        smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
    },
    "icloud.com": {
        label: "iCloud",
        imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
        smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
    },
    "me.com": {
        label: "iCloud",
        imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" },
        smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" },
    },
    "verizon.net": {
        // Verizon mail is hosted by AOL/Yahoo (Verizon migrated to AOL's stack).
        label: "Verizon",
        imap: { host: "imap.aol.com", port: 993, tls: true, auth: "password" },
        smtp: { host: "smtp.aol.com", port: 587, tls: true, auth: "password" },
    },
};
/** Single source of truth for provider IMAP/SMTP defaults by email domain.
 *  desktop setup (`detectEmailProvider`) and ANY other setup path must use THIS,
 *  not a private re-listing — `detectEmailProvider` only knew Gmail+Outlook, so
 *  AOL/Yahoo/iCloud never auto-configured (Bob 2026-06-19, economy of mechanism /
 *  "prefer Drive with local caching should be a shared component"). */
export function providerForDomain(domain) {
    return PROVIDERS[(domain || "").toLowerCase()];
}
/** Canonical form of an email address — applies provider-specific
 *  equivalences so two superficially-different strings that resolve to the
 *  SAME mailbox produce the same canonical key. Today: Gmail (and
 *  googlemail.com) ignores dots in the local-part and treats everything
 *  from `+` onward as a tag — `Bob.Frankston+work@Gmail.com`,
 *  `bobfrankston@gmail.com`, and `BOBFRANKSTON@GOOGLEMAIL.COM` all collapse
 *  to `bobfrankston@gmail.com`. Other domains: lowercase only. Used by the
 *  OAuth token cache so a Gmail account doesn't end up with two parallel
 *  token directories (one with dots, one without) racing each other and
 *  triggering full reauth (Bob 2026-05-25).
 *
 *  Output is suitable for direct comparison; for filesystem dir names use
 *  `tokenDirName(email)` which applies the `[@.]/_` sanitization on top. */
export function canonicalEmail(email) {
    const s = (email || "").trim().toLowerCase();
    const at = s.indexOf("@");
    if (at < 0)
        return s;
    let local = s.slice(0, at);
    const domain = s.slice(at + 1);
    const isGmail = domain === "gmail.com" || domain === "googlemail.com";
    if (isGmail) {
        const plus = local.indexOf("+");
        if (plus >= 0)
            local = local.slice(0, plus);
        local = local.replace(/\./g, "");
        return `${local}@gmail.com`;
    }
    return `${local}@${domain}`;
}
/** Compute the OAuth token-cache directory name for an email address.
 *  Canonicalizes first (so Gmail dot/`+tag`/case variants share one dir),
 *  then sanitizes `@`/`.` to `_`. Use everywhere a token cache path is
 *  derived from an email so all code paths land on the same directory. */
export function tokenDirName(email) {
    return canonicalEmail(email).replace(/[@.]/g, "_");
}
/** Fill in provider defaults for an account based on email domain.
 *  Exported so mailx-service's leanAccountsJsonc helper can reuse the same
 *  canonicalization rules that loadAccounts uses. */
export function normalizeAccount(acct, globalName, globalSig, globalSignature) {
    const email = acct.email || "";
    const localPart = email.split("@")[0]?.toLowerCase() || "";
    const domain = email.split("@")[1]?.toLowerCase() || "";
    const provider = PROVIDERS[domain];
    const user = acct.imap?.user || acct.user || email;
    // P14: auto-derive id and label so a known-provider account works with just
    // { email, password? } in accounts.jsonc.
    //  - id/label MIRROR each other when only one is given (the normal case is
    //    that they're the same): label→id already existed; id→label added.
    //  - When NEITHER is given, the auto-id is the FULL email address. A bare
    //    local-part ("bob") or domain stem is ambiguous — bob@aol.com and
    //    bob@gmail.com would collide — so the whole address is the only
    //    unambiguous derivation. label then prefers the provider's display name.
    const autoId = email || "account";
    return {
        id: acct.id || acct.label || autoId,
        name: acct.name || globalName || localPart,
        label: acct.label || provider?.label || acct.id || autoId,
        email,
        imap: {
            host: acct.imap?.host || provider?.imap.host || `imap.${domain}`,
            port: acct.imap?.port || provider?.imap.port || 993,
            tls: acct.imap?.tls ?? provider?.imap.tls ?? true,
            auth: acct.imap?.auth || provider?.imap.auth || "password",
            user: acct.imap?.user || user,
            password: acct.imap?.password || acct.password,
        },
        smtp: {
            host: acct.smtp?.host || provider?.smtp.host || `smtp.${domain}`,
            port: acct.smtp?.port || provider?.smtp.port || 587,
            tls: acct.smtp?.tls ?? provider?.smtp.tls ?? true,
            auth: acct.smtp?.auth || provider?.smtp.auth || "password",
            user: acct.smtp?.user || user,
            password: acct.smtp?.password || acct.password,
        },
        enabled: acct.enabled ?? true,
        primary: acct.primary,
        primaryCalendar: acct.primaryCalendar,
        primaryTasks: acct.primaryTasks,
        primaryContacts: acct.primaryContacts,
        defaultSend: acct.defaultSend,
        syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
        relayDomains: acct.relayDomains,
        identityDomains: acct.identityDomains,
        // `spam` passthrough retired 2026-04-22 — markAsSpamMessages now finds
        // the junk folder via `specialUse === "junk"` on the DB folder record
        // (populated by mailx-imap from iflow's getSpecialFolders()). Authoritative
        // per-server info beats per-domain guesses.
        // `signature` is on AccountConfig in mailx-types but the workspace
        // build order sometimes leaves a stale .d.ts for type-check; using
        // `as any` is the minimum-blast-radius way to add the field without
        // blocking the build. Once mailx-types has rebuilt once (post-field)
        // this cast can be removed.
        ...(acct.signature ? { signature: acct.signature } : (globalSignature ? { signature: globalSignature } : {})),
        ...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
            ? { sig: { text: acct.sig.text, html: !!acct.sig.html } }
            : (globalSig && typeof globalSig === "object" && typeof globalSig.text === "string"
                ? { sig: { text: globalSig.text, html: !!globalSig.html } } : {})),
    };
}
// ── Defaults ──
const DEFAULT_ACCOUNTS = [];
const DEFAULT_PREFERENCES = {
    ui: {
        theme: "system",
        editor: "quill",
        folderWidth: 220,
        listViewerSplit: 40,
        fontSize: 15,
    },
    sync: {
        intervalMinutes: 5,
        historyDays: 30,
        prefetch: true,
    },
    reminders: {
        // Sound played when a calendar/task reminder fires.
        //   ""      → built-in chime (a soft two-note tone, no file needed)
        //   "none"  → silent
        //   <name>  → a custom sound FILE, resolved relative to the config:
        //             tried in the LOCAL config dir (~/.rmfmail/<name>) first,
        //             then in the shared cloud config folder on Drive. If it
        //             can't be read or played, falls back to the built-in chime.
        sound: "",
    },
    autocomplete: {
        enabled: false,
        provider: "ollama",
        ollamaUrl: "http://localhost:11434",
        ollamaModel: "qwen2.5-coder:1.5b",
        cloudApiKey: "",
        cloudModel: "claude-sonnet-4-5",
        debounceMs: 600,
        maxTokens: 60,
    },
};
const DEFAULT_AUTOCOMPLETE = {
    enabled: false,
    provider: "ollama",
    ollamaUrl: "http://localhost:11434",
    ollamaModel: "qwen2.5-coder:1.5b",
    cloudApiKey: "",
    cloudModel: "claude-sonnet-4-5",
    debounceMs: 600,
    maxTokens: 60,
};
const DEFAULT_ALLOWLIST = {
    senders: [],
    domains: [],
    recipients: [],
    // Flagged senders/domains — surfaced as a red warning on the
    // remote-content banner. Distinct from the positive lists above:
    // these don't auto-allow anything, they make the banner louder when
    // a known-suspicious correspondent's mail shows up. Future: a shared
    // GitHub list users can opt in to (community-flagged phishing /
    // tracker-heavy senders) — see TODO.md.
    flaggedSenders: [],
    flaggedDomains: [],
};
// ── Public API ──
/** Load account configs */
export function loadAccounts() {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, "accounts.jsonc");
    const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
    // Try shared first, then local cache
    let accounts = readJsonc(sharedPath);
    if (!accounts)
        accounts = readJsonc(localPath);
    if (accounts?.accounts || Array.isArray(accounts)) {
        // Cache shared to local for offline fallback — but ONLY if the
        // content actually differs. Unconditionally writing on every load
        // retriggers fs.watch on the local copy, which fires the config-
        // changed banner and cloud-poll cycle even when nothing changed.
        // Result: "accounts.jsonc changed" notification firing constantly.
        if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
            try {
                const sharedContent = fs.readFileSync(sharedPath, "utf-8");
                let localContent = "";
                try {
                    localContent = fs.readFileSync(localPath, "utf-8");
                }
                catch { /* missing */ }
                // Normalize before comparing — GDrive-mounted copies often
                // differ in BOM / line endings / trailing newline without any
                // semantic change, and that triggered the spurious banner.
                const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
                if (norm(sharedContent) !== norm(localContent)) {
                    fs.mkdirSync(LOCAL_DIR, { recursive: true });
                    fs.writeFileSync(localPath, sharedContent);
                }
            }
            catch { /* ignore */ }
        }
        const raw = accounts.accounts || accounts;
        const globalName = accounts.name || "";
        const globalSig = accounts.sig;
        const globalSignature = accounts.signature;
        const result = deduplicateAccounts(raw.map((a) => normalizeAccount(a, globalName, globalSig, globalSignature)));
        return applyAccountOverrides(result);
    }
    // Legacy: read from settings.jsonc
    const legacy = loadLegacySettings();
    if (legacy?.accounts)
        return applyAccountOverrides(legacy.accounts.map((a) => normalizeAccount(a, legacy.name, legacy.sig, legacy.signature)));
    return DEFAULT_ACCOUNTS;
}
/** Normalize email for dedup — Gmail ignores dots before @ */
function normalizeEmail(email) {
    const [local, domain] = email.toLowerCase().split("@");
    if (!domain)
        return email.toLowerCase();
    if (domain === "gmail.com" || domain === "googlemail.com") {
        return local.replace(/\./g, "") + "@gmail.com";
    }
    return `${local}@${domain}`;
}
/** Remove duplicate accounts (same email after normalization) */
function deduplicateAccounts(accounts) {
    const seen = new Set();
    return accounts.filter(a => {
        const key = normalizeEmail(a.email);
        if (seen.has(key))
            return false;
        seen.add(key);
        return true;
    });
}
/** Apply local per-account overrides (enabled, etc.) */
function applyAccountOverrides(accounts) {
    const localConfig = readLocalConfig();
    const overrides = localConfig.accountOverrides;
    if (!overrides)
        return accounts;
    for (const acct of accounts) {
        const ov = overrides[acct.id];
        if (!ov)
            continue;
        if (ov.enabled !== undefined)
            acct.enabled = ov.enabled;
    }
    return accounts;
}
/** Load accounts, preferring the cloud copy when cloud is configured.
 *  The local file in `~/.rmfmail/` is an offline cache, NOT the source of
 *  truth — earlier versions returned the local copy whenever it was non-empty,
 *  which left the desktop reading a stale single-account file forever after
 *  the GDrive folder lookup got fixed. */
export async function loadAccountsAsync() {
    // Make sure pendingCloudConfig is initialized (calling getSharedDir as a
    // side effect — it sets pendingCloudConfig from the local config.jsonc).
    getSharedDir();
    // Cloud configured → cloud is canonical.
    if (pendingCloudConfig) {
        const content = await cloudRead("accounts.jsonc");
        if (content) {
            const data = parseJsonc(content);
            if (data?.accounts || Array.isArray(data)) {
                const raw = data.accounts || data;
                const globalName = data.name || "";
                const globalSig = data.sig;
                const globalSignature = data.signature;
                // cloudRead has already cached content to LOCAL_DIR.
                return applyAccountOverrides(raw.map((a) => normalizeAccount(a, globalName, globalSig, globalSignature)));
            }
        }
        // Cloud unreachable / unparseable — fall through to local cache.
    }
    return loadAccounts();
}
/** Strip default-valued fields from a normalized AccountConfig so the
 *  serialized JSONC stays compact and human-editable. The previous version
 *  was round-tripping every defaulted field (port: 993, tls: true, auth:
 *  "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
 *  to 60+ lines and embedded unnecessary "knowledge" about defaults.
 *
 *  Rules:
 *  - Drop fields that match the provider default (host/port/tls/auth derived
 *    from email domain via PROVIDERS).
 *  - Drop `enabled: true` (default).
 *  - Drop `name` if equal to the file-level `globalName`.
 *  - Drop `imap.user` / `smtp.user` if they equal the email.
 *  - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
 *  - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
 *    → relayDomains → identityDomains → syncContacts → sig.
 *    Curated for readability, not alphabetic. */
export function denormalizeAccount(acct, globalName) {
    const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
    const provider = PROVIDERS[domain];
    const out = {};
    out.id = acct.id;
    if (acct.label && acct.label !== provider?.label && acct.label !== acct.id)
        out.label = acct.label;
    out.email = acct.email;
    if (acct.name && acct.name !== globalName)
        out.name = acct.name;
    if (acct.primary)
        out.primary = true;
    if (acct.primaryCalendar !== undefined)
        out.primaryCalendar = acct.primaryCalendar;
    if (acct.primaryTasks !== undefined)
        out.primaryTasks = acct.primaryTasks;
    if (acct.primaryContacts !== undefined)
        out.primaryContacts = acct.primaryContacts;
    // imap — keep only fields that differ from provider defaults.
    const imapOut = {};
    if (acct.imap?.host && acct.imap.host !== provider?.imap.host)
        imapOut.host = acct.imap.host;
    if (acct.imap?.user && acct.imap.user !== acct.email)
        imapOut.user = acct.imap.user;
    if (acct.imap?.password)
        imapOut.password = acct.imap.password;
    if (acct.imap?.port && acct.imap.port !== (provider?.imap.port ?? 993))
        imapOut.port = acct.imap.port;
    if (acct.imap?.tls !== undefined && acct.imap.tls !== (provider?.imap.tls ?? true))
        imapOut.tls = acct.imap.tls;
    if (acct.imap?.auth && acct.imap.auth !== (provider?.imap.auth ?? "password"))
        imapOut.auth = acct.imap.auth;
    if (Object.keys(imapOut).length > 0)
        out.imap = imapOut;
    // smtp — same treatment.
    const smtpOut = {};
    if (acct.smtp?.host && acct.smtp.host !== provider?.smtp.host)
        smtpOut.host = acct.smtp.host;
    if (acct.smtp?.user && acct.smtp.user !== acct.email && acct.smtp.user !== acct.imap?.user)
        smtpOut.user = acct.smtp.user;
    if (acct.smtp?.password)
        smtpOut.password = acct.smtp.password;
    if (acct.smtp?.port && acct.smtp.port !== (provider?.smtp.port ?? 587))
        smtpOut.port = acct.smtp.port;
    if (acct.smtp?.tls !== undefined && acct.smtp.tls !== (provider?.smtp.tls ?? true))
        smtpOut.tls = acct.smtp.tls;
    if (acct.smtp?.auth && acct.smtp.auth !== (provider?.smtp.auth ?? "password"))
        smtpOut.auth = acct.smtp.auth;
    if (Object.keys(smtpOut).length > 0)
        out.smtp = smtpOut;
    if (acct.defaultSend)
        out.defaultSend = true;
    if (acct.enabled === false)
        out.enabled = false; // default true → omit
    if (acct.relayDomains && acct.relayDomains.length > 0)
        out.relayDomains = acct.relayDomains;
    if (acct.identityDomains && acct.identityDomains.length > 0)
        out.identityDomains = acct.identityDomains;
    // syncContacts default: true for OAuth, false otherwise. Only emit when
    // the user overrode the default.
    const syncContactsDefault = provider?.imap.auth === "oauth2";
    if (acct.syncContacts !== undefined && acct.syncContacts !== syncContactsDefault) {
        out.syncContacts = acct.syncContacts;
    }
    if (acct.signature)
        out.signature = acct.signature;
    if (acct.sig?.text) {
        // html: false is default; only keep the html flag when explicitly true.
        out.sig = acct.sig.html ? { text: acct.sig.text, html: true } : { text: acct.sig.text };
    }
    return out;
}
/** Save account configs */
/** Save accounts — merges with cloud copy by email (multi-client safe).
 *  Writes the lean form via denormalizeAccount so accounts.jsonc stays
 *  compact and human-editable. Preserves the top-level `keys` field
 *  (AI API keys) if present in the existing cloud or local file. */
export async function saveAccounts(accounts) {
    // Read existing top-level fields we need to preserve (keys, etc.) before
    // we rewrite. Prefer cloud over local.
    let preservedKeys;
    let cloudCopy;
    try {
        const cloudContent = await cloudRead("accounts.jsonc");
        if (cloudContent)
            cloudCopy = parseJsonc(cloudContent);
    }
    catch { /* ignore */ }
    if (!cloudCopy) {
        try {
            const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
            if (fs.existsSync(localPath))
                cloudCopy = readJsonc(localPath);
        }
        catch { /* ignore */ }
    }
    if (cloudCopy?.keys && typeof cloudCopy.keys === "object")
        preservedKeys = cloudCopy.keys;
    // Merge with cloud: keep all accounts, deduplicate by normalized email
    if (cloudCopy) {
        const cloudAccts = cloudCopy.accounts || (Array.isArray(cloudCopy) ? cloudCopy : []);
        if (cloudAccts.length > 0) {
            const seen = new Set(accounts.map(a => normalizeEmail(a.email)));
            for (const ca of cloudAccts) {
                if (ca.email && !seen.has(normalizeEmail(ca.email))) {
                    // Cloud entries are already lean — feed through
                    // normalizeAccount to coerce to AccountConfig shape.
                    accounts.push(normalizeAccount(ca));
                    seen.add(normalizeEmail(ca.email));
                }
            }
        }
    }
    // Promote a shared "name" to file level when every account has the same
    // name — keeps the JSONC tidy ({ "name": "Bob Frankston", "accounts": [...] }
    // instead of repeating "name" on each entry).
    const names = new Set(accounts.map(a => a.name).filter(Boolean));
    const globalName = names.size === 1 ? [...names][0] : undefined;
    const lean = accounts.map(a => denormalizeAccount(a, globalName));
    const payload = globalName ? { name: globalName, accounts: lean } : { accounts: lean };
    if (preservedKeys)
        payload.keys = preservedKeys;
    saveFile("accounts.jsonc", payload);
}
/** Load top-level AI keys from accounts.jsonc. Returns empty placeholders
 *  ({ anthropic: "", openai: "" }) when the file lacks a `keys` section,
 *  so callers can call this unconditionally without missing-section guards.
 *  Reads from the same shared/local path resolution as loadAccounts. */
export function loadKeys() {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, "accounts.jsonc");
    const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
    let raw = readJsonc(sharedPath);
    if (!raw)
        raw = readJsonc(localPath);
    const keys = raw?.keys;
    return {
        anthropic: typeof keys?.anthropic === "string" ? keys.anthropic : "",
        openai: typeof keys?.openai === "string" ? keys.openai : "",
    };
}
/** Save AI keys back to accounts.jsonc, preserving the rest of the file
 *  (accounts list, file-level name). Used by Settings → AI to persist a
 *  key the user just entered. Cloud-synced via saveFile. */
export async function saveKeys(keys) {
    let existing = {};
    try {
        const cloudContent = await cloudRead("accounts.jsonc");
        if (cloudContent)
            existing = parseJsonc(cloudContent) || {};
    }
    catch { /* ignore */ }
    if (!existing.accounts) {
        try {
            const localPath = path.join(LOCAL_DIR, "accounts.jsonc");
            if (fs.existsSync(localPath))
                existing = readJsonc(localPath) || existing;
        }
        catch { /* ignore */ }
    }
    const merged = { ...existing.keys, ...keys };
    // Drop empty-string fields so the on-disk file stays tidy when the user
    // clears a key (writes `""` from the UI) — keep only meaningful values.
    const cleaned = {};
    if (merged.anthropic)
        cleaned.anthropic = merged.anthropic;
    if (merged.openai)
        cleaned.openai = merged.openai;
    existing.keys = cleaned;
    saveFile("accounts.jsonc", existing);
}
/** First-run helper: ensure `keys: { anthropic: "", openai: "" }` is
 *  materialized in accounts.jsonc so the user sees placeholders for every
 *  supported provider when they open the config editor. Adds missing
 *  fields without disturbing existing values, so `keys: {}` becomes
 *  `keys: { anthropic: "", openai: "" }` and `keys: { anthropic: "sk-..." }`
 *  becomes `keys: { anthropic: "sk-...", openai: "" }`. Idempotent. */
export async function ensureKeysSectionExists() {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, "accounts.jsonc");
    const raw = readJsonc(sharedPath);
    if (!raw)
        return; // no accounts file yet — first-time setup will handle it
    const existing = (raw.keys && typeof raw.keys === "object") ? raw.keys : {};
    const merged = {
        anthropic: typeof existing.anthropic === "string" ? existing.anthropic : "",
        openai: typeof existing.openai === "string" ? existing.openai : "",
    };
    // Skip the write if the on-disk shape already matches — avoids touching
    // accounts.jsonc on every startup and the spurious "config changed" banner.
    if (raw.keys
        && typeof raw.keys === "object"
        && raw.keys.anthropic === merged.anthropic
        && raw.keys.openai === merged.openai
        && Object.keys(raw.keys).length === 2)
        return;
    raw.keys = merged;
    saveFile("accounts.jsonc", raw);
}
/** First-run helper: write a `groups` sample into contacts.jsonc so the
 *  user sees how to set up a mailing list. Compose's `expandRecipients`
 *  loads `cfg.groups` from this file at send time and resolves group
 *  names to the member addresses. Idempotent — only writes if `groups`
 *  is missing entirely (won't clobber a user-defined map). The sample
 *  uses example.com so it's harmless if the user accidentally sends to
 *  the unmodified placeholder. */
export async function ensureGroupsSampleExists() {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, "contacts.jsonc");
    const raw = readJsonc(sharedPath);
    if (!raw)
        return; // contacts.jsonc not bootstrapped yet — service will seed
    if (raw.groups && typeof raw.groups === "object")
        return; // user has groups
    raw.groups = {
        "Joes (sample — rename or remove me)": [
            "joe@example.com",
            "jo@example.com",
        ],
    };
    saveFile("contacts.jsonc", raw);
}
/** First-run helper: materialize the `autocomplete` block in
 *  preferences.jsonc with explicit defaults. loadPreferences() merges
 *  defaults at read time, but the file on disk often has no
 *  `autocomplete` key, so a user opening Settings → Edit config files
 *  doesn't see the AI provider knobs. This writes the defaults out so
 *  the user can edit them. Idempotent. */
export async function ensureAutocompletePrefsExist() {
    const sharedDir = getSharedDir();
    const sharedPath = path.join(sharedDir, "preferences.jsonc");
    let raw = readJsonc(sharedPath);
    if (!raw)
        raw = {};
    if (raw.autocomplete && typeof raw.autocomplete === "object")
        return; // already present
    raw.autocomplete = { ...DEFAULT_AUTOCOMPLETE };
    // Strip the cloudApiKey field — the API key now lives in
    // accounts.jsonc.keys, not here. Writing "" would mislead users into
    // editing the wrong file.
    delete raw.autocomplete.cloudApiKey;
    saveFile("preferences.jsonc", raw);
}
/** Load preferences (shared + local overrides, with legacy fallback) */
export function loadPreferences() {
    let shared = loadFile("preferences.jsonc", DEFAULT_PREFERENCES);
    // Legacy fallback: read ui/sync from settings.jsonc if preferences.jsonc had only defaults
    const legacy = loadLegacySettings();
    if (legacy?.ui)
        shared = { ...shared, ui: { ...shared.ui, ...legacy.ui } };
    if (legacy?.sync)
        shared = { ...shared, sync: { ...shared.sync, ...legacy.sync } };
    const localConfig = readLocalConfig();
    // Local overrides
    if (localConfig.historyDays !== undefined) {
        shared.sync.historyDays = localConfig.historyDays;
    }
    // Pass through any non-typed keys (calendar.showHolidays /
    // showJewishHolidays, checkDomainReputation, externalEditor, …) so
    // loadSettings's extras pickup sees them. Earlier this return shape
    // was hard-restricted to {ui, sync, autocomplete} and silently
    // dropped every other key the file contained — which is why
    // toggling holidays looked persisted in the file but didn't take
    // effect: loadSettings only saw ui/sync/autocomplete back.
    const extras = {};
    for (const k of Object.keys(shared)) {
        if (k === "ui" || k === "sync" || k === "autocomplete")
            continue;
        extras[k] = shared[k];
    }
    return {
        ui: { ...DEFAULT_PREFERENCES.ui, ...shared.ui },
        sync: { ...DEFAULT_PREFERENCES.sync, ...shared.sync },
        autocomplete: { ...DEFAULT_AUTOCOMPLETE, ...shared.autocomplete },
        ...extras,
    };
}
/** Save preferences */
export function savePreferences(prefs) {
    saveFile("preferences.jsonc", prefs);
}
/** Load autocomplete settings */
export function loadAutocomplete() {
    const prefs = loadPreferences();
    return prefs.autocomplete;
}
/** Save autocomplete settings */
export function saveAutocomplete(settings) {
    const prefs = loadPreferences();
    prefs.autocomplete = settings;
    savePreferences(prefs);
}
/** Load remote content allow-list (sync — local/shared filesystem only) */
export function loadAllowlist() {
    return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
}
/** Load the allow-list, cloud-canonical. Mirrors loadAccountsAsync: when a
 *  cloud provider is configured (drive.file scope — no filesystem mount), the
 *  Drive copy is authoritative and cloudRead() caches it to LOCAL_DIR so the
 *  sync loadAllowlist() picks it up afterward. Without this, a fresh install
 *  never pulls the shared allowlist.jsonc and falls back to DEFAULT_ALLOWLIST. */
export async function loadAllowlistAsync() {
    // Side effect: initializes pendingCloudConfig from the local config.jsonc.
    getSharedDir();
    if (pendingCloudConfig) {
        try {
            const content = await cloudRead("allowlist.jsonc");
            if (content) {
                const data = parseJsonc(content);
                if (data) {
                    // cloudRead has already cached content to LOCAL_DIR.
                    return { ...DEFAULT_ALLOWLIST, ...data };
                }
            }
        }
        catch { /* cloud unreachable / unparseable — fall through to local cache */ }
    }
    return loadAllowlist();
}
/** Save allow-list — merges with existing cloud copy (multi-client safe) */
export async function saveAllowlist(list) {
    // Read current cloud version and merge (other clients may have added entries)
    let merged = { ...list };
    try {
        const cloudContent = await cloudRead("allowlist.jsonc");
        if (cloudContent) {
            const cloud = parseJsonc(cloudContent);
            if (cloud) {
                const mergeArrays = (local, remote) => [...new Set([...local, ...remote])];
                merged = {
                    senders: mergeArrays(list.senders || [], cloud.senders || []),
                    domains: mergeArrays(list.domains || [], cloud.domains || []),
                    recipients: mergeArrays(list.recipients || [], cloud.recipients || []),
                    flaggedSenders: mergeArrays(list.flaggedSenders || [], cloud.flaggedSenders || []),
                    flaggedDomains: mergeArrays(list.flaggedDomains || [], cloud.flaggedDomains || []),
                };
            }
        }
    }
    catch { /* cloud read failed — save local version */ }
    saveFile("allowlist.jsonc", merged);
}
// User dictionary lives in a plain CSV (single column, one word per line)
// so it's trivially hand-editable on the server. `# ...` lines and blanks
// are ignored.
const USER_DICT_FILE = "userdict.csv";
/** Parse the single-column user-dictionary CSV. Blank lines and `#` comment
 *  lines are skipped; a quoted or multi-field row contributes its first
 *  field — so a file exported from a spreadsheet still loads cleanly. */
function parseDictCsv(text) {
    return text.split(/\r?\n/)
        .map(l => l.trim())
        .filter(l => l && !l.startsWith("#"))
        .map(l => l.split(",")[0].trim().replace(/^"(.*)"$/, "$1").trim())
        .filter(Boolean);
}
/** Serialize words to CSV text — sorted, one per line, with a header note. */
function buildDictCsv(words) {
    const sorted = [...new Set(words)].filter(Boolean).sort((a, b) => a.localeCompare(b));
    return "# rmfmail user dictionary — one word per line\n" + sorted.join("\n") + "\n";
}
function atomicWriteText(filePath, text) {
    const dir = path.dirname(filePath);
    fs.mkdirSync(dir, { recursive: true });
    const tmp = filePath + ".tmp";
    fs.writeFileSync(tmp, text);
    fs.renameSync(tmp, filePath);
}
/** Load user-added dictionary words. Mirrored to GDrive so "Add to dictionary"
 *  on one machine appears on every machine. Returns a deduped string array.
 *  Tries the cloud copy first (so a fresh machine sees prior words), then
 *  the local cache. Empty array when nothing has been saved yet. */
export async function loadUserDict() {
    try {
        const cloudCsv = await cloudRead(USER_DICT_FILE);
        if (cloudCsv != null)
            return [...new Set(parseDictCsv(cloudCsv))];
    }
    catch { /* cloud unreachable — fall back to local */ }
    const sharedPath = path.join(getSharedDir(), USER_DICT_FILE);
    const localPath = path.join(LOCAL_DIR, USER_DICT_FILE);
    try {
        if (fs.existsSync(sharedPath))
            return [...new Set(parseDictCsv(fs.readFileSync(sharedPath, "utf-8")))];
        if (fs.existsSync(localPath))
            return [...new Set(parseDictCsv(fs.readFileSync(localPath, "utf-8")))];
    }
    catch { /* unreadable */ }
    return [];
}
/** Save user dictionary — merges with the cloud copy so concurrent edits on
 *  different machines union rather than overwrite. */
export async function saveUserDict(words) {
    const merged = new Set(words);
    try {
        const cloudCsv = await cloudRead(USER_DICT_FILE);
        if (cloudCsv)
            for (const w of parseDictCsv(cloudCsv))
                merged.add(w);
    }
    catch { /* cloud unreachable — save local version */ }
    const text = buildDictCsv([...merged]);
    const sharedDir = getSharedDir();
    atomicWriteText(path.join(sharedDir, USER_DICT_FILE), text);
    if (sharedDir !== LOCAL_DIR) {
        try {
            atomicWriteText(path.join(LOCAL_DIR, USER_DICT_FILE), text);
        }
        catch { /* ignore */ }
    }
    if (pendingCloudConfig) {
        cloudWrite(USER_DICT_FILE, text)
            .then(() => console.log(`  [cloud] Saved ${USER_DICT_FILE} via ${pendingCloudConfig.provider} API`))
            .catch(e => console.error(`  [cloud] Failed to save ${USER_DICT_FILE}: ${e.message}`));
    }
}
// ── Reminder dismissed/snoozed state (cross-device) ──
// Same rails as userdict.csv: cloud copy is authoritative, local file is the
// offline cache, every save is read-merge-write so concurrent devices union
// rather than clobber. Merge/prune logic lives in mailx-types so the
// Android/web service shares it. See reminder-state.ts for the data model.
/** Load merged reminder state — cloud first, local cache fallback. */
export async function loadReminderState() {
    const now = Date.now();
    try {
        const cloudJson = await cloudRead(REMINDER_STATE_FILE);
        if (cloudJson != null)
            return normalizeReminderState(parseJsonc(cloudJson), now);
    }
    catch { /* cloud unreachable — fall back to local */ }
    const sharedPath = path.join(getSharedDir(), REMINDER_STATE_FILE);
    const localPath = path.join(LOCAL_DIR, REMINDER_STATE_FILE);
    try {
        if (fs.existsSync(sharedPath))
            return normalizeReminderState(parseJsonc(fs.readFileSync(sharedPath, "utf-8")), now);
        if (fs.existsSync(localPath))
            return normalizeReminderState(parseJsonc(fs.readFileSync(localPath, "utf-8")), now);
    }
    catch { /* unreadable */ }
    return { dismissed: {}, snoozed: {} };
}
/** Merge `patch` (a device's full local state) into the shared copy and
 *  persist. Returns the merged result so the caller can adopt it as its new
 *  local state — one round-trip does both push and pull. Skips the cloud
 *  write when the merge changed nothing (the common poll case). */
export async function saveReminderState(patch) {
    const now = Date.now();
    const local = normalizeReminderState(patch, now);
    let cloud = { dismissed: {}, snoozed: {} };
    let cloudJson = null;
    try {
        cloudJson = await cloudRead(REMINDER_STATE_FILE);
    }
    catch { /* offline — merge with local file below */ }
    if (cloudJson != null) {
        cloud = normalizeReminderState(parseJsonc(cloudJson), now);
    }
    else {
        // Cloud unreachable/absent — merge against the local cache so a
        // flaky network can't shrink the state file.
        try {
            const sharedPath = path.join(getSharedDir(), REMINDER_STATE_FILE);
            if (fs.existsSync(sharedPath))
                cloud = normalizeReminderState(parseJsonc(fs.readFileSync(sharedPath, "utf-8")), now);
        }
        catch { /* */ }
    }
    const merged = mergeReminderStates(cloud, local, now);
    const text = JSON.stringify(merged, null, 2) + "\n";
    const sharedDir = getSharedDir();
    atomicWriteText(path.join(sharedDir, REMINDER_STATE_FILE), text);
    if (sharedDir !== LOCAL_DIR) {
        try {
            atomicWriteText(path.join(LOCAL_DIR, REMINDER_STATE_FILE), text);
        }
        catch { /* ignore */ }
    }
    // Only push to Drive when this device actually contributed something —
    // otherwise every alarm poll on every machine would burn a write.
    if (pendingCloudConfig && !(cloudJson != null && reminderStatesEqual(merged, cloud))) {
        cloudWrite(REMINDER_STATE_FILE, text)
            .then(() => console.log(`  [cloud] Saved ${REMINDER_STATE_FILE} via ${pendingCloudConfig.provider} API`))
            .catch(e => console.error(`  [cloud] Failed to save ${REMINDER_STATE_FILE}: ${e.message}`));
    }
    return merged;
}
// ── Legacy compatibility ──
function loadLegacySettings() {
    const config = readLocalConfig();
    if (config.settingsPath)
        return readJsonc(resolvePath(config.settingsPath));
    // Try shared dir first, then local
    const sharedDir = getSharedDir();
    const shared = readJsonc(path.join(sharedDir, "settings.jsonc"));
    if (shared)
        return shared;
    return readJsonc(path.join(LOCAL_DIR, "settings.jsonc"));
}
/** Load settings — unified view combining all files (backward compatible) */
export function loadSettings() {
    const accounts = loadAccounts();
    const prefs = loadPreferences();
    const localConfig = readLocalConfig();
    // Pull through every non-{ui,sync,autocomplete} key the preferences
    // file had — calendar.showHolidays, checkDomainReputation,
    // externalEditor, etc. saveSettings stores them; loadSettings has to
    // hand them back or the round-trip is lossy and toggles "don't stick."
    const extras = {};
    for (const k of Object.keys(prefs)) {
        if (k === "ui" || k === "sync" || k === "autocomplete")
            continue;
        extras[k] = prefs[k];
    }
    return {
        accounts,
        ui: prefs.ui,
        sync: prefs.sync,
        autocomplete: prefs.autocomplete,
        store: {
            basePath: localConfig.storePath || DEFAULT_STORE_PATH,
            compressionBoundaryDays: 365,
        },
        ...extras,
    };
}
/** Save settings — writes to split files.
 *  Preserves arbitrary extra keys (calendar.showHolidays, checkDomainReputation,
 *  externalEditor, …) by round-tripping the prior preferences file and
 *  *deep-merging* the new payload over it. A shallow merge would let one
 *  partial save (e.g. `calendar.showJewishHolidays:true`) wholesale-replace
 *  the prior `calendar` object and clobber sibling keys (showHolidays).
 *
 *  Also: skip re-writing accounts.jsonc when accounts haven't actually
 *  changed. Every saveSettings call previously paid the sync GDrive write
 *  cost (~34 s observed in the daemon log) even for a single checkbox
 *  toggle, because the client sends the full settings object back.
 *  Identity comparison via JSON.stringify is good enough — JS object
 *  identity is meaningless across an IPC round-trip. */
function deepMerge(target, source) {
    if (source === null || source === undefined)
        return target;
    if (typeof source !== "object" || Array.isArray(source))
        return source;
    const out = (typeof target === "object" && target !== null && !Array.isArray(target)) ? { ...target } : {};
    for (const k of Object.keys(source)) {
        out[k] = deepMerge(out[k], source[k]);
    }
    return out;
}
let _lastAccountsJson = null;
export async function saveSettings(settings) {
    // Skip the slow accounts write when nothing changed. Initialize the
    // baseline lazily from the current on-disk file on first call so a
    // restart picks up the right "did this change?" reference.
    if (_lastAccountsJson === null) {
        try {
            _lastAccountsJson = JSON.stringify(loadAccounts());
        }
        catch {
            _lastAccountsJson = "";
        }
    }
    const incomingJson = JSON.stringify(settings.accounts);
    if (incomingJson !== _lastAccountsJson) {
        await saveAccounts(settings.accounts);
        _lastAccountsJson = incomingJson;
    }
    const prior = loadPreferences();
    const incoming = { ...settings };
    delete incoming.accounts;
    delete incoming.store;
    const next = deepMerge(prior, incoming);
    savePreferences(next);
}
/** Get the local store base path */
export function getStorePath() {
    const config = readLocalConfig();
    return config.storePath ? resolvePath(config.storePath) : DEFAULT_STORE_PATH;
}
/** Get the local data directory (DB, store, etc.) */
export function getConfigDir() {
    return LOCAL_DIR;
}
/** Get the shared settings directory */
export { getSharedDir };
// detectSharedDir() removed — cloud storage is configured via API (gdrive/onedrive),
// not auto-detected from filesystem mounts. Setup form triggers initCloudConfig().
/** Initialize local config if it doesn't exist */
export function initLocalConfig(sharedDir, storePath) {
    if (fs.existsSync(LOCAL_CONFIG_PATH) && !sharedDir && !storePath)
        return;
    const existing = readLocalConfig();
    // Use explicit sharedDir or preserve existing — no auto-detection.
    // Cloud storage is configured when user adds an account (initCloudConfig).
    const resolvedSharedDir = sharedDir || existing.sharedDir;
    const config = {
        ...existing,
        sharedDir: resolvedSharedDir,
        // storePath is now optional — omit when default. Resolves to
        // ~/.rmfmail/mailxstore via getStorePath() if not specified.
        ...(storePath || existing.storePath ? { storePath: storePath || existing.storePath } : {}),
    };
    fs.mkdirSync(LOCAL_DIR, { recursive: true });
    atomicWrite(LOCAL_CONFIG_PATH, config);
}
/** Initialize config with Google Drive cloud storage.
 *  Finds or creates the app-owned ".rmfmail" folder via Drive API and stores its ID.
 *  No mount scanning — API only. Existing settings at other paths (e.g., home/.rmfmail
 *  from Desktop sync) must be migrated manually or via config.jsonc importPath. */
export async function initCloudConfig(provider = "gdrive") {
    const existing = readLocalConfig();
    if (existing.sharedDir)
        return; // Already configured
    let sharedDir;
    if (provider === "onedrive") {
        // Path-addressed — no folder discovery; the first write creates the
        // folder chain (oneDriveEnsurePath). Same layout convention as Drive.
        sharedDir = { provider, path: "home/.rmfmail" };
    }
    else {
        // Find or create the settings folder via Drive API. Stored path is the
        // canonical post-rebrand location; the folderId is what actually drives
        // subsequent reads/writes, so even legacy users with the old folder name
        // work fine since gDriveFindOrCreateFolder resolves whichever exists.
        const folderId = await gDriveFindOrCreateFolder();
        sharedDir = { provider, path: "home/.rmfmail", folderId: folderId || undefined };
    }
    const config = { ...existing, sharedDir, storePath: existing.storePath || DEFAULT_STORE_PATH };
    fs.mkdirSync(LOCAL_DIR, { recursive: true });
    atomicWrite(LOCAL_CONFIG_PATH, config);
    pendingCloudConfig = sharedDir;
    console.log(`  Initialized cloud config: ${provider} (${sharedDir.folderId ? `folder ID: ${sharedDir.folderId}` : sharedDir.path})`);
}
const DEFAULT_SETTINGS = {
    accounts: [],
    ui: DEFAULT_PREFERENCES.ui,
    sync: DEFAULT_PREFERENCES.sync,
    autocomplete: DEFAULT_AUTOCOMPLETE,
    store: { basePath: DEFAULT_STORE_PATH, compressionBoundaryDays: 365 },
};
/** Get historyDays for an account: per-account override > system override > shared default */
export function getHistoryDays(accountId) {
    const localConfig = readLocalConfig();
    if (accountId && localConfig.accountOverrides?.[accountId]?.historyDays !== undefined) {
        return localConfig.accountOverrides[accountId].historyDays;
    }
    if (localConfig.historyDays !== undefined)
        return localConfig.historyDays;
    const prefs = loadPreferences();
    return prefs.sync.historyDays || 0;
}
/** Get prefetch setting: download bodies during sync (default true) */
export function getPrefetch() {
    const prefs = loadPreferences();
    return prefs.sync.prefetch !== false;
}
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
/** Deploy the app-owned `.md` reference docs to the shared cloud folder so
 *  users can read them next to the `.jsonc` files they document. The .md
 *  files live in the app bundle (`<workspace>/app/docs/` in dev, or
 *  `<package>/docs/` in published builds) and are overwritten on every
 *  release — the file header documents that fact, so users know not to
 *  edit them.
 *
 *  Skip-path: a manifest file `.docs-version` on the cloud records the
 *  app version that last deployed. We re-deploy only when the running
 *  app's version differs, so startup doesn't pound Drive on every launch.
 *
 *  This function is fire-and-forget at the call site; failures are logged
 *  but never throw. The `.md` files are reference material, not load-
 *  bearing — the app works without them.
 */
export async function deployDocs(appVersion) {
    if (!pendingCloudConfig || (pendingCloudConfig.provider !== "onedrive" && !pendingCloudConfig.folderId))
        return;
    const provider = getCloudProvider(pendingCloudConfig.provider, cloudLocator(pendingCloudConfig));
    if (!provider)
        return;
    // Resolve the source docs dir. In dev the workspace has `app/docs/`;
    // in published packages we look for `docs/` next to this file.
    const candidates = [
        path.join(__dirname, "..", "..", "docs"), // workspace dev
        path.join(__dirname, "docs"), // sibling in published package
        path.join(__dirname, "..", "docs"), // one level up in some layouts
    ];
    let docsDir = "";
    for (const c of candidates) {
        if (fs.existsSync(c) && fs.readdirSync(c).some(f => f.endsWith(".md"))) {
            docsDir = c;
            break;
        }
    }
    if (!docsDir) {
        console.log("  [docs] no docs/ dir found in package — skipping deploy");
        return;
    }
    // Whitelist: only deploy docs that document a `.jsonc` config file or a
    // user-facing feature. Internal release-notes / publishing playbooks
    // (`prod.md`, `prod-android.md`, `npmglobalize-disttag.md`, `rmf-tiny.md`,
    // `push-relay.md`, etc.) live in the same docs/ folder for developer
    // reference but have no business on the user's GDrive.
    // NOTE: only `.jsonc` config-file help belongs here. Feature help (search,
    // editor, …) is HTML compiled into the client app — see
    // client/help/*.ts — and must NOT be deployed as a `.md` doc.
    const USER_DOC_WHITELIST = new Set([
        "accounts.md", "allowlist.md", "clients.md", "config.md",
        "contacts.md", "contact-rules.md", "preferences.md",
        "editor.md",
    ]);
    try {
        const deployedVersion = (await provider.read(".docs-version") || "").trim();
        if (deployedVersion === appVersion)
            return; // already up to date
        const mdFiles = fs.readdirSync(docsDir).filter(f => f.endsWith(".md") && USER_DOC_WHITELIST.has(f));
        // Parallel cloud writes — each provider.write is independent.
        // The previous serial loop took ~5-10 s for the 9 .md files
        // because every write was one round-trip to GDrive. The doc-
        // deploy is fire-and-forget at the outer caller, but holding
        // GDrive's API for 10 s starves the *other* fire-and-forget
        // tasks (account refresh, contact sync) that share the
        // provider. Parallel cuts that to ~1 s on a normal connection.
        await Promise.all(mdFiles.map(async (f) => {
            const content = fs.readFileSync(path.join(docsDir, f), "utf-8");
            await provider.write(f, content);
        }));
        await provider.write(".docs-version", appVersion);
        console.log(`  [docs] deployed ${mdFiles.length} .md file(s) for app v${appVersion}`);
    }
    catch (e) {
        console.warn(`  [docs] deploy failed: ${e.message}`);
    }
}
//# sourceMappingURL=index.js.map