/**
 * Job CRUD and execution-result logic.
 *
 * Pure functions that operate on CronJob objects — no I/O.
 */
import crypto from 'node:crypto';
import { computeNextRunAtMs } from './schedule.js';
// -- Constants ---------------------------------------------------------------
/** Clear runningAtMs markers older than this (stuck run detection). */
export const STUCK_RUN_MS = 2 * 60 * 60 * 1000;
/** Exponential backoff delays indexed by consecutive error count. */
const BACKOFF_MS = [
    30_000, // 1st → 30s
    60_000, // 2nd → 1m
    5 * 60_000, // 3rd → 5m
    15 * 60_000, // 4th → 15m
    60 * 60_000 // 5th+ → 60m
];
/** Minimum gap between consecutive fires to prevent spin-loops. */
const MIN_REFIRE_GAP_MS = 2_000;
/** Max consecutive schedule errors before auto-disabling. */
const MAX_SCHEDULE_ERRORS = 3;
/** Max transient retries for one-shot (at) jobs. */
const MAX_TRANSIENT_RETRIES = 3;
const TRANSIENT_PATTERNS = [
    /rate[_ ]limit|too many requests|429/i,
    /overloaded|529|high demand/i,
    /network|econnreset|econnrefused|fetch failed|socket/i,
    /timeout|etimedout/i,
    /(?:status|http|code)[:\s]+5\d{2}\b/i
];
// -- Helpers -----------------------------------------------------------------
function errorBackoffMs(consecutiveErrors) {
    const idx = Math.min(consecutiveErrors - 1, BACKOFF_MS.length - 1);
    return BACKOFF_MS[Math.max(0, idx)];
}
function isTransientError(error) {
    if (!error)
        return false;
    return TRANSIENT_PATTERNS.some((re) => re.test(error));
}
// -- Create / Patch ----------------------------------------------------------
export function createJob(input, nowMs) {
    const id = crypto.randomUUID();
    const schedule = input.schedule.kind === 'every' && input.schedule.anchorMs == null
        ? { ...input.schedule, anchorMs: nowMs }
        : input.schedule;
    const deleteAfterRun = typeof input.deleteAfterRun === 'boolean'
        ? input.deleteAfterRun
        : schedule.kind === 'at'
            ? true
            : undefined;
    const job = {
        id,
        name: input.name,
        description: input.description,
        enabled: input.enabled ?? true,
        deleteAfterRun,
        createdAtMs: nowMs,
        updatedAtMs: nowMs,
        schedule,
        payload: input.payload,
        state: { ...input.state }
    };
    job.state.nextRunAtMs = computeJobNextRun(job, nowMs);
    return job;
}
export function applyJobPatch(job, patch, nowMs) {
    if ('name' in patch && patch.name != null)
        job.name = patch.name;
    if ('description' in patch)
        job.description = patch.description;
    if (typeof patch.enabled === 'boolean')
        job.enabled = patch.enabled;
    if (typeof patch.deleteAfterRun === 'boolean')
        job.deleteAfterRun = patch.deleteAfterRun;
    if (patch.schedule)
        job.schedule = patch.schedule;
    if (patch.payload)
        job.payload = { ...job.payload, ...patch.payload };
    if (patch.state)
        job.state = { ...job.state, ...patch.state };
    job.updatedAtMs = nowMs;
    // Recompute nextRunAtMs if schedule or enabled changed
    if (patch.schedule != null || patch.enabled != null) {
        job.state.nextRunAtMs = job.enabled ? computeJobNextRun(job, nowMs) : undefined;
        if (!job.enabled)
            job.state.runningAtMs = undefined;
        if (patch.schedule != null)
            job.state.scheduleErrorCount = 0;
    }
}
// -- Schedule computation ----------------------------------------------------
export function computeJobNextRun(job, nowMs) {
    if (!job.enabled)
        return undefined;
    // For 'every' jobs, anchor to lastRunAtMs if available
    if (job.schedule.kind === 'every') {
        const lastRun = job.state.lastRunAtMs;
        if (typeof lastRun === 'number' && Number.isFinite(lastRun)) {
            const next = Math.floor(lastRun) + Math.max(1, Math.floor(job.schedule.everyMs));
            if (next > nowMs)
                return next;
        }
    }
    // For 'at' jobs that already ran successfully, don't re-schedule
    if (job.schedule.kind === 'at' && job.state.lastRunStatus === 'ok') {
        return undefined;
    }
    return computeNextRunAtMs(job.schedule, nowMs);
}
// -- Job-due check -----------------------------------------------------------
export function isJobDue(job, nowMs, forced = false) {
    if (!job.enabled)
        return false;
    if (typeof job.state.runningAtMs === 'number')
        return false;
    if (forced)
        return true;
    return typeof job.state.nextRunAtMs === 'number' && nowMs >= job.state.nextRunAtMs;
}
/**
 * Apply an execution result to a job's state.
 * Returns true if the job should be deleted (one-shot success with deleteAfterRun).
 */
export function applyJobResult(job, result) {
    job.state.runningAtMs = undefined;
    job.state.lastRunAtMs = result.startedAt;
    job.state.lastRunStatus = result.status;
    job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt);
    job.state.lastError = result.error;
    job.updatedAtMs = result.endedAt;
    if (result.status === 'error') {
        job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
    }
    else {
        job.state.consecutiveErrors = 0;
    }
    const shouldDelete = job.schedule.kind === 'at' &&
        job.deleteAfterRun === true &&
        (result.status === 'ok' || result.status === 'skipped');
    if (shouldDelete)
        return true;
    if (job.schedule.kind === 'at') {
        if (result.status === 'ok' || result.status === 'skipped') {
            job.enabled = false;
            job.state.nextRunAtMs = undefined;
        }
        else {
            // One-shot error: retry if transient
            const consecutive = job.state.consecutiveErrors ?? 1;
            if (isTransientError(result.error) && consecutive <= MAX_TRANSIENT_RETRIES) {
                job.state.nextRunAtMs = result.endedAt + errorBackoffMs(consecutive);
            }
            else {
                job.enabled = false;
                job.state.nextRunAtMs = undefined;
            }
        }
    }
    else if (result.status === 'error' && job.enabled) {
        // Recurring job error: apply backoff
        const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
        const normalNext = computeJobNextRun(job, result.endedAt);
        const backoffNext = result.endedAt + backoff;
        job.state.nextRunAtMs = normalNext != null ? Math.max(normalNext, backoffNext) : backoffNext;
    }
    else if (job.enabled) {
        // Recurring job success — enforce minimum gap to prevent spin loops
        const naturalNext = computeJobNextRun(job, result.endedAt);
        const minNext = result.endedAt + MIN_REFIRE_GAP_MS;
        job.state.nextRunAtMs = naturalNext != null ? Math.max(naturalNext, minNext) : minNext;
    }
    else {
        job.state.nextRunAtMs = undefined;
    }
    return false;
}
// -- Maintenance -------------------------------------------------------------
/**
 * Normalize tick state: clear stuck runningAtMs, repair missing nextRunAtMs.
 * Returns true if any field was changed.
 */
export function normalizeJobState(job, nowMs) {
    let changed = false;
    // Clear stuck runs
    const runningAt = job.state.runningAtMs;
    if (typeof runningAt === 'number' && nowMs - runningAt > STUCK_RUN_MS) {
        job.state.runningAtMs = undefined;
        changed = true;
    }
    // Disabled jobs should have no schedule
    if (!job.enabled) {
        if (job.state.nextRunAtMs != null) {
            job.state.nextRunAtMs = undefined;
            changed = true;
        }
        if (job.state.runningAtMs != null) {
            job.state.runningAtMs = undefined;
            changed = true;
        }
        return changed;
    }
    // Repair missing nextRunAtMs for enabled jobs
    if (job.state.nextRunAtMs == null) {
        const computed = computeJobNextRun(job, nowMs);
        if (computed != null) {
            job.state.nextRunAtMs = computed;
            changed = true;
        }
    }
    return changed;
}
/**
 * Record a schedule computation error. Auto-disables after MAX_SCHEDULE_ERRORS.
 */
export function recordScheduleError(job, err) {
    const count = (job.state.scheduleErrorCount ?? 0) + 1;
    job.state.scheduleErrorCount = count;
    job.state.nextRunAtMs = undefined;
    job.state.lastError = `schedule error: ${String(err)}`;
    if (count >= MAX_SCHEDULE_ERRORS) {
        job.enabled = false;
    }
}
//# sourceMappingURL=jobs.js.map