/**
 * SyncQueue — the formal enqueue surface for server-side mirroring of
 * local actions.
 *
 * Local-first contract: every UI write commits to the local store first,
 * then hands off here. Nothing in this file does network I/O; the
 * Reconciler drains the queue.
 *
 * Implementation note: the underlying persistence already exists. Message
 * ops live in `sync_actions` (drained by ImapManager.processSyncActions),
 * non-message ops in `store_sync` (drained by the per-domain workers).
 * This class is the public API surface — call sites switch to it so the
 * architecture's seam is visible, the rules are enforceable, and we can
 * later bolt on body-fetch / draft-push / send-push lanes without
 * touching every call site again.
 *
 * Priority lanes (interactive > sync > prefetch > backfill) are tracked
 * here as ordering hints; the actual scheduler is in the Reconciler.
 *
 * Part of docs/local-first-plan.md (steps 3-4).
 */

import type { MailxDB } from "@bobfrankston/mailx-store";
import type { ImapManager } from "@bobfrankston/mailx-imap";

export type Lane = "interactive" | "sync" | "prefetch" | "backfill";

/** In-memory body-fetch dedupe + ordering. We keep this as a Set rather
 *  than persisted rows because body fetches are idempotent (the file is
 *  named after the (folder, uid) pair) and a re-fetch on next click is
 *  cheap if a process crash drops the queued item. */
interface BodyFetchKey { accountId: string; folderId: number; uid: number; lane: Lane; attempts: number }

export class SyncQueue {
    private bodyFetches = new Map<string, BodyFetchKey>();
    /** Per-draft serialization slot. Autosave fires every ~2 s but an IMAP
     *  search-delete-append cycle takes longer; unserialized pushes
     *  overlapped, each one's "find the stale copy" running before a
     *  sibling's "append" — so neither saw the other's new copy and the
     *  Drafts folder filled with dozens of duplicates (Bob 2026-05-19).
     *  One push in flight per draft; while it runs, only the LATEST queued
     *  raw message is kept (intermediate autosaves are superseded). */
    private draftPushes = new Map<string, {
        inFlight: boolean;
        pending: { rawMessage: string } | null;
        /** UID of the copy WE last appended for this draft. Passed as the next
         *  push's previousDraftUid so dedup is a reliable delete-by-UID rather
         *  than trusting IMAP header-search — Gmail's IMAP can't find a draft
         *  appended seconds ago (indexing lag / limited custom-header search),
         *  so header-only dedup let every ~2-5s checkpoint append a NEW draft
         *  (Bob 2026-07-01: Trash flooded with 76 copies of one 19-min reply). */
        lastUid?: number;
        /** Wall-clock of the last push we actually started. Server pushes are
         *  THROTTLED (trailing edge) — every IMAP checkpoint is an APPEND +
         *  delete-previous, so a 2s cadence churns the Drafts row UID every
         *  save: the list rebinds, the user's selection/preview evaporate
         *  (Bob 2026-07-12 "even if I select it it is not showing"), and
         *  Gmail-style dup floods get more chances. The LOCAL editing/.eml
         *  checkpoint (crash safety) still happens on every save — only the
         *  server mirror is throttled. */
        lastPushAt?: number;
        throttleTimer?: ReturnType<typeof setTimeout> | null;
        /** Set by forgetDraft (send/discard) while a push is IN FLIGHT: the
         *  in-flight APPEND will land AFTER the send-time cleanup already
         *  ran, resurrecting the draft ("sent but still in drafts"). When
         *  cancelled, the push completion deletes its own append instead of
         *  keeping it. */
        cancelled?: boolean;
    }>();
    private static readonly DRAFT_PUSH_MIN_INTERVAL_MS = 30_000;
    /** Per-account debounce timers. Multiple rapid enqueues (3 flag toggles,
     *  bulk move/delete) coalesce into one processSyncActions drain after
     *  DRAIN_DEBOUNCE_MS. Was previously in ImapManager.debounceSyncActions;
     *  moved here because the queue surface is now the source of truth for
     *  "what needs draining" and it's where the rate limit belongs. */
    private drainTimers = new Map<string, ReturnType<typeof setTimeout>>();
    private static readonly DRAIN_DEBOUNCE_MS = 1000;

    constructor(
        private db: MailxDB,
        private imapManager: ImapManager,
    ) {}

    private scheduleDrain(accountId: string): void {
        const existing = this.drainTimers.get(accountId);
        if (existing) clearTimeout(existing);
        this.drainTimers.set(accountId, setTimeout(() => {
            this.drainTimers.delete(accountId);
            this.imapManager.processSyncActions(accountId).catch(() => { /* retried by reconciler */ });
        }, SyncQueue.DRAIN_DEBOUNCE_MS));
    }

    // ── Message-mirror enqueues (commit local DB row first; then call here) ──

    /** Queue a server-side move. The local rows have already been moved in
     *  the DB; the mirror reaches the server on the next reconciler pass. */
    enqueueMove(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void {
        this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
        this.scheduleDrain(accountId);
    }

    /** Queue a server-side flag update (\Seen, \Flagged, \Answered, etc.). */
    enqueueFlag(accountId: string, uid: number, folderId: number, flags: string[]): void {
        this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
        this.scheduleDrain(accountId);
    }

    /** Queue a server-side COPY (message stays in the source folder, a copy
     *  is APPENDed to the target). processSyncActions handles it via
     *  fetch+append. Unlike move, no local row is removed — the source row
     *  stays and the target copy is imported by a target-folder sync. */
    enqueueCopy(accountId: string, uid: number, fromFolderId: number, toFolderId: number): void {
        this.db.queueSyncAction(accountId, "copy", uid, fromFolderId, { targetFolderId: toFolderId });
        this.scheduleDrain(accountId);
    }

    /** Queue a server-side delete (or trash, depending on action). */
    enqueueDelete(accountId: string, uid: number, folderId: number, kind: "delete" | "trash" = "delete"): void {
        this.db.queueSyncAction(accountId, kind, uid, folderId, {});
        this.scheduleDrain(accountId);
    }

    /** Cancel a pending MOVE action if it hasn't drained yet. Returns true
     *  when an action was found and marked complete (effectively retracted),
     *  false when no matching action exists — caller then queues a
     *  counter-move (the server already saw the original move).
     *
     *  Used by undelete: if the to-trash MOVE is still queued, retract it
     *  rather than queue a redundant trash→original. */
    cancelPendingMove(accountId: string, uid: number, fromFolderId: number, targetFolderId: number): boolean {
        const pending = this.db.findPendingSyncAction(accountId, "move", uid, fromFolderId, targetFolderId);
        if (!pending) return false;
        this.db.completeSyncAction(pending.id);
        return true;
    }

    /** Queue an IMAP APPEND of a draft body. Drafts already have crash
     *  recovery via the editing/.eml on disk, so this lane is in-memory
     *  fire-and-forget for now — the architectural value is centralizing
     *  the call site, not adding a second on-disk store for the same
     *  bytes. The reconciler retries on transient IMAP failure via the
     *  existing sync_actions backoff after first failure. */
    enqueueDraftPush(accountId: string, rawMessage: string, previousDraftUid?: number, draftId?: string): void {
        const key = `${accountId}:${draftId || ""}`;
        let slot = this.draftPushes.get(key);
        if (!slot) { slot = { inFlight: false, pending: null }; this.draftPushes.set(key, slot); }
        // Seed lastUid from the caller only until WE learn a real one. After the
        // first successful append the queue owns the authoritative UID.
        if (slot.lastUid == null && previousDraftUid) slot.lastUid = previousDraftUid;
        if (slot.inFlight) {
            // A push is already running for this draft — supersede: keep only
            // the newest content. The in-flight push finishes, then this runs.
            slot.pending = { rawMessage };
            return;
        }
        // Trailing-edge throttle (see slot.lastPushAt comment).
        const sincePush = Date.now() - (slot.lastPushAt || 0);
        if (slot.lastPushAt && sincePush < SyncQueue.DRAFT_PUSH_MIN_INTERVAL_MS) {
            slot.pending = { rawMessage };
            if (!slot.throttleTimer) {
                slot.throttleTimer = setTimeout(() => {
                    slot.throttleTimer = null;
                    const held = slot.pending;
                    slot.pending = null;
                    if (held) this.enqueueDraftPush(accountId, held.rawMessage, undefined, draftId);
                }, SyncQueue.DRAFT_PUSH_MIN_INTERVAL_MS - sincePush);
            }
            return;
        }
        slot.lastPushAt = Date.now();
        slot.inFlight = true;
        const run = (raw: string): void => {
            // Delete the copy WE last appended (delete-by-UID is reliable) rather
            // than the client's stale/absent previousDraftUid. The slot is NOT
            // deleted between checkpoints, so lastUid persists across the debounce
            // gaps and every save replaces the single server draft.
            this.imapManager.saveDraft(accountId, raw, slot!.lastUid, draftId)
                .then((uid: number | null) => {
                    if (typeof uid === "number" && uid > 0) slot!.lastUid = uid;
                    if (slot!.cancelled && typeof uid === "number" && uid > 0) {
                        // Send/discard happened while this push was in flight —
                        // reap the copy we just created (server + local row).
                        console.log(`  [sync-queue] draft push landed after send/discard — deleting own append uid=${uid} (${draftId})`);
                        this.imapManager.deleteDraft(accountId, uid, draftId)
                            .catch((e: any) => console.error(`  [sync-queue] post-cancel draft reap failed: ${e?.message || e}`));
                    }
                })
                .catch((e: any) => {
                    console.error(`  [sync-queue] draft push deferred (${draftId}): ${e?.message || e}`);
                    this.imapManager.emit("draftSaveDeferred", { accountId, draftId, error: String(e?.message || e) });
                })
                .finally(() => {
                    const next = slot!.pending;
                    slot!.pending = null;
                    if (next) {
                        run(next.rawMessage);   // drain the coalesced save
                    } else {
                        // Keep the slot (and its lastUid) alive for the next
                        // checkpoint — deleting it here was the bug: each new
                        // autosave restarted with no known UID and re-appended.
                        slot!.inFlight = false;
                    }
                });
        };
        run(rawMessage);
    }

    /** Drop the retained draft-push slot once a draft is sent or discarded, so
     *  its tracked UID doesn't linger for the compose session's lifetime. */
    forgetDraft(accountId: string, draftId?: string): void {
        const slot = this.draftPushes.get(`${accountId}:${draftId || ""}`);
        if (slot?.throttleTimer) clearTimeout(slot.throttleTimer);
        if (slot) {
            slot.pending = null;          // a held checkpoint must never push post-send
            slot.cancelled = true;        // an in-flight push reaps its own append
        }
        this.draftPushes.delete(`${accountId}:${draftId || ""}`);
    }

    /** The AUTHORITATIVE UID of the draft's current local/server row. The
     *  local-insert path assigns a fresh UID on every checkpoint, so the
     *  client's remembered draftUid goes stale within seconds of typing —
     *  cleanup callers MUST read this BEFORE forgetDraft(). Sending with the
     *  stale UID left the newest local Drafts row behind until the 30-min
     *  reconcile grace finally swept it (Bob 2026-07-12 "sent but still in
     *  drafts"). */
    getDraftLastUid(accountId: string, draftId?: string): number | undefined {
        return this.draftPushes.get(`${accountId}:${draftId || ""}`)?.lastUid ?? undefined;
    }

    /** Queue an outbox send. Today the outbox/<acct>/*.ltr directory IS
     *  the queue and a separate worker drains it; this seam is reserved
     *  for when that worker moves under the reconciler. */
    enqueueSend(_accountId: string, _outboxId: string): void {
        // No-op — outbox dir-based queue stays as-is per plan decision #5.
    }

    // ── Body fetch lane (in-memory; idempotent) ──

    /** Request a body fetch. Dedupes per (account, folder, uid). The
     *  `lane` argument hints urgency: `interactive` is a click; `prefetch`
     *  is the background backfiller; `backfill` is the one-time post-sync
     *  catch-up. Reconciler picks higher priority first. */
    enqueueBodyFetch(accountId: string, folderId: number, uid: number, lane: Lane = "interactive"): void {
        // Reject invalid UIDs at the door. IMAP UIDs start at 1; 0 / negative /
        // non-finite means an upstream code path lost the value (uninitialized
        // envelope, broken join). Without this guard the queue dispatched
        // `UID FETCH 0` to Dovecot, which returned BAD "Invalid uidset" and
        // burned a connection slot. Log with a stack so the caller can be
        // tracked down (Bob 2026-05-25: "lots fetch and prefetch failures").
        if (!uid || !Number.isFinite(uid) || uid <= 0) {
            console.error(`[queue] enqueueBodyFetch rejected invalid uid=${uid} for ${accountId}/folder${folderId}`, new Error("invalid uid trace").stack);
            return;
        }
        const key = `${accountId}:${folderId}:${uid}`;
        const existing = this.bodyFetches.get(key);
        if (existing) {
            // Upgrade lane if the new caller is more urgent.
            const rank: Record<Lane, number> = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
            if (rank[lane] < rank[existing.lane]) existing.lane = lane;
            return;
        }
        this.bodyFetches.set(key, { accountId, folderId, uid, lane, attempts: 0 });
    }

    /** Re-enqueue a transient failure — the reconciler calls this after a
     *  fetch errored with a transient code. Returns `true` if the item was
     *  put back in the queue (still under retry budget) or `false` if its
     *  budget is exhausted (caller surfaces the error to the UI). */
    requeueBodyFetch(key: BodyFetchKey, maxAttempts = 3): boolean {
        if (key.attempts + 1 >= maxAttempts) return false;
        const k = `${key.accountId}:${key.folderId}:${key.uid}`;
        // Don't downgrade lane on retry; keep the one the original caller set.
        this.bodyFetches.set(k, { ...key, attempts: key.attempts + 1 });
        return true;
    }

    /** Pop the next body-fetch task at or above `maxLane`. Used by the
     *  reconciler to drain interactive clicks before prefetch backfill. */
    nextBodyFetch(maxLane: Lane = "backfill"): BodyFetchKey | null {
        const rank: Record<Lane, number> = { interactive: 0, sync: 1, prefetch: 2, backfill: 3 };
        const cap = rank[maxLane];
        let best: BodyFetchKey | null = null;
        for (const v of this.bodyFetches.values()) {
            if (rank[v.lane] > cap) continue;
            if (!best || rank[v.lane] < rank[best.lane]) best = v;
        }
        if (best) this.bodyFetches.delete(`${best.accountId}:${best.folderId}:${best.uid}`);
        return best;
    }

    /** Pending count for diagnostics + the sync-status pill. */
    pendingCount(): { messageActions: number; bodyFetches: number } {
        return {
            messageActions: this.db.getTotalPendingSyncCount(),
            bodyFetches: this.bodyFetches.size,
        };
    }
}
