'use strict';
const EventEmitter = require('events');

let _instance = null;

/**
 * Default minimum gap between consecutive fires of the same job.
 *
 * Safety net against spin-loops when a job's `intervalMs` is misconfigured
 * to a tiny truthy value (1ms) or mutated post-register via updateInterval,
 * or when `wake()` is invoked immediately after a just-finished run.
 * Borrowed from OpenClaw's MIN_REFIRE_GAP_MS (src/cron/service/timer.ts:42-48).
 * Kept generous (2s) so it never masks a legitimate schedule but always
 * breaks an infinite re-trigger cycle. Override per-Scheduler with
 * `new Scheduler({ minRefireGapMs })` — tests use a smaller value.
 */
const MIN_REFIRE_GAP_MS = 2_000;

/**
 * Restart catch-up policy.
 * Borrowed from OpenClaw (src/cron/service/timer.ts:54-55).
 *   DEFAULT_MISSED_JOB_STAGGER_MS = 5_000ms between catch-up runs
 *   DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5 max immediate replays
 */
const DEFAULT_MISSED_JOB_STAGGER_MS = 5_000;
const DEFAULT_MAX_MISSED_JOBS_PER_RESTART = 5;

/** Default cascade check: true if any numeric value in result is > 0 */
function _defaultCascadeCheck(result) {
  for (const v of Object.values(result)) {
    if (typeof v === 'number' && v > 0) return true;
  }
  return false;
}

class Scheduler extends EventEmitter {
  /**
   * @param {Object} opts
   * @param {number} [opts.tickMs=5000]
   * @param {Object} [opts.pools] - Resource pool limits: { llm: 2, ollama: 1, ... }
   * @param {number} [opts.shutdownTimeoutMs=5000]
   */
  constructor(opts = {}) {
    super();
    this._tickMs = opts.tickMs || 5000;
    this._shutdownTimeoutMs = opts.shutdownTimeoutMs || 5000;
    this._minRefireGapMs = opts.minRefireGapMs ?? MIN_REFIRE_GAP_MS;
    this._shuttingDown = false;
    this._tickTimer = null;
    this._startedAt = null;
    this._immediateTickScheduled = false;

    // Failure-alert hook (off by default — opt in via opts.failureAlert).
    // Production agent.js passes failureAlert:true; tests leave it off so
    // they don't accidentally write to brain.kv.
    this._failureAlertEnabled = !!opts.failureAlert;
    this._failureAlertModule = opts.failureAlertModule || (this._failureAlertEnabled
      ? require('./scheduler-failure-alert')
      : null);
    this._failureAlertBrain = opts.failureAlertBrain || null;
    this._failureAlertOpts = opts.failureAlertOpts || undefined;

    // Persistent job state (off by default — opt in via opts.persistJobState).
    // When enabled, register() hydrates from the brain DB and every completion
    // (success or error) writes back. runMissedJobs() reads the same store
    // to replay jobs whose nextEligibleAt fell while the daemon was down.
    this._persistJobState = !!opts.persistJobState;
    this._persistBrain = opts.persistBrain || null;
    this._missedJobStaggerMs = opts.missedJobStaggerMs ?? DEFAULT_MISSED_JOB_STAGGER_MS;
    this._maxMissedJobsPerRestart = opts.maxMissedJobsPerRestart ?? DEFAULT_MAX_MISSED_JOBS_PER_RESTART;

    this._poolLimits = {
      llm: 2,
      ollama: 1,
      embedding: 1,
      io: 3,
      ...opts.pools,
    };

    // Map<string, { descriptor, state, runPromise, _wakeTrigger, _lastWakeAt, _epoch }>
    this._jobs = new Map();

    // Track active jobs per pool: Map<string, Set<string>>
    this._poolActive = new Map();
    for (const pool of Object.keys(this._poolLimits)) {
      this._poolActive.set(pool, new Set());
    }
  }

  static getInstance() { return _instance; }
  static setInstance(s) { _instance = s; }

  /**
   * Register a job.
   * @param {Object} desc
   * @param {string}   desc.name
   * @param {Function} desc.run          - async ({ trigger }) => result
   * @param {number}   desc.intervalMs
   * @param {string}   [desc.pool]       - Resource pool name
   * @param {number}   [desc.priority=5] - 1 (highest) to 10 (lowest)
   * @param {string[]} [desc.dependsOn]  - Jobs that must complete once before this runs
   * @param {string[]} [desc.cascadeFrom] - Jobs whose results trigger immediate eligibility
   * @param {number}   [desc.startDelayMs=0]
   * @param {number}   [desc.timeoutMs]  - Watchdog timeout
   * @param {number}   [desc.debounceMs] - Min gap between event-driven wakes
   * @param {boolean}  [desc.enabled=true]
   * @param {Function} [desc.shouldRun]  - () => boolean guard
   * @param {Function} [desc.onResult]   - (result) => void
   * @param {Function} [desc.onError]    - (err) => void
   * @returns {Scheduler}
   */
  register(desc) {
    if (!desc.name || !desc.run || !desc.intervalMs) {
      throw new Error('Job requires name, run, and intervalMs');
    }
    // Clone to avoid mutating caller's object
    desc = { priority: 5, enabled: true, ...desc };

    const now = Date.now();
    const initialState = {
      status: 'idle',
      lastRunAt: null,
      lastFinishedAt: null,
      lastDurationMs: null,
      lastResult: null,
      lastError: null,
      runCount: 0,
      errorCount: 0,
      nextEligibleAt: now + (desc.startDelayMs || 0),
      completedOnce: false,
    };

    // Hydrate from persistent store if enabled. Only adopts last_run_at /
    // last_finished_at / next_eligible_at / consecutive_errors / counts —
    // never adopts in-flight state (status:running) since the previous
    // process is dead. If the persisted next_eligible_at is in the past,
    // the job is "missed" and runMissedJobs() will pick it up. If it's in
    // the future, the existing schedule is preserved across restart.
    if (this._persistJobState && this._persistBrain) {
      try {
        const stored = this._persistBrain.getSchedulerJobState(desc.name);
        if (stored) {
          initialState.lastRunAt = stored.last_run_at || null;
          initialState.lastFinishedAt = stored.last_finished_at || null;
          initialState.nextEligibleAt = stored.next_eligible_at
            || (now + (desc.startDelayMs || 0));
          initialState.consecutiveErrors = stored.consecutive_errors || 0;
          initialState.lastError = stored.last_error || null;
          initialState.runCount = stored.run_count || 0;
          initialState.errorCount = stored.error_count || 0;
          initialState.completedOnce = (stored.run_count || 0) > 0;
        }
      } catch (e) {
        console.warn(`[scheduler] Could not hydrate state for "${desc.name}": ${e.message}`);
      }
    }

    this._jobs.set(desc.name, {
      descriptor: desc,
      state: initialState,
      runPromise: null,
      _wakeTrigger: null,
      _lastWakeAt: 0,
      _epoch: 0, // incremented on force-reset; stale promises check this
    });

    // Ensure pool tracking exists for custom pools
    if (desc.pool && !this._poolActive.has(desc.pool)) {
      this._poolActive.set(desc.pool, new Set());
      if (this._poolLimits[desc.pool] === undefined) this._poolLimits[desc.pool] = 1;
    }

    return this;
  }

  unregister(name) {
    this._jobs.delete(name);
    // Clean up pool tracking
    for (const [, active] of this._poolActive) {
      active.delete(name);
    }
  }

  start() {
    if (this._tickTimer) return;
    this._startedAt = Date.now();
    this._tickTimer = setInterval(() => this._tick(), this._tickMs);
  }

  wake(name, reason = 'manual') {
    const job = this._jobs.get(name);
    if (!job || this._shuttingDown) return;

    if (job.descriptor.debounceMs) {
      if (Date.now() - job._lastWakeAt < job.descriptor.debounceMs) return;
      job._lastWakeAt = Date.now();
    }

    if (job.state.status === 'running') return;

    job._wakeTrigger = reason;
    // Honor minRefireGapMs even on explicit wake — spin-loop guard.
    const earliest = (job.state.lastFinishedAt || 0) + this._minRefireGapMs;
    job.state.nextEligibleAt = Math.max(Date.now(), earliest);

    // Wall-E analog of OpenClaw's nextWakeAtMs() setTimeout re-arm
    // (src/cron/service/timer.ts:627-695): when an external caller pokes
    // a job, evaluate immediately rather than waiting up to tickMs (5s in
    // production) for the next setInterval tick. Only schedules if the job
    // is now eligible — otherwise we'd just re-do the work the periodic
    // tick will do.
    if (this._tickTimer && !this._immediateTickScheduled && job.state.nextEligibleAt <= Date.now()) {
      this._immediateTickScheduled = true;
      setImmediate(() => {
        this._immediateTickScheduled = false;
        if (!this._shuttingDown) this._tick();
      });
    }
  }

  pause(name) {
    const job = this._jobs.get(name);
    if (job) job.descriptor.enabled = false;
  }

  resume(name) {
    const job = this._jobs.get(name);
    if (job) job.descriptor.enabled = true;
  }

  hasJob(name) {
    return this._jobs.has(name);
  }

  updateInterval(name, newMs) {
    const entry = this._jobs.get(name);
    if (!entry) return false;
    entry.descriptor.intervalMs = newMs;
    return true;
  }

  getState(name) {
    if (name) {
      const job = this._jobs.get(name);
      if (!job) return null;
      return this._stateSnapshot(job);
    }
    const result = {};
    for (const [n, job] of this._jobs) {
      result[n] = this._stateSnapshot(job);
    }
    return result;
  }

  getJobs() {
    return Object.entries(this.getState())
      .map(([name, state]) => ({ name, ...state }))
      .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
  }

  /**
   * Read all per-job model overrides from kv_store. Returns a plain
   * object { jobName: { model, modelCandidates } }. Cached for 1s
   * to avoid hitting the DB on every job tick.
   *
   * Item A of provider-routing deep-dive.
   */
  _readModelOverrides() {
    if (!this._persistBrain) return null;
    const now = Date.now();
    if (this._modelOverridesCache && now - this._modelOverridesCacheAt < 1000) {
      return this._modelOverridesCache;
    }
    let overrides = null;
    try {
      const db = this._persistBrain.getDb();
      const row = db
        .prepare("SELECT value FROM kv_store WHERE key = ?")
        .get('scheduler_model_overrides');
      if (row && row.value) overrides = JSON.parse(row.value);
    } catch {}
    this._modelOverridesCache = overrides;
    this._modelOverridesCacheAt = now;
    return overrides;
  }

  /**
   * Set the per-job model override. Set value=null to clear an existing
   * override. Persisted to kv_store; affects next tick.
   *
   * @param {string} jobName
   * @param {{model?: string, modelCandidates?: string[]} | null} override
   */
  setModelOverride(jobName, override) {
    if (!this._persistBrain) {
      throw new Error('Scheduler has no persist brain — cannot set model override');
    }
    const db = this._persistBrain.getDb();
    let current = {};
    try {
      const row = db.prepare("SELECT value FROM kv_store WHERE key = ?").get('scheduler_model_overrides');
      if (row && row.value) current = JSON.parse(row.value);
    } catch {}
    if (override == null) {
      delete current[jobName];
    } else {
      current[jobName] = {
        model: override.model || null,
        modelCandidates: Array.isArray(override.modelCandidates) ? override.modelCandidates : null,
      };
    }
    db.prepare(
      "INSERT OR REPLACE INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)"
    ).run('scheduler_model_overrides', JSON.stringify(current), new Date().toISOString());
    // Bust cache so the very next tick sees the new value
    this._modelOverridesCacheAt = 0;
    return current;
  }

  /** Get the current model overrides. Read-only API for callers/UI. */
  getModelOverrides() {
    return this._readModelOverrides() || {};
  }

  _stateSnapshot(job) {
    const { lastResult, ...rest } = job.state; // exclude lastResult — can be large
    return { ...rest, ...this._descriptorInfo(job.descriptor) };
  }

  _descriptorInfo(desc) {
    return {
      enabled: desc.enabled,
      pool: desc.pool || null,
      priority: desc.priority,
      intervalMs: desc.intervalMs,
    };
  }

  getPoolState() {
    const result = {};
    for (const [pool, active] of this._poolActive) {
      const queued = [];
      for (const [name, job] of this._jobs) {
        if (job.descriptor.pool === pool && job.state.status === 'queued') {
          queued.push(name);
        }
      }
      result[pool] = {
        active: [...active],
        max: this._poolLimits[pool],
        queued,
      };
    }
    return result;
  }

  uptime() {
    return this._startedAt ? Date.now() - this._startedAt : 0;
  }

  async shutdown() {
    if (this._shuttingDown) return;
    this._shuttingDown = true;
    this.emit('shuttingDown');

    if (this._tickTimer) {
      clearInterval(this._tickTimer);
      this._tickTimer = null;
    }

    // Reset queued jobs
    for (const [, job] of this._jobs) {
      if (job.state.status === 'queued') {
        job.state.status = 'idle';
      }
    }

    // Wait for in-flight jobs
    const inFlight = [];
    for (const [name, job] of this._jobs) {
      if (job.state.status === 'running' && job.runPromise) {
        inFlight.push(name);
      }
    }

    if (inFlight.length > 0) {
      const deadline = Date.now() + this._shutdownTimeoutMs;
      while (Date.now() < deadline) {
        const stillRunning = inFlight.filter(n => this._jobs.get(n)?.state.status === 'running');
        if (stillRunning.length === 0) break;
        await new Promise(r => setTimeout(r, 100));
      }
      const stillRunning = inFlight.filter(n => this._jobs.get(n)?.state.status === 'running');
      if (stillRunning.length > 0) {
        console.warn(`[scheduler] Force-closing with ${stillRunning.length} job(s) still running: ${stillRunning.join(', ')}`);
      }
    }

    this.emit('shutdown');
  }

  _tick() {
    if (this._shuttingDown) return;

    try {
      this._tickInner();
    } catch (err) {
      // Log and recover — don't let a single bad tick kill the scheduler loop.
      // setInterval keeps firing, so the next tick will retry.
      console.error('[scheduler] Tick error (recovering):', err);
      this.emit('tickError', err);
    }
  }

  _tickInner() {
    const now = Date.now();

    // 0. Reset queued→idle so they're reconsidered this tick
    for (const [, job] of this._jobs) {
      if (job.state.status === 'queued') job.state.status = 'idle';
    }

    // 1. Watchdog: check for stuck jobs
    for (const [name, job] of this._jobs) {
      if (job.state.status === 'running' && job.descriptor.timeoutMs && job.state.lastRunAt) {
        if (now - job.state.lastRunAt > job.descriptor.timeoutMs) {
          console.error(`[scheduler] Job "${name}" stuck for ${now - job.state.lastRunAt}ms, force-resetting`);
          this._forceReset(name);
        }
      }
    }

    // 2. Collect due candidates (idle, past nextEligibleAt, dependencies met, guard passes)
    const candidates = [];
    for (const [, job] of this._jobs) {
      if (this._ineligibleReason(job, now)) continue;
      candidates.push(job);
    }

    // 3. Sort by priority (lower = higher priority)
    candidates.sort((a, b) => a.descriptor.priority - b.descriptor.priority);

    // 4. Dispatch respecting pool limits
    for (const job of candidates) {
      const pool = job.descriptor.pool;
      if (pool && !this._poolHasCapacity(pool)) {
        job.state.status = 'queued';
        continue;
      }
      this._dispatch(job);
    }
  }

  _poolHasCapacity(pool) {
    const active = this._poolActive.get(pool);
    const limit = this._poolLimits[pool];
    if (!active || limit === undefined) return true;
    return active.size < limit;
  }

  _dependenciesMet(descriptor) {
    if (!descriptor.dependsOn || descriptor.dependsOn.length === 0) return true;
    return descriptor.dependsOn.every(dep => {
      const depJob = this._jobs.get(dep);
      return depJob && depJob.state.completedOnce;
    });
  }

  _ineligibleReason(job, now, opts = {}) {
    if (job.state.status !== 'idle') return 'not-idle';
    if (!job.descriptor.enabled) return 'disabled';
    if (!opts.ignoreNextEligibleAt && job.state.nextEligibleAt && now < job.state.nextEligibleAt) {
      return 'not-due';
    }
    if (!this._dependenciesMet(job.descriptor)) return 'dependencies';
    if (job.descriptor.shouldRun && !job.descriptor.shouldRun()) return 'should-run';
    return null;
  }

  _recordJobError(name, err, state) {
    if (!this._failureAlertEnabled) return;
    try {
      this._failureAlertModule.recordJobError({
        jobName: name,
        err,
        state,
        brain: this._failureAlertBrain,
        opts: this._failureAlertOpts,
      });
    } catch (e) {
      console.warn(`[scheduler] failure-alert error-hook failed: ${e.message}`);
    }
  }

  _persistState(name, job) {
    if (!this._persistJobState || !this._persistBrain) return;
    try {
      this._persistBrain.upsertSchedulerJobState({
        job_name: name,
        last_run_at: job.state.lastRunAt,
        last_finished_at: job.state.lastFinishedAt,
        next_eligible_at: job.state.nextEligibleAt,
        consecutive_errors: job.state.consecutiveErrors || 0,
        last_error: job.state.lastError || null,
        run_count: job.state.runCount,
        error_count: job.state.errorCount,
      });
    } catch (e) {
      console.warn(`[scheduler] Could not persist state for "${name}": ${e.message}`);
    }
  }

  _dispatch(job) {
    const { name, pool, run, onResult, onError } = job.descriptor;
    job.state.status = 'running';
    job.state.lastRunAt = Date.now();
    const epoch = job._epoch; // capture epoch to detect force-reset

    if (pool) this._poolActive.get(pool)?.add(name);

    job.runPromise = (async () => {
      try {
        // Plug-provider-routing Item A: per-job model override.
        // If the user has set kv_store[scheduler_model_overrides] for
        // this job name, thread the override into the run() context so
        // the underlying loop can pass it to skill-fallback.
        let modelOverride;
        try {
          if (this._persistBrain && typeof this._persistBrain.getDb === 'function') {
            const overrides = this._readModelOverrides();
            if (overrides && overrides[name]) modelOverride = overrides[name];
          }
        } catch {}
        const result = await run({
          trigger: job._wakeTrigger || 'interval',
          modelOverride: modelOverride || null,
        });
        // If force-reset happened while we were running, this promise is stale — bail
        if (job._epoch !== epoch) return;
        job._wakeTrigger = null;
        job.state.lastResult = result;
        job.state.lastError = null;
        job.state.runCount++;
        job.state.completedOnce = true;

        // Recovery: clear consecutive-error tracking + dismiss any active
        // scheduler:job_failure:<name> alert. Failure-alert behavior is
        // disabled in tests by default (failureAlert opt = false).
        if (this._failureAlertEnabled) {
          try {
            this._failureAlertModule.recordJobSuccess({
              jobName: name,
              state: job.state,
              brain: this._failureAlertBrain,
            });
          } catch (e) {
            console.warn(`[scheduler] failure-alert success-hook failed: ${e.message}`);
          }
        }

        if (onResult) onResult(result);
        this.emit('jobComplete', name, result);

        // Self-stopping
        if (result && result._done) {
          this.unregister(name);
          return;
        }

        this._checkCascades(name, result);
      } catch (err) {
        if (job._epoch !== epoch) return; // stale after force-reset
        job.state.lastError = err.message;
        job.state.errorCount++;

        // Failure-alert hook: bumps consecutiveErrors, emits service alert
        // when threshold (default 2) hit and outside cooldown (default 1h).
        this._recordJobError(name, err, job.state);

        if (onError) onError(err);
        this.emit('jobError', name, err);
      } finally {
        if (job._epoch !== epoch) return; // stale — _forceReset already cleaned up
        // Job may have been unregistered by self-stop
        if (this._jobs.has(name)) {
          job.state.status = 'idle';
          job.state.lastFinishedAt = Date.now();
          job.state.lastDurationMs = job.state.lastFinishedAt - job.state.lastRunAt;
          // Floor next-eligible at lastFinishedAt + minRefireGapMs so a
          // misconfigured interval (0/NaN/negative) cannot spin-loop.
          const baseNext = job.state.lastFinishedAt + job.descriptor.intervalMs;
          const floorNext = job.state.lastFinishedAt + this._minRefireGapMs;
          job.state.nextEligibleAt = Math.max(baseNext, floorNext);
          job.runPromise = null;

          // Persist state for restart catch-up.
          this._persistState(name, job);
        }
        if (pool) this._poolActive.get(pool)?.delete(name);
      }
    })();
  }

  _checkCascades(completedJobName, result) {
    if (!result || typeof result !== 'object') return;

    for (const [, job] of this._jobs) {
      if (!job.descriptor.cascadeFrom?.includes(completedJobName)) continue;
      // Use job's custom cascadeCheck if provided, otherwise check any numeric value > 0
      const check = job.descriptor.cascadeCheck || _defaultCascadeCheck;
      if (check(result)) {
        job.state.nextEligibleAt = Date.now();
      }
    }
  }

  _forceReset(name) {
    const job = this._jobs.get(name);
    if (!job) return;
    job._epoch++; // invalidate the orphaned promise so its finally block no-ops
    job.state.status = 'idle';
    job.state.errorCount++;
    job.state.lastError = 'watchdog timeout';
    job.state.lastFinishedAt = Date.now();
    if (job.state.lastRunAt) {
      job.state.lastDurationMs = job.state.lastFinishedAt - job.state.lastRunAt;
    }
    const now = Date.now();
    const baseNext = now + job.descriptor.intervalMs;
    const floorNext = now + this._minRefireGapMs;
    job.state.nextEligibleAt = Math.max(baseNext, floorNext);
    job.runPromise = null;
    if (job.descriptor.pool) {
      this._poolActive.get(job.descriptor.pool)?.delete(name);
    }
    this._recordJobError(name, new Error('watchdog timeout'), job.state);
    this._persistState(name, job);
    this.emit('jobTimeout', name);
  }

  /**
   * Run jobs whose persisted nextEligibleAt fell while the daemon was down.
   * Borrowed from OpenClaw's runMissedJobs (src/cron/service/timer.ts:962-1040).
   *
   * Behavior:
   *   - Pulls stored states via brain.listSchedulerJobStates()
   *   - Filters to registered + enabled jobs whose next_eligible_at <= now
   *   - Sorts by oldest next_eligible_at first
   *   - Runs at most maxMissedJobsPerRestart (default 5) immediately
   *   - Defers the rest (the periodic tick will pick them up naturally)
   *   - Staggers the immediate runs by missedJobStaggerMs (default 5s)
   *
   * Must be called BEFORE start() — it does NOT use the periodic tick.
   * Returns a summary so the caller can log + telemeter.
   */
  async runMissedJobs(opts = {}) {
    if (!this._persistJobState || !this._persistBrain) {
      return { ran: 0, deferred: 0, reason: 'persistence-disabled' };
    }
    const max = opts.max ?? this._maxMissedJobsPerRestart;
    const staggerMs = opts.staggerMs ?? this._missedJobStaggerMs;
    const skipNames = new Set(opts.skipNames || []);

    let states;
    try {
      states = this._persistBrain.listSchedulerJobStates();
    } catch (e) {
      console.warn(`[scheduler] runMissedJobs read failed: ${e.message}`);
      return { ran: 0, deferred: 0, reason: 'read-error' };
    }

    const now = Date.now();
    const candidates = [];
    const skipped = [];
    for (const stored of states) {
      if (skipNames.has(stored.job_name)) continue;
      const job = this._jobs.get(stored.job_name);
      if (!job) continue;                       // registered? if not, skip
      if (!job.descriptor.enabled) continue;
      if (job.state.status === 'running') continue;
      const next = stored.next_eligible_at;
      if (typeof next !== 'number' || next > now) continue;
      const reason = this._ineligibleReason(job, now, { ignoreNextEligibleAt: true });
      if (reason) {
        skipped.push({ job, reason });
        continue;
      }
      if (job.descriptor.pool && !this._poolHasCapacity(job.descriptor.pool)) {
        skipped.push({ job, reason: 'pool-full' });
        continue;
      }
      candidates.push({ stored, job });
    }
    // Sort by oldest-due first (most lag = highest priority)
    candidates.sort((a, b) =>
      (a.stored.next_eligible_at || 0) - (b.stored.next_eligible_at || 0)
    );
    const immediate = candidates.slice(0, max);
    const deferred = candidates.slice(max);

    if (deferred.length > 0) {
      console.log(
        `[scheduler] ${deferred.length} missed job(s) deferred to periodic tick: `
        + deferred.map((c) => c.stored.job_name).join(', ')
      );
    }
    if (skipped.length > 0) {
      console.log(
        `[scheduler] ${skipped.length} missed job(s) not eligible for restart catch-up: `
        + skipped.map((c) => `${c.job.descriptor.name}:${c.reason}`).join(', ')
      );
    }
    if (immediate.length > 0) {
      console.log(
        `[scheduler] Running ${immediate.length} missed job(s) after restart`
        + (immediate.length > 1 ? ` (staggered ${staggerMs}ms apart)` : '')
        + `: ` + immediate.map((c) => c.stored.job_name).join(', ')
      );
    }

    let ran = 0;
    for (let i = 0; i < immediate.length; i++) {
      const { job } = immediate[i];
      // Mark eligible RIGHT NOW so _dispatch picks it up
      job.state.nextEligibleAt = Date.now();
      job._wakeTrigger = 'restart-catchup';
      // Run via the same _dispatch path so pool semaphores + persistence
      // hooks all apply uniformly. Wait for the run to finish before
      // staggering the next one.
      try {
        this._dispatch(job);
        if (job.runPromise) await job.runPromise;
        ran++;
      } catch (e) {
        console.warn(`[scheduler] Missed-job replay for "${job.descriptor.name}" threw: ${e.message}`);
      }
      if (i < immediate.length - 1 && staggerMs > 0) {
        await new Promise((r) => setTimeout(r, staggerMs));
      }
    }

    return {
      ran,
      deferred: deferred.length + skipped.length,
      jobs_ran: immediate.map((c) => c.stored.job_name),
      jobs_deferred: [
        ...deferred.map((c) => c.stored.job_name),
        ...skipped.map((c) => c.job.descriptor.name),
      ],
      jobs_skipped: skipped.map((c) => ({ name: c.job.descriptor.name, reason: c.reason })),
    };
  }
}

module.exports = {
  Scheduler,
  MIN_REFIRE_GAP_MS,
  DEFAULT_MISSED_JOB_STAGGER_MS,
  DEFAULT_MAX_MISSED_JOBS_PER_RESTART,
};
