/**
 * @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 type { MailxSettings, AccountConfig, AutocompleteSettings, AiKeys } from "@bobfrankston/mailx-types";
import { getCloudProvider, gDriveFindOrCreateFolder, gDriveValidateCachedFolder, type CloudProvider } 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: string[] = [];
        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: string, dst: string): void => {
                    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: any) {
                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: any) {
            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");

interface SharedDirConfig {
    provider: "gdrive" | "google" | "local";
    path: string;
    /** Google Drive folder ID — cached locally, avoids name-based search */
    folderId?: string;
}

interface AccountOverride {
    enabled?: boolean;
    historyDays?: number;
}

interface LocalConfig {
    /** Directory containing shared settings — string, { provider, path }, or array to try in order */
    sharedDir?: string | SharedDirConfig | (string | SharedDirConfig)[];
    /** Legacy: single settings file path */
    settingsPath?: string;
    /** Local message store path */
    storePath?: string;
    /** Per-machine: days of history to sync (overrides shared) */
    historyDays?: number;
    /** Per-account overrides on this machine (enabled, historyDays) */
    accountOverrides?: Record<string, AccountOverride>;
}

/** Resolve a path from config — relative to ~/.mailx/, ~ expands to home */
function resolvePath(p: string): string {
    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(): LocalConfig {
    // 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: SharedDirConfig): string | undefined {
    // Cloud providers use API only — no filesystem mount scanning
    if (cfg.provider === "gdrive" || cfg.provider === "google") 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: SharedDirConfig | null = null;
/** Last cloud API error (for UI display) */
let lastCloudError: string | null = null;

/** Subscribers notified whenever lastCloudError changes (push to UI immediately). */
type CloudErrorListener = (error: string | null, context?: { op: "read" | "write"; filename: string }) => void;
const cloudErrorListeners: CloudErrorListener[] = [];
export function onCloudError(cb: CloudErrorListener): () => void {
    cloudErrorListeners.push(cb);
    return () => {
        const i = cloudErrorListeners.indexOf(cb);
        if (i >= 0) cloudErrorListeners.splice(i, 1);
    };
}
function setCloudError(error: string | null, context?: { op: "read" | "write"; filename: string }): void {
    lastCloudError = error;
    for (const cb of cloudErrorListeners) {
        try { cb(error, context); } catch { /* listener faults shouldn't break writes */ }
    }
}
export function getLastCloudError(): string | null { return lastCloudError; }

function resolveSharedEntry(entry: string | SharedDirConfig): string | undefined {
    if (typeof entry === "string") {
        const p = resolvePath(entry);
        return fs.existsSync(p) ? p : undefined;
    }
    return resolveProvider(entry);
}

function getSharedDir(): string {
    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") as SharedDirConfig | undefined;
            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: string): Promise<string | null> {
    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
    if (!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 as string, pendingCloudConfig.folderId);
    if (!provider) { setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename }); return null; }
    console.log(`  [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
    const content = await provider.read(filename);
    if (content) {
        setCloudError(null);
        // Cache locally
        try {
            fs.mkdirSync(LOCAL_DIR, { recursive: true });
            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;
}

/** 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: string, content: string): Promise<void> {
    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
    if (!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 as string, pendingCloudConfig.folderId);
    if (!provider) {
        const err = `No cloud provider for ${pendingCloudConfig.provider}`;
        setCloudError(err, { op: "write", filename });
        throw new Error(err);
    }
    try {
        await provider.write(filename, content);
        setCloudError(null);
    } catch (e: any) {
        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: string): void {
    try {
        const config = readLocalConfig();
        if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
            (config.sharedDir as SharedDirConfig).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(): void {
    try {
        const config = readLocalConfig();
        if (config.sharedDir && typeof config.sharedDir === "object" && !Array.isArray(config.sharedDir)) {
            delete (config.sharedDir as SharedDirConfig).folderId;
            atomicWrite(LOCAL_CONFIG_PATH, config);
        }
    } catch { /* non-critical */ }
}

/** Whether cloud API fallback is active */
export function isCloudMode(): boolean {
    return pendingCloudConfig !== null;
}

/** Get storage provider info for display */
export function getStorageInfo(): { provider: string; mode: "mount" | "api" | "local"; cloudPath?: string; folderName?: string; folderId?: string; configDir?: string; cloudError?: string } {
    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) {
                // Has folder ID → 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: string): any {
    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: any) {
        console.error(`Failed to read ${actual}: ${e.message}`);
        return null;
    }
}

function atomicWrite(filePath: string, data: any): void {
    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<T>(filename: string, defaults: T): T {
    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: string, data: any): void {
    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.
    }
}

// ── Provider defaults ──

interface ProviderDefaults {
    label: string;
    imap: { host: string; port: number; tls: boolean; auth: "password" | "oauth2" };
    smtp: { host: string; port: number; tls: boolean; auth: "password" | "oauth2" };
    // The former per-provider `spam` path field was removed 2026-04-22 —
    // markAsSpamMessages now finds the junk folder via `specialUse === "junk"`
    // on the DB-stored folder record, populated by mailx-imap from iflow's
    // getSpecialFolders() (RFC 6154 flags with sensible defaults for servers
    // that don't advertise them). The provider-default path was never
    // authoritative anyway — servers don't agree ("Junk Email" vs "Junk"
    // vs "SPAM" vs "_Spam" vs "Bulk Mail") and we already have the right
    // answer from the server.
}

const PROVIDERS: Record<string, ProviderDefaults> = {
    "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" },
    },
};

/** 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: string): string {
    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: string): string {
    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: any, globalName?: string, globalSig?: any, globalSignature?: string): AccountConfig {
    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 defaults to local-part (most
    // accounts have a unique local-part); label defaults to provider name or id.
    // Generic local-parts (info, admin, support, no-reply, ...) fall back to
    // the domain stem to avoid collisions across accounts.
    const GENERIC_LOCALS = new Set(["info", "admin", "support", "no-reply", "noreply", "contact", "hello", "mail", "office"]);
    const domainStem = domain.split(".")[0] || "";
    const autoId = (localPart && !GENERIC_LOCALS.has(localPart)) ? localPart : (domainStem || "account");

    return {
        id: acct.id || 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 as any).primaryCalendar,
        primaryTasks: (acct as any).primaryTasks,
        primaryContacts: (acct as any).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 } } : {})),
    } as AccountConfig;
}

// ── Defaults ──

const DEFAULT_ACCOUNTS: AccountConfig[] = [];

const DEFAULT_PREFERENCES = {
    ui: {
        theme: "system" as "system" | "dark" | "light",
        editor: "quill" as "quill" | "tiptap",
        folderWidth: 220,
        listViewerSplit: 40,
        fontSize: 15,
    },
    sync: {
        intervalMinutes: 5,
        historyDays: 30,
        prefetch: true,
    },
    autocomplete: {
        enabled: false,
        provider: "ollama" as const,
        ollamaUrl: "http://localhost:11434",
        ollamaModel: "qwen2.5-coder:1.5b",
        cloudApiKey: "",
        cloudModel: "claude-sonnet-4-5",
        debounceMs: 600,
        maxTokens: 60,
    },
};

const DEFAULT_AUTOCOMPLETE: AutocompleteSettings = {
    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: [] as string[],
    domains: [] as string[],
    recipients: [] as string[],
    // 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: [] as string[],
    flaggedDomains: [] as string[],
};

// ── Public API ──

/** Load account configs */
export function loadAccounts(): AccountConfig[] {
    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: string): string =>
                    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: any[] = accounts.accounts || accounts;
        const globalName: string = accounts.name || "";
        const globalSig = accounts.sig;
        const globalSignature: string | undefined = accounts.signature;
        const result = deduplicateAccounts(raw.map((a: any) => 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: any) => normalizeAccount(a, legacy.name, legacy.sig, legacy.signature)));

    return DEFAULT_ACCOUNTS;
}

/** Normalize email for dedup — Gmail ignores dots before @ */
function normalizeEmail(email: string): string {
    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: AccountConfig[]): AccountConfig[] {
    const seen = new Set<string>();
    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: AccountConfig[]): AccountConfig[] {
    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(): Promise<AccountConfig[]> {
    // 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: any[] = data.accounts || data;
                const globalName: string = data.name || "";
                const globalSig = data.sig;
                const globalSignature: string | undefined = data.signature;
                // cloudRead has already cached content to LOCAL_DIR.
                return applyAccountOverrides(raw.map((a: any) => 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: AccountConfig, globalName?: string): any {
    const domain = (acct.email || "").split("@")[1]?.toLowerCase() || "";
    const provider = PROVIDERS[domain];
    const out: any = {};
    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 as any).primaryCalendar !== undefined) out.primaryCalendar = (acct as any).primaryCalendar;
    if ((acct as any).primaryTasks !== undefined) out.primaryTasks = (acct as any).primaryTasks;
    if ((acct as any).primaryContacts !== undefined) out.primaryContacts = (acct as any).primaryContacts;
    // imap — keep only fields that differ from provider defaults.
    const imapOut: any = {};
    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: any = {};
    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 as any).signature) out.signature = (acct as any).signature;
    if (acct.sig?.text) {
        // html: false is default; only keep the html flag when explicitly true.
        out.sig = (acct.sig as any).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: AccountConfig[]): Promise<void> {
    // Read existing top-level fields we need to preserve (keys, etc.) before
    // we rewrite. Prefer cloud over local.
    let preservedKeys: AiKeys | undefined;
    let cloudCopy: any;
    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: any[] = 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: any = 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(): AiKeys {
    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: AiKeys): Promise<void> {
    let existing: any = {};
    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: AiKeys = {};
    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(): Promise<void> {
    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(): Promise<void> {
    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(): Promise<void> {
    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(): typeof DEFAULT_PREFERENCES {
    let shared: any = 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: any = {};
    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,
    } as typeof DEFAULT_PREFERENCES;
}

/** Save preferences */
export function savePreferences(prefs: any): void {
    saveFile("preferences.jsonc", prefs);
}

/** Load autocomplete settings */
export function loadAutocomplete(): AutocompleteSettings {
    const prefs = loadPreferences();
    return prefs.autocomplete;
}

/** Save autocomplete settings */
export function saveAutocomplete(settings: AutocompleteSettings): void {
    const prefs = loadPreferences() as any;
    prefs.autocomplete = settings;
    savePreferences(prefs);
}

/** Load remote content allow-list */
export function loadAllowlist(): typeof DEFAULT_ALLOWLIST {
    return loadFile("allowlist.jsonc", DEFAULT_ALLOWLIST);
}

/** Save allow-list — merges with existing cloud copy (multi-client safe) */
export async function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void> {
    // 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: string[], remote: string[]): string[] =>
                    [...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: string): string[] {
    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: string[]): string {
    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: string, text: string): void {
    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(): Promise<string[]> {
    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: string[]): Promise<void> {
    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}`));
    }
}

// ── Legacy compatibility ──

function loadLegacySettings(): any {
    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(): MailxSettings {
    const accounts = loadAccounts();
    const prefs = loadPreferences() as any;
    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: Record<string, any> = {};
    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,
    } as MailxSettings;
}

/** 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: any, source: any): any {
    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: string | null = null;
export async function saveSettings(settings: MailxSettings): Promise<void> {
    // 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() as any;
    const incoming = { ...(settings as any) };
    delete incoming.accounts;
    delete incoming.store;
    const next = deepMerge(prior, incoming);
    savePreferences(next);
}

/** Get the local store base path */
export function getStorePath(): string {
    const config = readLocalConfig();
    return config.storePath ? resolvePath(config.storePath) : DEFAULT_STORE_PATH;
}

/** Get the local data directory (DB, store, etc.) */
export function getConfigDir(): string {
    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?: string, storePath?: string): void {
    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: LocalConfig["sharedDir"] = sharedDir || existing.sharedDir;

    const config: LocalConfig = {
        ...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" = "gdrive"): Promise<void> {
    const existing = readLocalConfig();
    if (existing.sharedDir) return; // Already configured
    // 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();
    const sharedDir: SharedDirConfig = { provider, path: "home/.rmfmail", folderId: folderId || undefined };
    const config: LocalConfig = { ...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} (folder ID: ${folderId || "pending"})`);
}

const DEFAULT_SETTINGS: MailxSettings = {
    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?: string): number {
    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(): boolean {
    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: string): Promise<void> {
    if (!pendingCloudConfig?.folderId) return;
    const provider = getCloudProvider(pendingCloudConfig.provider as string, pendingCloudConfig.folderId);
    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: any) {
        console.warn(`  [docs] deploy failed: ${e.message}`);
    }
}
