/**
 * Scheduler Service — queues emails for future delivery.
 *
 * Scheduled emails are persisted to a JSON file so they survive process
 * restarts. A background interval (60 s) checks for due emails and sends
 * them via the SMTPService. Overdue emails from a previous run are processed
 * immediately on startup.
 */
import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync } from "fs";
import { logger } from "../utils/logger.js";
import { tracer } from "../utils/tracer.js";
/** Maximum number of seconds in the future for a scheduled send (30 days). */
const MAX_SCHEDULE_AHEAD_MS = 30 * 24 * 60 * 60 * 1000;
/** Minimum lead time: at least 60 s in the future. */
const MIN_LEAD_TIME_MS = 60 * 1000;
/** Background check interval. */
const POLL_INTERVAL_MS = 60 * 1000;
/** Number of send attempts before marking an item as permanently failed. */
const MAX_RETRIES = 3;
/** Base unit for per-item exponential backoff between retry attempts. */
const RETRY_BACKOFF_BASE_MS = 60 * 1000;
/** Ceiling on a single backoff delay so a high retryCount can't push it absurdly far out. */
const RETRY_BACKOFF_MAX_MS = 30 * 60 * 1000;
/**
 * Exponential per-item backoff: attempt N waits base * 2^(N-1), capped.
 * SMTP-003 — without this, a transient Bridge outage causes every due item to
 * be re-attempted on every 60 s poll tick (a tight blast against a down peer).
 */
function backoffMs(retryCount) {
    const exp = RETRY_BACKOFF_BASE_MS * Math.pow(2, Math.max(0, retryCount - 1));
    return Math.min(exp, RETRY_BACKOFF_MAX_MS);
}
/** Maximum age (ms) for completed/failed/cancelled records kept in history. */
const MAX_HISTORY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/** Hard cap on non-pending records retained in history (safety valve). */
const MAX_HISTORY_RECORDS = 1000;
const VALID_STATUSES = new Set(["pending", "sending", "sent", "failed", "cancelled"]);
/** Validate a deserialized ScheduledEmail record. Returns false if malformed. */
function isValidRecord(r) {
    if (!r || typeof r !== "object")
        return false;
    const o = r;
    if (typeof o.id !== "string" || !o.id)
        return false;
    if (typeof o.scheduledAt !== "string" || isNaN(Date.parse(o.scheduledAt)))
        return false;
    if (typeof o.createdAt !== "string" || isNaN(Date.parse(o.createdAt)))
        return false;
    if (typeof o.status !== "string" || !VALID_STATUSES.has(o.status))
        return false;
    if (!o.options || typeof o.options !== "object")
        return false;
    return true;
}
export class SchedulerService {
    smtpService;
    storePath;
    items = [];
    timer = null;
    isProcessing = false;
    constructor(smtpService, storePath) {
        this.smtpService = smtpService;
        this.storePath = storePath;
    }
    // ─── Lifecycle ──────────────────────────────────────────────────────────────
    start() {
        if (this.timer)
            return; // already running — prevent duplicate timers
        tracer.spanSync('scheduler.start', {}, () => {
            this.load();
            // Process any overdue emails from a previous session immediately
            void this.processDue().catch(err => logger.error("Scheduler processDue error", "Scheduler", err));
            this.timer = setInterval(() => void this.processDue().catch(err => logger.error("Scheduler processDue error", "Scheduler", err)), POLL_INTERVAL_MS);
            logger.info(`Scheduler started (${this.pending().length} pending)`, "Scheduler");
        }); // end tracer.spanSync('scheduler.start')
    }
    stop() {
        tracer.spanSync('scheduler.stop', {}, () => {
            if (this.timer) {
                clearInterval(this.timer);
                this.timer = null;
            }
            this.persist();
            logger.info("Scheduler stopped", "Scheduler");
        }); // end tracer.spanSync('scheduler.stop')
    }
    // ─── Public API ─────────────────────────────────────────────────────────────
    /**
     * Queue an email for delivery at `sendAt`.
     *
     * @throws If `sendAt` is not in the valid window [now+60s, now+30d].
     * @returns The assigned scheduled email ID.
     */
    schedule(options, sendAt) {
        return tracer.spanSync('scheduler.schedule', { sendAtMs: sendAt.getTime(), hasAttachments: !!(options.attachments?.length) }, () => {
            const now = Date.now();
            const delta = sendAt.getTime() - now;
            if (delta < MIN_LEAD_TIME_MS) {
                throw new Error(`send_at must be at least 60 seconds in the future (got ${Math.round(delta / 1000)}s).`);
            }
            if (delta > MAX_SCHEDULE_AHEAD_MS) {
                throw new Error(`send_at must be within 30 days from now (got ${Math.round(delta / 86400000)}d).`);
            }
            const item = {
                id: crypto.randomUUID(),
                scheduledAt: sendAt.toISOString(),
                options,
                status: "pending",
                createdAt: new Date().toISOString(),
            };
            this.items.push(item);
            this.persist();
            logger.info(`Email scheduled for ${item.scheduledAt}`, "Scheduler", { id: item.id });
            return item.id;
        }); // end tracer.spanSync('scheduler.schedule')
    }
    /**
     * Cancel a scheduled email.
     *
     * - `"pending"`: marked cancelled, persisted, returns `{ ok: true }`.
     * - `"sending"`: the cooperative-stop signal — status is flipped to
     *   `"cancelled"` so processDue's post-await guard will skip the
     *   status-to-sent flip, but the SMTP send is already on the wire and may
     *   succeed. Returns `{ ok: false, error: "in_flight" }` so the caller can
     *   warn the user that the message may still be delivered.
     * - `"sent" | "failed" | "cancelled"`: terminal state, no-op, returns
     *   `{ ok: false, error: "already_final" }`.
     * - unknown id: `{ ok: false, error: "not_found" }`.
     */
    cancel(id) {
        return tracer.spanSync('scheduler.cancel', { id }, () => {
            const item = this.items.find(i => i.id === id);
            if (!item)
                return { ok: false, error: "not_found" };
            if (item.status === "pending") {
                item.status = "cancelled";
                this.persist();
                logger.info(`Scheduled email cancelled`, "Scheduler", { id });
                return { ok: true };
            }
            if (item.status === "sending") {
                // Cooperative-stop signal — processDue's post-await guard reads this
                // and skips the status-to-sent flip. Persist so a crash mid-send still
                // reflects the user's intent on next start().
                item.status = "cancelled";
                this.persist();
                logger.warn(`Cancel arrived while send was in flight; message may still be delivered`, "Scheduler", { id });
                return { ok: false, error: "in_flight" };
            }
            return { ok: false, error: "already_final" };
        }); // end tracer.spanSync('scheduler.cancel')
    }
    /** Return all scheduled emails sorted by scheduledAt ascending. */
    list() {
        const tags = {};
        return tracer.spanSync('scheduler.list', tags, () => {
            const result = [...this.items].sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
            tags.resultCount = result.length;
            return result;
        }); // end tracer.spanSync('scheduler.list')
    }
    /** Return only pending items. */
    pending() {
        const tags = {};
        return tracer.spanSync('scheduler.pending', tags, () => {
            const result = this.items.filter(i => i.status === "pending");
            tags.resultCount = result.length;
            return result;
        }); // end tracer.spanSync('scheduler.pending')
    }
    // ─── Internal ───────────────────────────────────────────────────────────────
    async processDue() {
        if (this.isProcessing) {
            // SMTP-008: a slow in-flight send blocks this tick. Emit a debug line so
            // "why didn't my email send at 12:34?" is diagnosable rather than silent.
            logger.debug("processDue skipped — previous tick still in flight", "Scheduler");
            return;
        }
        this.isProcessing = true;
        const now = new Date();
        const due = this.items.filter(i => i.status === "pending" &&
            new Date(i.scheduledAt) <= now &&
            // SMTP-003: honor per-item backoff — a failed attempt sets nextAttemptAt
            // into the future; skip until that window has elapsed.
            (!i.nextAttemptAt || new Date(i.nextAttemptAt) <= now));
        if (due.length === 0) {
            this.isProcessing = false;
            return;
        }
        return tracer.span('scheduler.processDue', { dueCount: due.length }, async () => {
            try {
                logger.info(`Processing ${due.length} due scheduled email(s)`, "Scheduler");
                for (const item of due) {
                    // SMTP-015: if the SMTP backoff gate is already tripped, every remaining
                    // item in this batch would return "backoff active" and burn a retryCount
                    // for a send that was never actually attempted — mass-failing the queue.
                    // Stop the batch early and leave the rest "pending" (not attempted) so
                    // the next tick retries cleanly once the backoff window elapses.
                    if (this.smtpService.backoff?.isBlocked()) {
                        logger.warn(`SMTP backoff active — deferring ${due.length - due.indexOf(item)} remaining due item(s) to a later tick`, "Scheduler");
                        break;
                    }
                    // SMTP-002: flip to "sending" + persist BEFORE the await so a
                    // concurrent cancel() can distinguish "still pending" from
                    // "already on the wire" and return the in_flight signal.
                    item.status = "sending";
                    this.persist();
                    try {
                        const result = await this.smtpService.sendEmail(item.options);
                        // SMTP-001: a cancel() landing between the await and this assignment
                        // flips item.status to "cancelled" — respect that signal and don't
                        // clobber it back to "sent"/"failed".
                        if (item.status !== "sending") {
                            logger.info(`Scheduled send completed but state changed mid-flight; preserving ${item.status}`, "Scheduler", { id: item.id, finalStatus: item.status });
                            continue;
                        }
                        if (result.success) {
                            item.status = "sent";
                            delete item.nextAttemptAt;
                            logger.info(`Scheduled email sent`, "Scheduler", { id: item.id, messageId: result.messageId });
                        }
                        else {
                            item.retryCount = (item.retryCount ?? 0) + 1;
                            item.error = result.error;
                            if ((item.retryCount ?? 0) >= MAX_RETRIES) {
                                item.status = "failed";
                                delete item.nextAttemptAt;
                                logger.warn(`Scheduled email permanently failed after ${MAX_RETRIES} attempts`, "Scheduler", { id: item.id, error: result.error });
                            }
                            else {
                                item.status = "pending";
                                item.nextAttemptAt = new Date(Date.now() + backoffMs(item.retryCount)).toISOString();
                                logger.warn(`Scheduled email send failed (attempt ${item.retryCount}/${MAX_RETRIES}), will retry`, "Scheduler", { id: item.id, error: result.error });
                            }
                        }
                        // SMTP-004: persist after each item's terminal/retry status flip so a
                        // crash mid-loop cannot leave a "sent" item on disk as "pending"
                        // (which would re-send a non-idempotent message on restart).
                        this.persist();
                    }
                    catch (err) {
                        // Same post-await guard for the throw path.
                        if (item.status !== "sending") {
                            const errMsg = err instanceof Error ? err.message : String(err);
                            logger.info(`Scheduled send threw but state changed mid-flight; preserving ${item.status}`, "Scheduler", { id: item.id, finalStatus: item.status, error: errMsg });
                            continue;
                        }
                        item.retryCount = (item.retryCount ?? 0) + 1;
                        const errMsg = err instanceof Error ? err.message : String(err);
                        item.error = errMsg;
                        if ((item.retryCount ?? 0) >= MAX_RETRIES) {
                            item.status = "failed";
                            delete item.nextAttemptAt;
                            logger.error(`Scheduled email permanently failed after ${MAX_RETRIES} attempts`, "Scheduler", { id: item.id, error: errMsg });
                        }
                        else {
                            item.status = "pending";
                            item.nextAttemptAt = new Date(Date.now() + backoffMs(item.retryCount)).toISOString();
                            logger.warn(`Scheduled email threw (attempt ${item.retryCount}/${MAX_RETRIES}), will retry`, "Scheduler", { id: item.id, error: errMsg });
                        }
                        // SMTP-004: persist after each item's status flip (throw path too).
                        this.persist();
                    }
                }
                this.persist();
            }
            finally {
                this.isProcessing = false;
            }
        }); // end tracer.span('scheduler.processDue')
    }
    load() {
        if (!existsSync(this.storePath))
            return;
        try {
            const raw = readFileSync(this.storePath, "utf-8");
            const parsed = JSON.parse(raw);
            if (Array.isArray(parsed)) {
                const valid = parsed.filter(isValidRecord);
                const skipped = parsed.length - valid.length;
                if (skipped > 0) {
                    logger.warn(`Skipped ${skipped} malformed record(s) from scheduled email store`, "Scheduler");
                }
                this.items = this.pruneHistory(valid);
                logger.debug(`Loaded ${this.items.length} scheduled emails from disk`, "Scheduler");
            }
        }
        catch (err) {
            logger.warn("Failed to load scheduled emails from disk — starting fresh", "Scheduler", err);
            this.items = [];
        }
    }
    /**
     * Remove old non-pending records to prevent unbounded array growth.
     *
     * Strategy:
     *   1. Keep all pending items (they must not be dropped).
     *   2. For non-pending (sent/failed/cancelled), keep only those created
     *      within MAX_HISTORY_AGE_MS.
     *   3. If the remaining non-pending count still exceeds MAX_HISTORY_RECORDS,
     *      keep only the most-recently-created MAX_HISTORY_RECORDS entries.
     */
    pruneHistory(items) {
        const cutoff = Date.now() - MAX_HISTORY_AGE_MS;
        const pending = items.filter(i => i.status === "pending");
        let history = items.filter(i => i.status !== "pending" && Date.parse(i.createdAt) >= cutoff);
        if (history.length > MAX_HISTORY_RECORDS) {
            // Sort newest-first, keep only the cap
            history = history
                .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
                .slice(0, MAX_HISTORY_RECORDS);
            logger.warn(`Pruned scheduled email history to ${MAX_HISTORY_RECORDS} most-recent non-pending records`, "Scheduler");
        }
        const pruned = pending.length + history.length;
        if (pruned < items.length) {
            logger.debug(`Pruned ${items.length - pruned} old non-pending scheduled email record(s)`, "Scheduler");
        }
        return [...pending, ...history];
    }
    persist() {
        // SMTP-018: prune on every write, not only at load(). A long-running process
        // accumulates >MAX_HISTORY_RECORDS non-pending records in memory and would
        // otherwise rewrite (and grow) the full blob on every schedule/cancel/tick.
        this.items = this.pruneHistory(this.items);
        const tmp = this.storePath + ".tmp";
        try {
            writeFileSync(tmp, JSON.stringify(this.items, null, 2), { encoding: "utf-8", mode: 0o600 });
            renameSync(tmp, this.storePath);
            // Ensure destination file has 0600 permissions (renameSync preserves the
            // temp file's mode on POSIX, but chmod is a no-op-safe belt-and-suspenders
            // guard; silently ignored on Windows where chmod has no effect).
            try {
                chmodSync(this.storePath, 0o600);
            }
            catch { /* ignore on Windows */ }
        }
        catch (err) {
            logger.warn("Failed to persist scheduled emails", "Scheduler", err);
        }
    }
}
//# sourceMappingURL=scheduler.js.map