"use strict";
/**
 * CronManager — minimal 5-field cron scheduler.
 *
 * Story 5.1 — OpenLife v1.3 Agent OS Integration.
 *
 * 5-field cron syntax: `minute hour day-of-month month day-of-week`
 *   - * (any)
 *   - n (literal)
 *   - n-m (range)
 *   - n,m (list)
 *   - * /n (step)
 *
 * Storage: `.openlife/crons.json` (atomic via AtomicWriter).
 *
 * Lifecycle: jobs are persisted; start() polls every 30s (or
 * tickIntervalMs) and fires any job whose due-time falls inside the
 * last interval. Idempotent start/stop.
 *
 * The trigger payload is dispatched via an optional `executor` callback
 * supplied at construction time. By default, the cron only logs the fire
 * event to `.openlife/cron-events.jsonl` — operators can wire QueueScheduler
 * or any other dispatcher.
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronManager = void 0;
exports.parseCronExpression = parseCronExpression;
exports.matchesCron = matchesCron;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const AtomicWriter_1 = require("./util/AtomicWriter");
const ToolsetGuard_1 = require("./toolset/ToolsetGuard");
class CronManager {
    file;
    eventsFile;
    tickIntervalMs;
    executor;
    timer = null;
    lastTickAt = 0;
    constructor(opts = {}) {
        const stateDir = opts.stateDir
            || process.env.OPENLIFE_STATE_DIR
            || path.join(process.cwd(), '.openlife');
        fs.mkdirSync(stateDir, { recursive: true });
        this.file = path.join(stateDir, 'crons.json');
        this.eventsFile = path.join(stateDir, 'cron-events.jsonl');
        this.tickIntervalMs = opts.tickIntervalMs ?? 30000;
        this.executor = opts.executor;
    }
    read() {
        if (!fs.existsSync(this.file))
            return [];
        try {
            return JSON.parse(fs.readFileSync(this.file, 'utf-8'));
        }
        catch {
            return [];
        }
    }
    write(jobs) {
        (0, AtomicWriter_1.writeJsonAtomic)(this.file, jobs);
    }
    list() {
        return this.read();
    }
    create(id, expression, payload = {}) {
        (0, ToolsetGuard_1.assertToolsetAllowed)('cron', 'CronManager.create');
        if (!/^[a-z0-9][a-z0-9._-]+$/i.test(id)) {
            throw Object.assign(new Error('invalid_cron_id'), { code: 'invalid_cron_id' });
        }
        const validation = parseCronExpression(expression);
        if (!validation.ok) {
            throw Object.assign(new Error(`invalid_cron_expression:${validation.error}`), { code: 'invalid_cron_expression', detail: validation.error });
        }
        const jobs = this.read();
        if (jobs.some((j) => j.id === id)) {
            throw Object.assign(new Error('cron_already_exists'), { code: 'cron_already_exists' });
        }
        const job = {
            id, expression, payload,
            status: 'active',
            createdAt: new Date().toISOString(),
        };
        jobs.push(job);
        this.write(jobs);
        return job;
    }
    remove(id) {
        const jobs = this.read();
        const filtered = jobs.filter((j) => j.id !== id);
        if (filtered.length === jobs.length)
            return false;
        this.write(filtered);
        return true;
    }
    pause(id) {
        return this.setStatus(id, 'paused');
    }
    resume(id) {
        return this.setStatus(id, 'active');
    }
    setStatus(id, status) {
        const jobs = this.read();
        const idx = jobs.findIndex((j) => j.id === id);
        if (idx === -1)
            return false;
        jobs[idx].status = status;
        this.write(jobs);
        return true;
    }
    /** Manually fire a job (regardless of schedule). */
    async runNow(id) {
        const jobs = this.read();
        const job = jobs.find((j) => j.id === id);
        if (!job)
            return { ok: false, error: 'not_found' };
        await this.fire(job);
        return { ok: true };
    }
    start() {
        if (this.timer)
            return;
        this.lastTickAt = Date.now();
        // Run an immediate tick to catch up, then schedule.
        void this.tick();
        this.timer = setInterval(() => { void this.tick(); }, this.tickIntervalMs);
        if (this.timer.unref)
            this.timer.unref();
    }
    stop() {
        if (this.timer)
            clearInterval(this.timer);
        this.timer = null;
    }
    /** Run one tick — fire any active jobs whose due-time falls in the last interval. */
    async tick(now = new Date()) {
        const fired = [];
        const jobs = this.read();
        let mutated = false;
        for (const job of jobs) {
            if (job.status !== 'active')
                continue;
            const expr = parseCronExpression(job.expression);
            if (!expr.ok || !expr.fields)
                continue;
            // Fire if the current minute matches the schedule AND we haven't
            // already fired in this minute (idempotency vs short tick intervals).
            if (!matchesCron(expr.fields, now))
                continue;
            const minuteKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}-${now.getHours()}-${now.getMinutes()}`;
            const lastKey = job.lastFiredAt ? (() => {
                const d = new Date(job.lastFiredAt);
                return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
            })() : null;
            if (minuteKey === lastKey)
                continue;
            job.lastFiredAt = now.toISOString();
            mutated = true;
            await this.fire(job);
            fired.push(job.id);
        }
        if (mutated)
            this.write(jobs);
        return fired;
    }
    async fire(job) {
        (0, AtomicWriter_1.appendJsonlAtomic)(this.eventsFile, {
            at: new Date().toISOString(),
            type: 'cron_fired',
            id: job.id,
            expression: job.expression,
        });
        if (this.executor) {
            try {
                await this.executor.execute(job);
            }
            catch (err) {
                (0, AtomicWriter_1.appendJsonlAtomic)(this.eventsFile, {
                    at: new Date().toISOString(),
                    type: 'cron_executor_error',
                    id: job.id,
                    error: err instanceof Error ? err.message : String(err),
                });
            }
        }
    }
}
exports.CronManager = CronManager;
function parseCronExpression(expr) {
    const parts = expr.trim().split(/\s+/);
    if (parts.length !== 5) {
        return { ok: false, error: 'expected_5_fields' };
    }
    try {
        const fields = {
            minutes: expandField(parts[0], 0, 59),
            hours: expandField(parts[1], 0, 23),
            daysOfMonth: expandField(parts[2], 1, 31),
            months: expandField(parts[3], 1, 12),
            daysOfWeek: expandField(parts[4], 0, 6),
        };
        return { ok: true, fields };
    }
    catch (err) {
        return { ok: false, error: err instanceof Error ? err.message : 'parse_error' };
    }
}
function expandField(field, min, max) {
    const out = new Set();
    for (const token of field.split(',')) {
        let stride = 1;
        let body = token;
        const slashIdx = body.indexOf('/');
        if (slashIdx >= 0) {
            stride = parseInt(body.slice(slashIdx + 1), 10);
            if (!Number.isFinite(stride) || stride <= 0)
                throw new Error(`invalid_step:${field}`);
            body = body.slice(0, slashIdx);
        }
        let lo;
        let hi;
        if (body === '*' || body === '') {
            lo = min;
            hi = max;
        }
        else if (body.includes('-')) {
            const [a, b] = body.split('-').map((n) => parseInt(n, 10));
            if (!Number.isFinite(a) || !Number.isFinite(b))
                throw new Error(`invalid_range:${field}`);
            lo = a;
            hi = b;
        }
        else {
            const n = parseInt(body, 10);
            if (!Number.isFinite(n))
                throw new Error(`invalid_number:${field}`);
            lo = n;
            hi = n;
        }
        if (lo < min || hi > max || lo > hi)
            throw new Error(`out_of_range:${field}`);
        for (let v = lo; v <= hi; v += stride)
            out.add(v);
    }
    return out;
}
function matchesCron(fields, d) {
    return (fields.minutes.has(d.getMinutes())
        && fields.hours.has(d.getHours())
        && fields.daysOfMonth.has(d.getDate())
        && fields.months.has(d.getMonth() + 1)
        && fields.daysOfWeek.has(d.getDay()));
}
