/**
 * Jobs scheduler — ticks every 60s, runs each due Job, dispatches new items.
 *
 * Started by lib/init.ts on first request. Runs inside the Next.js worker
 * alongside pipeline-scheduler. Each tick is async — the loop never waits
 * for a job's connector call to finish (just kicks it off and advances
 * next_run_at). The job's own run row is updated when the work completes.
 */

import {
  ensureSchema, getDueJobs, hasInflightRun, startRun, finishRun,
  markSeen, isSeen, recordDispatch, getJob, updateJob, setNextRunAt,
} from './store';
import { CronExpressionParser } from 'cron-parser';
import { ensureInstalledInProject } from '../skills';
import type { Job, JobRunStatus, PipelineDispatchParams, ChatDispatchParams } from './types';
import { dispatchTool } from '../chat/tool-dispatcher';
import { dispatchToPipeline, dispatchToChat, dispatchToChatSummary } from './dispatcher';
import { getDb } from '../../src/core/db/database';
import { getDbPath } from '../../src/config';
import { existsSync, readFileSync } from 'node:fs';
import { join as joinPath } from 'node:path';
import { getDataDir } from '../dirs';

/** Reconcile stale pipeline_runs rows against the canonical JSON
 *  state on disk. Called before any count, so counts return real
 *  inflight numbers — not zombies left behind by missed
 *  syncRunStatus calls (process crash / ReferenceError swallowed by
 *  empty catch / etc).
 *
 *  Rules:
 *    - Rows older than 30s in 'running'/'pending' get checked
 *      (younger ones might genuinely not have written first state).
 *    - JSON missing → mark DB row 'failed' (pipeline was cleaned up).
 *    - JSON in terminal state → sync DB row to that state.
 *    - JSON still running → leave the DB row.
 *
 *  Costs one stat + small file read per stale row. Idempotent.
 */
function reconcileStalePipelineRuns(): void {
  try {
    const db = getDb(getDbPath());
    const stale = db.prepare(
      `SELECT id, pipeline_id FROM pipeline_runs
        WHERE status IN ('running', 'pending')
          AND datetime(created_at) < datetime('now', '-30 seconds')`,
    ).all() as { id: string; pipeline_id: string }[];

    if (stale.length === 0) return;

    const update = db.prepare(`UPDATE pipeline_runs SET status = ? WHERE id = ?`);
    const pipelineDir = joinPath(getDataDir(), 'pipelines');

    for (const row of stale) {
      const file = joinPath(pipelineDir, `${row.pipeline_id}.json`);
      if (!existsSync(file)) {
        update.run('failed', row.id);
        console.warn(`[scheduler] reconciled zombie pipeline_run ${row.id} → failed (json gone)`);
        continue;
      }
      try {
        const p = JSON.parse(readFileSync(file, 'utf8')) as { status?: string };
        if (p.status && p.status !== 'running' && p.status !== 'pending') {
          update.run(p.status, row.id);
          console.warn(`[scheduler] reconciled pipeline_run ${row.id} → ${p.status}`);
        }
      } catch (e) {
        console.warn(`[scheduler] failed to read ${file} during reconcile: ${(e as Error).message}`);
      }
    }
  } catch (e) {
    console.warn(`[scheduler] reconcileStalePipelineRuns failed: ${(e as Error).message}`);
  }
}

/** Count pipelines currently running or pending — global. Used to
 *  enforce maxConcurrentPipelines. Reconciles stale rows first. */
function countActivePipelines(): number {
  reconcileStalePipelineRuns();
  try {
    const r = getDb(getDbPath()).prepare(
      `SELECT COUNT(*) AS n FROM pipeline_runs WHERE status IN ('running', 'pending')`,
    ).get() as { n: number } | undefined;
    return r?.n ?? 0;
  } catch (e) {
    console.warn(`[scheduler] countActivePipelines failed: ${(e as Error).message}`);
    return 0;
  }
}

/** Count THIS Job's previously-dispatched pipelines that are still
 *  running or pending. Used by sequential mode to gate the next tick.
 *  Reconciles stale rows first — without that, zombie 'running'
 *  rows from previous Forge crashes would block sequential Jobs forever. */
function countMyInflightPipelines(jobId: string): number {
  reconcileStalePipelineRuns();
  try {
    const r = getDb(getDbPath()).prepare(`
      SELECT COUNT(*) AS n
        FROM pipeline_runs pr
       WHERE pr.status IN ('running', 'pending')
         AND pr.pipeline_id IN (
           SELECT jd.dispatch_target_id
             FROM job_dispatches jd
             JOIN job_runs        jr ON jr.id = jd.job_run_id
            WHERE jr.job_id = ?
              AND jd.dispatch_type = 'pipeline'
              AND jd.created_at > datetime('now', '-1 day')
         )
    `).get(jobId) as { n: number } | undefined;
    return r?.n ?? 0;
  } catch (e) {
    console.warn(`[scheduler] countMyInflightPipelines(${jobId}) failed: ${(e as Error).message}`);
    return 0;
  }
}

/** "Is this Job busy right now?" — used by:
 *   1. Manual fire endpoint to refuse double-clicks (return 409).
 *   2. GET /api/jobs to render disabled state on Run / Force buttons.
 *
 *  Busy ⇔
 *    - there's an inflight job_run (tick currently executing) OR
 *    - it's a sequential Job whose previously-dispatched pipeline
 *      is still running/pending.
 *
 *  Reconciles stale rows before checking so zombies don't pin a
 *  Job as "busy" forever.
 */
export function isJobBusy(jobId: string): { busy: boolean; reason: string } {
  if (hasInflightRun(jobId)) {
    return { busy: true, reason: 'a tick of this Job is currently executing' };
  }
  const job = getJob(jobId);
  // Default-or-explicit sequential — check pipeline inflight.
  if (job && (job as any).concurrency_mode !== 'parallel' && job.dispatch_type === 'pipeline') {
    const n = countMyInflightPipelines(jobId);
    if (n > 0) {
      return { busy: true, reason: `${n} pipeline${n === 1 ? '' : 's'} from a prior run still active (sequential mode)` };
    }
  }
  return { busy: false, reason: '' };
}

/** Read settings.maxConcurrentPipelines (default 5, ceiling 20). */
async function getMaxConcurrentPipelines(): Promise<number> {
  try {
    const { loadSettings } = await import('@/lib/settings');
    const s = loadSettings();
    const v = (s as any).maxConcurrentPipelines;
    if (!Number.isFinite(v)) return 5;
    return Math.min(Math.max(Math.trunc(v), 1), 20);
  } catch {
    return 5;
  }
}

const TICK_INTERVAL_MS = 60_000;

const schedulerKey = Symbol.for('forge-jobs-scheduler');
const g = globalThis as any;

export function startScheduler(): void {
  if (g[schedulerKey]) return;
  g[schedulerKey] = true;
  ensureSchema();
  console.log('[jobs-scheduler] started');
  void tick();
  setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
}

async function tick(): Promise<void> {
  let due: Job[] = [];
  try { due = getDueJobs(); } catch (e) { console.error('[jobs-scheduler] getDueJobs failed', e); return; }

  for (const job of due) {
    if (hasInflightRun(job.id)) {
      console.log(`[jobs] skip ${job.id} — previous run still inflight`);
      // Still advance the schedule so we re-evaluate at the next interval.
      advanceSchedule(job);
      continue;
    }
    advanceSchedule(job);
    // Kick off the run; don't await — long connector calls / pipeline triggers
    // shouldn't block the scheduler loop.
    const { runId } = prepareRun(job, 'schedule');
    void executeRun(job, runId, 'schedule').catch((e) => {
      console.error(`[jobs] runJob ${job.id} crashed`, e);
    });
  }
}

function toSqlIso(d: Date): string {
  return d.toISOString().replace('T', ' ').slice(0, 19);
}

/**
 * Compute and store the job's next_run_at based on schedule_kind.
 * For 'once' jobs we also auto-disable after the firing tick so they
 * don't fire repeatedly when their schedule_at time is in the past.
 */
function advanceSchedule(job: Job): void {
  const now = Date.now();

  if (job.schedule_kind === 'manual') {
    // Manual jobs are filtered out of getDueJobs and should never get
    // here. Belt-and-suspenders: clear any stray next_run_at.
    setNextRunAt(job.id, null);
    return;
  }

  if (job.schedule_kind === 'once') {
    // One-shot: this tick is the fire. Disable for future, no next run.
    setNextRunAt(job.id, null);
    try { updateJob(job.id, { enabled: false }); } catch (e) {
      console.warn(`[jobs] failed to auto-disable one-shot ${job.id}:`, e);
    }
    return;
  }

  if (job.schedule_kind === 'cron' && job.schedule_cron) {
    try {
      const iter = CronExpressionParser.parse(job.schedule_cron, { currentDate: new Date(now) });
      const next = iter.next().toDate();
      setNextRunAt(job.id, toSqlIso(next));
      return;
    } catch (e) {
      console.warn(`[jobs] cron parse failed for ${job.id} (expr "${job.schedule_cron}"):`, (e as Error).message);
      // Fall through to period-style backoff so a broken cron doesn't
      // tight-loop the tick.
    }
  }

  // Default / 'period' path.
  const minutes = Math.max(1, job.schedule_interval_minutes || 30);
  const next = new Date(now + minutes * 60_000);
  setNextRunAt(job.id, toSqlIso(next));
}

/**
 * Execute one Job tick fully. Public for use by the scheduler loop and
 * the manual-fire API. The manual route prefers prepareRun + executeRun
 * (split below) so it can return the run id before the work finishes.
 */
export async function runJob(jobOrId: Job | string, trigger: 'schedule' | 'manual'): Promise<string> {
  const { job, runId } = prepareRun(jobOrId, trigger);
  await executeRun(job, runId, trigger);
  return runId;
}

/**
 * Insert a run row and return its id immediately. Use with executeRun(job, runId)
 * when you want fire-and-forget semantics (e.g. manual API trigger).
 */
export function prepareRun(jobOrId: Job | string, trigger: 'schedule' | 'manual'): { job: Job; runId: string } {
  const job = typeof jobOrId === 'string' ? getJob(jobOrId) : jobOrId;
  if (!job) throw new Error(`job not found: ${typeof jobOrId === 'string' ? jobOrId : '(null)'}`);
  const run = startRun(job.id, trigger);
  return { job, runId: run.id };
}

/**
 * Do the actual tick work (connector call + dedup + dispatch). Updates the run
 * row, including a verbose plain-text execution log saved to `job_runs.log`
 * for inspection in the UI ("show log" expansion under each run row).
 *
 * Every meaningful step appends one timestamped line to a per-tick buffer;
 * we also mirror the high-level lines to console for live tailing via
 * `tail -f forge.log | grep [jobs]`.
 */
export async function executeRun(job: Job, runId: string, trigger: 'schedule' | 'manual' = 'schedule'): Promise<void> {
  const t0 = Date.now();
  let itemsSeen = 0, itemsNew = 0, itemsDispatched = 0;
  let runError: string | null = null;
  const callName = `${job.source_connector}.${job.source_tool}`;
  const lines: string[] = [];
  const logLine = (level: 'info' | 'warn' | 'error', msg: string, mirror = true) => {
    const ts = new Date().toISOString().slice(11, 23);
    const ms = String(Date.now() - t0).padStart(5, ' ') + 'ms';
    lines.push(`${ts}  ${ms}  ${level.toUpperCase().padEnd(5)} ${msg}`);
    if (mirror) {
      const tag = `[jobs] ${job.id} ${callName}`;
      if (level === 'error') console.error(tag, msg);
      else if (level === 'warn') console.warn(tag, msg);
      else console.log(tag, msg);
    }
  };
  const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n) + ` …(${s.length - n} bytes truncated)` : s);

  // Always persist whatever log we accumulated, even on uncaught throws.
  const persist = (extra: { status: JobRunStatus; error?: string | null; notes?: string | null }) => {
    finishRun(runId, {
      status: extra.status,
      items_seen: itemsSeen,
      items_new: itemsNew,
      items_dispatched: itemsDispatched,
      error: extra.error ?? null,
      notes: extra.notes ?? null,
      log: lines.join('\n'),
    });
  };

  try {
    const { __mark_existing_as_seen, ...sourceInput } = job.source_input as any;
    logLine('info', `tick start trigger=${(__mark_existing_as_seen ? 'backfill' : 'normal')} dispatch=${job.dispatch_type}`);
    logLine('info', `source input: ${JSON.stringify(sourceInput)}`);

    // ── Source path A: inline ───────────────────────────────────
    // When source_connector === 'inline', the recipe / Job baked the
    // items list directly into source_input.items at instantiation —
    // no tool dispatch, no extension round-trip. Common case: a
    // user-typed list of ids being fanned out into pipeline runs.
    // Skips the entire dispatchTool / JSON parse layer.
    let parsed: unknown;
    let respBytes = 0;
    if (job.source_connector === 'inline') {
      logLine('info', `inline source — skipping tool dispatch`);
      parsed = { items: Array.isArray(sourceInput.items) ? sourceInput.items : [] };
      respBytes = JSON.stringify(parsed).length;
    } else {
      logLine('info', `calling connector ${callName}…`);
      const toolResult = await dispatchTool(
        { id: `job-${runId}`, name: callName, input: sourceInput },
        // We JSON.parse the response — the 8KB LLM-friendly truncation
        // would break parsing on any moderately large list (Todos, big MR
        // searches, etc.). Ask for the raw body.
        { noTruncation: true },
      );
      respBytes = toolResult.content?.length ?? 0;
      logLine(toolResult.is_error ? 'error' : 'info',
        `connector returned ${toolResult.is_error ? 'is_error=true ' : ''}${respBytes} bytes`);
      logLine('info', `response preview:\n${truncate(toolResult.content || '(empty)', 600)}`, false);

      if (toolResult.is_error) {
        throw new Error(`connector ${callName} failed: ${toolResult.content.slice(0, 500)}`);
      }
      parsed = safeParseJson(toolResult.content);
      if (parsed === undefined) {
        const note = `Connector returned non-JSON content (${respBytes} bytes). Preview: ${toolResult.content.slice(0, 200)}`;
        logLine('warn', note);
        persist({ status: 'ok', notes: note });
        return;
      }
    }
    logLine('info', `parsed JSON; type=${Array.isArray(parsed) ? 'array' : typeof parsed}`);

    let items = pickPath(parsed, job.items_path) as unknown;
    // Detail-style connectors (mantis.get_bug, gitlab.get_issue, …) return a
    // single object, not an array. Treat that as a 1-item list so the same
    // Job machinery (dedup, dispatch, render) works for both list and detail
    // tools. This is what lets you test a pipeline end-to-end with one
    // known item without writing a separate code path.
    if (items && typeof items === 'object' && !Array.isArray(items)) {
      logLine('info', `items_path resolved to a single object — wrapping as 1-item list`);
      items = [items];
    }
    if (!Array.isArray(items)) {
      const topKeys = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
        ? Object.keys(parsed as Record<string, unknown>).join(', ')
        : Array.isArray(parsed) ? '(top-level is an array — leave items_path empty)' : typeof parsed;
      const note = `items_path "${job.items_path || '(empty)'}" did not resolve to an array or object. Top-level keys: ${topKeys}`;
      logLine('warn', note);
      persist({ status: 'ok', notes: note });
      return;
    }
    const itemsArr = items as unknown[];
    itemsSeen = itemsArr.length;
    logLine('info', `items_path="${job.items_path || '(root)'}" → ${itemsSeen} item(s)`);

    // ── Backfill guard ───────────────────────────────────────────
    if (__mark_existing_as_seen) {
      let backfilledKeys = 0, missingKey = 0;
      for (const item of itemsArr) {
        const key = pickDedupKey(item, job.dedup_field);
        if (key) { markSeen(job.id, key); backfilledKeys++; }
        else missingKey++;
      }
      updateJob(job.id, { source_input: sourceInput });
      logLine('info', `backfill complete: marked ${backfilledKeys} items as seen` + (missingKey ? `, ${missingKey} skipped (missing dedup_field "${job.dedup_field}")` : ''));
      const note = `Backfill (first tick): marked ${backfilledKeys} existing items as seen — no dispatches by design. Future ticks dispatch only NEW items. To re-process everything, click "Reset dedup".`;
      persist({ status: 'ok', notes: note });
      return;
    }

    // ── Chat summary mode — bundle all NEW items into ONE message ──
    // Useful when search_bugs returns N items and you want the LLM to
    // analyze + summarize, instead of getting N separate chat messages
    // (or just the first item if reuse_session was off).
    if (job.dispatch_type === 'chat' &&
        (job.dispatch_params as ChatDispatchParams).mode === 'summary') {
      const chatParams = job.dispatch_params as ChatDispatchParams;
      let dedupHits = 0, missingKey = 0;
      const newItems: unknown[] = [];
      for (const [idx, item] of itemsArr.entries()) {
        const key = pickDedupKey(item, job.dedup_field);
        if (!key) { missingKey++; logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`); continue; }
        if (!markSeen(job.id, key)) { dedupHits++; continue; }
        itemsNew++;
        newItems.push(item);
      }
      if (newItems.length === 0) {
        const note = itemsSeen === 0
          ? 'Connector returned 0 items.'
          : `All ${itemsSeen} items already seen — nothing new to summarize.`;
        logLine('info', `summary mode: ${note}`);
        persist({ status: 'ok', notes: note });
        return;
      }
      logLine('info', `summary mode: bundling ${newItems.length} new item(s) into one chat message`);
      // Pull the connector's server-side total (e.g. mantis search_bugs
      // total_matching = "151" parsed from "Viewing Bugs (1 - 50 / 151)").
      // The {{count}} placeholder in the user's template now means "items
      // they're actually seeing"; {{total_matching}} is "how many matched
      // the filter on the server". Falls back to parsed.total when the
      // connector doesn't emit total_matching.
      const totalMatching = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
        ? (parsed as any).total_matching ?? (parsed as any).total ?? null
        : null;
      try {
        const out = await dispatchToChatSummary(chatParams, newItems, job.id, { totalMatching, skills: job.skills });
        recordDispatch({
          job_run_id: runId,
          item_key: `summary-${runId}`,
          item_preview: `[${newItems.length} items folded into one message]`,
          dispatch_type: 'chat', dispatch_target_id: out.target_id, status: 'dispatched',
        });
        itemsDispatched = 1; // one logical dispatch, regardless of item count
        logLine('info', `summary → chat:${out.target_id} (${newItems.length} items)`);
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        recordDispatch({
          job_run_id: runId, item_key: `summary-${runId}`, item_preview: '',
          dispatch_type: 'chat', dispatch_target_id: null, status: 'error', error: msg,
        });
        logLine('error', `summary dispatch failed: ${msg}`);
      }
      logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched (summary), ${dedupHits} dedup hits` + (missingKey ? `, ${missingKey} missing-key` : ''));
      persist({ status: 'ok', notes: null });
      return;
    }

    // ── Per-item dispatch ────────────────────────────────────────
    //
    // Two budgets gate dispatch:
    //   (a) Per-Job `max_per_tick` (default 5, capped 1-10) — how many
    //       NEW items this job is allowed to fan out per tick. Anything
    //       over that stays unmarked → rolls over to next tick.
    //   (b) Global concurrent-pipeline cap (settings.maxConcurrentPipelines,
    //       default 5, max 20) — counted across ALL running/pending
    //       pipelines, not just this job's. Prevents one job from
    //       monopolizing all slots.
    // Why both: a single job with max_per_tick=10 can still go over if
    // there are already 15 pipelines from OTHER jobs in flight.
    const concurrencyMode: 'parallel' | 'sequential' =
      (job as any).concurrency_mode === 'parallel' ? 'parallel' : 'sequential';
    const budget = concurrencyMode === 'sequential' ? 1 : (() => {
      const v = (job as any).max_per_tick;
      if (!Number.isFinite(v) || v == null) return 5;
      return Math.min(Math.max(Math.trunc(v), 1), 10);
    })();
    const globalCap = await getMaxConcurrentPipelines();

    // Sequential gate: if any pipeline this Job previously dispatched
    // is still running, defer the entire tick. Items stay un-dedup-
    // marked so the next tick re-encounters them. This guarantees at
    // most one pipeline from this Job runs at a time — solves
    // GitLab/Mantis rate-limit + browser-tab race classes of bugs.
    if (concurrencyMode === 'sequential' && job.dispatch_type === 'pipeline') {
      // on_failure='stop': if the most recent dispatched pipeline ended
      // in 'failed', halt the drain. User has to Force-run to resume.
      // Default 'continue' just falls through to the regular gate check.
      //
      // Manual fires (Run now / Force run) bypass this check — they
      // ARE the user's "resume after a failure" action; halting them
      // would deadlock the Job.
      const onFailure: 'continue' | 'stop' = (job as any).on_failure === 'stop' ? 'stop' : 'continue';
      if (onFailure === 'stop' && trigger !== 'manual') {
        try {
          const recent = getDb(getDbPath()).prepare(`
            SELECT pr.status FROM pipeline_runs pr
              JOIN job_dispatches jd ON jd.dispatch_target_id = pr.pipeline_id
              JOIN job_runs       jr ON jr.id = jd.job_run_id
             WHERE jr.job_id = ?
               AND jd.dispatch_type = 'pipeline'
             ORDER BY jd.created_at DESC LIMIT 1
          `).get(job.id) as { status?: string } | undefined;
          if (recent?.status === 'failed') {
            logLine('warn', `on_failure=stop: previous pipeline FAILED — halting sequential drain. Clear with Force run.`);
            try { setNextRunAt(job.id, null); } catch {}
            persist({
              status: 'ok',
              notes: `Halted: on_failure=stop and previous pipeline failed. Force run to resume.`,
            });
            return;
          }
        } catch (e) {
          logLine('warn', `on_failure check failed: ${(e as Error).message} — proceeding as if 'continue'`);
        }
      }

      const myInflight = countMyInflightPipelines(job.id);
      if (myInflight > 0) {
        logLine('info', `sequential mode: ${myInflight} pipeline from this Job still running — deferring entire tick`);
        // Drain-mode: schedule another tick in 60s so the queue keeps
        // draining regardless of schedule_kind. Without this a manual
        // Job whose advanceSchedule cleared next_run_at would never get
        // re-picked-up, and the deferred items would sit forever.
        try {
          const nextDrain = new Date(Date.now() + 60_000);
          setNextRunAt(job.id, toSqlIso(nextDrain));
        } catch (e) {
          logLine('warn', `sequential drain (gate): failed to set next_run_at: ${(e as Error).message}`);
        }
        persist({
          status: 'ok',
          notes: `Sequential: previous pipeline still inflight (${myInflight}). All ${itemsArr.length} item(s) deferred — will retry next tick.`,
        });
        return;
      }
    }

    let dispatchedThisTick = 0;
    let dedupHits = 0, missingKey = 0, deferred = 0;
    for (const [idx, item] of itemsArr.entries()) {
      const key = pickDedupKey(item, job.dedup_field);
      if (!key) {
        missingKey++;
        logLine('warn', `[${idx}] item missing dedup_field "${job.dedup_field}" — skipping`);
        continue;
      }
      // Read-only dedup check — don't mark yet, in case we hit budget.
      if (isSeen(job.id, key)) {
        dedupHits++;
        logLine('info', `[${idx}] ${key} — already seen, skip`, false);
        continue;
      }
      // Per-Job budget?
      if (dispatchedThisTick >= budget) {
        deferred++;
        continue;
      }
      // Global cap? Re-check each iteration since other jobs' pipelines
      // may finish or new ones start while we loop.
      if (job.dispatch_type === 'pipeline') {
        const inFlight = countActivePipelines();
        if (inFlight >= globalCap) {
          deferred++;
          logLine('info', `[${idx}] ${key} — global pipeline cap (${globalCap}) reached; deferring to next tick`);
          continue;
        }
      }
      // OK to commit dedup + dispatch.
      markSeen(job.id, key);
      itemsNew++;
      dispatchedThisTick++;
      const preview = renderItemPreview(item);
      logLine('info', `[${idx}] ${key} — new — dispatching ${job.dispatch_type}…`);
      const dispatchStart = Date.now();
      // Auto-install any skill named on the job into the dispatch
      // target project, so the spawned task can actually invoke them.
      // Skipped for chat dispatches — chat backend already sees globally-
      // installed skills and we don't have a target project here.
      if (job.skills && job.skills.length && job.dispatch_type === 'pipeline') {
        const targetProject = (job.dispatch_params as PipelineDispatchParams).project_path;
        if (targetProject) {
          for (const skillName of job.skills) {
            try {
              const r = await ensureInstalledInProject(skillName, targetProject);
              if (!r.installed) logLine('warn', `skill "${skillName}" not installable: ${r.reason}`);
            } catch (err) {
              logLine('warn', `skill "${skillName}" install failed: ${(err as Error).message}`);
            }
          }
        }
      }
      try {
        const out = job.dispatch_type === 'pipeline'
          ? dispatchToPipeline(job.dispatch_params as PipelineDispatchParams, item, key, { skills: job.skills })
          : await dispatchToChat(job.dispatch_params as ChatDispatchParams, item, job.id, { skills: job.skills });
        recordDispatch({
          job_run_id: runId, item_key: key, item_preview: preview,
          dispatch_type: job.dispatch_type, dispatch_target_id: out.target_id, status: 'dispatched',
        });
        itemsDispatched++;
        logLine('info', `[${idx}] ${key} → ${job.dispatch_type}:${out.target_id} (${Date.now() - dispatchStart}ms)`);
        const emptyKeys = (out as any).empty_keys as string[] | undefined;
        if (emptyKeys && emptyKeys.length > 0) {
          logLine('warn', `[${idx}] ${key} — these input_template keys rendered EMPTY (likely a {{item.X}} where X isn't on the item): ${emptyKeys.join(', ')}. Pipeline node will probably fail with 'is required' — fix by typing a literal value in the job's input_template.`);
        }
      } catch (e) {
        const msg = e instanceof Error ? e.message : String(e);
        recordDispatch({
          job_run_id: runId, item_key: key, item_preview: preview,
          dispatch_type: job.dispatch_type, dispatch_target_id: null, status: 'error', error: msg,
        });
        logLine('error', `[${idx}] ${key} dispatch failed: ${msg}`);
      }
    }

    let note: string | null = null;
    if (itemsDispatched === 0) {
      if (itemsSeen === 0) note = 'Connector returned 0 items.';
      else if (dedupHits === itemsSeen) note = `All ${itemsSeen} items were already seen (dedup hits). No new items to dispatch. Click "Reset dedup" to re-process everything.`;
      else if (missingKey === itemsSeen) note = `All ${itemsSeen} items lacked the dedup_field "${job.dedup_field}". Check the field name vs the connector's response shape.`;
    }

    if (deferred > 0) {
      const baseNote = note ? note + ' ' : '';
      note = `${baseNote}${deferred} item(s) deferred to next tick (per-Job budget ${budget} or global cap ${globalCap} reached).`;
    }

    // Sequential drain mode: if this Job is sequential AND has deferred
    // items waiting, force next_run_at to a short interval so the scheduler
    // keeps picking it up until the batch is drained. Works for ANY
    // schedule_kind — including 'manual' Jobs where the user did one
    // Force run and expects all batched items to process automatically
    // afterwards. Without this, manual + sequential = "one Force run
    // dispatches exactly one item, stop" — which surprised the user.
    if (deferred > 0 && concurrencyMode === 'sequential' && job.dispatch_type === 'pipeline') {
      try {
        const nextDrain = new Date(Date.now() + 60_000); // 60s — tick cycle
        setNextRunAt(job.id, toSqlIso(nextDrain));
        logLine('info', `sequential drain: ${deferred} item(s) still queued — next tick at ${nextDrain.toISOString()}`);
      } catch (e) {
        logLine('warn', `sequential drain: failed to set next_run_at: ${(e as Error).message}`);
      }
    }

    logLine('info', `tick done in ${Date.now() - t0}ms — ${itemsSeen} seen, ${itemsNew} new, ${itemsDispatched} dispatched, ${dedupHits} dedup hits` + (deferred ? `, ${deferred} deferred` : '') + (missingKey ? `, ${missingKey} missing-key` : ''));
    persist({ status: 'ok', notes: note });
  } catch (e) {
    runError = e instanceof Error ? e.message : String(e);
    logLine('error', `tick failed: ${runError}`);
    persist({ status: 'error', error: runError });
  }
}

// ─── Helpers ──────────────────────────────────────────────

function safeParseJson(content: string): unknown | undefined {
  try { return JSON.parse(content); }
  catch {}
  // Strip the http-protocol preamble — `HTTP 200 OK · GET <url>\n[(...)]\n\n`
  // (and the truncation note line that sometimes follows) — and retry.
  // The LLM-facing payload bakes in a status line we don't want here.
  const firstJson = content.search(/[\[{]/);
  if (firstJson > 0) {
    try { return JSON.parse(content.slice(firstJson)); }
    catch {}
  }
  return undefined;
}

function pickPath(obj: unknown, path: string): unknown {
  if (!path) return obj;
  const parts = path.split('.');
  let cur: any = obj;
  for (const p of parts) {
    if (cur == null || typeof cur !== 'object') return undefined;
    cur = cur[p];
  }
  return cur;
}

function pickDedupKey(item: unknown, field: string): string | null {
  // `field` can be a single dot-path ("user.id") OR a colon-joined
  // composite of multiple dot-paths ("iid:user_notes_count"). The
  // composite form yields a stable signature for "this entity's
  // current change state" — e.g. an MR id + its comment count, so a
  // new comment bumps the signature and triggers a fresh dispatch.
  // Any segment missing → null (caller skips item).
  if (field.includes(':')) {
    const parts: string[] = [];
    for (const seg of field.split(':')) {
      const v = pickPath(item, seg);
      if (v == null) return null;
      parts.push(typeof v === 'string' ? v : String(v));
    }
    return parts.join(':');
  }
  const v = pickPath(item, field);
  if (v == null) return null;
  return typeof v === 'string' ? v : String(v);
}

function renderItemPreview(item: unknown): string {
  try {
    const s = JSON.stringify(item);
    return s.length > 200 ? s.slice(0, 200) + '…' : s;
  } catch { return ''; }
}
