import { DEFAULT_MISFIRE_GRACE_SECONDS, Time } from '@n8n/constants';
import { z } from 'zod';

import { Config, Env } from '../decorators';
import { positiveIntSchema } from '../schemas';

/**
 * Allows 0 (the clamp disabled) in addition to any positive value. Used for a
 * setting where 0 is a meaningful "off", not just a smaller number.
 */
const nonNegativeIntSchema = z.number({ coerce: true }).int().nonnegative();

/**
 * A jitter ratio: a fraction in [0, 1). 0 disables jitter; 1 or above would
 * allow a zero or negative delay between ticks, so they are rejected.
 */
const jitterRatioSchema = z.number({ coerce: true }).nonnegative().lt(1);

/**
 * Configuration for the durable scheduler: the engine that runs time-based
 * workflows (such as Schedule Trigger nodes) from a database-backed queue.
 *
 * Unlike the in-memory scheduler, due runs are recorded in the database before
 * they start, so they survive restarts and, in a multi-instance setup, each run
 * is picked up by exactly one instance instead of every instance firing it.
 *
 * Off by default. While {@link enabled} is false none of these settings have any
 * effect and the existing in-memory Schedule Trigger behaviour is unchanged. The
 * tunables below only matter once the scheduler is turned on; the defaults suit
 * most instances, so change them only to tune timing precision or storage.
 */
@Config
export class SchedulerConfig {
	/**
	 * Turn on the durable scheduler. Off by default so existing self-hosted setups
	 * keep using the in-memory scheduler and behave exactly as before.
	 *
	 * When on, scheduled runs are stored in the database before they execute, so a
	 * restart doesn't drop them and, across multiple instances, each run executes
	 * once rather than once per instance.
	 */
	@Env('N8N_SCHEDULER_ENABLED')
	enabled: boolean = false;

	/**
	 * How far into the future, in seconds, the scheduler works out and records the
	 * runs that are coming up. Defaults to 60 seconds.
	 *
	 * A larger window commits more runs to the database in advance (more resilient
	 * to downtime, slightly more storage churn); a smaller one keeps less ahead.
	 * Must be greater than 0.
	 *
	 * Together with {@link executorIntervalSeconds} this also sets a lower bound on
	 * the misfire grace a Schedule Trigger node may ask for, so raising it raises
	 * the effective grace of any node configured below the new value.
	 */
	@Env('N8N_SCHEDULER_MATERIALIZATION_WINDOW', positiveIntSchema)
	materializationWindowSeconds: number = Time.minutes.toSeconds;

	/**
	 * How often, in seconds, the scheduler scans active schedules to record their
	 * upcoming runs (those falling within the window above). Defaults to 10 seconds.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_MATERIALIZATION_INTERVAL', positiveIntSchema)
	materializationIntervalSeconds: number = 10;

	/**
	 * How long, in seconds, a single scan for upcoming runs may take before it is
	 * abandoned and retried on its next interval. Guards against a scan stuck on
	 * a slow or unresponsive database holding its slot forever.
	 * Defaults to 60 seconds.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_MATERIALIZATION_TIMEOUT', positiveIntSchema)
	materializationTimeoutSeconds: number = Time.minutes.toSeconds;

	/**
	 * How often, in seconds, the scheduler checks for recorded runs whose time has
	 * arrived and starts them. Defaults to 5 seconds.
	 *
	 * This sets the worst-case delay between a run's scheduled time and when it
	 * actually starts. Lower it for tighter timing at the cost of more frequent
	 * polling. Must be greater than 0.
	 *
	 * Together with {@link materializationWindowSeconds} this also sets a lower bound
	 * on the misfire grace a Schedule Trigger node may ask for, so raising it raises
	 * the effective grace of any node configured below the new value.
	 */
	@Env('N8N_SCHEDULER_EXECUTOR_INTERVAL', positiveIntSchema)
	executorIntervalSeconds: number = 5;

	/**
	 * How long, in seconds, a single check for due runs may take before it is
	 * abandoned and retried on its next interval.
	 * Guards against a check stuck on a slow or unresponsive database holding its slot forever.
	 * Defaults to 60 seconds.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_EXECUTOR_TIMEOUT', positiveIntSchema)
	executorTimeoutSeconds: number = Time.minutes.toSeconds;

	/**
	 * The most runs a single claim takes from the queue in one pass. Defaults to 100.
	 * Larger batches drain a backlog faster but hold more work on one instance per
	 * tick. Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_CLAIM_BATCH_SIZE', positiveIntSchema)
	claimBatchSize: number = 100;

	/**
	 * How often, in seconds, the scheduler looks for runs that an instance claimed
	 * but never finished (for example because it crashed or was shut down) and
	 * makes them available again so another instance can pick them up. Defaults to
	 * 30 seconds. Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_REAPER_INTERVAL', positiveIntSchema)
	reaperIntervalSeconds: number = 30;

	/**
	 * The most expired-lease tasks a single reaper sweep reclaims. Defaults to 100.
	 * Larger batches recover a backlog faster but hold more work on one instance per
	 * sweep. Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_REAPER_BATCH_SIZE', positiveIntSchema)
	reaperBatchSize: number = 100;

	/**
	 * How long, in seconds, a single recovery sweep may take
	 * before it is abandoned and retried on its next interval.
	 * Guards against a sweep stuck on a slow or unresponsive database holding its slot forever.
	 * Defaults to 60 seconds.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_REAPER_TIMEOUT', positiveIntSchema)
	reaperTimeoutSeconds: number = Time.minutes.toSeconds;

	/**
	 * How long, in seconds, a single instance holds an exclusive claim on a run it
	 * has picked up, so no other instance starts the same one. Defaults to 60 seconds.
	 *
	 * If that instance stops without finishing, the claim expires after this long
	 * and another instance may take the run over. Keep it comfortably above the
	 * time a run needs to get going: too short risks two instances starting the
	 * same run; too long delays recovery after a crash. Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_LEASE_DURATION', positiveIntSchema)
	leaseDurationSeconds: number = Time.minutes.toSeconds;

	/**
	 * How long, in seconds, tasks that finished cleanly
	 * (succeeded or were cancelled) are kept before being deleted.
	 *
	 * These rows exist only as recent history, so a short window keeps the
	 * scheduler's run table small on busy instances.
	 *
	 * Raise it to keep scheduling history longer for auditing.
	 * Lower it to reclaim database space sooner.
	 *
	 * Defaults to 1 day.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_RETENTION', positiveIntSchema)
	retentionSeconds: number = Time.days.toSeconds;

	/**
	 * How long, in seconds, tasks that went wrong
	 * (failed, or missed their moment entirely) are kept before being deleted.
	 *
	 * Meant to be kept longer than cleanly finished runs (`N8N_SCHEDULER_RETENTION`)
	 * so there is time to notice and debug a problem before its evidence is deleted.
	 *
	 * The scheduler warns when this is set below it.
	 * Must be greater than 0.
	 * Defaults to 7 days.
	 */
	@Env('N8N_SCHEDULER_FAILED_RETENTION', positiveIntSchema)
	failedRetentionSeconds: number = 7 * Time.days.toSeconds;

	/**
	 * How often, in seconds, the scheduler deletes finished tasks older than the
	 * retention windows above.
	 *
	 * Must be greater than 0.
	 * Defaults to 1 hour.
	 */
	@Env('N8N_SCHEDULER_RETENTION_INTERVAL', positiveIntSchema)
	retentionIntervalSeconds: number = Time.hours.toSeconds;

	/**
	 * How long, in seconds, a single cleanup of old finished tasks may take
	 * before it is abandoned and retried on its next interval.
	 * Defaults to 300 seconds.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_RETENTION_TIMEOUT', positiveIntSchema)
	retentionTimeoutSeconds: number = 5 * Time.minutes.toSeconds;

	/**
	 * The most background checks of the same kind (for example several scans for
	 * upcoming runs) allowed to run at the same time on one instance, when the
	 * database supports overlapping checks (Postgres).
	 * When a check is due while this many are still running, it is skipped.
	 * On SQLite checks never overlap and this setting has no effect.
	 * Defaults to 10.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_MAX_CONCURRENT_PASSES', positiveIntSchema)
	maxConcurrentPasses: number = 10;

	/**
	 * Adds a small random variation to the timing of the scheduler's periodic
	 * background checks (the intervals configured above), as a fraction of each
	 * interval. For example, with 0.1 a check configured to run every 10 seconds
	 * actually runs every 9 to 11 seconds. Defaults to 0.1.
	 *
	 * Without this variation, several n8n instances started at the same time
	 * (for example during a rolling deploy) would all query the database at the
	 * same moments; the randomness spreads those queries out.
	 *
	 * Must be at least 0 and below 1. Set it to 0 to make the checks run at
	 * exact intervals, or raise it to spread database load across instances
	 * more evenly.
	 */
	@Env('N8N_SCHEDULER_JITTER_RATIO', jitterRatioSchema)
	jitterRatio: number = 0.1;

	/**
	 * The smallest gap, in seconds, allowed between consecutive runs of the same
	 * schedule. A schedule set to run more often than this is slowed down to this
	 * gap. Defaults to 0, which disables the limit and honours whatever interval
	 * each schedule specifies.
	 *
	 * Set it to put a floor on how frequently any schedule can run, for example to
	 * stop a misconfigured every-second schedule from overloading the instance.
	 */
	@Env('N8N_SCHEDULER_MIN_INTERVAL', nonNegativeIntSchema)
	minIntervalSeconds: number = 0;

	/**
	 * How a Schedule Trigger node's "every N seconds/minutes" schedules run under
	 * the durable scheduler. Defaults to `legacy`.
	 *
	 * - `legacy`: fires stay aligned to the clock (on the minute, on the hour) and
	 *   match the in-memory scheduler exactly.
	 * - `new`: fires are spaced a steady N apart, timed from when the workflow was
	 *   activated rather than from clock boundaries.
	 *
	 * `new` runs "every N" more faithfully. The legacy timing restarts every
	 * minute, so an interval that doesn't divide evenly drifts (every 7 seconds,
	 * for example, leaves a 4-second gap across each minute boundary); `new` keeps
	 * a uniform gap throughout.
	 *
	 * `legacy` is the default so timing is unchanged while the durable scheduler
	 * rolls out; `new` is the intended future default. Only "every N
	 * seconds/minutes" schedules are affected: longer schedules (every N
	 * hours/days/weeks/months) and raw cron expressions run the same way under
	 * either setting.
	 */
	@Env('N8N_SCHEDULER_TRIGGER_NODE_MODE', z.enum(['legacy', 'new']))
	triggerNodeMode: 'legacy' | 'new' = 'legacy';

	/**
	 * Whether nodes that poll on a schedule (e.g. checking an inbox or API on an
	 * interval) are scheduled by the durable scheduler instead of n8n's
	 * in-process timer. Off by default; requires {@link enabled} to also be on.
	 */
	@Env('N8N_SCHEDULER_POLL_TRIGGERS_ENABLED')
	enabledForPollTriggers: boolean = false;

	/**
	 * How long, in seconds, a single poll of an external source (an inbox, an API)
	 * may take before it is abandoned. Defaults to 45 seconds.
	 *
	 * An abandoned poll skips no data: its position in the source is left where it
	 * was and the next scheduled poll covers the same ground. It counts as a poll
	 * failure, so a source that keeps timing out is polled at a widening interval.
	 * Guards against a poll stuck on an unresponsive source running indefinitely.
	 *
	 * Keep it below {@link leaseDurationSeconds}: the deadline only starts after
	 * the occurrence's setup reads, so a poll allowed to run as long as the claim
	 * on its run can still be in flight when that claim expires and another
	 * instance takes the run over. The default leaves that headroom, and the
	 * scheduler warns at startup when the timeout reaches the lease duration.
	 * Must be greater than 0 and at most one day.
	 */
	@Env('N8N_SCHEDULER_POLL_TIMEOUT', positiveIntSchema.max(Time.days.toSeconds))
	pollTimeoutSeconds: number = 45;

	/**
	 * Whether a poll trigger's cursor advance and the execution it produced are saved
	 * together, atomically. When disabled, a crash between the two can leave a poll
	 * pointing past items whose execution was never saved (or vice versa).
	 *
	 * Requires {@link enabled} and {@link enabledForPollTriggers} to also be on;
	 * on its own it has no effect.
	 *
	 * The env var name keeps its historic `N8N_POLLER_` prefix from before this flag
	 * moved into the scheduler config; it is referenced by the rollout plan, so do
	 * not rename it mid-ramp.
	 */
	@Env('N8N_POLLER_DURABLE_CURSORS_ENABLED')
	durableCursorsEnabled: boolean = false;

	/**
	 * Whether n8n's internal maintenance jobs (for example pruning old executions,
	 * compacting insights data, or renewing the license) are scheduled by the
	 * durable scheduler instead of n8n's in-process timers. Off by default;
	 * requires {@link enabled} to also be on.
	 *
	 * In a multi-instance setup, only turn this on once every instance runs a
	 * version of n8n that supports it. While older and newer versions run side by
	 * side (for example during a rolling deploy), the older instances still run
	 * these jobs on their own timers, so the same job could run twice at the
	 * same time.
	 */
	@Env('N8N_SCHEDULER_SYSTEM_TASKS_ENABLED')
	enabledForSystemTasks: boolean = false;

	/**
	 * Temporary escape hatch for the durable-scheduler rollout (preview to GA).
	 * Off by default; intended to be removed once the durable scheduler is GA.
	 *
	 * When on, the Schedule Trigger node shows a "Skip Durable Scheduler" toggle; a
	 * trigger with it checked keeps using the in-memory scheduler even while
	 * {@link enabled} is on, so an operator can move an individual schedule back
	 * while testing. When off, the toggle is hidden and every schedule follows
	 * {@link enabled}.
	 *
	 * Named with the `N8N_ENV_FEAT_` prefix so the frontend picks it up through the
	 * env-feature-flag channel that gates the node property's visibility. Only has
	 * an effect while {@link enabled} is on: with the durable scheduler off, every
	 * schedule already runs in memory.
	 */
	@Env('N8N_ENV_FEAT_SKIP_DURABLE_SCHEDULER')
	allowSkipDurableScheduler: boolean = false;

	/**
	 * How many times a scheduled run may be reclaimed (for example after a crash)
	 * or retried on error before it's given up on and dead-lettered. Defaults to
	 * 5. Raise it on infrastructure prone to instance restarts or transient
	 * errors, so a single crash doesn't drop a run; lower it to dead-letter and
	 * move on sooner. Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_MAX_ATTEMPTS', positiveIntSchema)
	maxAttempts: number = 5;

	/**
	 * How late, in seconds, a scheduled run may start and still count as on time. A
	 * run later than this counts as missed, and the schedule's misfire policy decides
	 * whether it still runs at all. Must be greater than 0, and capped at 30 days.
	 *
	 * This is the default a schedule inherits. A Schedule Trigger node may set its own
	 * grace instead, which is raised to the lower bound described on
	 * {@link executorIntervalSeconds} and {@link materializationWindowSeconds} if it
	 * falls below it.
	 *
	 * Should exceed {@link executorIntervalSeconds} and {@link materializationWindowSeconds}:
	 * a run has to survive until the next executor tick to be offered at all. The
	 * scheduler warns at startup if it doesn't.
	 */
	@Env('N8N_SCHEDULER_MISFIRE_GRACE', positiveIntSchema.max(30 * Time.days.toSeconds))
	misfireGraceSeconds: number = DEFAULT_MISFIRE_GRACE_SECONDS;

	/**
	 * Whether the scheduler periodically checks that every schedule still has
	 * something that owns it, and cleans up the ones left behind by a deletion
	 * that didn't finish. On by default.
	 *
	 * Turning it off leaves those leftovers in place, disabled but never removed.
	 */
	@Env('N8N_SCHEDULER_OWNER_RECONCILIATION_ENABLED')
	ownerReconciliationEnabled: boolean = true;

	/**
	 * How often, in seconds, that check runs. Defaults to 15 minutes. It is a
	 * safety net rather than the normal cleanup path, so a long interval is fine.
	 * Must be greater than 0.
	 */
	@Env('N8N_SCHEDULER_OWNER_RECONCILIATION_INTERVAL', positiveIntSchema)
	ownerReconciliationIntervalSeconds: number = 15 * Time.minutes.toSeconds;

	/**
	 * How long, in seconds, one of those checks may run before it is abandoned
	 * and retried on its next interval. Defaults to 5 minutes. Must be greater
	 * than 0.
	 */
	@Env('N8N_SCHEDULER_OWNER_RECONCILIATION_TIMEOUT', positiveIntSchema)
	ownerReconciliationTimeoutSeconds: number = 5 * Time.minutes.toSeconds;

	/**
	 * How many owners the check looks at per database round-trip. Defaults to 500.
	 * Must be between 1 and 1000.
	 *
	 * The owner ids of a batch travel as one `IN` list, so the cap keeps the
	 * statement under the driver's bind-parameter ceiling.
	 */
	@Env('N8N_SCHEDULER_OWNER_RECONCILIATION_BATCH_SIZE', positiveIntSchema.max(1000))
	ownerReconciliationBatchSize: number = 500;

	/**
	 * How long, in seconds, a schedule found to have no owner is kept disabled
	 * before it is deleted. Defaults to 1 day. Must be greater than 0.
	 *
	 * The schedule stops running immediately, so this window only delays the
	 * delete. Raise it for a longer window to correct a wrong "no owner" answer,
	 * lower it to reclaim rows sooner.
	 */
	@Env('N8N_SCHEDULER_OWNER_QUARANTINE_GRACE', positiveIntSchema)
	ownerQuarantineGraceSeconds: number = Time.days.toSeconds;

	/**
	 * How long, in seconds, a newly created schedule is left out of that check.
	 * Defaults to 300 seconds. Must be greater than 0.
	 *
	 * A schedule can be written a moment before the thing that owns it, so this
	 * grace stops a brand-new schedule being mistaken for an abandoned one.
	 */
	@Env('N8N_SCHEDULER_OWNER_SETTLE_PERIOD', positiveIntSchema)
	ownerSettleSeconds: number = 5 * Time.minutes.toSeconds;
}
