/**
 * Android bootstrap — wires WebMailxDB + WebMessageStore + GmailApiWebProvider + WebMailxService
 * into the mailxapi bridge. This replaces Node.js backend for Android WebView.
 *
 * On Android, everything runs in the same JavaScript context:
 * - wa-sqlite for metadata (via WebMailxDB)
 * - IndexedDB for message bodies (via WebMessageStore)
 * - Gmail/Outlook sync via REST APIs (plain fetch — no native bridge needed)
 * - IMAP accounts use BridgeTransport (via MAUI TCP bridge) — not yet implemented
 *
 * The existing client UI (app.ts, components/) is completely unchanged —
 * it calls window.mailxapi.* which this module provides.
 */

import { WebMailxDB } from "./db.js";
import { WebMessageStore } from "./web-message-store.js";
import { WebMailxService, type WebSyncManager } from "./web-service.js";
import {
    loadAccounts, loadAccountsFromCloud, saveAccounts, loadSettings, clearSettings, getDeviceId,
    setGDriveTokenProvider, setGDriveFolderId, primeSharedConfigs
} from "./web-settings.js";
import { GmailApiWebProvider } from "./gmail-api-web.js";
import { ImapWebProvider } from "./imap-web-provider.js";
import { SmtpClient, type SmtpAuth } from "@bobfrankston/smtp-direct";
import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
import type { MailProvider, ProviderMessage } from "./provider-types.js";
import type { Folder, EmailAddress, AccountConfig } from "@bobfrankston/mailx-types";

// ── State ──

let db: WebMailxDB;
let bodyStore: WebMessageStore;
let service: WebMailxService;
let syncManager: AndroidSyncManager;
const eventHandlers: ((event: any) => void)[] = [];

// ── Event emitter ──

function emitEvent(event: any): void {
    for (const h of eventHandlers) {
        try { h(event); } catch { /* ignore */ }
    }
    if (typeof (window as any)._msgapiServiceEvent === "function") {
        (window as any)._msgapiServiceEvent(event);
    }
}

// ── Helpers ──

function toEmailAddress(addr: { name?: string; address?: string } | undefined): EmailAddress {
    return { name: addr?.name || "", address: addr?.address || "" };
}

/** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */
function vlog(msg: string): void {
    try {
        fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => {});
    } catch { /* ignore */ }
}

/** C156: user-visible bootstrap narration. A new-device install looked
 *  frozen (Bob 2026-07-22) because the stage breadcrumbs only went to vlog.
 *  This logs (console capture shows it pre-UI) AND emits a bootstrapStatus
 *  event the client renders in the status bar + folder-tree loading slot. */
function narrate(msg: string): void {
    console.log(`[android] ${msg}`);
    emitEvent({ type: "bootstrapStatus", message: msg });
}

// ── Sync Manager ──

/** Race a promise against a wall-clock timeout. On timeout it REJECTS so the
 *  caller's try/catch moves on — the underlying op may keep running in the
 *  background, but it can no longer STALL the whole sync. Critical on Android:
 *  an auth-failing IMAP account (outlook/aol) or a slow BridgeTransport
 *  listFolders/fetch would otherwise hang Phase 1 forever and new mail for the
 *  healthy accounts never arrived (Bob 2026-06-27). */
function withTimeout<T>(label: string, ms: number, fn: () => Promise<T>): Promise<T> {
    return new Promise<T>((resolve, reject) => {
        let done = false;
        const timer = setTimeout(() => {
            if (done) return;
            done = true;
            reject(new Error(`timeout after ${ms}ms: ${label}`));
        }, ms);
        fn().then(
            (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } },
            (e) => { if (!done) { done = true; clearTimeout(timer); reject(e); } },
        );
    });
}

class AndroidSyncManager implements WebSyncManager {
    private providers = new Map<string, MailProvider>();
    private tokenProviders = new Map<string, () => Promise<string>>();
    // Dedicated body-fetch lane per IMAP account, mirroring the desktop's
    // per-workload connections (v1.2.50). Body fetches used the SAME
    // connection as folder sync; iflow-direct serializes single commands but
    // not SELECT+FETCH *pairs*, so a concurrent folder sync could re-SELECT
    // another mailbox between fetchOne's SELECT and its UID FETCH — the FETCH
    // then ran against the wrong mailbox and returned nothing, which the
    // viewer showed as "no body text" (Bob 2026-07-03, bobma/4970037-38).
    // Lazy: the connection opens on the first body fetch. Gmail accounts skip
    // this — the Gmail provider is stateless HTTP with no SELECT state.
    private fetchProviders = new Map<string, MailProvider>();
    private imapAccountConfigs = new Map<string, AccountConfig>();
    // Per-account FIFO so two body fetches (interactive click vs prefetch)
    // can't interleave their own SELECT+FETCH pairs on the fetch lane either.
    private fetchChains = new Map<string, Promise<void>>();
    // One prefetch session per account — prevents every syncAll tick from
    // spawning parallel fetch loops that race on IndexedDB and blow through
    // Gmail's per-user quota.
    private prefetchingAccounts = new Set<string>();
    // Single-flight guard for syncAll. It's triggered from THREE places — boot,
    // a 60s interval, and every visibilitychange→visible — with no coordination.
    // On Android everything (incl. wa-sqlite) runs on the main thread, so
    // overlapping runs stacked up: re-listing folders repeatedly, contending on
    // the DB, and freezing the event loop ~1s at a time — the sync never
    // advanced to fetching new mail, so the list stayed stale despite "Synced"
    // (Bob 2026-06-27). Coalesce concurrent calls to one in-flight run.
    private syncAllInflight: Promise<void> | null = null;
    // Separate guard for the Phase-2/3 background pass (other folders + body
    // prefetch). It runs DETACHED from the inbox guard so a long folder sweep
    // can't make the 60s poll skip the inbox refresh (new mail must keep
    // flowing). One background pass at a time — overlapping triggers no-op.
    private bgSyncInflight: Promise<void> | null = null;

    constructor(
        private db: WebMailxDB,
        private bodyStore: WebMessageStore,
    ) {}

    on(_event: string, _handler: (...args: any[]) => void): void { /* stub */ }
    emit(event: string, ...args: any[]): void { emitEvent({ type: event, ...args[0] }); }

    async addAccount(account: AccountConfig): Promise<void> {
        vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
        this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
        // Idempotent: a provider already registered for this account is kept
        // as-is. The Android bootstrap re-runs the account load periodically;
        // recreating the provider each time built a fresh ImapWebProvider +
        // BridgeTransport and replaced the live one — which orphaned any
        // body fetch in flight on the old provider, so `fetchOne` came back
        // with no source and the message showed "Body parsed empty". A real
        // config change is rare and handled by a restart.
        if (this.providers.has(account.id)) {
            vlog(`addAccount ${account.id}: provider already registered — keeping it`);
            return;
        }
        if (this.isGmailAccount(account)) {
            const tokenProvider = this.tokenProviders.get(account.id);
            if (tokenProvider) {
                this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
                console.log(`[sync] ${account.id}: Gmail API provider registered`);
            } else {
                console.warn(`[sync] ${account.id}: no token provider`);
            }
        } else if (account.imap?.host && account.imap?.user) {
            // Generic IMAP account — use BridgeTransport through MAUI's TCP bridge
            try {
                const provider = new ImapWebProvider({
                    server: account.imap.host,
                    port: account.imap.port || 993,
                    username: account.imap.user,
                    password: account.imap.password,
                    inactivityTimeout: 300000,   // 300s for slow Dovecot
                    fetchChunkSize: 10,
                    fetchChunkSizeMax: 100,
                }, () => new BridgeTcpTransport());
                this.providers.set(account.id, provider);
                this.imapAccountConfigs.set(account.id, account);
                vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
                console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
            } catch (e: any) {
                vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
                console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
            }
        } else {
            vlog(`addAccount ${account.id}: no imap config, skipping`);
        }
    }

    setTokenProvider(accountId: string, provider: () => Promise<string>): void {
        this.tokenProviders.set(accountId, provider);
    }

    private isGmailAccount(account: AccountConfig): boolean {
        return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
    }

    private getProvider(accountId: string): MailProvider | null {
        return this.providers.get(accountId) || null;
    }

    /** Provider for BODY fetches. IMAP accounts get their own lazily-opened
     *  connection so a fetch can never share (and race) the sync connection's
     *  SELECT state. Gmail (stateless HTTP) reuses the main provider. */
    private getFetchProvider(accountId: string): MailProvider | null {
        const cfg = this.imapAccountConfigs.get(accountId);
        if (!cfg?.imap?.host || !cfg.imap.user) return this.getProvider(accountId);
        let p = this.fetchProviders.get(accountId);
        if (!p) {
            try {
                p = new ImapWebProvider({
                    server: cfg.imap.host,
                    port: cfg.imap.port || 993,
                    username: cfg.imap.user,
                    password: cfg.imap.password,
                    inactivityTimeout: 300000,
                    fetchChunkSize: 10,
                    fetchChunkSizeMax: 100,
                }, () => new BridgeTcpTransport());
                this.fetchProviders.set(accountId, p);
                console.log(`[fetchBody] ${accountId}: dedicated fetch connection opened`);
            } catch (e: any) {
                console.error(`[fetchBody] ${accountId}: fetch-lane provider failed (${e?.message || e}) — falling back to sync connection`);
                return this.getProvider(accountId);
            }
        }
        return p;
    }

    /** Per-account FIFO for body fetches: one SELECT+FETCH pair at a time on
     *  the fetch lane. A rejected task never breaks the chain. */
    private enqueueFetch<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
        const prev = this.fetchChains.get(accountId) || Promise.resolve();
        const next = prev.then(fn, fn);
        this.fetchChains.set(accountId, next.then(() => { /* */ }, () => { /* */ }));
        return next;
    }

    async syncAll(): Promise<void> {
        // The inbox guard covers ONLY Phase 1 (inbox sync). Overlapping triggers
        // coalesce so they can't thrash the single main thread.
        if (this.syncAllInflight) {
            console.log("[sync] inbox sync already in flight — coalescing this trigger");
            return this.syncAllInflight;
        }
        const t0 = Date.now();
        console.log("[sync] syncAll START (inbox phase)");
        this.syncAllInflight = this._syncInboxes()
            .catch((e: any) => { console.error(`[sync] inbox sync error: ${e?.message || e}`); })
            .finally(() => {
                console.log(`[sync] inbox phase DONE in ${Date.now() - t0}ms`);
                this.syncAllInflight = null;
            });
        // Phase 2/3 (other folders + body prefetch) run DETACHED, under their own
        // guard — they must not hold the inbox guard, or a slow folder sweep would
        // make the next 60s poll skip new inbox mail.
        this._kickBackgroundSync();
        return this.syncAllInflight;
    }

    /** Phase 1: sync every account's INBOX IN PARALLEL so a slow account (e.g.
     *  Gmail's metadata fetch, or an auth-failing IMAP account) can't delay new
     *  mail for the others. Each account is time-boxed; network fetches overlap
     *  even though JS is single-threaded (the awaits yield). */
    private async _syncInboxes(): Promise<void> {
        const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id));
        vlog(`syncInboxes: ${accounts.length} accounts: ${accounts.map(a => a.id).join(",")}`);
        await Promise.all(accounts.map(account =>
            withTimeout(`${account.id} inbox`, 60000, async () => {
                const folders = await this.syncFolders(account.id);
                const inbox = folders.find(f => f.specialUse === "inbox");
                if (inbox) {
                    console.log(`[sync] ${account.id}: fetching INBOX (folderId=${inbox.id})`);
                    await this.syncFolder(account.id, inbox.id);
                    console.log(`[sync] ${account.id}: INBOX fetch done`);
                    emitEvent({ type: "syncComplete", accountId: account.id });
                }
            }).catch((e: any) => console.error(`[sync] ${account.id} inbox: ${e?.message || e}`)),
        ));
    }

    /** Phase 2 + 3: remaining folders, then body prefetch. Runs in the
     *  background under its own single-flight guard. */
    private _kickBackgroundSync(): void {
        if (this.bgSyncInflight) return;
        const t0 = Date.now();
        this.bgSyncInflight = this._syncRemainingAndPrefetch()
            .catch((e: any) => console.error(`[sync] background sweep error: ${e?.message || e}`))
            .finally(() => {
                console.log(`[sync] background sweep DONE in ${Date.now() - t0}ms`);
                this.bgSyncInflight = null;
            });
    }

    private async _syncRemainingAndPrefetch(): Promise<void> {
        const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id));
        // Phase 2: remaining folders (per account, per folder time-boxed).
        for (const account of accounts) {
            try {
                const folders = this.db.getFolders(account.id);
                const remaining = folders.filter(f => f.specialUse !== "inbox");
                for (const folder of remaining) {
                    try { await withTimeout(`${account.id} ${folder.path}`, 45000, () => this.syncFolder(account.id, folder.id)); }
                    catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); }
                }
                this.db.updateLastSync(account.id, Date.now());
                emitEvent({ type: "syncComplete", accountId: account.id });
            } catch (e: any) {
                console.error(`[sync] ${account.id}: ${e.message}`);
                emitEvent({ type: "syncError", accountId: account.id, error: e.message });
            }
        }
        // Phase 3: body prefetch (fire-and-forget per account).
        for (const account of accounts) {
            this.prefetchBodies(account.id).catch(e =>
                console.error(`[prefetch] ${account.id}: ${e.message}`));
        }
    }

    /** Background body prefetch — download bodies for messages that don't have
     *  them yet, so tapping a message in the list opens instantly from cache. */
    async prefetchBodies(accountId: string): Promise<void> {
        if (this.prefetchingAccounts.has(accountId)) return;
        this.prefetchingAccounts.add(accountId);
        try {
            const BATCH_SIZE = 20;
            const THROTTLE_MS = 150;
            const RATE_LIMIT_PAUSE_MS = 30000;
            const ERROR_BUDGET = 10;
            const CONCURRENCY = 2;       // S62: 2 in-flight per account
            let totalFetched = 0;
            let errors = 0;
            let announced = false;
            // Per-session blacklist: messages whose fetch errored once.
            // `getMessagesWithoutBody` returns the same set every batch
            // (no per-row failed-marker in DB yet), so without this set
            // a single failing message would burn the entire ERROR_BUDGET
            // by being retried first in every batch. Skipping locally
            // unblocks subsequent messages; next syncAll cycle starts
            // a new prefetch session and the blacklist resets.
            const failedThisSession = new Set<number>();
            // S62: INBOX always first. Within each folder the DB returns rows
            // most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
            // mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
            // starve INBOX any more.
            const folderPriority = (folderId: number): number => {
                const f = this.db.getFolders(accountId).find((x: any) => x.id === folderId);
                return f?.specialUse === "inbox" ? 0 : 1;
            };
            let rateLimitCooldownUntil = 0;

            while (true) {
                const allMissing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4);
                const missing = allMissing.filter((m: any) => !failedThisSession.has(m.uid));
                if (missing.length === 0) break;
                // Cap to BATCH_SIZE after filtering so a deep blacklist
                // doesn't leave us with a tiny working set per pass.
                if (missing.length > BATCH_SIZE) missing.length = BATCH_SIZE;
                if (!announced) {
                    console.log(`[prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
                    vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
                    announced = true;
                }
                // Sort this batch INBOX-first. getMessagesWithoutBody doesn't
                // know the priority, and re-querying per folder would multiply
                // the SELECTs. One in-memory sort is cheap.
                missing.sort((a: any, b: any) => folderPriority(a.folderId) - folderPriority(b.folderId));
                let progressedThisBatch = false;
                let batchAborted = false;

                // Bounded-concurrency worker pool. Each worker pulls the next
                // unclaimed item from `missing`. Shared flags (errors,
                // rateLimitCooldownUntil, progressedThisBatch) are updated
                // inside the loop — sql.js is single-threaded so there's no
                // actual race on reads/writes.
                let cursor = 0;
                const worker = async (): Promise<void> => {
                    while (cursor < missing.length) {
                        if (batchAborted) return;
                        if (errors >= ERROR_BUDGET) return;
                        const idx = cursor++;
                        const m = missing[idx];
                        // Honor rate-limit cooldown across workers.
                        const now = Date.now();
                        if (rateLimitCooldownUntil > now) {
                            await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
                        }
                        if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
                            this.db.updateBodyPath(accountId, m.folderId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
                            progressedThisBatch = true;
                            continue;
                        }
                        try {
                            const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
                            if (result) {
                                totalFetched++;
                                progressedThisBatch = true;
                                emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
                            } else {
                                errors++;
                                failedThisSession.add(m.uid);
                            }
                        } catch (e: any) {
                            errors++;
                            failedThisSession.add(m.uid);
                            const msg = String(e?.message || "");
                            if (/429|rate|too many/i.test(msg)) {
                                console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
                                rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
                            } else {
                                console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
                            }
                        }
                        // Throttle kept per-request to spread load on flaky
                        // phone networks; concurrency-2 means effective request
                        // rate is ~1 per THROTTLE_MS/2.
                        await new Promise(r => setTimeout(r, THROTTLE_MS));
                    }
                };
                await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
                if (errors >= ERROR_BUDGET) {
                    console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
                    vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
                    return;
                }
                if (!progressedThisBatch) {
                    console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
                    break;
                }
            }
            if (totalFetched > 0) {
                console.log(`[prefetch] ${accountId}: done — cached ${totalFetched} bodies`);
                vlog(`prefetch ${accountId} done: ${totalFetched} cached`);
            }
        } finally {
            this.prefetchingAccounts.delete(accountId);
        }
    }

    async syncFolders(accountId: string): Promise<Folder[]> {
        const provider = this.getProvider(accountId);
        if (!provider) {
            const existing = this.db.getFolders(accountId);
            vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
            return existing;
        }
        emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
        console.log(`[sync] ${accountId}: listing folders from provider`);
        let providerFolders: any[] = [];
        try {
            providerFolders = await provider.listFolders();
        } catch (e: any) {
            console.error(`[sync] ${accountId}: listFolders threw: ${e?.message || e}`);
            throw e;
        }
        console.log(`[sync] ${accountId}: provider returned ${providerFolders.length} folders` +
            (providerFolders.length > 0 ? ` (sample: ${providerFolders.slice(0, 3).map((f: any) => f.path || f.name).join(", ")})` : ""));
        for (const folder of providerFolders) {
            const flags = folder.flags || [];
            if (flags.some((f: string) => f.toLowerCase() === "\\noselect")) continue;
            this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
        }
        emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
        const dbFolders = this.db.getFolders(accountId);
        console.log(`[sync] ${accountId}: ${dbFolders.length} folders in db, inbox=${dbFolders.some((f: any) => f.specialUse === "inbox") ? "yes" : "no"}`);
        emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
        return dbFolders;
    }

    async syncFolder(accountId: string, folderId: number): Promise<void> {
        const provider = this.getProvider(accountId);
        if (!provider) return;
        const folders = this.db.getFolders(accountId);
        const folder = folders.find(f => f.id === folderId);
        if (!folder) return;

        emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
        const highestUid = this.db.getHighestUid(accountId, folderId);
        const startDate = new Date(Date.now() - 30 * 86400000);
        const account = this.db.getAccounts().find(a => a.id === accountId);
        const isGoogle = !!account && isGoogleAccount(account);

        let messages: ProviderMessage[];
        if (highestUid > 0) {
            const opts: { source: boolean; knownUids?: Set<number> } = { source: false };
            if (isGoogle) {
                // Gmail re-lists a recent page (IDs aren't monotonic). Give it
                // the set of UIDs we already have so it fetches only new ones
                // instead of re-pulling ~200 every sync (Bob 2026-06-27).
                opts.knownUids = new Set(this.db.getUidsForFolder(accountId, folderId));
            }
            messages = await provider.fetchSince(folder.path, highestUid, opts);
            // IMAP UIDs are monotonic — keep the high-water guard. For Gmail the
            // knownUids skip already returned only NEW messages; applying
            // `uid > highestUid` to Gmail's HASH uids is a lottery that wrongly
            // drops new mail whose hash falls below the mark (the long-standing
            // "Gmail new messages missing" bug).
            if (!isGoogle) {
                messages = messages.filter(m => m.uid > highestUid);
            }
        } else {
            const tomorrow = new Date(Date.now() + 86400000);
            messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
        }

        if (messages.length > 0) {
            console.log(`[sync] ${folder.path}: ${messages.length} messages`);
            this.storeProviderMessages(accountId, folderId, messages);
            this.db.recalcFolderCounts(folderId);
            emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
        }

        // Reconcile deletions — messages present locally but no longer on the
        // server (moved away, deleted on another client). Without this, the
        // Android client never drops removed rows: e.g., moves to _spam from
        // another client showed up in _spam (next time it synced) but never
        // disappeared from INBOX.
        //
        // Same safety guards as the desktop reconcile path:
        //   - Skip if the server list is empty but local has messages (likely
        //     a transient API failure that returned []).
        //   - Refuse to delete more than 50% of local in one pass — better to
        //     keep phantoms than to wipe a folder on a sync bug. Rebuild local
        //     cache fixes a stuck state.
        try {
            const serverUidsArr = await provider.getUids(folder.path);
            const serverUids = new Set(serverUidsArr);
            const localUids = this.db.getUidsForFolder(accountId, folderId);
            if (serverUidsArr.length === 0 && localUids.length > 0) {
                console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
            } else {
                const toDelete = localUids.filter(uid => !serverUids.has(uid));
                const RECONCILE_DELETE_THRESHOLD = 0.5;
                if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
                    console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
                } else {
                    for (const uid of toDelete) {
                        this.db.deleteMessage(accountId, folderId, uid);
                        this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {});
                    }
                    if (toDelete.length > 0) {
                        console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
                        this.db.recalcFolderCounts(folderId);
                        emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
                    }
                }
            }
        } catch (e: any) {
            console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
        }

        emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
        emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
    }

    private storeProviderMessages(accountId: string, folderId: number, messages: ProviderMessage[]): void {
        this.db.beginTransaction();
        try {
            for (const msg of messages) {
                const flags: string[] = [];
                if (msg.seen) flags.push("\\Seen");
                if (msg.flagged) flags.push("\\Flagged");
                if (msg.answered) flags.push("\\Answered");
                if (msg.draft) flags.push("\\Draft");
                // Store the Gmail providerId in bodyPath as "gmail:<id>" so we can
                // fetch the body directly without re-listing 1000 messages from the folder
                const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
                const sentRaw = (msg as any).sentDate instanceof Date ? (msg as any).sentDate.getTime() : NaN;
                this.db.upsertMessage({
                    accountId, folderId, uid: msg.uid,
                    messageId: msg.messageId || "", inReplyTo: "", references: [],
                    date: msg.date ? msg.date.getTime() : Date.now(),
                    sentDate: Number.isFinite(sentRaw) ? sentRaw : undefined,
                    subject: msg.subject || "",
                    from: toEmailAddress(msg.from?.[0]),
                    to: msg.to.map(a => toEmailAddress(a)),
                    cc: msg.cc.map(a => toEmailAddress(a)),
                    flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
                });
            }
            this.db.commitTransaction();
        } catch (e: any) {
            this.db.rollbackTransaction();
            console.error(`[sync] storeMessages error: ${e.message}`);
        }
    }

    async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null> {
        const t0 = Date.now();
        if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
            // Cache hit is the common case on every body open — not worth a
            // log line each time. Only an actual fetch (below) is logged.
            return await this.bodyStore.getMessage(accountId, folderId, uid);
        }
        const provider = this.getFetchProvider(accountId);
        if (!provider) {
            console.warn(`[fetchBody] No provider for ${accountId}`);
            return null;
        }
        // Look up the Gmail providerId stored in body_path during sync
        const envelope = this.db.getMessageByUid(accountId, uid, folderId);
        const bp = (envelope as any)?.bodyPath || "";
        // 60 s wall-clock cap — infinite hang was the user-reported symptom
        // ("fetch message body on android is infinite"). A dead BridgeTransport
        // socket won't recover by waiting. Legit fetches finish in seconds.
        const FETCH_TIMEOUT_MS = 60_000;
        let __folderPath = "(n/a)";
        let msg: any = null;
        try {
            // FIFO per account: the SELECT+FETCH pair must not interleave with
            // another body fetch's pair on the shared fetch-lane connection.
            msg = await this.enqueueFetch(accountId, () => Promise.race([
                (async (): Promise<any> => {
                    if (bp.startsWith("gmail:") && (provider as any).fetchById) {
                        const providerId = bp.substring(6);
                        __folderPath = `gmail:${bp.substring(6)}`;
                        return (provider as any).fetchById(providerId, { source: true });
                    }
                    const folders = this.db.getFolders(accountId);
                    const folder = folders.find(f => f.id === folderId);
                    if (!folder) { __folderPath = "(folder row missing)"; return null; }
                    __folderPath = folder.path || "(empty path)";
                    return provider.fetchOne(folder.path, uid, { source: true });
                })(),
                new Promise((_, reject) => setTimeout(
                    () => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)),
                    FETCH_TIMEOUT_MS
                )),
            ]));
        } catch (e: any) {
            console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
            throw e;
        }
        if (!msg?.source) {
            // Detail so the next log pins WHY: folder path used, and whether
            // the provider returned null vs a message with an empty body.
            const why = msg == null ? "fetchOne returned null" : "message had no .source";
            console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, path=${__folderPath}, ${why}, ${Date.now() - t0}ms)`);
            return null;
        }
        // Encode the UTF-8 string back to bytes for storage
        const raw = new TextEncoder().encode(msg.source);
        await this.bodyStore.putMessage(accountId, folderId, uid, raw);
        this.db.updateBodyPath(accountId, folderId, uid, `idb:${accountId}/${folderId}/${uid}`);
        console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
        return raw;
    }

    async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void> {
        this.db.updateMessageFlags(accountId, folderId, uid, flags);
        this.db.recalcFolderCounts(folderId);
        this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
        emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
    }

    async trashMessage(accountId: string, folderId: number, uid: number): Promise<void> {
        this.db.deleteMessage(accountId, folderId, uid);
        this.db.queueSyncAction(accountId, "trash", uid, folderId);
        emitEvent({ type: "messageDeleted", accountId, folderId, uid });
        emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
    }

    async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void> {
        for (const m of messages) await this.trashMessage(accountId, m.folderId, m.uid);
    }

    async moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void> {
        this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
        emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
    }

    async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void> {
        for (const m of messages) await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
    }

    async moveMessageCrossAccount(): Promise<void> {
        throw new Error("Cross-account move not supported on mobile");
    }

    async undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void> {
        this.db.queueSyncAction(accountId, "undelete", uid, folderId);
    }

    /** Q112: drain queued move/flag/trash actions to the provider. Android is
     *  standalone — it pushes state changes directly to Gmail (or other
     *  provider) the same way desktop does. Called from the periodic 2-min
     *  tick above. `send` actions drain separately via `processSendQueue`. */
    async processSyncActions(accountId: string): Promise<void> {
        const provider: any = this.providers.get(accountId);
        if (!provider) return;
        const pending = this.db.getPendingSyncActions(accountId)
            .filter((a: any) => a.action !== "send");
        if (pending.length === 0) return;
        const folders = this.db.getFolders(accountId);
        const folderPath = (id: number): string | null => {
            const f = folders.find((x: any) => x.id === id);
            return f?.path || null;
        };
        for (const p of pending) {
            const path = folderPath(p.folderId);
            if (!path) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`); continue; }
            try {
                if (p.action === "flags" && typeof provider.setFlags === "function") {
                    await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
                } else if (p.action === "trash" && typeof provider.trashMessage === "function") {
                    // Pass the stored Gmail id so the provider trashes by id
                    // instead of its capped list-and-hash search — that search
                    // misses messages past the most-recent ~1000 in a large
                    // mailbox, fails, and the deletion un-happens.
                    await provider.trashMessage(path, p.uid, this.db.getProviderId(accountId, p.uid));
                } else if (p.action === "move" && typeof provider.moveMessage === "function") {
                    const toId = p.targetFolderId as number;
                    const toPath = folderPath(toId);
                    if (!toPath) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`); continue; }
                    await provider.moveMessage(path, p.uid, toPath, this.db.getProviderId(accountId, p.uid));
                } else {
                    this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
                    continue;
                }
                this.db.completeSyncActionByUid(accountId, p.action, p.uid);
            } catch (e: any) {
                const msg = e?.message || String(e);
                console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
                this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
            }
        }
    }

    /** In-flight send tracker keyed by queueUid. Prevents
     *  processSendQueue from re-firing the same row when it overlaps
     *  with an in-progress attempt (e.g., the periodic tick fires while
     *  the original attemptSend's promise is still pending). Without
     *  this, a slow Gmail/SMTP send race-conditions into a double-send. */
    private sendInFlight = new Set<number>();

    async queueOutgoingLocal(accountId: string, rawMessage: string): Promise<void> {
        // Local-first: PERSIST to sync_actions before attempting the network
        // send, so a crash / offline / process kill between now and SMTP ACK
        // doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
        // synchronously; Android writes a sync_actions row and now FLUSHES
        // sql.js → IndexedDB before returning. The previous version relied on
        // the 1-second scheduleSave debounce, so a tab-close inside the debounce
        // window erased the row before it was persisted — the "letter just
        // disappeared" symptom user-reported 2026-04-30.
        //
        // Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
        const queueUid = -Date.now();
        this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
        await this.db.flush();
        this.attemptSend(accountId, queueUid, rawMessage);
    }

    /** Kick off a send for a message that's already in the queue. Called by
     *  queueOutgoingLocal on a fresh submit AND by processSendQueue on
     *  startup / periodic tick for anything stranded from a prior run.
     *  Guards against double-send via sendInFlight. */
    private attemptSend(accountId: string, queueUid: number, rawMessage: string): void {
        if (this.sendInFlight.has(queueUid)) return;
        this.sendInFlight.add(queueUid);
        // Helper to mark complete + flush + clear in-flight — used on every
        // success/failure exit. Flush ensures the row deletion or attempt
        // counter actually reaches IndexedDB before the next process-kill,
        // matching the "persist before network" rule for the post-network
        // outcome too. Without flushing on completion, a successful send
        // followed by a fast app-close left the row in the queue, which
        // looked like a "stuck" message on next launch.
        const finishSend = (success: boolean, error?: string) => {
            if (success) {
                this.db.completeSyncActionByUid(accountId, "send", queueUid);
            } else {
                this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
            }
            this.db.flush().catch(() => { /* save will retry on next mutation */ });
            this.sendInFlight.delete(queueUid);
        };

        const provider = this.getProvider(accountId);
        if (provider && typeof (provider as any).sendRaw === "function") {
            (provider as any).sendRaw(rawMessage)
                .then((result: { id: string; threadId: string }) => {
                    console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
                    finishSend(true);
                    emitEvent({ type: "sendComplete", accountId, messageId: result.id });
                })
                .catch((e: any) => {
                    console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
                    finishSend(false, e.message || String(e));
                    emitEvent({ type: "sendError", accountId, error: e.message });
                });
            return;
        }

        // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
        // stored account JSON.
        const accounts = db.getAccountConfigs();
        const row = accounts.find(a => a.id === accountId);
        if (!row) {
            const e = "Unknown account";
            console.error(`[send] ${accountId}: ${e}`);
            finishSend(false, e);
            emitEvent({ type: "sendError", accountId, error: e });
            return;
        }
        let account: AccountConfig;
        try { account = JSON.parse(row.configJson); }
        catch {
            const e = "Account config malformed";
            finishSend(false, e);
            emitEvent({ type: "sendError", accountId, error: e });
            return;
        }
        if (!account.smtp) {
            const e = "No SMTP config for this account";
            console.error(`[send] ${accountId}: ${e}`);
            finishSend(false, e);
            emitEvent({ type: "sendError", accountId, error: e });
            return;
        }

        this.sendViaSmtpDirect(accountId, account, rawMessage)
            .then((result) => {
                console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
                finishSend(true);
                emitEvent({ type: "sendComplete", accountId });
            })
            .catch((e: any) => {
                console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
                finishSend(false, e.message || String(e));
                emitEvent({ type: "sendError", accountId, error: e.message });
            });
    }

    /** Drain any stranded 'send' queue entries — called at startup and on
     *  each periodic sync tick so messages queued while offline or stranded
     *  by a crash get a retry. Each row keeps its queueUid as tracking key. */
    async processSendQueue(accountId: string): Promise<void> {
        const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage);
        if (pending.length === 0) return;
        console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`);
        for (const p of pending) {
            this.attemptSend(accountId, p.uid, p.rawMessage);
        }
    }

    /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
    private async sendViaSmtpDirect(
        accountId: string, account: AccountConfig, raw: string,
    ): Promise<{ accepted: string[]; rejected: { address: string; code: number; message: string }[] }> {
        const SMTP_PORT_STARTTLS = 587;
        const SMTP_PORT_IMPLICIT_TLS = 465;
        const smtp = account.smtp!;
        const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
        const smtpHost = smtp.host || account.imap?.host;
        if (!smtpHost) throw new Error("No SMTP host");

        // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
        const smtpUser = smtp.user || account.imap?.user || account.email;
        const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
        let auth: SmtpAuth | undefined;
        if (authType === "password") {
            const pass = smtp.password || account.imap?.password;
            if (!pass) throw new Error("SMTP password not configured");
            auth = { method: "PLAIN", user: smtpUser, pass };
        } else if (authType === "oauth2") {
            const tp = this.tokenProviders.get(accountId);
            if (!tp) throw new Error("OAuth token provider not registered");
            const token = await tp();
            auth = { method: "XOAUTH2", user: smtpUser, token };
        }

        // Recipients from the HEADER SECTION only, unfolded — matching over
        // the whole raw file scraped Cc/Bcc lines out of the quoted reply
        // chain in the body and silently added those addresses to RCPT TO
        // (desktop hit this 2026-07-21). See mailx-imap sendRaw.
        const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
        const headerEnd = raw.search(/\r?\n\r?\n/);
        const headerSec = (headerEnd === -1 ? raw : raw.slice(0, headerEnd))
            .replace(/\r?\n[ \t]+/g, " ");
        const toMatch = headerSec.match(/^To:\s*(.+)$/mi);
        const ccMatch = headerSec.match(/^Cc:\s*(.+)$/mi);
        const bccMatch = headerSec.match(/^Bcc:\s*(.+)$/mi);
        const fromMatch = headerSec.match(/^From:\s*(.+)$/mi);
        const recipients = [
            ...(toMatch ? parseAddrs(toMatch[1]) : []),
            ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
            ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
        ];
        const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
        if (recipients.length === 0) throw new Error("No recipients");

        // Bcc stripped from the transmitted copy — headers only, folds included.
        const stripBcc = (h: string): string =>
            h.replace(/^Bcc:[^\n]*(\r?\n[ \t][^\n]*)*(\r?\n|$)/mi, "").replace(/\r?\n$/, "");
        const rawToSend = headerEnd === -1
            ? stripBcc(raw)
            : stripBcc(raw.slice(0, headerEnd)) + raw.slice(headerEnd);

        const client = new SmtpClient({
            host: smtpHost,
            port: smtpPort,
            secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
            auth,
            localname: "mailx-android",
        }, () => new BridgeTcpTransport());
        try {
            await client.connect();
            return await client.sendMail({ from: sender, to: recipients }, rawToSend);
        } finally {
            try { await client.quit(); } catch { /* ignore */ }
        }
    }

    async saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise<number | null> {
        return null;
    }

    async deleteDraft(_accountId: string, _draftUid: number): Promise<void> { }

    async reauthenticate(_accountId: string): Promise<boolean> { return false; }

    async searchOnServer(): Promise<number[]> { return []; }

    async syncAllContacts(): Promise<void> { }
}

// ── OAuth credentials (same "installed" client as desktop) ──

// Same credentials as desktop mailx (iflow-credentials.json from @bobfrankston/iflow-direct)
const OAUTH_CLIENT = {
    clientId: "884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u.apps.googleusercontent.com",
    clientSecret: "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",
    authUri: "https://accounts.google.com/o/oauth2/auth",
    tokenUri: "https://oauth2.googleapis.com/token",
    // Reverse client ID scheme — auto-allowed for Google "installed" apps
    redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
};

// Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
// drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
// even with the same client_id. drive (full) lets us see all files the user has access to.
// `tasks` added 2026-05-05 to match desktop. Without it, the Tasks pane's
// HTTP calls 403 even though the same OAuth grant works for Calendar.
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks";

// ── Token cache (IndexedDB) ──

/** Canonicalize email so Gmail's dot/+tag/case equivalences collapse to one
 *  cache key. `bob.frankston+work@Gmail.com` and `bobfrankston@gmail.com`
 *  hit the same mailbox — they must hit the same cached token, or the
 *  user sees a fresh consent prompt every time the dotted vs dotless form
 *  is used by different code paths (mirrors the desktop fix in
 *  mailx-settings/index.ts:canonicalEmail; can't import that here because
 *  this file runs in the Android WebView, not Node). */
function canonEmail(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);
    if (domain === "gmail.com" || domain === "googlemail.com") {
        const plus = local.indexOf("+");
        if (plus >= 0) local = local.slice(0, plus);
        local = local.replace(/\./g, "");
        return `${local}@gmail.com`;
    }
    return `${local}@${domain}`;
}
function tokenKey(email: string): string {
    return `oauth-token-${canonEmail(email).replace(/[@.]/g, "_")}`;
}

async function getCachedToken(email: string): Promise<{ access_token: string; refresh_token?: string; expires_at?: number } | null> {
    const key = tokenKey(email);
    const raw = localStorage.getItem(key);
    if (!raw) {
        console.log(`[oauth] no cached token for ${email} (localStorage key=${key} missing — fresh consent will follow)`);
        return null;
    }
    try {
        const parsed = JSON.parse(raw);
        const hasRefresh = !!parsed?.refresh_token;
        const expiresAt = parsed?.expires_at || 0;
        const expiresIn = expiresAt ? Math.round((expiresAt - Date.now()) / 1000) : 0;
        console.log(`[oauth] cached token for ${email}: hasRefresh=${hasRefresh}, expiresIn=${expiresIn}s`);
        return parsed;
    } catch { return null; }
}

async function setCachedToken(email: string, token: { access_token: string; refresh_token?: string; expires_at?: number }): Promise<void> {
    const key = tokenKey(email);
    localStorage.setItem(key, JSON.stringify(token));
}

async function clearCachedToken(email: string): Promise<void> {
    const key = tokenKey(email);
    localStorage.removeItem(key);
}

// ── Token exchange ──

async function exchangeCodeForTokens(code: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
    const body = new URLSearchParams({
        code,
        client_id: OAUTH_CLIENT.clientId,
        client_secret: OAUTH_CLIENT.clientSecret,
        redirect_uri: OAUTH_CLIENT.redirectUri,
        grant_type: "authorization_code",
    });
    const res = await fetch(OAUTH_CLIENT.tokenUri, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
    });
    if (!res.ok) {
        const text = await res.text();
        throw new Error(`Token exchange failed: ${res.status} ${text}`);
    }
    return res.json();
}

async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> {
    const body = new URLSearchParams({
        refresh_token: refreshToken,
        client_id: OAUTH_CLIENT.clientId,
        client_secret: OAUTH_CLIENT.clientSecret,
        grant_type: "refresh_token",
    });
    const res = await fetch(OAUTH_CLIENT.tokenUri, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
    });
    if (!res.ok) {
        const text = await res.text();
        throw new Error(`Token refresh failed: ${res.status} ${text}`);
    }
    return res.json();
}

// ── Token provider (browser OAuth, same as desktop) ──

function createNativeTokenProvider(email: string): () => Promise<string> {
    return () => {
        // C158: single-flight per email. Startup fires several concurrent
        // token requests (GDrive reconcile, contacts sync, syncAll) — with
        // no cached token each one launched its OWN browser-consent intent
        // (three "Starting OAuth flow" in one boot, 2026-07-22 logit trail).
        // Concurrent callers now share one resolution. Keyed on a window
        // global, not a module local, because the double-init bug that
        // exposed this also loads the module twice — two module instances
        // must still share the guard.
        const w = window as any;
        const inflight: Map<string, Promise<string>> = w.__rmfOAuthInflight ||= new Map();
        const key = canonEmail(email);
        const existing = inflight.get(key);
        if (existing) return existing;
        const p = fetchTokenForEmail(email).finally(() => inflight.delete(key));
        inflight.set(key, p);
        return p;
    };
}

async function fetchTokenForEmail(email: string): Promise<string> {
    // Check cached token first
    const cached = await getCachedToken(email);
    if (cached?.access_token) {
        const expiresAt = cached.expires_at || 0;
        const bufferMs = 5 * 60 * 1000; // 5 min buffer
        if (Date.now() < expiresAt - bufferMs) {
            return cached.access_token;
        }
        // Try refresh
        if (cached.refresh_token) {
            try {
                console.log(`[oauth] Refreshing token for ${email}`);
                const refreshed = await refreshAccessToken(cached.refresh_token);
                const token = {
                    access_token: refreshed.access_token,
                    refresh_token: cached.refresh_token,
                    expires_at: Date.now() + refreshed.expires_in * 1000,
                };
                await setCachedToken(email, token);
                return token.access_token;
            } catch (e: any) {
                console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
            }
        }
    }

    // No valid token — start browser OAuth flow
    const bridge = (window as any)._nativeBridge;
    if (!bridge?.app?.startOAuth) {
        throw new Error("No native OAuth bridge");
    }

    const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
        client_id: OAUTH_CLIENT.clientId,
        redirect_uri: OAUTH_CLIENT.redirectUri,
        response_type: "code",
        scope: OAUTH_SCOPES,
        access_type: "offline",
        prompt: "consent",
        login_hint: email,
    }).toString();

    console.log(`[oauth] Starting browser consent for ${email}`);
    narrate(`Signing into ${email}…`);
    const code = await bridge.app.startOAuth(authUrl);
    const tokens = await exchangeCodeForTokens(code);
    const token = {
        access_token: tokens.access_token,
        refresh_token: tokens.refresh_token,
        expires_at: Date.now() + tokens.expires_in * 1000,
    };
    await setCachedToken(email, token);
    console.log(`[oauth] Token obtained for ${email}`);
    return token.access_token;
}

// ── GDrive folder lookup ──

async function registerDeviceInGDrive(
    tokenProvider: () => Promise<string>,
    folderId: string,
    accountIds: string[]
): Promise<void> {
    try {
        const token = await tokenProvider();
        // Use persistent Android device ID (survives factory reset & app data clear)
        const bridge = (window as any)._nativeBridge;
        let deviceId = "android-unknown";
        if (bridge?.app?.getAndroidId) {
            try {
                const androidId = await bridge.app.getAndroidId();
                deviceId = `android-${androidId.substring(0, 12)}`;
            } catch {
                deviceId = `android-${getDeviceId().substring(0, 8)}`;
            }
        }
        // Read existing clients.jsonc
        const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`);
        const listRes = await fetch(
            `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`,
            { headers: { "Authorization": `Bearer ${token}` } }
        );
        if (!listRes.ok) {
            console.warn(`[gdrive] clients.jsonc list failed: ${listRes.status}`);
            return;
        }
        const listData = await listRes.json() as any;
        const fileId = listData.files?.[0]?.id;
        let clients: any = {};
        if (fileId) {
            const readRes = await fetch(
                `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
                { headers: { "Authorization": `Bearer ${token}` } }
            );
            if (readRes.ok) {
                try { clients = JSON.parse(await readRes.text()); } catch { /* */ }
            }
        }
        // Remove stale android-* entries (from old random-UUID approach) — keep only this device
        for (const key of Object.keys(clients)) {
            if (key.startsWith("android-") && key !== deviceId) {
                delete clients[key];
            }
        }
        clients[deviceId] = {
            hostname: deviceId,
            platform: "android",
            accounts: accountIds,
            lastSeen: new Date().toISOString(),
            version: (window as any)._nativeBridge?.info?.version || "?",
        };
        const content = JSON.stringify(clients, null, 2);
        if (fileId) {
            const upRes = await fetch(
                `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
                {
                    method: "PATCH",
                    headers: {
                        "Authorization": `Bearer ${token}`,
                        "Content-Type": "application/json",
                    },
                    body: content,
                }
            );
            if (upRes.ok) console.log(`[android] Registered device in clients.jsonc as ${deviceId}`);
            else console.warn(`[gdrive] clients.jsonc update failed: ${upRes.status}`);
        }
    } catch (e: any) {
        console.warn(`[android] Device registration failed: ${e.message}`);
    }
}

// ── Google Contacts sync (People API, incremental) ──
//
// Mirrors mailx-imap's syncGoogleContactsImpl. Persists nextSyncToken in
// localStorage per account so subsequent calls only fetch deltas. Web DB
// has no kv table, so we use localStorage (one row per device — that's
// what we want; sync tokens are per-account per-device).
//
// In-flight guard prevents the periodic timer from stacking calls when a
// sync is still running.

const contactsSyncing = new Map<string, Promise<number>>();

function getContactsSyncToken(accountId: string): string {
    try { return localStorage.getItem(`mailx-contacts-synctoken-${accountId}`) || ""; }
    catch { return ""; }
}

function setContactsSyncToken(accountId: string, token: string | null): void {
    try {
        const key = `mailx-contacts-synctoken-${accountId}`;
        if (token === null) localStorage.removeItem(key);
        else localStorage.setItem(key, token);
    } catch { /* private mode */ }
}

/** True when the account is Google-backed (Gmail address or Gmail IMAP host).
 *  Only Google accounts have a People API + OAuth token; running the contacts
 *  sync for a plain IMAP account is meaningless and triggers a bogus OAuth
 *  consent prompt for an address Google doesn't own. */
function isGoogleAccount(account: { email?: string; imap?: { host?: string } }): boolean {
    return !!(account.imap?.host?.toLowerCase().includes("gmail")
        || account.email?.toLowerCase().endsWith("@gmail.com"));
}

async function syncGoogleContactsForAccount(
    db: WebMailxDB,
    accountId: string,
    tokenProvider: () => Promise<string>,
): Promise<number> {
    const inFlight = contactsSyncing.get(accountId);
    if (inFlight) return inFlight;
    const promise = (async (): Promise<number> => {
        const token = await tokenProvider();
        if (!token) return 0;

        let changed = 0;
        let nextPageToken: string | undefined;
        let syncToken = getContactsSyncToken(accountId);

        try {
            do {
                const params = new URLSearchParams({
                    // Full contact card — matches the desktop sync in
                    // mailx-imap so mobile doesn't silently hold less.
                    personFields: "names,emailAddresses,organizations,photos,phoneNumbers,addresses,urls,biographies,birthdays",
                    pageSize: "100",
                });
                if (nextPageToken) params.set("pageToken", nextPageToken);
                if (syncToken) params.set("syncToken", syncToken);
                else params.set("requestSyncToken", "true");

                const url = `https://people.googleapis.com/v1/people/me/connections?${params}`;
                const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });

                if (!res.ok) {
                    const err = await res.text().catch(() => "");
                    // Expired sync token: People API returns HTTP 400
                    // FAILED_PRECONDITION / EXPIRED_SYNC_TOKEN (older behavior
                    // was 410 — match both via the body). Drop the token and
                    // restart as a full sync.
                    if (syncToken && (res.status === 410 || /EXPIRED_SYNC_TOKEN|sync token is expired/i.test(err))) {
                        console.log(`[contacts] ${accountId}: sync token expired — clearing for full resync`);
                        setContactsSyncToken(accountId, null);
                        syncToken = "";
                        nextPageToken = undefined;
                        changed = 0;
                        continue;
                    }
                    console.error(`[contacts] API error for ${accountId}: ${res.status} ${err}`);
                    return changed;
                }

                const data = await res.json() as any;
                if (data.connections) {
                    for (const person of data.connections) {
                        const googleId = person.resourceName || "";
                        if (person.metadata?.deleted) {
                            const removed = db.deleteContactByGoogleId(googleId);
                            if (removed > 0) changed += removed;
                            continue;
                        }
                        const name = person.names?.[0]?.displayName || "";
                        const org = person.organizations?.[0]?.name || "";
                        // Google returns typed arrays, primary first; keep
                        // the first of each. Birthday year is optional, so
                        // an unknown one is written as --MM-DD.
                        const bd = person.birthdays?.[0]?.date;
                        const pad = (n: number) => String(n).padStart(2, "0");
                        const card = {
                            title: person.organizations?.[0]?.title || "",
                            phone: person.phoneNumbers?.[0]?.value || "",
                            address: person.addresses?.[0]?.formattedValue || "",
                            website: person.urls?.[0]?.value || "",
                            notes: person.biographies?.[0]?.value || "",
                            birthday: bd?.month && bd?.day ? `${bd.year ? bd.year : "-"}-${pad(bd.month)}-${pad(bd.day)}` : "",
                        };
                        for (const e of person.emailAddresses || []) {
                            const email = e.value?.toLowerCase();
                            if (!email) continue;
                            const existing = db.searchContacts(email, 1);
                            const wasNew = !(existing.length > 0 && existing[0].email === email);
                            db.recordSentAddress(name, email);
                            db.setContactGoogleId(email, googleId, org, card);
                            if (wasNew) changed++;
                        }
                    }
                }
                nextPageToken = data.nextPageToken;
                if (data.nextSyncToken) {
                    setContactsSyncToken(accountId, data.nextSyncToken);
                    syncToken = data.nextSyncToken;
                }
            } while (nextPageToken);

            console.log(`[contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`);
        } catch (e: any) {
            console.error(`[contacts] Sync error for ${accountId}: ${e.message}`);
        }
        return changed;
    })().finally(() => contactsSyncing.delete(accountId));
    contactsSyncing.set(accountId, promise);
    return promise;
}

async function findGDriveMailxFolder(tokenProvider: () => Promise<string>): Promise<{ id: string; name: string; path: string; ownerEmail?: string } | null> {
    const token = await tokenProvider();
    const headers = { "Authorization": `Bearer ${token}` };
    // Two-step lookup: My Drive root → home/ → .rmfmail
    // Direct My-Drive-root scope finds shared folders too (spouse's .rmfmail
    // appears at root of "Shared with me"). Path scope avoids that AND
    // matches the user's actual layout: ~/home/.rmfmail.
    const homeQ = encodeURIComponent("name='home' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false");
    const homeRes = await fetch(
        `https://www.googleapis.com/drive/v3/files?q=${homeQ}&fields=files(id,name)&spaces=drive`,
        { headers }
    );
    if (!homeRes.ok) {
        console.warn(`[gdrive] home folder search failed: ${homeRes.status}`);
        return null;
    }
    const homeData = await homeRes.json() as any;
    const home = homeData.files?.[0];
    if (!home?.id) {
        console.warn("[gdrive] 'home' folder not found at My Drive root");
        return null;
    }
    const q = encodeURIComponent(`name='.rmfmail' and mimeType='application/vnd.google-apps.folder' and '${home.id}' in parents and trashed=false`);
    const res = await fetch(
        `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name,owners(emailAddress))&spaces=drive`,
        { headers }
    );
    if (!res.ok) {
        console.warn(`[gdrive] .rmfmail search failed: ${res.status}`);
        return null;
    }
    const data = await res.json() as any;
    const folder = data.files?.[0];
    if (!folder?.id) return null;
    const homeName = home.name || "home";
    const folderName = folder.name || ".rmfmail";
    return {
        id: folder.id,
        name: folderName,
        path: `My Drive/${homeName}/${folderName}`,
        ownerEmail: folder.owners?.[0]?.emailAddress,
    };
}

// ── Initialization ──

async function waitForNativeBridge(timeoutMs: number = 5000): Promise<void> {
    if ((window as any)._nativeBridge) return;
    return new Promise((resolve) => {
        const start = Date.now();
        const check = () => {
            if ((window as any)._nativeBridge || Date.now() - start > timeoutMs) {
                resolve();
            } else {
                setTimeout(check, 50);
            }
        };
        // Also listen for the event C# dispatches after bridge injection
        window.addEventListener("nativebridgeready", () => resolve(), { once: true });
        check();
    });
}

export function initAndroid(): Promise<void> {
    // C158: idempotency guard. The 2026-07-22 fold/unfold logit trail showed
    // ONE WebView reload executing the boot module TWICE — duplicate "bridge
    // installed", duplicate GDrive lookups, tripled OAuth launches. The
    // guard lives on window (not a module local) so it holds even when the
    // module itself is instantiated twice (bundle + package-path specifiers
    // resolve to distinct module instances).
    const w = window as any;
    if (w.__rmfInitAndroid) {
        console.warn("[android] initAndroid called again — duplicate suppressed (C158)");
        vlog("C158: duplicate initAndroid call suppressed");
        return w.__rmfInitAndroid;
    }
    return w.__rmfInitAndroid = initAndroidOnce();
}

async function initAndroidOnce(): Promise<void> {
    console.log("[android] Initializing mailx (main-thread mode)...");
    // First words on screen. A fresh install has no local accounts, so the
    // UI's zero-account path used to paint an empty pane while the Drive
    // pull ran — "blank screen and 'syncing' at the bottom" (Bob
    // 2026-08-01). Every stage below narrates; this is the one that lands
    // before there is anything at all to show.
    narrate("Initializing…");

    // Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI,
    // and only sql.js is CPU-bound enough to maybe warrant a Worker later.
    // Worker path was reverted 2026-04-14 (stuck at "Initializing..." on Android).
    await waitForNativeBridge();
    if ((window as any)._nativeBridge && !(window as any).msgapi) {
        (window as any).msgapi = (window as any)._nativeBridge;
    }

    db = new WebMailxDB("mailx");
    await db.waitReady();
    bodyStore = new WebMessageStore();
    syncManager = new AndroidSyncManager(db, bodyStore);
    service = new WebMailxService(db, bodyStore, syncManager);

    let accounts = await loadAccounts();
    console.log(`[android] ${accounts.length} account(s) found`);
    narrate(accounts.length
        ? `Opening ${accounts.map(a => a.email || a.id).join(", ")}…`
        : "No local accounts yet — checking Google Drive…");

    // Find a Gmail account to use as the GDrive token provider
    let gmailTokenProvider: (() => Promise<string>) | null = null;
    for (const account of accounts) {
        if (!account.enabled) continue;
        const domain = account.email?.split("@")[1]?.toLowerCase() || "";
        if (domain === "gmail.com" || domain === "googlemail.com") {
            const tp = createNativeTokenProvider(account.email);
            syncManager.setTokenProvider(account.id, tp);
            if (!gmailTokenProvider) gmailTokenProvider = tp;
        }
        await syncManager.addAccount(account);
    }

    // Install the mailxapi bridge + drain pending queues IMMEDIATELY using
    // the local-cache account list. UI shouldn't wait on GDrive (which can
    // be slow on cold network) before becoming actionable. GDrive
    // reconciliation (below) runs in the background and re-registers fresh
    // accounts when it returns.
    installBridge();
    for (const account of accounts) {
        if (!account.enabled) continue;
        syncManager.processSendQueue(account.id)
            .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
        syncManager.processSyncActions(account.id)
            .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
    }
    // First sync from local accounts on a tiny delay so the UI gets to paint.
    setTimeout(() => {
        syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
    }, 1000);

    // GDrive reconciliation runs in the background — accounts.jsonc on the
    // shared cloud may have been edited from another device, so we re-pull
    // and re-register if it differs from the cached copy. The user can
    // already see and use mail by the time this resolves.
    if (gmailTokenProvider) {
        const tp = gmailTokenProvider;
        // Retry loop, not one-shot. On a brand-new install this runs seconds
        // after the OAuth redirect; the native token fetch can hang or fail
        // while the exchange is still settling, and the old one-shot then
        // left the device with ONLY the locally-created account until the
        // next full app restart (Bob 2026-07-22: "only showing me gmail —
        // not my other mailboxes"). Each attempt logs breadcrumbs (vlog →
        // logit) so a silent stall is visible in the server log.
        const RECONCILE_RETRY_MS = 30_000;
        const RECONCILE_MAX_TRIES = 5;
        let reconcileTry = 0;
        const reconcile = async (): Promise<void> => {
        reconcileTry++;
        setGDriveTokenProvider(tp);
        try {
            narrate(`Fetching settings from Google Drive… (try ${reconcileTry})`);
            vlog(`gdrive-reconcile try ${reconcileTry}: token fetch`);
            // Watchdog the token fetch — it crosses into native code and has
            // hung on cold boots; without a timeout the whole reconcile just
            // vanishes with no log line.
            await Promise.race([
                tp(),
                new Promise((_, rej) => setTimeout(() => rej(new Error("token fetch timed out (20s)")), 20_000)),
            ]);
            vlog(`gdrive-reconcile try ${reconcileTry}: folder lookup`);
            const folder = await findGDriveMailxFolder(tp);
            if (!folder) {
                emitEvent({
                    type: "fatal",
                    key: "gdrive-folder-missing",
                    message: "GDrive folder '.rmfmail' not found — app cannot start. Create it or sign in with the correct Google account.",
                });
            } else {
                const folderId = folder.id;
                setGDriveFolderId(folderId, folder.name, folder.ownerEmail, folder.path);
                console.log(`[android] GDrive ${folder.path} folder: ${folderId} (owner=${folder.ownerEmail || "?"})`);
                // DEBUG: list all files in the folder
                try {
                    const tk = await tp();
                    const lr = await fetch(
                        `https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`,
                        { headers: { "Authorization": `Bearer ${tk}` } }
                    );
                    if (lr.ok) {
                        const ld = await lr.json() as any;
                        const names = (ld.files || []).map((f: any) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
                        console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
                    } else {
                        console.warn(`[android] List folder failed: ${lr.status}`);
                    }
                } catch (e: any) {
                    console.warn(`[android] List debug: ${e.message}`);
                }
                // Shared allowlist/preferences: pull NOW that Drive auth is
                // live, instead of waiting for the first message read to
                // trigger the lazy revalidate (which is itself served stale).
                primeSharedConfigs()
                    .catch(e => console.warn(`[android] prime shared configs: ${e?.message || e}`));
                // Read accounts directly from GDrive (bypass IndexedDB cache)
                narrate("Loading accounts from Google Drive…");
                const gdriveAccounts = await loadAccountsFromCloud();
                console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
                if (gdriveAccounts.length > 0) {
                    // Use canonical GDrive accounts (upsert handles overwrites)
                    accounts = gdriveAccounts;
                    const enabled = accounts.filter(a => a.enabled);
                    narrate(`Found ${enabled.length} account(s): ${enabled.map(a => a.email || a.id).join(", ")}`);
                    let setUp = 0;
                    for (const account of accounts) {
                        vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
                        if (!account.enabled) {
                            vlog(`init: ${account.id} disabled, skipping`);
                            continue;
                        }
                        narrate(`Setting up ${account.email || account.id}… (${++setUp}/${enabled.length})`);
                        const domain = account.email?.split("@")[1]?.toLowerCase() || "";
                        if (domain === "gmail.com" || domain === "googlemail.com") {
                            syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
                        }
                        await syncManager.addAccount(account);
                    }
                    console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
                    // C157: sync NOW. The boot-time syncAll ran before these
                    // providers existed (fresh install: zero local accounts),
                    // so without this kick the folder tree stayed empty until
                    // the next 60 s poll tick — on top of OAuth consent time,
                    // that read as "frozen for minutes". syncAll's inbox
                    // phase lists folders first, so the tree paints in
                    // seconds; message counts stream in behind.
                    narrate(`Syncing ${enabled.map(a => a.email || a.id).join(", ")}…`);
                    syncManager.syncAll().catch(e => console.error(`[android] post-reconcile sync: ${e.message}`));
                }
                // Register this Android device in clients.jsonc
                await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
            }
        } catch (e: any) {
            vlog(`gdrive-reconcile try ${reconcileTry} FAILED: ${e.message}`);
            if (reconcileTry < RECONCILE_MAX_TRIES) {
                console.warn(`[android] GDrive reconcile failed (${e.message}) — retrying in ${RECONCILE_RETRY_MS / 1000}s`);
                setTimeout(() => { void reconcile(); }, RECONCILE_RETRY_MS);
                return;
            }
            emitEvent({
                type: "fatal",
                key: "gdrive-access-failed",
                message: `GDrive access failed after ${RECONCILE_MAX_TRIES} attempts: ${e.message}. Mail from this device's local accounts still works; restart the app to retry cloud settings.`,
            });
        }
        };
        void reconcile();
    }

    // Startup contacts load. TWO sources, both wanted:
    //  1. The shared GDrive contacts.jsonc — desktop's full union (everything
    //     it discovered from its mailbox + Google contacts + preferred). This
    //     is how Android gets contacts it never saw in its own partial corpus.
    //  2. Google People API — keeps Google-side contacts current. Runs for
    //     Google accounts ONLY; an IMAP account has no People API and would
    //     pop a bogus OAuth consent.
    service.loadContactsConfig()
        .catch((e: any) => console.error(`[android] startup contacts.jsonc load: ${e?.message || e}`));
    for (const account of db.getAccounts()) {
        if (!account.email || !isGoogleAccount(account)) continue;
        const tp = createNativeTokenProvider(account.email);
        syncGoogleContactsForAccount(db, account.id, tp)
            .catch(e => console.error(`[android] startup contacts sync ${account.id}: ${e.message}`));
    }

    // Periodic re-sync (no IDLE on Android — WebView can't hold a long-lived
    // socket reliably, and Gmail API doesn't expose an equivalent — so we
    // poll). 60 s is the sweet spot Bob 2026-05-25: 2 min "wasn't picking
    // up new posts in a timely fashion". A 30 s tick is fine power-wise on
    // Gmail (one cheap REST call with a since-modseq filter), but 60 s
    // keeps a margin for shared-IP rate limits. Pull-to-refresh remains
    // the explicit "I want it now" gesture (PTR_THRESHOLD in app.ts).
    const SYNC_INTERVAL_MS = 60 * 1000;
    let contactsSyncTickCounter = 0;
    setInterval(() => {
        console.log("[sync] periodic poll");
        vlog("periodic sync poll");
        // Retry any failed/stranded sends every poll tick
        for (const account of db.getAccounts()) {
            syncManager.processSendQueue(account.id)
                .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
            syncManager.processSyncActions(account.id)
                .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
        }
        syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
        // Shared contacts.jsonc freshness: one Drive metadata request,
        // self-throttled inside the service (~3 min); full reload only when
        // modifiedTime changed. This is how a denylist/preferred edit made
        // on desktop (or directly on Drive) reaches a running phone —
        // startup-only loading left it stale (Bob 2026-07-07).
        service.maybeRefreshContactsConfig()
            .catch(e => console.error(`[android] contacts.jsonc freshness check: ${e?.message || e}`));
        // Same for allowlist/preferences (self-throttled to 5 min inside).
        // Desktop polls its shared configs every 3 min; without this the
        // phone only refreshed at launch/resume, so a site approved on the
        // PC could sit unseen for as long as the app stayed open.
        primeSharedConfigs(false)
            .catch(e => console.error(`[android] shared config freshness check: ${e?.message || e}`));
        // Contacts: once every 8 ticks (~16 min). Incremental, so cheap —
        // a clean tick is one HTTP round-trip with empty connections list.
        if (++contactsSyncTickCounter % 8 === 0) {
            for (const account of db.getAccounts()) {
                if (!account.email || !isGoogleAccount(account)) continue;
                const tp = createNativeTokenProvider(account.email);
                syncGoogleContactsForAccount(db, account.id, tp)
                    .catch(e => console.error(`[android] periodic contacts sync ${account.id}: ${e.message}`));
            }
        }
    }, SYNC_INTERVAL_MS);

    // Immediate sync + send-queue drain when app comes back to foreground
    // (e.g. user switches from another app). Without the send-queue drain,
    // a message queued while offline waits up to 2 minutes after resume
    // before retrying — long enough for the user to think it's stuck.
    document.addEventListener("visibilitychange", () => {
        if (document.visibilityState === "visible") {
            console.log("[sync] resume poll");
            for (const account of db.getAccounts()) {
                syncManager.processSendQueue(account.id)
                    .catch(e => console.error(`[android] resume send-drain ${account.id}: ${e.message}`));
            }
            syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
            service.maybeRefreshContactsConfig()
                .catch(e => console.error(`[android] resume contacts.jsonc check: ${e?.message || e}`));
            // Same freshness treatment for allowlist/preferences — throttled
            // (5 min) so a quick app-switch costs nothing.
            primeSharedConfigs(false)
                .catch(e => console.error(`[android] resume shared config check: ${e?.message || e}`));
        }
    });

    console.log("[android] Initialization complete");
    emitEvent({ type: "connected" });
}

export async function resetStore(): Promise<void> {
    await service.resetStore();
    await clearSettings();
    console.log("[android] Store reset");
}

// ── mailxapi Bridge ──

function installBridge(): void {
    const api = {
        isApp: true,
        platform: "android",
        // Link / contact / calendar handoff to native apps. On Android THIS
        // object is window.mailxapi — NOT client/lib/mailxapi.js — so the
        // openExternal there never ran on the phone, and this object had no
        // openExternal at all, so app.ts fell through to window.open() (a no-op
        // in the WebView). That's why preview links / the LinkedIn "View message"
        // button did nothing even after the mailxapi.js fixes (Bob 2026-06-18,
        // proven by the [android-link] trace stopping at "parent got linkClick").
        // We're definitively on the Android host here, so just navigate the top
        // frame to the mailxapi-intent:// scheme that MainPage.xaml.cs intercepts.
        openExternal: (url: string) => {
            if (!url) return;
            try {
                console.log("[android-link] android-api openExternal → intent " + url);
                window.location.href = "mailxapi-intent://open/" + encodeURIComponent(url);
            } catch (e) { console.error("[android-link] openExternal failed", e); }
        },
        openContact: (email: string) => {
            if (!email) return;
            try { window.location.href = "mailxapi-intent://contact/" + encodeURIComponent(email); }
            catch (e) { console.error("openContact failed", e); }
        },
        openCalendarEvent: (htmlLink: string) => {
            if (!htmlLink) return;
            try { window.location.href = "mailxapi-intent://calendar/" + encodeURIComponent(htmlLink); }
            catch (e) { console.error("openCalendarEvent failed", e); }
        },
        getAccounts: () => service.getAccounts(),
        getFolders: (accountId: string) => service.getFolders(accountId),
        // Full arg pass-through — this adapter used to stop at pageSize, so
        // sort toggles and the per-folder search box silently did nothing on
        // the phone (service.getMessages supports all seven).
        getMessages: (accountId: string, folderId: number, page: number, pageSize: number, sort?: string, sortDir?: string, search?: string) =>
            service.getMessages(accountId, folderId, page, pageSize, sort, sortDir, search),
        getUnifiedInbox: (page: number, pageSize: number) => service.getUnifiedInbox(page, pageSize),
        getMessage: (accountId: string, uid: number, allowRemote: boolean, folderId?: number) =>
            service.getMessage(accountId, uid, allowRemote, folderId),
        updateFlags: async (accountId: string, uid: number, flags: string[]) => {
            await service.updateFlags(accountId, uid, flags); return { ok: true };
        },
        deleteMessage: async (accountId: string, uid: number) => {
            await service.deleteMessage(accountId, uid); return { ok: true };
        },
        deleteMessages: async (accountId: string, uids: number[]) => {
            await service.deleteMessages(accountId, uids); return { ok: true, count: uids.length };
        },
        undeleteMessage: async (accountId: string, uid: number, folderId: number) => {
            await service.undeleteMessage(accountId, uid, folderId); return { ok: true };
        },
        moveMessage: async (accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) => {
            await service.moveMessage(accountId, uid, targetFolderId, targetAccountId); return { ok: true };
        },
        moveMessages: async (accountId: string, uids: number[], targetFolderId: number) => {
            await service.moveMessages(accountId, uids, targetFolderId); return { ok: true, count: uids.length };
        },
        sendMessage: async (msg: any) => { await service.send(msg); return { ok: true }; },
        saveDraft: (p: any) => service.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId, p.bcc, p.from, p.attachments),
        deleteDraft: async (accountId: string, draftUid: number) => {
            await service.deleteDraft(accountId, draftUid); return { ok: true };
        },
        // No IMAP-side draft probe on Android — compose keeps its local copy.
        checkDraftNewer: async (): Promise<null> => null,
        // Full arg pass-through — dropping scope/accountId/folderId made every
        // Android search behave as all-mailboxes regardless of the UI scope.
        searchMessages: (query: string, page: number, pageSize: number, scope?: string, accountId?: string, folderId?: number, includeTrashSpam?: boolean) =>
            service.searchMessages(query, page, pageSize, scope, accountId, folderId, includeTrashSpam),
        searchContacts: (query: string) => service.searchContacts(query),
        listContacts: (query: string, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize),
        upsertContact: (name: string, email: string) => service.upsertContact(name || "", email),
        deleteContact: (email: string) => service.deleteContact(email),
        addContact: (name: string, email: string) => service.addContact(name || "", email),
        hasCcHistoryTo: (email: string) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
        hasBccHistoryTo: (email: string) => ({ hasBcc: service.hasBccHistoryTo?.(email) ?? false }),
        // ⊘ / ★ in compose autocomplete. The service has implemented these
        // since the contacts-config share (web-service.ts), but they were
        // never exposed here, so the compose iframe's IPC relay failed with
        // `parent bridge has no method "addToDenylist"` (Bob 2026-07-07
        // screenshot). Signatures mirror client/lib/mailxapi.js — positional
        // args in, object built here.
        addPreferredContact: async (name: string, email: string, source?: string, organization?: string) => {
            await service.addPreferredContact({ name, email, source, organization });
            return { ok: true };
        },
        addToDenylist: async (email: string) => {
            await service.addToDenylist(email);
            return { ok: true };
        },
        loadContactsConfig: () => service.loadContactsConfig(),
        // Cross-device reminder dismissed/snoozed state (reminders.jsonc on
        // Drive) — alarm poller pushes/pulls through these.
        getReminderState: () => service.getReminderState(),
        mergeReminderState: (patch: any) => service.mergeReminderState(patch),
        syncAll: async () => { await service.syncAll(); return { ok: true }; },
        syncAccount: async (accountId: string) => { await service.syncAccount(accountId); return { ok: true }; },
        getSyncPending: () => service.getSyncPending(),
        getPrimaryAccount: (feature?: string) => {
            // Resolve primary account for a feature (calendar/tasks/contacts):
            // per-feature flag → catch-all `primary` → first account.
            const all = db.getAccountConfigs().map(r => {
                try { return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) }; }
                catch { return { id: r.id, name: r.name, email: r.email }; }
            });
            if (feature) {
                const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
                const perFeature = all.find((a: any) => a[key]);
                if (perFeature) return perFeature;
            }
            return all.find((a: any) => a.primary) || all[0] || null;
        },
        reauthenticate: async (accountId: string) => ({ ok: await service.reauthenticate(accountId) }),
        markFolderRead: (_accountId: string, folderId: number) => { service.markFolderRead(folderId); return { ok: true }; },
        createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
        renameFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
        deleteFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
        emptyFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
        allowRemoteContent: async (type: string, value: string) => {
            await service.allowRemoteContent(type as any, value); return { ok: true };
        },
        flagSenderOrDomain: async (type: string, value: string) => {
            return await service.flagSenderOrDomain(type as any, value);
        },
        getPriorityLists: () => service.getPriorityLists(),
        setPrioritySender: async (email: string, value: boolean, name?: string) => {
            await service.setPrioritySender(email, !!value, name); return { ok: true };
        },
        setPriorityDomain: async (domain: string, value: boolean) => {
            await service.setPriorityDomain(domain, !!value); return { ok: true };
        },
        getSettings: () => service.getSettings(),
        saveSettingsData: async (data: any) => { await service.saveSettingsData(data); return { ok: true }; },
        getVersion: async () => {
            const settings = await service.getSettings();
            const nativeVersion = (window as any)._nativeBridge?.info?.version || "?";
            return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
        },
        getAutocompleteSettings: () => service.getAutocompleteSettings(),
        saveAutocompleteSettings: async (settings: any) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
        getDeviceAccounts: async () => {
            const bridge = (window as any)._nativeBridge;
            if (bridge?.app?.getDeviceAccounts) {
                return bridge.app.getDeviceAccounts();
            }
            return [];
        },
        setupAccount: async (name: string, email: string, _password: string) => {
            try {
                if (!email || !email.includes("@")) {
                    return { ok: false, error: "Email address required" };
                }
                const domain = email.split("@")[1].toLowerCase();
                const id = domain.split(".")[0] || "account";
                const account: AccountConfig = {
                    id,
                    name: name || email.split("@")[0],
                    email,
                    enabled: true,
                    imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2" as const, user: email },
                    smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2" as const, user: email },
                };
                // Apply known provider defaults
                if (domain === "gmail.com" || domain === "googlemail.com") {
                    account.label = "Gmail";
                    account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email };
                    account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email };
                }
                const existing = await loadAccounts();
                if (existing.some(a => a.email === email)) {
                    return { ok: true, message: "Account already exists" };
                }
                existing.push(account);
                await saveAccounts(existing);
                // Set up token provider before adding account
                const setupDomain = email.split("@")[1].toLowerCase();
                if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") {
                    syncManager.setTokenProvider(account.id, createNativeTokenProvider(email));
                }
                await syncManager.addAccount(account);
                db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
                console.log(`[android] Account added: ${email}`);
                // C157: the "Syncing..." message below was aspirational — no
                // sync was actually kicked, so the new account showed nothing
                // until the next 60 s poll tick.
                syncManager.syncAll().catch(e => console.error(`[android] post-setup sync: ${e.message}`));
                return { ok: true, message: `Added ${email}. Syncing...` };
            } catch (e: any) {
                return { ok: false, error: e.message };
            }
        },
        repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
        resetStore: () => resetStore(),
        resetAll: async () => {
            const bridge = (window as any)._nativeBridge;
            if (bridge?.app?.resetAll) {
                await bridge.app.resetAll();
            } else {
                await resetStore();
                location.reload();
            }
        },
        restart: () => { location.reload(); },
        onEvent: (handler: (event: any) => void) => { eventHandlers.push(handler); },

        // ── Wire-shape adapters (C154) ──
        // These service methods return the raw value, but api-client (and the
        // desktop jsonrpc dispatcher) speak a wrapped shape on the wire —
        // adapt here so both platforms answer identically.
        readJsoncFile: async (name: string) => ({ content: await service.readJsoncFile(name) }),
        writeJsoncFile: async (name: string, content: string) => {
            await service.writeJsoncFile(name, content); return { ok: true };
        },
        formatJsonc: async (content: string) => ({ content: await service.formatJsonc(content) }),
        readConfigHelp: async (name: string) => ({ content: await service.readConfigHelp(name) }),
        drainStoreSync: async () => { await service.drainStoreSync(); return { ok: true }; },
        syncFolderNow: async (accountId: string, folderId: number) => {
            await service.syncFolderNow(accountId, folderId); return { ok: true };
        },
    };

    // ── Contract backstop (C154) ──
    // Every remaining MailxApi method the service implements (real impl or
    // explicit notImpl stub) gets a direct pass-through. The contract's
    // positional signatures match api-client's call shapes, so no per-method
    // adapter is needed — and a future service method can never again
    // silently miss the phone and fail as `parent bridge has no method "X"`
    // (the ⊘ denylist bug, Bob 2026-07-07). Methods whose wire shape differs
    // from the service return are adapted explicitly above and win here.
    const svcAny = service as any;
    const svcNames = new Set<string>([
        ...Object.getOwnPropertyNames(Object.getPrototypeOf(service)),
        ...Object.getOwnPropertyNames(service),
    ]);
    let backfilled = 0;
    for (const name of svcNames) {
        if (name === "constructor" || name === "notImpl") continue;
        if (typeof svcAny[name] !== "function") continue;
        if (name in api) continue;
        (api as any)[name] = (...args: any[]) => svcAny[name](...args);
        backfilled++;
    }

    (window as any).mailxapi = api;
    window.dispatchEvent(new CustomEvent("mailxapiready"));
    console.log(`[android] mailxapi bridge installed (${backfilled} contract methods backfilled)`);
}
