/**
 * Sqlite-backed CRUD for Jobs. Schema lives here so the table is auto-
 * created on first use (mirrors lib/issue-scanner.ts style).
 */

import { getDb } from '../../src/core/db/database';
import { getDbPath } from '../../src/config';
import { randomUUID } from 'node:crypto';
import { toIsoUTC } from '../iso-time';
import { CronExpressionParser } from 'cron-parser';
import type {
  Job, JobRun, JobDispatch, CreateJobInput,
  JobRunStatus, JobRunTrigger, JobDispatchStatus,
  DispatchParams,
} from './types';

function db() { return getDb(getDbPath()); }

let ensured = false;
export function ensureSchema(): void {
  if (ensured) return;
  db().exec(`
    CREATE TABLE IF NOT EXISTS jobs (
      id                          TEXT PRIMARY KEY,
      name                        TEXT NOT NULL,
      enabled                     INTEGER NOT NULL DEFAULT 1,
      schedule_interval_minutes   INTEGER NOT NULL DEFAULT 30,
      source_connector            TEXT NOT NULL,
      source_tool                 TEXT NOT NULL,
      source_input                TEXT NOT NULL DEFAULT '{}',
      items_path                  TEXT,
      dedup_field                 TEXT NOT NULL,
      dispatch_type               TEXT NOT NULL,
      dispatch_params             TEXT NOT NULL DEFAULT '{}',
      /** JSON array of skill names (from forge-skills registry).
          Composed into the task system prompt at dispatch time. */
      skills                      TEXT NOT NULL DEFAULT '[]',
      /** 'period' (default — fire every schedule_interval_minutes),
          'once' (fire once at schedule_at, then auto-disable), or
          'cron' (fire on each cron tick per schedule_cron). */
      schedule_kind               TEXT NOT NULL DEFAULT 'period',
      schedule_at                 TEXT,
      schedule_cron               TEXT,
      /** Per-tick dispatch budget. The scheduler dispatches at most
          this many NEW items per run; the rest stay unmarked and get
          picked up on the next tick. Hard ceiling enforced in code
          to protect against catastrophic fan-out (e.g. mantis search
          returning 200 bugs and spawning 200 worktrees). */
      max_per_tick                INTEGER NOT NULL DEFAULT 5,
      /** 'sequential' (default) | 'sequential' — see Job.concurrency_mode
          in types.ts. Sequential mode dispatches one pipeline at a
          time and waits for it to reach a terminal state before
          starting the next. */
      concurrency_mode            TEXT NOT NULL DEFAULT 'sequential',
      /** 'continue' (default) | 'stop' — what to do when a dispatched
          pipeline fails in sequential drain. See Job.on_failure. */
      on_failure                  TEXT NOT NULL DEFAULT 'continue',
      last_run_at                 TEXT,
      next_run_at                 TEXT,
      created_at                  TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at                  TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE TABLE IF NOT EXISTS job_runs (
      id                  TEXT PRIMARY KEY,
      job_id              TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
      started_at          TEXT NOT NULL DEFAULT (datetime('now')),
      finished_at         TEXT,
      status              TEXT NOT NULL DEFAULT 'running',
      items_seen          INTEGER NOT NULL DEFAULT 0,
      items_new           INTEGER NOT NULL DEFAULT 0,
      items_dispatched    INTEGER NOT NULL DEFAULT 0,
      error               TEXT,
      trigger             TEXT NOT NULL DEFAULT 'schedule',
      notes               TEXT,
      /** Plain-text execution log for this tick — one timestamped line per step. */
      log                 TEXT
    );
    CREATE TABLE IF NOT EXISTS job_seen (
      job_id              TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
      dedup_key           TEXT NOT NULL,
      first_seen_at       TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (job_id, dedup_key)
    );
    CREATE TABLE IF NOT EXISTS job_dispatches (
      id                  TEXT PRIMARY KEY,
      job_run_id          TEXT NOT NULL REFERENCES job_runs(id) ON DELETE CASCADE,
      item_key            TEXT NOT NULL,
      item_preview        TEXT,
      dispatch_type       TEXT NOT NULL,
      dispatch_target_id  TEXT,
      status              TEXT NOT NULL DEFAULT 'dispatched',
      error               TEXT,
      created_at          TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at DESC);
    CREATE INDEX IF NOT EXISTS idx_job_dispatches_run ON job_dispatches(job_run_id, created_at DESC);
  `);
  // Migrations for already-existing job_runs tables.
  try { db().exec(`ALTER TABLE job_runs ADD COLUMN notes TEXT`); } catch {}
  try { db().exec(`ALTER TABLE job_runs ADD COLUMN log TEXT`); } catch {}
  // Migration for already-existing jobs table.
  try { db().exec(`ALTER TABLE jobs ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_kind TEXT NOT NULL DEFAULT 'period'`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_at TEXT`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN schedule_cron TEXT`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN max_per_tick INTEGER NOT NULL DEFAULT 5`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN concurrency_mode TEXT NOT NULL DEFAULT 'sequential'`); } catch {}
  try { db().exec(`ALTER TABLE jobs ADD COLUMN on_failure TEXT NOT NULL DEFAULT 'continue'`); } catch {}
  ensured = true;
}

// ─── Row → Job/JobRun/JobDispatch mapping ─────────────────

function rowToJob(r: any): Job {
  return {
    id: r.id,
    name: r.name,
    enabled: !!r.enabled,
    schedule_interval_minutes: r.schedule_interval_minutes,
    source_connector: r.source_connector,
    source_tool: r.source_tool,
    source_input: safeParse(r.source_input, {}) as Record<string, unknown>,
    items_path: r.items_path || '',
    dedup_field: r.dedup_field,
    dispatch_type: r.dispatch_type,
    dispatch_params: safeParse(r.dispatch_params, {}) as DispatchParams,
    skills: safeParse(r.skills, []) as string[],
    schedule_kind: (r.schedule_kind as 'period' | 'once' | 'cron' | 'manual') || 'period',
    schedule_at: toIsoUTC(r.schedule_at),
    schedule_cron: r.schedule_cron || null,
    max_per_tick: typeof r.max_per_tick === 'number' ? r.max_per_tick : 5,
    concurrency_mode: r.concurrency_mode === 'parallel' ? 'parallel' : 'sequential',
    on_failure: r.on_failure === 'stop' ? 'stop' : 'continue',
    last_run_at: toIsoUTC(r.last_run_at),
    next_run_at: toIsoUTC(r.next_run_at),
    created_at: toIsoUTC(r.created_at) || r.created_at,
    updated_at: toIsoUTC(r.updated_at) || r.updated_at,
  };
}

function rowToRun(r: any): JobRun {
  return {
    id: r.id,
    job_id: r.job_id,
    started_at: toIsoUTC(r.started_at) || r.started_at,
    finished_at: toIsoUTC(r.finished_at),
    status: r.status as JobRunStatus,
    items_seen: r.items_seen,
    items_new: r.items_new,
    items_dispatched: r.items_dispatched,
    error: r.error,
    trigger: r.trigger as JobRunTrigger,
    notes: r.notes || null,
    log: r.log || null,
  };
}

function rowToDispatch(r: any): JobDispatch {
  return {
    id: r.id,
    job_run_id: r.job_run_id,
    item_key: r.item_key,
    item_preview: r.item_preview,
    dispatch_type: r.dispatch_type,
    dispatch_target_id: r.dispatch_target_id,
    status: r.status as JobDispatchStatus,
    error: r.error,
    created_at: toIsoUTC(r.created_at) || r.created_at,
  };
}

function safeParse(raw: string | null, fallback: unknown): unknown {
  if (!raw) return fallback;
  try { return JSON.parse(raw); } catch { return fallback; }
}

// ─── Job CRUD ─────────────────────────────────────────────

export function listJobs(): Job[] {
  ensureSchema();
  const rows = db().prepare('SELECT * FROM jobs ORDER BY created_at DESC').all() as any[];
  return rows.map(rowToJob);
}

export function getJob(id: string): Job | null {
  ensureSchema();
  const r = db().prepare('SELECT * FROM jobs WHERE id = ?').get(id) as any;
  return r ? rowToJob(r) : null;
}

/** Per-tick dispatch budget — clamped [1, 10]. Centralized so the
 *  ceiling is one constant, not duplicated across createJob/updateJob/
 *  scheduler. Catastrophic fan-out (mantis returns 200 bugs → 200
 *  worktrees → disk full) was the motivating incident. */
export const MAX_PER_TICK_CEILING = 10;
function clampMaxPerTick(v: number | undefined): number {
  if (!Number.isFinite(v) || v == null) return 5;
  return Math.min(Math.max(Math.trunc(v as number), 1), MAX_PER_TICK_CEILING);
}

export function createJob(input: CreateJobInput): Job {
  ensureSchema();
  const id = randomUUID().slice(0, 12);
  db().prepare(`
    INSERT INTO jobs (id, name, enabled, schedule_interval_minutes,
      source_connector, source_tool, source_input,
      items_path, dedup_field,
      dispatch_type, dispatch_params, skills,
      schedule_kind, schedule_at, schedule_cron, max_per_tick,
      concurrency_mode, on_failure)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    input.name,
    input.enabled === false ? 0 : 1,
    input.schedule_interval_minutes ?? 30,
    input.source_connector,
    input.source_tool,
    JSON.stringify(input.source_input ?? {}),
    input.items_path ?? '',
    input.dedup_field,
    input.dispatch_type,
    JSON.stringify(input.dispatch_params),
    JSON.stringify(Array.isArray(input.skills) ? input.skills : []),
    input.schedule_kind || 'period',
    input.schedule_at || null,
    input.schedule_cron || null,
    clampMaxPerTick(input.max_per_tick),
    input.concurrency_mode === 'parallel' ? 'parallel' : 'sequential',
    input.on_failure === 'stop' ? 'stop' : 'continue',
  );

  // Backfill guard: if mark_existing_as_seen is true (default), we don't pre-seed
  // here — the first tick will do it (calling the connector once, marking
  // everything, dispatching nothing). This keeps create() pure DB work.
  // The flag is stashed in source_input under __mark_existing_as_seen so the
  // first tick can read & clear it.
  if (input.mark_existing_as_seen !== false) {
    const inputWithFlag = { ...(input.source_input ?? {}), __mark_existing_as_seen: true };
    db().prepare('UPDATE jobs SET source_input = ? WHERE id = ?').run(JSON.stringify(inputWithFlag), id);
  }

  // Seed next_run_at so the scheduler picks the job up at the right
  // moment instead of immediately (which getDueJobs would otherwise
  // do for any NULL next_run_at).
  if (input.schedule_kind === 'once' && input.schedule_at) {
    const t = new Date(input.schedule_at);
    if (!Number.isNaN(t.getTime())) setNextRunAt(id, t.toISOString().replace('T', ' ').slice(0, 19));
  } else if (input.schedule_kind === 'cron' && input.schedule_cron) {
    try {
      const iter = CronExpressionParser.parse(input.schedule_cron, { currentDate: new Date() });
      const next = iter.next().toDate();
      setNextRunAt(id, next.toISOString().replace('T', ' ').slice(0, 19));
    } catch {}
  }

  return getJob(id)!;
}

export function updateJob(id: string, patch: Partial<{
  name: string; enabled: boolean; schedule_interval_minutes: number;
  source_connector: string; source_tool: string; source_input: Record<string, unknown>;
  items_path: string; dedup_field: string;
  dispatch_type: 'pipeline' | 'chat'; dispatch_params: DispatchParams;
  skills: string[];
  schedule_kind: 'period' | 'once' | 'cron' | 'manual';
  schedule_at: string | null;
  schedule_cron: string | null;
  max_per_tick: number;
  concurrency_mode: 'parallel' | 'sequential';
  on_failure: 'continue' | 'stop';
}>): boolean {
  ensureSchema();
  const sets: string[] = []; const vals: any[] = [];
  if (patch.name !== undefined) { sets.push('name = ?'); vals.push(patch.name); }
  if (patch.enabled !== undefined) { sets.push('enabled = ?'); vals.push(patch.enabled ? 1 : 0); }
  if (patch.schedule_interval_minutes !== undefined) { sets.push('schedule_interval_minutes = ?'); vals.push(patch.schedule_interval_minutes); }
  if (patch.source_connector !== undefined) { sets.push('source_connector = ?'); vals.push(patch.source_connector); }
  if (patch.source_tool !== undefined) { sets.push('source_tool = ?'); vals.push(patch.source_tool); }
  if (patch.source_input !== undefined) { sets.push('source_input = ?'); vals.push(JSON.stringify(patch.source_input)); }
  if (patch.items_path !== undefined) { sets.push('items_path = ?'); vals.push(patch.items_path); }
  if (patch.dedup_field !== undefined) { sets.push('dedup_field = ?'); vals.push(patch.dedup_field); }
  if (patch.dispatch_type !== undefined) { sets.push('dispatch_type = ?'); vals.push(patch.dispatch_type); }
  if (patch.dispatch_params !== undefined) { sets.push('dispatch_params = ?'); vals.push(JSON.stringify(patch.dispatch_params)); }
  if (patch.skills !== undefined) { sets.push('skills = ?'); vals.push(JSON.stringify(Array.isArray(patch.skills) ? patch.skills : [])); }
  if (patch.schedule_kind !== undefined) { sets.push('schedule_kind = ?'); vals.push(patch.schedule_kind); }
  if (patch.schedule_at !== undefined) { sets.push('schedule_at = ?'); vals.push(patch.schedule_at); }
  if (patch.schedule_cron !== undefined) { sets.push('schedule_cron = ?'); vals.push(patch.schedule_cron); }
  if (patch.max_per_tick !== undefined) { sets.push('max_per_tick = ?'); vals.push(clampMaxPerTick(patch.max_per_tick)); }
  if (patch.concurrency_mode !== undefined) {
    sets.push('concurrency_mode = ?');
    vals.push(patch.concurrency_mode === 'parallel' ? 'parallel' : 'sequential');
  }
  if (patch.on_failure !== undefined) {
    sets.push('on_failure = ?');
    vals.push(patch.on_failure === 'stop' ? 'stop' : 'continue');
  }
  if (sets.length === 0) return false;
  sets.push("updated_at = datetime('now')");
  vals.push(id);
  const r = db().prepare(`UPDATE jobs SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
  return r.changes > 0;
}

export function deleteJob(id: string): boolean {
  ensureSchema();
  const r = db().prepare('DELETE FROM jobs WHERE id = ?').run(id);
  return r.changes > 0;
}

export function setNextRunAt(id: string, nextRunAt: string | null): void {
  ensureSchema();
  db().prepare("UPDATE jobs SET next_run_at = ?, last_run_at = datetime('now') WHERE id = ?").run(nextRunAt, id);
}

/** Recompute next_run_at from the job's trigger without touching last_run_at.
 *  Use on resume: cancelJobDrain nulled next_run_at, and NULL = due now, so
 *  a bare re-enable would fire off-schedule. once-in-the-past fires next tick;
 *  period seeds now + interval to avoid an instant re-run. */
export function seedNextRunAt(id: string): void {
  ensureSchema();
  const j = getJob(id);
  if (!j) return;
  const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
  let next: string | null = null;
  if (j.schedule_kind === 'once' && j.schedule_at) {
    const t = new Date(j.schedule_at);
    if (!Number.isNaN(t.getTime())) next = fmt(t);
  } else if (j.schedule_kind === 'cron' && j.schedule_cron) {
    try {
      const iter = CronExpressionParser.parse(j.schedule_cron, { currentDate: new Date() });
      next = fmt(iter.next().toDate());
    } catch { next = null; }
  } else if (j.schedule_kind === 'period') {
    const mins = j.schedule_interval_minutes > 0 ? j.schedule_interval_minutes : 30;
    next = fmt(new Date(Date.now() + mins * 60_000));
  }
  db().prepare('UPDATE jobs SET next_run_at = ? WHERE id = ?').run(next, id);
}

/** Jobs due to run: enabled AND (next_run_at IS NULL OR next_run_at <= now). */
export function getDueJobs(): Job[] {
  ensureSchema();
  // Exclude schedule_kind='manual' — those only run when explicitly
  // fired via /api/jobs/[id]/fire, never on the scheduler tick.
  const rows = db().prepare(`
    SELECT * FROM jobs
    WHERE enabled = 1
      AND schedule_kind != 'manual'
      AND (next_run_at IS NULL OR next_run_at <= datetime('now'))
    ORDER BY (next_run_at IS NULL) DESC, next_run_at ASC
  `).all() as any[];
  return rows.map(rowToJob);
}

// ─── Run CRUD ─────────────────────────────────────────────

export function startRun(jobId: string, trigger: JobRunTrigger): JobRun {
  ensureSchema();
  const id = randomUUID().slice(0, 12);
  db().prepare(`INSERT INTO job_runs (id, job_id, trigger) VALUES (?, ?, ?)`).run(id, jobId, trigger);
  return db().prepare('SELECT * FROM job_runs WHERE id = ?').get(id) as any as JobRun;
}

export function finishRun(runId: string, patch: { status: JobRunStatus; items_seen?: number; items_new?: number; items_dispatched?: number; error?: string | null; notes?: string | null; log?: string | null }): void {
  ensureSchema();
  db().prepare(`
    UPDATE job_runs
    SET finished_at = datetime('now'),
        status = ?,
        items_seen = COALESCE(?, items_seen),
        items_new = COALESCE(?, items_new),
        items_dispatched = COALESCE(?, items_dispatched),
        error = ?,
        notes = COALESCE(?, notes),
        log = COALESCE(?, log)
    WHERE id = ?
  `).run(patch.status, patch.items_seen ?? null, patch.items_new ?? null, patch.items_dispatched ?? null, patch.error ?? null, patch.notes ?? null, patch.log ?? null, runId);
}

export function listRuns(jobId: string, limit = 20): JobRun[] {
  ensureSchema();
  const rows = db().prepare('SELECT * FROM job_runs WHERE job_id = ? ORDER BY started_at DESC LIMIT ?').all(jobId, limit) as any[];
  return rows.map(rowToRun);
}

export function getRun(runId: string): JobRun | null {
  ensureSchema();
  const r = db().prepare('SELECT * FROM job_runs WHERE id = ?').get(runId) as any;
  return r ? rowToRun(r) : null;
}

/** True if a previous tick is still running for this job. */
export function hasInflightRun(jobId: string): boolean {
  ensureSchema();
  const r = db().prepare(`SELECT 1 FROM job_runs WHERE job_id = ? AND status = 'running' AND finished_at IS NULL LIMIT 1`).get(jobId);
  return !!r;
}

// ─── Dedup ────────────────────────────────────────────────

/** Returns true if the (job, key) pair was newly inserted. */
export function markSeen(jobId: string, key: string): boolean {
  ensureSchema();
  const r = db().prepare(`INSERT OR IGNORE INTO job_seen (job_id, dedup_key) VALUES (?, ?)`).run(jobId, key);
  return r.changes > 0;
}

/** Read-only check — does NOT mutate dedup state. Use when you might
 *  defer a "new" item to a later tick (budget cap) and don't want to
 *  prematurely mark it as seen. */
export function isSeen(jobId: string, key: string): boolean {
  ensureSchema();
  const r = db().prepare(`SELECT 1 FROM job_seen WHERE job_id = ? AND dedup_key = ? LIMIT 1`).get(jobId, key);
  return !!r;
}

export function resetDedup(jobId: string): number {
  ensureSchema();
  const r = db().prepare('DELETE FROM job_seen WHERE job_id = ?').run(jobId);
  return r.changes;
}

// ─── Dispatches ───────────────────────────────────────────

export function recordDispatch(input: {
  job_run_id: string;
  item_key: string;
  item_preview?: string;
  dispatch_type: 'pipeline' | 'chat';
  dispatch_target_id?: string | null;
  status: JobDispatchStatus;
  error?: string | null;
}): JobDispatch {
  ensureSchema();
  const id = randomUUID().slice(0, 12);
  db().prepare(`
    INSERT INTO job_dispatches (id, job_run_id, item_key, item_preview, dispatch_type, dispatch_target_id, status, error)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  `).run(id, input.job_run_id, input.item_key, input.item_preview ?? null, input.dispatch_type, input.dispatch_target_id ?? null, input.status, input.error ?? null);
  return db().prepare('SELECT * FROM job_dispatches WHERE id = ?').get(id) as any as JobDispatch;
}

export function listDispatches(runId: string): JobDispatch[] {
  ensureSchema();
  const rows = db().prepare('SELECT * FROM job_dispatches WHERE job_run_id = ? ORDER BY created_at ASC').all(runId) as any[];
  return rows.map(rowToDispatch);
}

/** Recent pipelines this Job has dispatched, decorated with live
 *  pipeline_runs status. Used by the Job row to show what's running,
 *  what's done, what failed — without making the user navigate to the
 *  Pipeline view for each. Capped at N per call to keep the UI fast. */
export interface JobDispatchedPipeline {
  dispatch_id: string;
  job_run_id: string;
  item_key: string;
  item_preview: string | null;
  pipeline_id: string;
  pipeline_status: string;  // 'running' | 'pending' | 'done' | 'failed' | 'cancelled' | 'unknown'
  workflow_name: string | null;
  dispatched_at: string;
}

export function listJobDispatchedPipelines(jobId: string, limit = 20): JobDispatchedPipeline[] {
  ensureSchema();
  const rows = db().prepare(`
    SELECT jd.id AS dispatch_id, jd.job_run_id, jd.item_key, jd.item_preview,
           jd.dispatch_target_id AS pipeline_id, jd.created_at AS dispatched_at,
           pr.status AS pipeline_status, pr.workflow_name AS workflow_name
      FROM job_dispatches jd
      JOIN job_runs        jr ON jr.id = jd.job_run_id
 LEFT JOIN pipeline_runs   pr ON pr.pipeline_id = jd.dispatch_target_id
     WHERE jr.job_id = ?
       AND jd.dispatch_type = 'pipeline'
     ORDER BY jd.created_at DESC
     LIMIT ?
  `).all(jobId, limit) as any[];
  return rows.map((r) => ({
    dispatch_id: r.dispatch_id,
    job_run_id: r.job_run_id,
    item_key: r.item_key,
    item_preview: r.item_preview,
    pipeline_id: r.pipeline_id,
    pipeline_status: r.pipeline_status || 'unknown',
    workflow_name: r.workflow_name,
    dispatched_at: toIsoUTC(r.dispatched_at) || r.dispatched_at,
  }));
}

/** Stop the sequential drain for this Job — clears next_run_at so the
 *  scheduler won't pick it up automatically. Does NOT cancel
 *  pipelines that are already running (caller can do that separately
 *  on the Pipeline view). Returns true if anything was changed. */
export function cancelJobDrain(jobId: string): boolean {
  ensureSchema();
  const r = db().prepare(`UPDATE jobs SET next_run_at = NULL WHERE id = ?`).run(jobId);
  return r.changes > 0;
}
