/**
 * Reconciler — single background loop that drains the SyncQueue, polls
 * for server changes, and emits state events the UI listens for.
 *
 * Local-first contract: the Reconciler is the ONLY code path that touches
 * IMAP / Gmail API on behalf of UI actions. UI handlers commit locally
 * and enqueue; the Reconciler picks up and mirrors. Reads from the UI
 * never await the Reconciler.
 *
 * What this owns today:
 * - body-fetch lane: drains `SyncQueue.nextBodyFetch()`, calls
 *   `imapManager.fetchMessageBody`, emits `bodyAvailable` / `bodyFetchError`.
 *   This is the new piece; previously body fetches happened inline on
 *   every getMessage call.
 * - sync-state pill events: emits `syncStateChanged` so the UI can show
 *   "Sync OK / Syncing N items".
 *
 * What it delegates (intentional — no duplication):
 * - message-action drain: ImapManager already runs a 30s actionsInterval
 *   that processes both outbox (processSendActions) and sync_actions
 *   (processSyncActions) per account. Per plan decision #5, outbox stays
 *   under ImapManager; we don't fork that work.
 * - poll loops (per-account quick check, full sync, prefetch, tombstone
 *   prune): all owned by ImapManager's startPeriodicSync. The Reconciler
 *   does NOT shadow them — back-pressure on those timers can be added
 *   later via a single `setBackPressure(level)` hook.
 *
 * Part of docs/local-first-plan.md (step 4).
 */

import type { ImapManager } from "@bobfrankston/mailx-imap";
import type { MailxDB } from "@bobfrankston/mailx-store";
import { storeBus } from "@bobfrankston/mailx-store";
import type { SyncQueue } from "./sync-queue.js";

export interface ReconcilerOptions {
    /** Max concurrent body fetches across all accounts (in addition to
     *  per-account semaphore in ImapManager). Keeps the interactive lane
     *  responsive even when prefetch is hammering. */
    bodyFetchConcurrency?: number;
    /** Periodic emit of syncStateChanged — drives the UI status pill. */
    statusEmitIntervalMs?: number;
    /** How often to fire the prefetch tick. Default 60s. */
    prefetchIntervalMs?: number;
    /** First prefetch fires this soon after start so the "not downloaded"
     *  dots fill in without making the user wait a minute. */
    prefetchInitialDelayMs?: number;
    /** Skip prefetch when the interactive body-fetch lane has at least
     *  this many items pending. Back-pressure keeps clicks responsive. */
    prefetchBackpressureThreshold?: number;
    /** Tombstone prune interval — bookkeeping only, hourly default. */
    tombstonePruneIntervalMs?: number;
    /** Tombstone retention — local-delete records older than this are
     *  removed by the periodic prune. */
    tombstoneRetentionDays?: number;
}

const DEFAULTS: Required<ReconcilerOptions> = {
    bodyFetchConcurrency: 2,
    statusEmitIntervalMs: 5_000,
    prefetchIntervalMs: 60_000,
    prefetchInitialDelayMs: 2_000,
    prefetchBackpressureThreshold: 1,
    tombstonePruneIntervalMs: 3600_000,   // hourly
    tombstoneRetentionDays: 30,
};

export class Reconciler {
    private opts: Required<ReconcilerOptions>;
    private running = false;
    private statusTimer: ReturnType<typeof setInterval> | null = null;
    private prefetchTimer: ReturnType<typeof setInterval> | null = null;
    private tombstoneTimer: ReturnType<typeof setInterval> | null = null;
    private inFlightFetches = 0;
    /** Last status payload — used to suppress no-op syncStateChanged events.
     *  During a heavy multi-folder sync the 5s tick fires a lot of identical
     *  events; the WebView's event handler runs DOM work for each, which
     *  stacks up under load. Diff-only emits keep the channel quiet. */
    private lastStatus = { messageActions: -1, bodyFetches: -1, inFlightFetches: -1 };

    constructor(
        private db: MailxDB,
        private imapManager: ImapManager,
        private queue: SyncQueue,
        opts: ReconcilerOptions = {},
    ) {
        this.opts = { ...DEFAULTS, ...opts };
    }

    start(): void {
        if (this.running) return;
        this.running = true;

        // Status pill: emit current totals every few seconds so the UI
        // can show "Syncing N items" without polling. Diff-only — emit
        // ONLY when something actually changed since the last tick so
        // we don't pipe a constant idle stream through the WebView's
        // event channel during a heavy sync (where the WebView main
        // thread is already busy painting list updates).
        this.statusTimer = setInterval(() => {
            const counts = this.queue.pendingCount();
            const next = { ...counts, inFlightFetches: this.inFlightFetches };
            if (next.messageActions === this.lastStatus.messageActions
                && next.bodyFetches === this.lastStatus.bodyFetches
                && next.inFlightFetches === this.lastStatus.inFlightFetches) {
                return;
            }
            this.lastStatus = next;
            this.imapManager.emit("syncStateChanged", next);
        }, this.opts.statusEmitIntervalMs);

        // Periodic prefetch tick — owned here (not in ImapManager) so it
        // can back off when the interactive body-fetch lane has work.
        // First tick fires soon after startup so "not downloaded" dots
        // start filling in promptly.
        const prefetchTick = (): void => this.runPrefetchTick();
        setTimeout(prefetchTick, this.opts.prefetchInitialDelayMs);
        this.prefetchTimer = setInterval(prefetchTick, this.opts.prefetchIntervalMs);

        // Tombstone prune — bookkeeping only. Trivial cost (one indexed
        // DELETE per hour), kept off the IMAP-touching code path because
        // it has nothing to do with sync.
        const pruneTick = (): void => this.runTombstonePrune();
        setTimeout(pruneTick, 30_000);   // first run after startup settles
        this.tombstoneTimer = setInterval(pruneTick, this.opts.tombstonePruneIntervalMs);

        // Kick the body-fetch loop.
        this.pumpBodyFetches();
    }

    stop(): void {
        this.running = false;
        if (this.statusTimer) { clearInterval(this.statusTimer); this.statusTimer = null; }
        if (this.prefetchTimer) { clearInterval(this.prefetchTimer); this.prefetchTimer = null; }
        if (this.tombstoneTimer) { clearInterval(this.tombstoneTimer); this.tombstoneTimer = null; }
    }

    /** Hourly bookkeeping — drop tombstones older than the retention window
     *  so the deleted-message lookup stays fast. */
    private runTombstonePrune(): void {
        const cutoff = Date.now() - this.opts.tombstoneRetentionDays * 86400_000;
        const n = this.db.pruneTombstones(cutoff);
        if (n > 0) console.log(`  [tombstones] pruned ${n} older than ${this.opts.tombstoneRetentionDays} days`);
    }

    /** Fire prefetchBodies on each account unless the interactive body-fetch
     *  lane has user-clicked work pending — in which case skip this tick.
     *  ImapManager's per-account `prefetchingAccounts` guard de-dupes if
     *  the previous tick is still in flight. */
    private runPrefetchTick(): void {
        if (!this.running) return;
        const counts = this.queue.pendingCount();
        if (counts.bodyFetches >= this.opts.prefetchBackpressureThreshold) {
            // Interactive work pending — let it drain. Next tick reconsiders.
            return;
        }
        for (const acct of this.db.getAccounts()) {
            this.imapManager.prefetchBodies(acct.id).catch(e =>
                console.error(`  [prefetch] ${acct.id}: ${e?.message || e}`)
            );
            // Local-disk reparse runs in the same tick as prefetch — same
            // back-pressure rules apply (interactive lane has priority).
            // Pure local work, no IMAP / Gmail API touch.
            this.imapManager.backfillBodyMeta(acct.id).catch(e =>
                console.error(`  [backfill] ${acct.id}: ${e?.message || e}`)
            );
        }
    }

    /** User-visible "sync now" — runs an immediate poll on all accounts
     *  (or just one) without waiting for the next periodic tick. */
    syncNow(accountId?: string): void {
        const accts = accountId ? [{ id: accountId }] : this.db.getAccounts();
        for (const a of accts) {
            this.imapManager.processSyncActions(a.id).catch(() => { /* */ });
        }
    }

    /** Drain the body-fetch lane up to `bodyFetchConcurrency`. Each
     *  fetch is fire-and-forget; on completion it kicks the pump again
     *  so a queue that grows during work stays drained. */
    private pumpBodyFetches(): void {
        if (!this.running) return;
        while (this.inFlightFetches < this.opts.bodyFetchConcurrency) {
            const next = this.queue.nextBodyFetch();
            if (!next) return;
            this.inFlightFetches++;
            (async () => {
                try {
                    // Wall-clock cap so a wedged fetchMessageBody (TCP half-
                    // open, server stalled mid-FETCH, parser blocked on a
                    // literal that never completes) can't pin inFlightFetches
                    // forever. Without this, one stuck UID drains the
                    // concurrency budget and every subsequent click sits
                    // behind it for the lifetime of the daemon. 90 s is
                    // generous (slow Dovecot reads of a 10 MB body legitimately
                    // approach 60 s) but bounds the wedge — beyond 90 s the
                    // retry path is more likely to recover than waiting on
                    // the same dead socket.
                    const FETCH_TIMEOUT_MS = 90_000;
                    let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
                    // An interactive click forces the fetch past the per-message
                    // prefetch-empty backoff — the user explicitly wants THIS
                    // body now, and a background prefetch miss must not suppress
                    // it (see fetchMessageBody's `force` note, bobma 4967554).
                    const force = next.lane === "interactive";
                    const raw = await Promise.race<Uint8Array | string | null>([
                        this.imapManager.fetchMessageBody(next.accountId, next.folderId, next.uid, force),
                        new Promise<null>((_, rej) => {
                            timeoutHandle = setTimeout(
                                () => rej(new Error(`body fetch timeout after ${FETCH_TIMEOUT_MS / 1000}s`)),
                                FETCH_TIMEOUT_MS,
                            );
                        }),
                    ]);
                    if (timeoutHandle) clearTimeout(timeoutHandle);
                    if (raw) {
                        // Look up uuid for the message topic; the body has just
                        // landed, so the DB row's body_path was updated by
                        // fetchMessageBody and the envelope is fresh.
                        const env: any = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
                        const msgUuid: string | undefined = env?.uuid;
                        storeBus.publish({
                            topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
                            kind: "bodyAvailable",
                            accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
                        });
                    } else {
                        // fetchMessageBody returned null without throwing.
                        // Causes seen in production: Gmail format=raw cap on
                        // big messages (~10MB), UID hash collision picking a
                        // stub, IMAP source extraction got a non-error empty
                        // response. Without an event the viewer hangs on
                        // "preview placeholder" forever — surface as a
                        // (non-transient) bodyFetchError so the message-
                        // viewer's listener can show a clear failure banner
                        // instead of a silent permanent blank.
                        const env: any = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
                        const msgUuid: string | undefined = env?.uuid;
                        storeBus.publish({
                            topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
                            kind: "bodyFetchError",
                            accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
                            error: "body fetch returned no content (message too large for the API or stub)",
                            transient: false,
                        });
                    }
                } catch (err: any) {
                    if (err?.isNotFound) {
                        try {
                            const env: any = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
                            const msgUuid: string | undefined = env?.uuid;
                            this.db.deleteMessage(next.accountId, next.folderId, next.uid, "reconciler: server returned isNotFound", "mailx-service reconciler");
                            this.db.recalcFolderCounts(next.folderId);
                            storeBus.publish({
                                topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
                                kind: "messageRemoved",
                                accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
                            });
                        } catch { /* */ }
                    } else {
                        const msg = err?.message || "body fetch failed";
                        // `IMAP command leaked into response stream` is the
                        // file-store's defensive rejection of a desync'd
                        // body. Treat as transient: the next fetch on a
                        // fresh client will likely produce a clean payload.
                        const transient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT|IMAP command leaked/i.test(msg);
                        // Transient: re-enqueue (budget=3) so the next pump
                        // tick retries. Surface the error only when the
                        // budget is exhausted — otherwise we'd flash banners
                        // for every blip on a slow server.
                        if (transient && this.queue.requeueBodyFetch(next)) {
                            // Backoff so we don't immediately re-hit the same
                            // wedge. Body-fetch lane is in-memory; setTimeout
                            // is fine.
                            const backoffMs = 500 * Math.pow(2, next.attempts);
                            setTimeout(() => this.pumpBodyFetches(), backoffMs);
                        } else {
                            const env: any = this.db.getMessageByUid(next.accountId, next.uid, next.folderId);
                            const msgUuid: string | undefined = env?.uuid;
                            storeBus.publish({
                                topic: msgUuid ? `message:${msgUuid}` : `folder:${next.folderId}`,
                                kind: "bodyFetchError",
                                accountId: next.accountId, folderId: next.folderId, uid: next.uid, msgUuid,
                                error: msg, transient,
                            });
                        }
                    }
                } finally {
                    this.inFlightFetches--;
                    this.pumpBodyFetches();
                }
            })();
        }
    }

    /** External nudge — call after enqueueing a high-priority body fetch. */
    kick(): void {
        this.pumpBodyFetches();
    }
}
