/**
 * No-reply reminder service.
 *
 * Persists reminders keyed to an already-sent email and fires them when a
 * user-chosen deadline elapses. A follow-up PR will add automatic
 * reply-detection via IMAP header search; for v1 the agent composes that
 * check itself by calling search_emails with the stored Message-ID.
 *
 * Storage is a single JSON file written atomically via tmp → rename, with
 * mode 0600 to match the rest of the project's credential-hygiene story.
 */
import { readFileSync, writeFileSync, existsSync, renameSync } from "fs";
import { randomBytes } from "crypto";
import { logger } from "../utils/logger.js";
export class ReminderService {
    path;
    reminders = [];
    constructor(path) {
        this.path = path;
        this.load();
    }
    load() {
        if (!existsSync(this.path)) {
            this.reminders = [];
            return;
        }
        try {
            const raw = readFileSync(this.path, "utf-8");
            const parsed = JSON.parse(raw);
            this.reminders = Array.isArray(parsed.reminders) ? parsed.reminders : [];
        }
        catch (err) {
            logger.warn(`ReminderService: failed to parse ${this.path}, starting empty`, "ReminderService", err);
            this.reminders = [];
        }
    }
    persist() {
        const payload = { version: 1, reminders: this.reminders };
        // SMTP-005: write the tmp file alongside the destination, not in tmpdir().
        // rename(2) is only atomic within a single filesystem; a tmpfs /tmp →
        // ext4 $HOME rename throws EXDEV on containerised / NFS-home installs.
        const tmp = `${this.path}.${randomBytes(8).toString("hex")}.tmp`;
        writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
        renameSync(tmp, this.path);
    }
    /**
     * SMTP-006: persist the current in-memory state, rolling it back to the
     * supplied snapshot if the write fails. Without this, a failed persist()
     * (EXDEV, ENOSPC, EACCES) leaves `this.reminders` advanced past disk; a
     * later successful persist() would then silently flush the half-baked
     * mutation — e.g. a "fired" reminder the caller never received getting
     * written as "fired", breaking check_reminders' at-least-once guarantee.
     *
     * SMTP-007: scanDue() routes its fire-transition through here, so the
     * "fired" flip is committed to disk before the method returns or rolled
     * back atomically with the in-memory state. The at-least-once limitation
     * (a dropped MCP response after a successful persist) remains by design
     * pending a dedicated acknowledge tool — see the SMTP-007 audit note.
     *
     * @param snapshot Deep copy of `this.reminders` taken BEFORE the mutation.
     * @throws Re-throws the underlying persist error after rolling back so the
     *         caller (and the MCP tool layer) sees the failure.
     */
    persistOrRollback(snapshot) {
        try {
            this.persist();
        }
        catch (err) {
            this.reminders = snapshot;
            logger.error(`ReminderService: persist failed — rolled back in-memory state to last durable snapshot`, "ReminderService", err);
            throw err;
        }
    }
    /** Deep-clone the current reminders for use as a rollback snapshot. */
    snapshot() {
        return this.reminders.map(r => ({ ...r }));
    }
    /**
     * Create a reminder. Returns the persisted record.
     * @param args.afterDays  Days from sentAt to the deadline. Minimum 1, maximum 365.
     */
    add(args) {
        if (!args.messageId)
            throw new Error("messageId is required");
        if (!args.recipient)
            throw new Error("recipient is required");
        const clampedDays = Math.min(Math.max(Math.trunc(args.afterDays), 1), 365);
        const fireAtMs = args.sentAt.getTime() + clampedDays * 24 * 60 * 60 * 1000;
        const record = {
            id: `r-${randomBytes(5).toString("hex")}`,
            messageId: args.messageId,
            imapUid: args.imapUid,
            recipient: args.recipient,
            subject: args.subject,
            sentAt: args.sentAt.toISOString(),
            fireAt: new Date(fireAtMs).toISOString(),
            status: "pending",
            note: args.note,
        };
        const snapshot = this.snapshot();
        this.reminders.push(record);
        this.persistOrRollback(snapshot);
        return record;
    }
    /** Return pending reminders sorted by earliest fireAt. */
    listPending() {
        return this.reminders
            .filter(r => r.status === "pending")
            .sort((a, b) => Date.parse(a.fireAt) - Date.parse(b.fireAt));
    }
    /** Return every reminder, regardless of status. */
    listAll() {
        return [...this.reminders];
    }
    cancel(id) {
        const r = this.reminders.find(x => x.id === id);
        if (!r || r.status !== "pending")
            return false;
        const snapshot = this.snapshot();
        r.status = "cancelled";
        this.persistOrRollback(snapshot);
        return true;
    }
    /**
     * Auto-cancel reminders whose tracked Message-ID appears in the
     * In-Reply-To / References headers of any of the given inbox messages.
     * Returns the IDs of the reminders that were cancelled.
     *
     * Case-insensitive match on the angle-bracket form of the Message-ID.
     * The caller typically passes a recent inbox slice (the autosync loop
     * already fetches one), so this is cheap and doesn't hit IMAP itself.
     */
    detectRepliesAndCancel(inbox) {
        const pending = this.reminders.filter(r => r.status === "pending");
        if (pending.length === 0)
            return [];
        const snapshot = this.snapshot();
        // Build a lowercased set of the Message-IDs we're watching for,
        // tolerating both <id@host> and bare id@host forms.
        const watched = new Set();
        for (const r of pending) {
            const mid = r.messageId.toLowerCase().trim();
            if (!mid)
                continue;
            watched.add(mid);
            watched.add(mid.replace(/^<|>$/g, ""));
        }
        const cancelled = [];
        for (const msg of inbox) {
            const headers = msg.headers ?? {};
            const candidates = [];
            for (const key of ["in-reply-to", "In-Reply-To", "references", "References"]) {
                const v = headers[key];
                if (Array.isArray(v))
                    candidates.push(...v);
                else if (typeof v === "string")
                    candidates.push(v);
            }
            for (const raw of candidates) {
                const tokens = raw.toLowerCase().match(/<[^<>\s]+>/g) ?? [raw.toLowerCase().trim()];
                for (const t of tokens) {
                    const bare = t.replace(/^<|>$/g, "");
                    if (watched.has(t) || watched.has(bare)) {
                        for (const r of pending) {
                            if (r.status !== "pending")
                                continue;
                            const m = r.messageId.toLowerCase();
                            if (m === t || m === bare || m.replace(/^<|>$/g, "") === bare) {
                                r.status = "cancelled";
                                cancelled.push(r.id);
                            }
                        }
                    }
                }
            }
        }
        if (cancelled.length > 0)
            this.persistOrRollback(snapshot);
        return cancelled;
    }
    /**
     * Advance each due pending reminder to "fired" and return them. Caller is
     * responsible for surfacing the list to the user (MCP log, tool response,
     * etc.) — this method does not push anywhere.
     */
    scanDue(now = new Date()) {
        const cutoffMs = now.getTime();
        const fired = [];
        const snapshot = this.snapshot();
        let mutated = false;
        for (const r of this.reminders) {
            if (r.status === "pending" && Date.parse(r.fireAt) <= cutoffMs) {
                r.status = "fired";
                fired.push({ ...r });
                mutated = true;
            }
        }
        // SMTP-007: commit the "fired" transition to disk BEFORE returning it. On
        // persist failure persistOrRollback() restores the snapshot and throws, so
        // we never hand the caller reminders whose fired-state didn't reach disk —
        // they stay pending and resurface on the next scan (at-least-once).
        if (mutated)
            this.persistOrRollback(snapshot);
        return fired;
    }
    /** Remove fired/cancelled reminders older than the retention window. */
    prune(retainDays = 30) {
        const cutoff = Date.now() - retainDays * 24 * 60 * 60 * 1000;
        const snapshot = this.snapshot();
        const before = this.reminders.length;
        this.reminders = this.reminders.filter(r => {
            if (r.status === "pending")
                return true;
            return Date.parse(r.fireAt) >= cutoff;
        });
        const removed = before - this.reminders.length;
        if (removed > 0)
            this.persistOrRollback(snapshot);
        return removed;
    }
}
//# sourceMappingURL=reminder-service.js.map