// agent-cron.js — scheduled call_agent (cron) for the clauth daemon (WP-P5).
//
// A single setInterval inside the daemon reads `call_agent_schedules`
// (enabled=true, next_run_at <= now) and, for each due row, fires the EXISTING
// call_agent path in async mode (so it persists to monkey_scratchpad exactly
// like any other call_agent job), then advances last_run_at + next_run_at from
// the row's 5-field cron expression.
//
// Design constraints (see WP-P5):
//   * OFF by default. The whole scheduler is gated behind CLAUTH_AGENT_CRON;
//     unset/`0`/`false` ⇒ nothing runs. The live host keeps it off until the
//     supervisor enables it post-review.
//   * Dependency-light. A minimal pure 5-field cron parser is vendored here — no
//     heavy npm dep is added to the credential daemon.
//   * Resilient. A due-schedule firing must NOT crash the daemon: every fire is
//     wrapped in try/catch, logged, and the loop continues. Slow agents never
//     block the interval — dispatch is fire-and-forget async. Concurrent fires
//     are bounded so the 2-worker pool isn't flooded; over-budget due rows are
//     simply left for the next tick. Cron never touches interactive call_agent
//     beyond sharing the same async dispatch entrypoint.
//
// This module is self-contained and daemon-free so its core (parser, due
// selection, fire-with-mocked-dispatch) is unit-testable WITHOUT the clauth
// vault, boot.key, Supabase, or a live `claude` binary.

// ── 5-field cron parser ──────────────────────────────────────────────────────
// Fields: minute hour day-of-month month day-of-week
//   minute       0-59
//   hour         0-23
//   day-of-month 1-31
//   month        1-12
//   day-of-week  0-6  (0 = Sunday; 7 also accepted as Sunday)
// Supported per field: `*`, `a`, `a-b`, `*/n`, `a-b/n`, and comma lists of those.
// Day-of-month / day-of-week follow standard cron OR semantics: when BOTH are
// restricted (neither is `*`), a minute matches if EITHER matches. When one is
// `*`, only the other constrains. Evaluation is in UTC (the daemon stores
// timestamptz; UTC is unambiguous and matches Supabase `now()`).

const FIELD_RANGES = [
  [0, 59], // minute
  [0, 23], // hour
  [1, 31], // day of month
  [1, 12], // month
  [0, 6], // day of week (7 normalized to 0)
];

function parseField(spec, idx) {
  const [min, max] = FIELD_RANGES[idx];
  const set = new Set();
  const addRange = (lo, hi, step) => {
    for (let v = lo; v <= hi; v += step) set.add(v);
  };
  for (const partRaw of String(spec).split(",")) {
    const part = partRaw.trim();
    if (part === "") throw new Error(`empty cron field segment in "${spec}"`);
    let step = 1;
    let rangePart = part;
    const slash = part.indexOf("/");
    if (slash >= 0) {
      rangePart = part.slice(0, slash);
      step = Number(part.slice(slash + 1));
      if (!Number.isInteger(step) || step <= 0) {
        throw new Error(`invalid step in cron field "${part}"`);
      }
    }
    let lo;
    let hi;
    if (rangePart === "*") {
      lo = min;
      hi = max;
    } else if (rangePart.includes("-")) {
      const [a, b] = rangePart.split("-");
      lo = Number(a);
      hi = Number(b);
    } else {
      lo = Number(rangePart);
      hi = lo;
    }
    if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
      throw new Error(`non-numeric cron field "${part}"`);
    }
    // Day-of-week: normalize 7 -> 0 (Sunday).
    if (idx === 4) {
      if (lo === 7) lo = 0;
      if (hi === 7) hi = 0;
    }
    if (lo > hi) throw new Error(`reversed range in cron field "${part}"`);
    if (lo < min || hi > max) {
      throw new Error(`cron field "${part}" out of range [${min},${max}]`);
    }
    addRange(lo, hi, step);
  }
  return set;
}

/**
 * Parse a 5-field cron expression into per-field match sets plus the raw
 * wildcard flags needed for day-of-month/day-of-week OR semantics.
 * @param {string} expr
 * @returns {{minute:Set,hour:Set,dom:Set,month:Set,dow:Set,domStar:boolean,dowStar:boolean}}
 */
export function parseCron(expr) {
  if (typeof expr !== "string") throw new Error("cron expression must be a string");
  const fields = expr.trim().split(/\s+/);
  if (fields.length !== 5) {
    throw new Error(`cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
  }
  return {
    minute: parseField(fields[0], 0),
    hour: parseField(fields[1], 1),
    dom: parseField(fields[2], 2),
    month: parseField(fields[3], 3),
    dow: parseField(fields[4], 4),
    domStar: fields[2].trim() === "*",
    dowStar: fields[4].trim() === "*",
  };
}

/** True iff `date` (UTC) matches the parsed cron at minute granularity. */
export function cronMatches(parsed, date) {
  const minute = date.getUTCMinutes();
  const hour = date.getUTCHours();
  const dom = date.getUTCDate();
  const month = date.getUTCMonth() + 1;
  const dow = date.getUTCDay(); // 0-6, Sunday=0

  if (!parsed.minute.has(minute)) return false;
  if (!parsed.hour.has(hour)) return false;
  if (!parsed.month.has(month)) return false;

  // Standard cron day OR semantics.
  if (parsed.domStar && parsed.dowStar) return true;
  if (parsed.domStar) return parsed.dow.has(dow);
  if (parsed.dowStar) return parsed.dom.has(dom);
  return parsed.dom.has(dom) || parsed.dow.has(dow);
}

/**
 * Compute the next UTC instant strictly AFTER `from` that matches the cron.
 * Minute-granular; scans forward minute-by-minute with a bounded horizon
 * (~4 years) so a never-matching field (e.g. Feb 30) returns null instead of
 * spinning forever.
 * @param {string} expr  5-field cron
 * @param {Date} [from]  base instant (default now)
 * @returns {Date|null}
 */
export function nextRun(expr, from = new Date()) {
  const parsed = parseCron(expr);
  // Start at the next whole minute after `from` (cron fires at minute boundaries).
  const d = new Date(from.getTime());
  d.setUTCSeconds(0, 0);
  d.setUTCMinutes(d.getUTCMinutes() + 1);
  const HORIZON_MIN = 366 * 4 * 24 * 60; // ~4 years of minutes
  for (let i = 0; i < HORIZON_MIN; i++) {
    if (cronMatches(parsed, d)) return new Date(d.getTime());
    d.setUTCMinutes(d.getUTCMinutes() + 1);
  }
  return null;
}

/** Validate a cron expression; returns { ok, error }. Never throws. */
export function validateCron(expr) {
  try {
    parseCron(expr);
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

// ── Due selection ────────────────────────────────────────────────────────────

/**
 * From a list of schedule rows, pick the ones that are due to fire at `now`.
 * A row is due iff enabled AND (next_run_at is null OR next_run_at <= now). A
 * null next_run_at means "never computed" — fire it now and seed its next run.
 * Rows with an unparseable cron are skipped (and surfaced via onInvalid) so one
 * bad row cannot wedge the scheduler.
 * @param {Array<object>} rows
 * @param {Date} now
 * @param {(row:object, error:string)=>void} [onInvalid]
 * @returns {Array<object>} due rows (input order preserved)
 */
export function selectDue(rows, now = new Date(), onInvalid) {
  const due = [];
  for (const row of Array.isArray(rows) ? rows : []) {
    if (!row || row.enabled === false) continue;
    const check = validateCron(row.cron);
    if (!check.ok) {
      if (typeof onInvalid === "function") onInvalid(row, check.error);
      continue;
    }
    const next = row.next_run_at ? new Date(row.next_run_at) : null;
    if (next == null || Number.isNaN(next.getTime()) || next.getTime() <= now.getTime()) {
      due.push(row);
    }
  }
  return due;
}

// ── Gating ───────────────────────────────────────────────────────────────────

/** True iff the scheduler is enabled via CLAUTH_AGENT_CRON (OFF by default). */
export function cronEnabled(env = process.env) {
  const v = String(env.CLAUTH_AGENT_CRON ?? "").trim().toLowerCase();
  return v === "1" || v === "true" || v === "on" || v === "yes";
}

// ── Scheduler ────────────────────────────────────────────────────────────────

export const DEFAULT_TICK_MS = 30000;
// Cap concurrent scheduled fires per tick so cron can't flood the warm pool and
// starve interactive call_agent. Over-budget due rows wait for the next tick.
export const DEFAULT_MAX_CONCURRENT_FIRES = 2;
// Live back-pressure budget: cron skips firing this tick when the shared
// AgentPool's pending queue is already at/above this depth, so scheduled jobs
// never pile onto the unbounded pool queue ahead of interactive call_agent.
// Defaults to maxConcurrentFires when unset (see AgentCron constructor).

/**
 * AgentCron — a self-contained scheduler. It does NOT import the daemon; the
 * daemon injects three thin callbacks so this module stays testable in
 * isolation:
 *   - listDueSchedules(now)  → Promise<Array<row>>   read due rows (Supabase)
 *   - fireSchedule(row)      → Promise<{jobId?}>      dispatch via call_agent
 *   - updateScheduleRun(row, { last_run_at, next_run_at, last_job_id })
 *                            → Promise<void>          persist the new run window
 *   - poolQueueDepth()       → number                 OPTIONAL: current pending
 *                            depth of the shared AgentPool queue (live
 *                            back-pressure). When supplied and the depth is at/
 *                            above maxQueueDepth, the tick fires NOTHING and
 *                            leaves all due rows due (their next_run_at is
 *                            untouched), so cron self-throttles against the pool
 *                            and never starves interactive call_agent. The tick
 *                            interval bounds re-evaluation, so a saturated pool
 *                            does not hot-loop.
 *
 * Any of these may throw/reject; the scheduler wraps each per-row fire in
 * try/catch and continues. A slow fireSchedule never blocks the tick because
 * fires run concurrently (bounded) and the tick does not await long agent work
 * — async dispatch returns a jobId immediately.
 */
export class AgentCron {
  /**
   * @param {object} opts
   * @param {(now:Date)=>Promise<Array<object>>} opts.listDueSchedules
   * @param {(row:object)=>Promise<{jobId?:string}>} opts.fireSchedule
   * @param {(row:object, run:object)=>Promise<void>} opts.updateScheduleRun
   * @param {()=>number} [opts.poolQueueDepth] live pool-queue depth probe
   * @param {number} [opts.maxQueueDepth]     back-pressure budget (default = maxConcurrentFires)
   * @param {number} [opts.tickMs]            sweep cadence (default 30s)
   * @param {number} [opts.maxConcurrentFires] per-tick fire budget (default 2)
   * @param {(msg:string, meta?:object)=>void} [opts.log]
   * @param {()=>Date} [opts.now]             injectable clock for tests
   */
  constructor(opts = {}) {
    this.listDueSchedules = opts.listDueSchedules;
    this.fireSchedule = opts.fireSchedule;
    this.updateScheduleRun = opts.updateScheduleRun;
    this.poolQueueDepth = typeof opts.poolQueueDepth === "function" ? opts.poolQueueDepth : null;
    this.tickMs = Number.isFinite(opts.tickMs) ? Math.max(1000, opts.tickMs) : DEFAULT_TICK_MS;
    this.maxConcurrentFires = Number.isFinite(opts.maxConcurrentFires)
      ? Math.max(1, opts.maxConcurrentFires)
      : DEFAULT_MAX_CONCURRENT_FIRES;
    // Back-pressure budget: when the live pool queue is at/above this, skip the
    // whole tick. Defaults to the per-tick fire budget so a tick never queues
    // more than one fire-budget's worth ahead of interactive call_agent.
    this.maxQueueDepth = Number.isFinite(opts.maxQueueDepth)
      ? Math.max(1, opts.maxQueueDepth)
      : this.maxConcurrentFires;
    this.log = typeof opts.log === "function" ? opts.log : () => {};
    this._now = typeof opts.now === "function" ? opts.now : () => new Date();

    this._timer = null;
    this._ticking = false; // overlap guard — a long tick must not re-enter
    this.stats = { ticks: 0, fired: 0, failed: 0, throttled: 0, lastTickAt: null, lastError: null };
  }

  /** Start the periodic sweep. Idempotent. */
  start() {
    if (this._timer) return;
    this._timer = setInterval(() => {
      this.tick().catch((e) => {
        this.stats.lastError = e?.message || String(e);
        this.log(`[agent-cron] tick error: ${this.stats.lastError}`);
      });
    }, this.tickMs);
    // unref so the scheduler never keeps the process alive on its own; the
    // daemon owns lifecycle and stops it on shutdown.
    if (this._timer && this._timer.unref) this._timer.unref();
    this.log(`[agent-cron] started (tick=${this.tickMs}ms, maxFires=${this.maxConcurrentFires})`);
  }

  /** Stop the sweep. Idempotent. */
  stop() {
    if (this._timer) {
      clearInterval(this._timer);
      this._timer = null;
      this.log("[agent-cron] stopped");
    }
  }

  /**
   * One sweep: read due rows, fire up to the budget, advance their run windows.
   * Never throws — every failure mode is caught and logged so the daemon and the
   * interval survive. Returns a small summary (used by tests).
   */
  async tick() {
    if (this._ticking) return { skipped: "overlap" }; // previous tick still running
    this._ticking = true;
    const now = this._now();
    this.stats.ticks++;
    this.stats.lastTickAt = now.toISOString();
    let fired = 0;
    let failed = 0;
    try {
      // Live back-pressure gate: if the shared AgentPool queue is already at/
      // above budget, fire NOTHING this tick. Due rows stay due (next_run_at
      // untouched) and fire on a later tick once the pool drains — so scheduled
      // jobs never pile onto the unbounded pool queue ahead of interactive
      // call_agent. The tick interval bounds re-checks; no hot-loop.
      if (this.poolQueueDepth) {
        let depth = NaN;
        try {
          depth = Number(this.poolQueueDepth());
        } catch (e) {
          // A failing probe must not wedge cron; treat as "unknown" and proceed.
          this.log(`[agent-cron] poolQueueDepth probe failed: ${e?.message || e}`);
          depth = NaN;
        }
        if (Number.isFinite(depth) && depth >= this.maxQueueDepth) {
          this.stats.throttled++;
          this.log(`[agent-cron] throttled: pool queue depth ${depth} >= budget ${this.maxQueueDepth}; skipping fires this tick`);
          return { fired: 0, failed: 0, throttled: true, queueDepth: depth };
        }
      }
      let due = [];
      try {
        due = (await this.listDueSchedules(now)) || [];
      } catch (e) {
        this.stats.lastError = `list: ${e?.message || e}`;
        this.log(`[agent-cron] listDueSchedules failed: ${this.stats.lastError}`);
        return { fired: 0, failed: 0, error: this.stats.lastError };
      }
      // Bound concurrent fires this tick; leftover due rows wait for next tick.
      const batch = due.slice(0, this.maxConcurrentFires);
      const results = await Promise.allSettled(batch.map((row) => this._fireOne(row, now)));
      for (const r of results) {
        if (r.status === "fulfilled" && r.value && r.value.ok) fired++;
        else failed++;
      }
      this.stats.fired += fired;
      this.stats.failed += failed;
      return { fired, failed, due: due.length, considered: batch.length };
    } finally {
      this._ticking = false;
    }
  }

  /**
   * Fire one due schedule and advance its run window. Isolated try/catch so one
   * bad schedule cannot affect the others or the loop. Always attempts to
   * advance next_run_at (even on a fire failure) so a persistently-broken
   * schedule does not re-fire every single tick.
   */
  async _fireOne(row, now) {
    let jobId = null;
    let fireOk = false;
    let fireErr = null;
    try {
      const res = await this.fireSchedule(row);
      jobId = res && res.jobId ? res.jobId : null;
      fireOk = !!(res && (res.ok !== false));
    } catch (e) {
      fireErr = e?.message || String(e);
      this.log(`[agent-cron] fire "${row?.name || row?.id}" failed: ${fireErr}`);
    }

    // Advance the run window regardless, so we don't hot-loop on a bad row.
    try {
      const next = nextRun(row.cron, now);
      await this.updateScheduleRun(row, {
        last_run_at: now.toISOString(),
        next_run_at: next ? next.toISOString() : null,
        last_job_id: jobId,
      });
    } catch (e) {
      // A failed persist means we may re-fire next tick; log but don't crash.
      this.log(`[agent-cron] updateScheduleRun "${row?.name || row?.id}" failed: ${e?.message || e}`);
    }

    return { ok: fireOk && !fireErr, jobId, error: fireErr };
  }
}

export default AgentCron;
