import cron from "node-cron";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { isValidTimezone } from "../config/types.js";
import { mergeAutoStoragePolicies, normalizeStoragePolicies, } from "./storage-policy.js";
export class Scheduler {
    tasksFile;
    jobs = new Map();
    handler = null;
    startedAt = 0;
    defaultTimezone;
    constructor(workspace, defaultTimezone) {
        this.tasksFile = path.join(workspace, "tasks", "tasks.json");
        this.defaultTimezone = defaultTimezone;
    }
    setHandler(handler) {
        this.handler = handler;
    }
    /** Load tasks from disk and start enabled cron jobs */
    start() {
        this.startedAt = Date.now();
        const tasks = this.loadTasks();
        let scheduled = 0;
        let failed = 0;
        for (const task of tasks) {
            if (!task.enabled)
                continue;
            try {
                this.scheduleJob(task);
                scheduled++;
            }
            catch (err) {
                failed++;
                const msg = err instanceof Error ? err.message : String(err);
                console.error(`  Failed to schedule task "${task.name}" [${task.id}]: ${msg}`);
            }
        }
        const tzNote = this.defaultTimezone
            ? ` (default tz: ${this.defaultTimezone})`
            : "";
        const failNote = failed ? `, ${failed} failed` : "";
        console.log(`⏰ Scheduler started: ${scheduled}/${tasks.length} tasks active${failNote}${tzNote}`);
    }
    stop() {
        for (const job of this.jobs.values()) {
            job.stop();
        }
        this.jobs.clear();
    }
    loadTasks() {
        if (!fs.existsSync(this.tasksFile))
            return [];
        const content = fs.readFileSync(this.tasksFile, "utf-8");
        return JSON.parse(content).map((task) => {
            if (task.storagePolicyMode === "off") {
                const rest = { ...task };
                delete rest.storagePolicies;
                return rest;
            }
            return {
                ...task,
                storagePolicies: normalizeStoragePolicies(task.storagePolicies, task.id),
            };
        });
    }
    saveTasks(tasks) {
        fs.mkdirSync(path.dirname(this.tasksFile), { recursive: true });
        fs.writeFileSync(this.tasksFile, JSON.stringify(tasks, null, 2), "utf-8");
    }
    addTask(name, cronExpr, prompt, timezone) {
        if (!cron.validate(cronExpr)) {
            throw new Error(`Invalid cron expression: ${cronExpr}`);
        }
        if (timezone && !isValidTimezone(timezone)) {
            throw new Error(`Invalid IANA timezone: ${timezone}`);
        }
        const tasks = this.loadTasks();
        const task = {
            id: crypto.randomUUID().slice(0, 8),
            name,
            cron: cronExpr,
            prompt,
            enabled: true,
        };
        const storagePolicies = mergeAutoStoragePolicies(task);
        if (storagePolicies.length) {
            task.storagePolicyMode = "auto";
            task.storagePolicies = storagePolicies;
        }
        if (timezone)
            task.timezone = timezone;
        tasks.push(task);
        this.saveTasks(tasks);
        this.scheduleJob(task);
        return task;
    }
    removeTask(id) {
        const tasks = this.loadTasks();
        const idx = tasks.findIndex((t) => t.id === id);
        if (idx < 0)
            return false;
        tasks.splice(idx, 1);
        this.saveTasks(tasks);
        const job = this.jobs.get(id);
        if (job) {
            job.stop();
            this.jobs.delete(id);
        }
        return true;
    }
    updateTask(id, patch) {
        const tasks = this.loadTasks();
        const task = tasks.find((t) => t.id === id);
        if (!task)
            return null;
        if (patch.cron !== undefined && !cron.validate(patch.cron)) {
            throw new Error(`Invalid cron expression: ${patch.cron}`);
        }
        if (patch.timezone && !isValidTimezone(patch.timezone)) {
            throw new Error(`Invalid IANA timezone: ${patch.timezone}`);
        }
        if (patch.name !== undefined)
            task.name = patch.name;
        if (patch.cron !== undefined)
            task.cron = patch.cron;
        if (patch.prompt !== undefined)
            task.prompt = patch.prompt;
        if (patch.storagePolicyMode !== undefined)
            task.storagePolicyMode = patch.storagePolicyMode;
        if (patch.timezone !== undefined) {
            if (patch.timezone === "")
                delete task.timezone;
            else
                task.timezone = patch.timezone;
        }
        if (patch.enabled !== undefined)
            task.enabled = patch.enabled;
        if (patch.name !== undefined ||
            patch.prompt !== undefined ||
            patch.storagePolicyMode !== undefined) {
            const storagePolicies = mergeAutoStoragePolicies(task);
            if (storagePolicies.length) {
                task.storagePolicyMode = task.storagePolicyMode ?? "auto";
                task.storagePolicies = storagePolicies;
            }
            else {
                delete task.storagePolicies;
                if (task.storagePolicyMode === "auto")
                    delete task.storagePolicyMode;
            }
        }
        else {
            task.storagePolicies = normalizeStoragePolicies(task.storagePolicies, task.id);
            if (!task.storagePolicies.length)
                delete task.storagePolicies;
        }
        this.saveTasks(tasks);
        // Always rebuild the job when the task is (now) enabled: scheduleJob's
        // callback closes over `task`, so even prompt/name changes must re-capture
        // the fresh object or the running handler will keep seeing stale fields.
        const existing = this.jobs.get(id);
        if (existing) {
            existing.stop();
            this.jobs.delete(id);
        }
        if (task.enabled) {
            this.scheduleJob(task);
        }
        return task;
    }
    toggleTask(id, enabled) {
        const tasks = this.loadTasks();
        const task = tasks.find((t) => t.id === id);
        if (!task)
            return null;
        if (enabled && task.timezone && !isValidTimezone(task.timezone)) {
            throw new Error(`Cannot enable task ${id}: invalid IANA timezone "${task.timezone}"`);
        }
        task.enabled = enabled;
        this.saveTasks(tasks);
        if (enabled) {
            this.scheduleJob(task);
        }
        else {
            const job = this.jobs.get(id);
            if (job) {
                job.stop();
                this.jobs.delete(id);
            }
        }
        return task;
    }
    updateTaskStatus(id, status) {
        const tasks = this.loadTasks();
        const task = tasks.find((t) => t.id === id);
        if (task) {
            task.lastRun = new Date().toISOString();
            task.lastStatus = status;
            task.runCount = (task.runCount || 0) + 1;
            if (status === "ok") {
                task.successCount = (task.successCount || 0) + 1;
            }
            this.saveTasks(tasks);
        }
    }
    linkSkill(taskId, skillRef) {
        const tasks = this.loadTasks();
        const task = tasks.find((t) => t.id === taskId);
        if (!task)
            return false;
        task.skillRef = skillRef;
        this.saveTasks(tasks);
        return true;
    }
    getTask(id) {
        return this.loadTasks().find((t) => t.id === id);
    }
    getNextRun() {
        let next = null;
        for (const job of this.jobs.values()) {
            const runAt = job.getNextRun();
            if (!runAt)
                continue;
            if (!next || runAt.getTime() < next.getTime()) {
                next = runAt;
            }
        }
        return next;
    }
    formatTaskList() {
        const tasks = this.loadTasks();
        if (tasks.length === 0)
            return "(no scheduled tasks)";
        return tasks
            .map((t) => {
            const status = t.enabled ? "✓" : "✗";
            const lastRun = t.lastRun
                ? ` (last: ${t.lastRun.slice(0, 16)} ${t.lastStatus || ""})`
                : "";
            const skill = t.skillRef ? ` [skill: ${t.skillRef}]` : "";
            const storage = t.storagePolicies?.length
                ? ` [storage: ${t.storagePolicies.length}]`
                : "";
            const runs = t.runCount
                ? ` [${t.successCount || 0}/${t.runCount} ok]`
                : "";
            const tz = t.timezone
                ? ` ${t.timezone}`
                : this.defaultTimezone
                    ? ` ${this.defaultTimezone} (default)`
                    : "";
            return `${status} [${t.id}] ${t.name} | ${t.cron}${tz}${skill}${storage}${runs}${lastRun}\n  → ${t.prompt}`;
        })
            .join("\n\n");
    }
    scheduleJob(task) {
        // Stop existing job if any
        const existing = this.jobs.get(task.id);
        if (existing)
            existing.stop();
        const timezone = task.timezone ?? this.defaultTimezone;
        const options = timezone ? { timezone } : undefined;
        const job = cron.schedule(task.cron, async () => {
            if (!this.handler)
                return;
            // Guard: node-cron fires immediately when cron matches the current second at
            // startup (getDelay returns 0). Only skip triggers within 2s of start to
            // catch this edge case without blocking legitimate cron fires.
            if (Date.now() - this.startedAt < 2_000) {
                console.log(`  Skipping startup-instant trigger for "${task.name}"`);
                return;
            }
            // Re-load task from disk on every trigger so live edits to tasks.json
            // (TG /update, direct file edit, future API) take effect on the next
            // tick instead of requiring a process restart. The closure still owns
            // the original `cron` expression — if the cron string itself is edited
            // externally the underlying schedule isn't rebuilt until restart or an
            // updateTask call. Prompt / enabled / timezone edits ARE live.
            const fresh = this.getTask(task.id);
            if (!fresh) {
                console.log(`  Task "${task.name}" [${task.id}] no longer in tasks.json; ignoring trigger`);
                return;
            }
            if (!fresh.enabled) {
                console.log(`  Task "${fresh.name}" [${fresh.id}] is disabled; ignoring trigger`);
                return;
            }
            console.log(`\n⏰ Running scheduled task: ${fresh.name} [${fresh.id}]`);
            try {
                await this.handler(fresh);
                this.updateTaskStatus(fresh.id, "ok");
            }
            catch (err) {
                console.error(`Task ${fresh.id} failed:`, err);
                this.updateTaskStatus(fresh.id, "error");
            }
        }, options);
        this.jobs.set(task.id, job);
    }
}
//# sourceMappingURL=scheduler.js.map