/**
 * Schedules SQLite store (V2). Two tables:
 *
 *   schedules         — config (body_kind/body_ref/input, action_*, schedule_*)
 *   schedule_runs     — every dispatched body (single source of truth
 *                       for "what did this schedule fire and how did action fare")
 *
 * V1 table names were `pipeline_schedules` / `pipeline_schedule_runs`
 * with hard-coded pipeline_name / pipeline_id columns. ensureSchema()
 * detects the old layout on first run and migrates in place.
 *
 * Schedule's `active_state` is NOT stored — computed from enabled +
 * inflight count + last_status (see state.ts).
 */

import { existsSync, readFileSync } from 'node:fs';
import { join as joinPath } from 'node:path';
import { randomUUID } from 'node:crypto';
import { getDb } from '../../src/core/db/database';
import { getDbPath } from '../../src/config';
import { getDataDir } from '../dirs';
import { toIsoUTC } from '../iso-time';
import type {
  Schedule,
  ScheduleRun,
  ScheduleKind,
  ScheduleBodyKind,
  ScheduleActionKind,
  ScheduleActionStatus,
  ScheduleRunStatus,
  ScheduleRunTrigger,
  CreateScheduleInput,
  UpdateScheduleInput,
} from './types';

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

let ensured = false;
export function ensureSchema(): void {
  if (ensured) return;
  const conn = db();

  // V2 schema — create new tables if absent.
  conn.exec(`
    CREATE TABLE IF NOT EXISTS schedules (
      id                          TEXT PRIMARY KEY,
      name                        TEXT NOT NULL,
      enabled                     INTEGER NOT NULL DEFAULT 1,

      body_kind                   TEXT NOT NULL DEFAULT 'pipeline',
      body_ref                    TEXT NOT NULL,
      input                       TEXT NOT NULL DEFAULT '{}',
      skills                      TEXT NOT NULL DEFAULT '[]',

      action_kind                 TEXT NOT NULL DEFAULT 'none',
      action_config               TEXT NOT NULL DEFAULT '{}',
      action_skip_on_empty        INTEGER NOT NULL DEFAULT 0,

      schedule_kind               TEXT NOT NULL DEFAULT 'period',
      schedule_interval_minutes   INTEGER NOT NULL DEFAULT 30,
      schedule_at                 TEXT,
      schedule_cron               TEXT,
      next_run_at                 TEXT,
      last_run_at                 TEXT,
      created_at                  TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at                  TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_schedules_due
      ON schedules(enabled, next_run_at);

    CREATE TABLE IF NOT EXISTS schedule_runs (
      id              TEXT PRIMARY KEY,
      schedule_id     TEXT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
      target_id       TEXT NOT NULL,
      trigger         TEXT NOT NULL,
      status          TEXT NOT NULL DEFAULT 'started',
      body_output     TEXT,
      action_status   TEXT,
      action_error    TEXT,
      started_at      TEXT NOT NULL DEFAULT (datetime('now')),
      finished_at     TEXT,
      error           TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_sr_schedule  ON schedule_runs(schedule_id, started_at DESC);
    CREATE INDEX IF NOT EXISTS idx_sr_target    ON schedule_runs(target_id);
    CREATE INDEX IF NOT EXISTS idx_sr_started   ON schedule_runs(status) WHERE status = 'started';
  `);

  // ── V1 → V2 migration ──
  // Detect legacy tables and copy data. We don't drop the legacy tables
  // here — leave them for the user to inspect; a follow-up migration
  // can drop them once V2 is verified working.
  migrateLegacyTables(conn);

  // Idempotent column adds for V2 tables created before the column existed.
  try { conn.exec(`ALTER TABLE schedules ADD COLUMN skills TEXT NOT NULL DEFAULT '[]'`); } catch {}

  ensured = true;
}

function migrateLegacyTables(conn: ReturnType<typeof db>): void {
  try {
    const legacyExists = conn.prepare(
      `SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedules'`,
    ).get();
    if (!legacyExists) return;

    // Already migrated? (Heuristic: if schedules has any rows, assume done.)
    // In that case still rename the legacy table out of the way so the
    // next start can't resurrect deleted rows by re-running the copy.
    const v2Count = conn.prepare(`SELECT COUNT(*) AS n FROM schedules`).get() as { n: number };
    if (v2Count.n > 0) {
      try {
        conn.exec(`ALTER TABLE pipeline_schedules RENAME TO pipeline_schedules_v1_backup`);
        const r = conn.prepare(
          `SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedule_runs'`,
        ).get();
        if (r) conn.exec(`ALTER TABLE pipeline_schedule_runs RENAME TO pipeline_schedule_runs_v1_backup`);
        console.log('[schedules] migration already done — renamed leftover v1 tables to *_v1_backup');
      } catch (e) {
        console.warn(`[schedules] leftover v1 rename failed: ${(e as Error).message}`);
      }
      return;
    }

    const legacyRows = conn.prepare(`SELECT * FROM pipeline_schedules`).all() as any[];
    if (legacyRows.length === 0) {
      console.log('[schedules] legacy pipeline_schedules table exists but empty — no migration needed');
      return;
    }

    console.log(`[schedules] migrating ${legacyRows.length} legacy schedule(s) from pipeline_schedules → schedules`);
    const insertSched = conn.prepare(`
      INSERT INTO schedules (
        id, name, enabled,
        body_kind, body_ref, input,
        action_kind, action_config, action_skip_on_empty,
        schedule_kind, schedule_interval_minutes, schedule_at, schedule_cron,
        next_run_at, last_run_at, created_at, updated_at
      ) VALUES (?, ?, ?, 'pipeline', ?, ?, 'none', '{}', 0, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
    const migrateOne = conn.transaction((rows: any[]) => {
      for (const r of rows) {
        insertSched.run(
          r.id, r.name, r.enabled ? 1 : 0,
          r.pipeline_name, r.input || '{}',
          r.schedule_kind || 'period',
          r.schedule_interval_minutes ?? 30,
          r.schedule_at || null, r.schedule_cron || null,
          r.next_run_at || null, r.last_run_at || null,
          r.created_at, r.updated_at,
        );
      }
    });
    migrateOne(legacyRows);

    // Migrate legacy runs too.
    const legacyRunsExists = conn.prepare(
      `SELECT name FROM sqlite_master WHERE type='table' AND name='pipeline_schedule_runs'`,
    ).get();
    if (legacyRunsExists) {
      const legacyRuns = conn.prepare(`SELECT * FROM pipeline_schedule_runs`).all() as any[];
      if (legacyRuns.length > 0) {
        const insertRun = conn.prepare(`
          INSERT INTO schedule_runs (
            id, schedule_id, target_id, trigger, status,
            body_output, action_status, action_error,
            started_at, finished_at, error
          ) VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)
        `);
        const migrateRuns = conn.transaction((rows: any[]) => {
          for (const r of rows) {
            insertRun.run(
              r.id, r.schedule_id, r.pipeline_id, r.trigger || 'schedule',
              r.status || 'done', r.started_at, r.finished_at || null, r.error || null,
            );
          }
        });
        migrateRuns(legacyRuns);
        console.log(`[schedules] migrated ${legacyRuns.length} legacy run(s)`);
      }
    }

    // Rename the v1 tables out of the way so a future user-driven delete
    // (which empties `schedules` to 0 rows) doesn't make the v2Count==0
    // heuristic above re-trigger and resurrect deleted schedules. We
    // rename rather than drop so the data stays recoverable if something
    // about the v2 schema turns out to be wrong.
    conn.exec(`ALTER TABLE pipeline_schedules RENAME TO pipeline_schedules_v1_backup`);
    if (legacyRunsExists) {
      conn.exec(`ALTER TABLE pipeline_schedule_runs RENAME TO pipeline_schedule_runs_v1_backup`);
    }
    console.log('[schedules] renamed v1 tables to *_v1_backup');
  } catch (e) {
    console.warn(`[schedules] legacy migration failed: ${(e as Error).message}`);
  }
}

// ─── Row mappers ──────────────────────────────────────────

function rowToSchedule(r: any): Schedule {
  return {
    id: r.id,
    name: r.name,
    enabled: !!r.enabled,
    body_kind: (r.body_kind as ScheduleBodyKind) || 'pipeline',
    body_ref: r.body_ref,
    input: safeJsonObject(r.input),
    skills: safeJsonStringArray(r.skills),
    action_kind: (r.action_kind as ScheduleActionKind) || 'none',
    action_config: safeJsonObject(r.action_config),
    action_skip_on_empty: !!r.action_skip_on_empty,
    schedule_kind: (r.schedule_kind as ScheduleKind) || 'period',
    schedule_interval_minutes: typeof r.schedule_interval_minutes === 'number'
      ? r.schedule_interval_minutes : 30,
    schedule_at: toIsoUTC(r.schedule_at),
    schedule_cron: r.schedule_cron || null,
    next_run_at: toIsoUTC(r.next_run_at),
    last_run_at: toIsoUTC(r.last_run_at),
    created_at: toIsoUTC(r.created_at) || r.created_at,
    updated_at: toIsoUTC(r.updated_at) || r.updated_at,
  };
}

function rowToRun(r: any): ScheduleRun {
  return {
    id: r.id,
    schedule_id: r.schedule_id,
    target_id: r.target_id,
    trigger: (r.trigger as ScheduleRunTrigger) || 'schedule',
    status: (r.status as ScheduleRunStatus) || 'started',
    body_output: r.body_output ?? null,
    action_status: (r.action_status as ScheduleActionStatus) ?? null,
    action_error: r.action_error ?? null,
    started_at: toIsoUTC(r.started_at) || r.started_at,
    finished_at: toIsoUTC(r.finished_at),
    error: r.error || null,
  };
}

function safeJsonObject(raw: unknown): Record<string, unknown> {
  if (!raw || typeof raw !== 'string') return {};
  try {
    const v = JSON.parse(raw);
    return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
  } catch { return {}; }
}

function safeJsonStringArray(raw: unknown): string[] {
  if (!raw || typeof raw !== 'string') return [];
  try {
    const v = JSON.parse(raw);
    return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
  } catch { return []; }
}

// ─── Schedule CRUD ────────────────────────────────────────

export function listSchedules(): Schedule[] {
  ensureSchema();
  const rows = db().prepare('SELECT * FROM schedules ORDER BY created_at DESC').all() as any[];
  return rows.map(rowToSchedule);
}

export function getSchedule(id: string): Schedule | null {
  ensureSchema();
  const r = db().prepare('SELECT * FROM schedules WHERE id = ?').get(id) as any;
  return r ? rowToSchedule(r) : null;
}

export function createSchedule(input: CreateScheduleInput): Schedule {
  ensureSchema();
  const id = `sch_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
  db().prepare(`
    INSERT INTO schedules (
      id, name, enabled,
      body_kind, body_ref, input, skills,
      action_kind, action_config, action_skip_on_empty,
      schedule_kind, schedule_interval_minutes, schedule_at, schedule_cron
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `).run(
    id,
    input.name,
    input.enabled === false ? 0 : 1,
    input.body_kind || 'pipeline',
    input.body_ref,
    JSON.stringify(input.input ?? {}),
    JSON.stringify(Array.isArray(input.skills) ? input.skills.filter((s) => typeof s === 'string') : []),
    input.action_kind || 'none',
    JSON.stringify(input.action_config ?? {}),
    input.action_skip_on_empty ? 1 : 0,
    input.schedule_kind || 'period',
    input.schedule_interval_minutes ?? 30,
    input.schedule_at || null,
    input.schedule_cron || null,
  );
  // Schedule the first run so scheduler picks it up.
  // For 'manual', leave next_run_at NULL.
  if ((input.schedule_kind || 'period') !== 'manual') {
    seedNextRunAt(id);
  }
  return getSchedule(id)!;
}

export function updateSchedule(id: string, patch: UpdateScheduleInput): 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.input !== undefined)                      { sets.push('input = ?');                      vals.push(JSON.stringify(patch.input)); }
  if (patch.skills !== undefined)                     { sets.push('skills = ?');                     vals.push(JSON.stringify(Array.isArray(patch.skills) ? patch.skills.filter((s) => typeof s === 'string') : [])); }
  if (patch.action_kind !== undefined)                { sets.push('action_kind = ?');                vals.push(patch.action_kind); }
  if (patch.action_config !== undefined)              { sets.push('action_config = ?');              vals.push(JSON.stringify(patch.action_config)); }
  if (patch.action_skip_on_empty !== undefined)       { sets.push('action_skip_on_empty = ?');       vals.push(patch.action_skip_on_empty ? 1 : 0); }
  if (patch.schedule_kind !== undefined)              { sets.push('schedule_kind = ?');              vals.push(patch.schedule_kind); }
  if (patch.schedule_interval_minutes !== undefined)  { sets.push('schedule_interval_minutes = ?');  vals.push(patch.schedule_interval_minutes); }
  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 (sets.length === 0) return false;
  sets.push("updated_at = datetime('now')");
  vals.push(id);
  const r = db().prepare(`UPDATE schedules SET ${sets.join(', ')} WHERE id = ?`).run(...vals);

  // If the schedule shape changed in a way that affects next_run_at,
  // re-seed (e.g. user switched manual → period).
  if (patch.schedule_kind !== undefined || patch.schedule_interval_minutes !== undefined ||
      patch.schedule_at !== undefined || patch.schedule_cron !== undefined || patch.enabled !== undefined) {
    const s = getSchedule(id);
    if (s && s.enabled && s.schedule_kind !== 'manual') {
      seedNextRunAt(id);
    } else if (s && (!s.enabled || s.schedule_kind === 'manual')) {
      setNextRunAt(id, null);
    }
  }
  return r.changes > 0;
}

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

// ─── Schedule tick helpers ────────────────────────────────

/** Set next_run_at = now for a freshly-created schedule so it fires
 *  on the next tick instead of waiting up to schedule_interval. */
export function seedNextRunAt(id: string): void {
  ensureSchema();
  db().prepare(`UPDATE schedules SET next_run_at = datetime('now') WHERE id = ?`).run(id);
}

export function setNextRunAt(id: string, iso: string | null): void {
  ensureSchema();
  db().prepare(`UPDATE schedules SET next_run_at = ? WHERE id = ?`).run(iso, id);
}

export function setLastRunAt(id: string, iso: string): void {
  ensureSchema();
  db().prepare(`UPDATE schedules SET last_run_at = ? WHERE id = ?`).run(iso, id);
}

/** Schedules whose enabled=1 AND (next_run_at IS NULL OR next_run_at <= now). */
export function listDueSchedules(): Schedule[] {
  ensureSchema();
  const rows = db().prepare(`
    SELECT * FROM schedules
     WHERE enabled = 1
       AND (next_run_at IS NULL OR datetime(next_run_at) <= datetime('now'))
       AND schedule_kind != 'manual'
     ORDER BY (next_run_at IS NULL) DESC, next_run_at ASC
  `).all() as any[];
  return rows.map(rowToSchedule);
}

// ─── Schedule runs ────────────────────────────────────────

export function insertScheduleRun(args: {
  schedule_id: string;
  target_id: string;
  trigger: ScheduleRunTrigger;
}): ScheduleRun {
  ensureSchema();
  const id = `sr_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
  db().prepare(`
    INSERT INTO schedule_runs (id, schedule_id, target_id, trigger, status)
    VALUES (?, ?, ?, ?, 'started')
  `).run(id, args.schedule_id, args.target_id, args.trigger);
  return getScheduleRun(id)!;
}

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

export function listScheduleRuns(scheduleId: string, limit = 20): ScheduleRun[] {
  ensureSchema();
  const rows = db().prepare(
    'SELECT * FROM schedule_runs WHERE schedule_id = ? ORDER BY started_at DESC LIMIT ?',
  ).all(scheduleId, limit) as any[];
  return rows.map(rowToRun);
}

export function countInflightForSchedule(scheduleId: string): number {
  ensureSchema();
  const r = db().prepare(
    `SELECT COUNT(*) AS n FROM schedule_runs
      WHERE schedule_id = ? AND status = 'started'`,
  ).get(scheduleId) as { n: number } | undefined;
  return r?.n ?? 0;
}

export function listInflightForSchedule(scheduleId: string): ScheduleRun[] {
  ensureSchema();
  const rows = db().prepare(
    `SELECT * FROM schedule_runs
      WHERE schedule_id = ? AND status = 'started'
      ORDER BY started_at DESC`,
  ).all(scheduleId) as any[];
  return rows.map(rowToRun);
}

export function lastTerminalStatusForSchedule(scheduleId: string): ScheduleRunStatus | null {
  ensureSchema();
  const r = db().prepare(`
    SELECT status FROM schedule_runs
     WHERE schedule_id = ?
       AND status IN ('done', 'failed', 'cancelled')
     ORDER BY finished_at DESC NULLS LAST
     LIMIT 1
  `).get(scheduleId) as { status: ScheduleRunStatus } | undefined;
  return r?.status ?? null;
}

/** Called by pipeline-scheduler.syncRunStatus (and future skill /
 *  connector_tool body completion) to settle the run's body status. */
export function updateScheduleRunStatus(args: {
  target_id: string;
  status: ScheduleRunStatus;
  error?: string | null;
}): void {
  ensureSchema();
  db().prepare(`
    UPDATE schedule_runs
       SET status = ?, finished_at = datetime('now'), error = ?
     WHERE target_id = ?
       AND status = 'started'
  `).run(args.status, args.error ?? null, args.target_id);
}

/** Store body's captured output. Called by scheduler.executeSchedule
 *  after the body finishes — feeds into action. */
export function setScheduleRunBodyOutput(runId: string, output: string | null): void {
  ensureSchema();
  db().prepare(
    `UPDATE schedule_runs SET body_output = ? WHERE id = ?`,
  ).run(output, runId);
}

/** Look up the live (status='started') schedule_run by target_id and write
 *  body_output. Used by pipeline-scheduler / skill task settler — they know
 *  the target id, not the run id. No-op if no live row matches. */
export function setScheduleRunBodyOutputByTarget(targetId: string, output: string | null): void {
  ensureSchema();
  const r = db().prepare(
    `SELECT id FROM schedule_runs WHERE target_id = ? AND status = 'started' LIMIT 1`,
  ).get(targetId) as { id: string } | undefined;
  if (r) setScheduleRunBodyOutput(r.id, output);
}

/** Settle the action half of a schedule_run (separate from body status). */
export function setScheduleRunAction(args: {
  run_id: string;
  status: ScheduleActionStatus;
  error?: string | null;
}): void {
  ensureSchema();
  db().prepare(
    `UPDATE schedule_runs SET action_status = ?, action_error = ? WHERE id = ?`,
  ).run(args.status, args.error ?? null, args.run_id);
}

// ─── Zombie reconciliation ────────────────────────────────

/** Any started+30s+ schedule_run whose target is gone OR in terminal
 *  state → fix the DB row. Handles body_kind=pipeline (read JSON file)
 *  and body_kind=prompt (read tasks table). */
export function reconcileStaleScheduleRuns(): void {
  try {
    const stale = db().prepare(`
      SELECT sr.id, sr.target_id, s.body_kind
        FROM schedule_runs sr
        JOIN schedules s ON s.id = sr.schedule_id
       WHERE sr.status = 'started'
         AND datetime(sr.started_at) < datetime('now', '-30 seconds')
    `).all() as { id: string; target_id: string; body_kind: string }[];

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

    const pipelineDir = joinPath(getDataDir(), 'pipelines');

    for (const row of stale) {
      // Only fire action AFTER body actually transitioned to terminal.
      // Previously this loop unconditionally called runActionForRunLazy
      // even when reconcilePipelineRun left status='started' (long-running
      // pipeline still in flight) — action then saw status='started' and
      // marked itself 'skipped' permanently with "body status was started",
      // and chat/email/telegram never fired even after body finished.
      let settled = false;
      if (row.body_kind === 'pipeline') {
        settled = reconcilePipelineRun(row.id, row.target_id, pipelineDir);
      } else if (row.body_kind === 'prompt') {
        settled = reconcileTaskBackedRun(row.id, row.target_id);
      } else {
        markRunStatus(row.id, 'failed');
        console.warn(`[schedules] reconciled schedule_run ${row.id} → failed (unsupported body_kind '${row.body_kind}')`);
        settled = true;
      }
      // Lazy-imported to avoid a static cycle: action-runner imports from
      // this file. Only when body actually settled — otherwise pipeline
      // is still in flight and we'll catch it on a later tick.
      if (settled) void runActionForRunLazy(row.id);
    }
  } catch (e) {
    console.warn(`[schedules] reconcileStaleScheduleRuns failed: ${(e as Error).message}`);
  }
}

function markRunStatus(runId: string, status: ScheduleRunStatus): void {
  db().prepare(
    `UPDATE schedule_runs SET status = ?, finished_at = datetime('now') WHERE id = ?`,
  ).run(status, runId);
}

/** Lazy-import dispatch into action-runner so reconciler can fire the
 *  action without creating a static cycle (action-runner depends on
 *  this file). Errors are swallowed; action_status will stay 'pending'
 *  if dispatch failed and the next reconciler tick will retry. */
function runActionForRunLazy(runId: string): void {
  import('./action-runner').then(({ runAction }) => {
    return runAction(runId);
  }).catch((e) => {
    console.warn(`[schedules] runAction(${runId}) crashed: ${(e as Error).message}`);
  });
}

/** Returns true if the run was settled into a terminal state (caller
 *  may then fire action). Returns false if the pipeline is still
 *  in flight — caller MUST NOT fire action yet. */
function reconcilePipelineRun(
  runId: string,
  pipelineId: string,
  pipelineDir: string,
): boolean {
  const file = joinPath(pipelineDir, `${pipelineId}.json`);
  if (!existsSync(file)) {
    markRunStatus(runId, 'failed');
    console.warn(`[schedules] reconciled zombie schedule_run ${runId} → failed (pipeline ${pipelineId} JSON gone)`);
    return true;
  }
  try {
    const p = JSON.parse(readFileSync(file, 'utf8')) as { status?: string };
    if (p.status && p.status !== 'running' && p.status !== 'pending') {
      const mapped: ScheduleRunStatus =
        p.status === 'done' || p.status === 'failed' || p.status === 'cancelled'
          ? p.status
          : 'failed';
      markRunStatus(runId, mapped);
      console.warn(`[schedules] reconciled schedule_run ${runId} → ${mapped}`);
      return true;
    }
    return false; // pipeline still running — don't fire action yet
  } catch (e) {
    console.warn(`[schedules] failed to read ${file}: ${(e as Error).message}`);
    return false;
  }
}

/** Same contract as reconcilePipelineRun: true if settled, false if
 *  task still in flight (caller must NOT fire action yet). */
function reconcileTaskBackedRun(runId: string, taskId: string): boolean {
  try {
    const t = db().prepare(
      `SELECT status, result_summary FROM tasks WHERE id = ?`,
    ).get(taskId) as { status?: string; result_summary?: string } | undefined;
    if (!t) {
      markRunStatus(runId, 'failed');
      console.warn(`[schedules] reconciled zombie schedule_run ${runId} → failed (task ${taskId} gone)`);
      return true;
    }
    if (t.status && t.status !== 'running' && t.status !== 'queued') {
      const mapped: ScheduleRunStatus =
        t.status === 'done' || t.status === 'failed' || t.status === 'cancelled'
          ? t.status
          : 'failed';
      db().prepare(`UPDATE schedule_runs SET body_output = ? WHERE id = ?`).run(t.result_summary || null, runId);
      markRunStatus(runId, mapped);
      console.warn(`[schedules] reconciled schedule_run ${runId} → ${mapped} (skill task ${taskId})`);
      return true;
    }
    return false; // task still running / queued — don't fire action yet
  } catch (e) {
    console.warn(`[schedules] failed to read task row ${taskId}: ${(e as Error).message}`);
    return false;
  }
}
