/**
 * Schedules scheduler — 60s tick loop parallel to the Job scheduler.
 *
 * Each tick:
 *   1. reconcile stale schedule_runs (zombie cleanup)
 *   2. for each due, non-manual schedule:
 *      - advance next_run_at (period / once / cron)
 *      - skip if it already has inflight body
 *      - dispatch body (pipeline / skill / connector_tool — phase-gated)
 *      - insert schedule_run row
 *
 * No sequential / on_failure / dedup / retry knobs. All body-internal.
 */

import { CronExpressionParser } from 'cron-parser';
import { startPipeline, getWorkflow } from '../pipeline';
import {
  createTask,
  getTask,
  onTaskEvent,
  taskAppendSystemPromptOverrides,
} from '../task-manager';
import { getProjectInfo, SCRATCH_PROJECT_NAME } from '../projects';
import { ensureInstalledInProject } from '../skills';
import { getPrompt } from '../prompts/store';
import { getDb } from '../../src/core/db/database';
import { getDbPath } from '../../src/config';
import {
  ensureSchema,
  listDueSchedules,
  countInflightForSchedule,
  setNextRunAt,
  setLastRunAt,
  insertScheduleRun,
  reconcileStaleScheduleRuns,
  updateSchedule,
  updateScheduleRunStatus,
  setScheduleRunBodyOutputByTarget,
  getSchedule,
} from './store';
import { runActionForTarget } from './action-runner';
import type { Schedule, ScheduleRunTrigger, ScheduleRunStatus } from './types';

const TICK_INTERVAL_MS = 60_000;
const schedulerKey = Symbol.for('forge-schedules-scheduler');

export function startSchedulesScheduler(): void {
  const g = globalThis as any;
  if (g[schedulerKey]) return;
  g[schedulerKey] = true;
  ensureSchema();
  installTaskBackedRunListener();
  console.log('[schedules-scheduler] started');
  void tick();
  setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
}

async function tick(): Promise<void> {
  try {
    reconcileStaleScheduleRuns();
  } catch (e) {
    console.warn(`[schedules-scheduler] reconcile failed: ${(e as Error).message}`);
  }

  let due: Schedule[] = [];
  try { due = listDueSchedules(); }
  catch (e) {
    console.error('[schedules-scheduler] listDue failed', e);
    return;
  }

  for (const s of due) {
    try {
      advanceSchedule(s);
    } catch (e) {
      console.warn(`[schedules-scheduler] advanceSchedule ${s.id} failed: ${(e as Error).message}`);
    }
    if (countInflightForSchedule(s.id) > 0) {
      // Prior body still running — skip this tick (will retry next minute).
      continue;
    }
    void executeSchedule(s, 'schedule').catch((err) => {
      console.error(`[schedules-scheduler] execute ${s.id} crashed`, err);
    });
  }
}

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

/**
 * Compute the next_run_at after this tick fires. Behaviour mirrors
 * the Job scheduler's advanceSchedule so users see consistent semantics.
 */
export function advanceSchedule(s: Schedule): void {
  const now = Date.now();

  if (s.schedule_kind === 'manual') {
    // Should never reach here (listDueSchedules excludes manual), but be safe.
    setNextRunAt(s.id, null);
    return;
  }

  if (s.schedule_kind === 'once') {
    // One-shot: this tick fires, then auto-disable.
    setNextRunAt(s.id, null);
    try { updateSchedule(s.id, { enabled: false }); } catch (e) {
      console.warn(`[schedules-scheduler] auto-disable once schedule ${s.id} failed:`, e);
    }
    return;
  }

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

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

/**
 * Launch one body run for this schedule. Used by the tick loop AND
 * the manual fire API. Returns the target_id (the dispatched body's id).
 */
export async function executeSchedule(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
  if (s.body_kind === 'pipeline') return executePipelineBody(s, trigger);
  if (s.body_kind === 'prompt')   return executePromptBody(s, trigger);
  throw new Error(`unknown body_kind: ${s.body_kind}`);
}

// ─── pipeline body ────────────────────────────────────────

async function executePipelineBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
  // Coerce input values to strings for the pipeline orchestrator.
  // Schedule.input is typed `Record<string, unknown>` because the UI form
  // can save anything (numbers, booleans, nested), but startPipeline
  // currently expects `Record<string, string>`. Stringify non-strings.
  const stringInput: Record<string, string> = {};
  for (const [k, v] of Object.entries(s.input || {})) {
    if (v == null) { stringInput[k] = ''; continue; }
    stringInput[k] = typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
  }
  // Pre-install skills into every project the workflow's nodes target,
  // mirroring Job's dispatcher (lib/jobs/scheduler.ts ~563). Without
  // this Claude's `/skill-name` invocations fail because the skill
  // SKILL.md isn't on disk under the project's .claude/skills/.
  await preinstallSkillsForPipeline(s.body_ref, stringInput, s.skills);
  const pipeline = startPipeline(s.body_ref, stringInput, { skills: s.skills });
  insertScheduleRun({ schedule_id: s.id, target_id: pipeline.id, trigger });
  setLastRunAt(s.id, toSqlIso(new Date()));
  return pipeline.id;
}

/** For each unique project name referenced by the workflow's nodes,
 *  resolve it (cheap `{{input.X}}` expansion) and install every skill
 *  there. Idempotent — `ensureInstalledInProject` no-ops if the skill
 *  is already at the right version. Errors are warned, not thrown. */
async function preinstallSkillsForPipeline(
  workflowName: string,
  input: Record<string, string>,
  skills: string[],
): Promise<void> {
  if (!skills || skills.length === 0) return;
  const workflow = getWorkflow(workflowName);
  if (!workflow) return;
  const projectNames = new Set<string>();
  for (const n of Object.values(workflow.nodes)) {
    if (n.project) projectNames.add(n.project);
  }
  const seenPaths = new Set<string>();
  for (const pName of projectNames) {
    // Lightweight template expansion — pipeline.ts resolveTemplate is
    // not exported. Only handles `{{input.X}}` which covers the
    // common case (`project: "{{input.project}}"`). Literal names
    // pass through unchanged.
    const resolved = pName.replace(/\{\{\s*input\.([\w-]+)\s*\}\}/g, (_, k: string) => input[k] ?? '');
    if (!resolved) continue;
    const pInfo = getProjectInfo(resolved);
    if (!pInfo || seenPaths.has(pInfo.path)) continue;
    seenPaths.add(pInfo.path);
    for (const skill of skills) {
      try {
        const r = await ensureInstalledInProject(skill, pInfo.path);
        if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${pInfo.path}: ${r.reason}`);
      } catch (e) {
        console.warn(`[schedules] skill "${skill}" install failed in ${pInfo.path}: ${(e as Error).message}`);
      }
    }
  }
}

// ─── prompt body (V3) ────────────────────────────────────

/**
 * Prompt body: load prompts/<body_ref>.yaml, dispatch a one-shot Claude
 * task with the YAML's prompt text + executor.agent + executor.skills.
 * Schedule.input.project chooses where the task runs. The schedule_run
 * carries task.id as target_id; the task event listener (below) settles
 * status + body_output when the task reaches terminal state.
 *
 * The user's prompt text is sent VERBATIM — no LLM rewrites, no template
 * expansion. Schedule.input is currently ignored (project is the only
 * required field); future versions may surface input.* as template vars.
 */
async function executePromptBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
  const def = getPrompt(s.body_ref);
  if (!def) throw new Error(`prompt definition not found: prompts/${s.body_ref}.yaml`);

  // input.project is optional for prompt body — empty / unset falls back
  // to the synthetic 'scratch' project (<dataDir>/scratch) materialized
  // at init time. Schedules created before the optional-project change
  // also work: empty string → scratch.
  const projectName = strField(s.input.project) || SCRATCH_PROJECT_NAME;
  const project = getProjectInfo(projectName);
  if (!project) throw new Error(`project not found: "${projectName}"`);

  // Merge prompt-yaml skills with any extra skills the schedule row carries.
  // YAML is the primary source (designed per-prompt); schedule.skills is a
  // legacy/override hook that we keep for parity with pipeline/skill bodies.
  const skills = [
    ...(def.executor.skills || []),
    ...(s.skills || []),
  ].filter((v, i, arr) => v && arr.indexOf(v) === i);

  for (const skill of skills) {
    try {
      const r = await ensureInstalledInProject(skill, project.path);
      if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${project.path}: ${r.reason}`);
    } catch (e) {
      console.warn(`[schedules] skill "${skill}" install failed in ${project.path}: ${(e as Error).message}`);
    }
  }

  const task = createTask({
    projectName: project.name,
    projectPath: project.path,
    prompt: def.prompt,
    conversationId: '',
    agent: def.executor.agent || undefined,
  });

  const append = renderSkillsAppendPrompt(skills);
  if (append) taskAppendSystemPromptOverrides.set(task.id, append);

  insertScheduleRun({ schedule_id: s.id, target_id: task.id, trigger });
  setLastRunAt(s.id, toSqlIso(new Date()));
  return task.id;
}

function strField(v: unknown): string {
  if (v == null) return '';
  return typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
}

/** Duplicated from lib/pipeline.ts:renderSkillsAppendPrompt — small enough
 *  to inline rather than create an import edge. */
function renderSkillsAppendPrompt(skills: string[] | undefined): string {
  if (!skills || skills.length === 0) return '';
  const lines = skills.map((s) => `  /${s}`).join('\n');
  return [
    'For this task, the following Forge skills are available and should be used as appropriate:',
    lines,
    "Read each skill's SKILL.md before invoking. Prefer invoking these skills (with /<name>) over reimplementing their workflows inline.",
  ].join('\n');
}

// ─── Task-backed run settler ──────────────────────────────

/**
 * Global task event listener that settles schedule_runs whose target_id
 * is a Claude task (body_kind='prompt' dispatches one-shot tasks and stores
 * task.id as target_id).
 *
 * We only act on terminal status events; reads getTask to capture
 * resultSummary as body_output.
 *
 * Idempotent: updateScheduleRunStatus only touches rows with status='started'
 * AND target_id=taskId, so a duplicate event (or a non-schedule task) is a no-op.
 */
let taskBackedListenerInstalled = false;
function installTaskBackedRunListener(): void {
  if (taskBackedListenerInstalled) return;
  taskBackedListenerInstalled = true;
  onTaskEvent((taskId, event, data) => {
    if (event !== 'status') return;
    if (data !== 'done' && data !== 'failed' && data !== 'cancelled') return;
    settleTaskBackedRun(taskId, data as ScheduleRunStatus);
  });
}

function settleTaskBackedRun(taskId: string, status: ScheduleRunStatus): void {
  const t = getTask(taskId);
  if (!t) {
    updateScheduleRunStatus({ target_id: taskId, status, error: 'task row gone' });
    return;
  }
  setScheduleRunBodyOutputByTarget(taskId, t.resultSummary || null);
  updateScheduleRunStatus({
    target_id: taskId,
    status,
    error: status === 'failed' ? (t.error || null) : null,
  });
  void runActionForTarget(taskId).catch((e) => {
    console.error(`[schedules-scheduler] action for task target ${taskId} crashed`, e);
  });
}

/**
 * Refusing a manual fire when something's already inflight unless caller
 * passed cancel_inflight=1. Returns null if not busy. Returns a reason
 * string if busy.
 */
export function isScheduleBusy(id: string): { busy: boolean; reason: string } {
  const s = getSchedule(id);
  if (!s) return { busy: false, reason: '' };
  const n = countInflightForSchedule(id);
  if (n > 0) return { busy: true, reason: `${n} body run${n === 1 ? '' : 's'} still running from this schedule` };
  return { busy: false, reason: '' };
}
