import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, rm, stat } from "node:fs/promises";
import { dirname, relative, resolve, posix as pathPosix } from "node:path";
import type { JsonValue, CoreDispatchMessage } from "../../shared/src/index";
import { isProviderDispatchCommand } from "../../shared/src/index";
import { ctx, sql, config, logger } from "./context";
import type {
  ScheduledTaskRow,
  ScheduledTaskRunRow,
  ScheduleSpec,
  ScheduleAction,
  DispatchScheduleAction,
  PgdataBackupScheduleAction,
  RawTaskRow,
} from "./types";
import { jsonResponse, compactJson, errorToJson, isJsonRecord, rowIso, truncateText } from "./http";
import { createAndSendTask, waitForTaskTerminal, rawTask } from "./task-dispatcher";
import { recordEvent } from "./db";

/* ─── helpers ─────────────────────────────────────────────────────────── */

function readLimit(url: URL, defaultLimit: number): number {
  const raw = url.searchParams.get("limit");
  if (raw === null) return defaultLimit;
  const parsed = Number(raw);
  if (!Number.isInteger(parsed) || parsed <= 0) return defaultLimit;
  return Math.min(parsed, 500);
}

function isTerminalTaskStatus(status: string): boolean {
  return status === "succeeded" || status === "failed";
}

/* ─── views ───────────────────────────────────────────────────────────── */

function scheduleRunView(row: ScheduledTaskRunRow): JsonValue {
  return {
    id: row.id,
    scheduleId: row.schedule_id,
    triggerType: row.trigger_type,
    status: row.status,
    taskId: row.task_id,
    result: row.result,
    error: row.error,
    startedAt: rowIso(row.started_at),
    finishedAt: rowIso(row.finished_at),
    durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
    createdAt: rowIso(row.created_at),
    updatedAt: rowIso(row.updated_at),
  };
}

function scheduledTaskView(row: ScheduledTaskRow, runs: ScheduledTaskRunRow[] = []): JsonValue {
  return {
    id: row.id,
    name: row.name,
    description: row.description,
    enabled: row.enabled,
    schedule: row.schedule_json,
    action: row.action_json,
    concurrencyPolicy: row.concurrency_policy,
    nextRunAt: rowIso(row.next_run_at),
    lastRunAt: rowIso(row.last_run_at),
    lastRunId: row.last_run_id,
    createdAt: rowIso(row.created_at),
    updatedAt: rowIso(row.updated_at),
    recentRuns: runs.map(scheduleRunView),
  };
}

function scheduledRawTaskJson(task: RawTaskRow | null): JsonValue {
  if (task === null) return null;
  return {
    id: task.id,
    providerId: task.provider_id,
    command: task.command,
    status: task.status,
    payload: compactJson(task.payload),
    result: compactJson(task.result ?? null),
    updatedAt: rowIso(task.updated_at),
  };
}

/* ─── normalization ───────────────────────────────────────────────────── */

function normalizeScheduleId(value: unknown): string {
  const raw = typeof value === "string" && value.trim().length > 0
    ? value.trim()
    : `schedule_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
  if (!/^[A-Za-z0-9_.:-]{1,120}$/u.test(raw)) {
    throw new Error("schedule id must be 1-120 chars using letters, numbers, _, ., :, or -");
  }
  return raw;
}

function normalizeTimeOfDay(value: unknown): string {
  const raw = typeof value === "string" && value.trim().length > 0 ? value.trim() : "03:00";
  const match = raw.match(/^([01]\d|2[0-3]):([0-5]\d)$/u);
  if (match === null) throw new Error("daily schedule timeOfDay must use HH:MM in UTC");
  return `${match[1]}:${match[2]}`;
}

function numberInRange(value: unknown, fallback: number, min: number, max: number): number {
  const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : fallback;
  if (!Number.isFinite(parsed)) return fallback;
  return Math.max(min, Math.min(max, Math.floor(parsed)));
}

function normalizeScheduleSpec(value: unknown): ScheduleSpec {
  const record = isJsonRecord(value) ? value : {};
  if (record.type === "interval") {
    return {
      type: "interval",
      everySeconds: numberInRange(record.everySeconds, 3600, 60, 366 * 24 * 3600),
    };
  }
  return {
    type: "daily",
    timeOfDay: normalizeTimeOfDay(record.timeOfDay),
    timezone: typeof record.timezone === "string" && record.timezone.length > 0 ? record.timezone : "Etc/UTC",
  };
}

function normalizeScheduleAction(value: unknown): ScheduleAction {
  if (!isJsonRecord(value)) throw new Error("scheduled task action must be an object");
  if (value.type === "dispatch") {
    const providerId = typeof value.providerId === "string" ? value.providerId.trim() : "";
    if (providerId.length === 0) throw new Error("dispatch action providerId is required");
    if (!isProviderDispatchCommand(value.command)) throw new Error("dispatch action command is invalid");
    const payload = isJsonRecord(value.payload) ? value.payload : {};
    const timeoutMs = value.timeoutMs === undefined ? undefined : numberInRange(value.timeoutMs, config().taskPendingTimeoutMs, 1_000, 24 * 3600_000);
    return { type: "dispatch", providerId, command: value.command, payload, ...(timeoutMs === undefined ? {} : { timeoutMs }) };
  }
  if (value.type === "pgdata_backup") {
    return {
      type: "pgdata_backup",
      volumeName: typeof value.volumeName === "string" && value.volumeName.length > 0 ? value.volumeName : config().databaseVolumeName,
      remoteBaseDir: normalizeRemoteDir(typeof value.remoteBaseDir === "string" ? value.remoteBaseDir : "/SERVER_DATA/UNIDESK_PG_DATA"),
      stagingSubdir: normalizeRelativeStagingPath(typeof value.stagingSubdir === "string" ? value.stagingSubdir : "server-data/unidesk-pg-data").relativePath,
      baiduBaseUrl: typeof value.baiduBaseUrl === "string" && value.baiduBaseUrl.length > 0 ? value.baiduBaseUrl : config().baiduNetdiskInternalUrl,
      timeoutMs: numberInRange(value.timeoutMs, 60 * 60_000, 60_000, 24 * 3600_000),
      cleanupLocal: value.cleanupLocal !== false,
    };
  }
  throw new Error("scheduled task action.type must be dispatch or pgdata_backup");
}

function computeNextRunAt(schedule: ScheduleSpec, after = new Date()): Date {
  if (schedule.type === "interval") {
    return new Date(after.getTime() + Math.max(60, schedule.everySeconds ?? 3600) * 1000);
  }
  const [hourText = "03", minuteText = "00"] = (schedule.timeOfDay ?? "03:00").split(":");
  const candidate = new Date(Date.UTC(after.getUTCFullYear(), after.getUTCMonth(), after.getUTCDate(), Number(hourText), Number(minuteText), 0, 0));
  if (candidate.getTime() <= after.getTime()) candidate.setUTCDate(candidate.getUTCDate() + 1);
  return candidate;
}

/* ─── path helpers ────────────────────────────────────────────────────── */

function normalizeRemoteDir(input: string): string {
  const raw = input.trim() || "/";
  if (raw.includes("\0")) throw new Error("remote directory must not contain null bytes");
  const normalized = pathPosix.normalize(raw.startsWith("/") ? raw : `/${raw}`);
  return normalized === "/" ? "/" : normalized.replace(/\/+$/u, "");
}

function normalizeRelativeStagingPath(input: string): { relativePath: string; absolutePath: string } {
  const cleaned = input.replace(/\\/g, "/").replace(/^\/+/u, "").trim();
  const normalized = pathPosix.normalize(cleaned || ".");
  if (normalized === "." || normalized.startsWith("../") || normalized === "..") throw new Error("staging path must be a relative child path");
  const absolutePath = resolve(config().pgdataBackupStagingDir, normalized);
  const rel = relative(config().pgdataBackupStagingDir, absolutePath);
  if (rel.startsWith("..") || rel.includes("\0") || resolve(absolutePath) === resolve(config().pgdataBackupStagingDir)) {
    throw new Error("staging path must stay inside PGDATA_BACKUP_STAGING_DIR");
  }
  return { relativePath: normalized, absolutePath };
}

function remoteFolderChain(remoteDir: string): string[] {
  const parts = normalizeRemoteDir(remoteDir).split("/").filter(Boolean);
  const folders: string[] = [];
  let current = "";
  for (const part of parts) {
    current = `${current}/${part}`;
    folders.push(current);
  }
  return folders;
}

function safeFilePart(value: string): string {
  return value.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80) || "data";
}

function utcBackupParts(date = new Date()): { date: string; time: string; month: string; stamp: string } {
  const year = String(date.getUTCFullYear()).padStart(4, "0");
  const monthNumber = String(date.getUTCMonth() + 1).padStart(2, "0");
  const day = String(date.getUTCDate()).padStart(2, "0");
  const hour = String(date.getUTCHours()).padStart(2, "0");
  const minute = String(date.getUTCMinutes()).padStart(2, "0");
  const second = String(date.getUTCSeconds()).padStart(2, "0");
  return { date: `${year}${monthNumber}${day}`, time: `${hour}${minute}${second}`, month: `${year}${monthNumber}`, stamp: `${year}${monthNumber}${day}_${hour}${minute}${second}` };
}

function databaseConnectionParts(): { host: string; port: string; user: string; password: string } {
  const url = new URL(config().databaseUrl);
  return {
    host: url.hostname || "database",
    port: url.port || "5432",
    user: decodeURIComponent(url.username || "postgres"),
    password: decodeURIComponent(url.password || ""),
  };
}

/* ─── local command / file utilities ──────────────────────────────────── */

async function runLocalCommand(
  command: string,
  args: string[],
  options: { cwd?: string; env?: Record<string, string>; timeoutMs: number },
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
  const proc = Bun.spawn([command, ...args], {
    cwd: options.cwd,
    env: { ...process.env, ...(options.env ?? {}) },
    stdout: "pipe",
    stderr: "pipe",
  });
  let timedOut = false;
  const timer = setTimeout(() => {
    timedOut = true;
    proc.kill("SIGTERM");
  }, Math.max(1, options.timeoutMs));
  try {
    const [stdout, stderr, exitCode] = await Promise.all([
      new Response(proc.stdout).text(),
      new Response(proc.stderr).text(),
      proc.exited,
    ]);
    return { ok: exitCode === 0 && !timedOut, stdout: truncateText(stdout, 4000), stderr: truncateText(stderr, 4000), exitCode, timedOut };
  } finally {
    clearTimeout(timer);
  }
}

async function sha256File(filePath: string): Promise<string> {
  const hash = createHash("sha256");
  await new Promise<void>((resolveHash, rejectHash) => {
    const stream = createReadStream(filePath);
    stream.on("data", (chunk) => hash.update(chunk));
    stream.on("error", rejectHash);
    stream.on("end", resolveHash);
  });
  return hash.digest("hex");
}

async function fetchJsonWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Record<string, unknown>> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
  try {
    const response = await fetch(url, { ...init, signal: controller.signal });
    const text = await response.text();
    let parsed: unknown = {};
    try {
      parsed = text.length > 0 ? JSON.parse(text) as unknown : {};
    } catch {
      parsed = { text };
    }
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${truncateText(text, 1000)}`);
    if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { value: parsed };
    return parsed as Record<string, unknown>;
  } finally {
    clearTimeout(timer);
  }
}

async function baiduJson(baseUrl: string, path: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<Record<string, unknown>> {
  const url = new URL(path, baseUrl);
  const headers = new Headers(init.headers);
  if (init.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
  const body = await fetchJsonWithTimeout(url.toString(), { ...init, headers }, timeoutMs);
  if (body.ok === false) throw new Error(`Baidu Netdisk API failed: ${JSON.stringify(compactJson(body))}`);
  return body;
}

function jobRecord(value: unknown): Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

async function waitForBaiduTransfer(baseUrl: string, jobId: string, timeoutMs: number): Promise<Record<string, unknown>> {
  const deadline = Date.now() + timeoutMs;
  let latest: Record<string, unknown> = {};
  let latestError = "";
  while (Date.now() < deadline) {
    try {
      const detail = await baiduJson(baseUrl, `/api/transfers/${encodeURIComponent(jobId)}`, {}, 30_000);
      latest = detail;
      latestError = "";
      const job = jobRecord(detail.job);
      const status = String(job.status || "");
      if (status === "succeeded") return detail;
      if (status === "failed" || status === "canceled") throw new Error(`Baidu transfer ${status}: ${String(job.error || "no error detail")}`);
    } catch (error) {
      latestError = error instanceof Error ? error.message : String(error);
      logger("warn", "baidu_transfer_poll_failed", { jobId, error: latestError });
    }
    await Bun.sleep(2000);
  }
  throw new Error(`Baidu transfer timed out after ${timeoutMs}ms: ${JSON.stringify(compactJson({ latest, latestError }))}`);
}

/* ─── action execution ────────────────────────────────────────────────── */

async function executeDispatchScheduleAction(action: DispatchScheduleAction): Promise<{ ok: boolean; taskId: string; result: JsonValue }> {
  const { taskId, providerOnline } = await createAndSendTask(action.providerId, action.command, {
    source: "scheduled-task",
    ...action.payload,
  });
  if (!providerOnline) {
    return { ok: false, taskId, result: { providerOnline, taskId, error: `provider is offline: ${action.providerId}` } };
  }
  const timeoutMs = action.timeoutMs ?? numberInRange(action.payload.timeoutMs, config().taskPendingTimeoutMs + 5000, 1000, 24 * 3600_000);
  const task = await waitForTaskTerminal(taskId, timeoutMs);
  const ok = task?.status === "succeeded";
  return {
    ok,
    taskId,
    result: {
      providerOnline,
      taskId,
      timeoutMs,
      terminal: task === null ? false : isTerminalTaskStatus(task.status),
      task: scheduledRawTaskJson(task),
    },
  };
}

async function executePgdataBackupAction(action: PgdataBackupScheduleAction, runId: string): Promise<{ ok: boolean; result: JsonValue }> {
  const startedAt = new Date();
  const parts = utcBackupParts(startedAt);
  const filename = `${parts.stamp}_${safeFilePart(action.volumeName)}.pg_basebackup.tar.gz`;
  const monthRelativeDir = pathPosix.join(action.stagingSubdir, parts.month);
  const backupRelativePath = pathPosix.join(monthRelativeDir, filename);
  const backupPath = normalizeRelativeStagingPath(backupRelativePath).absolutePath;
  const tempRelativeDir = pathPosix.join(action.stagingSubdir, parts.month, `.tmp_${runId}`);
  const tempDir = normalizeRelativeStagingPath(tempRelativeDir).absolutePath;
  const remoteDir = normalizeRemoteDir(pathPosix.join(action.remoteBaseDir, parts.month));
  const remotePath = normalizeRemoteDir(pathPosix.join(remoteDir, filename));
  const db = databaseConnectionParts();
  await mkdir(dirname(backupPath), { recursive: true });
  await rm(tempDir, { recursive: true, force: true });
  await mkdir(tempDir, { recursive: true });
  let uploadDetail: Record<string, unknown> | null = null;
  let uploadJobId = "";
  let backupBytes = 0;
  let backupSha256 = "";
  try {
    const backupTimeoutMs = Math.max(60_000, Math.floor(action.timeoutMs * 0.55));
    const packageTimeoutMs = Math.max(60_000, Math.floor(action.timeoutMs * 0.15));
    const uploadTimeoutMs = Math.max(60_000, action.timeoutMs - backupTimeoutMs - packageTimeoutMs);
    const basebackup = await runLocalCommand("pg_basebackup", [
      "-h", db.host,
      "-p", db.port,
      "-U", db.user,
      "-D", tempDir,
      "-Ft",
      "-X", "stream",
      "-z",
      "--checkpoint=fast",
      "--no-sync",
    ], { env: { PGPASSWORD: db.password }, timeoutMs: backupTimeoutMs });
    if (!basebackup.ok) throw new Error(`pg_basebackup failed exit=${basebackup.exitCode} timedOut=${basebackup.timedOut}: ${basebackup.stderr || basebackup.stdout}`);
    const packaged = await runLocalCommand("tar", ["-czf", backupPath, "-C", tempDir, "."], { timeoutMs: packageTimeoutMs });
    if (!packaged.ok) throw new Error(`tar packaging failed exit=${packaged.exitCode} timedOut=${packaged.timedOut}: ${packaged.stderr || packaged.stdout}`);
    const info = await stat(backupPath);
    backupBytes = info.size;
    backupSha256 = await sha256File(backupPath);
    for (const folder of remoteFolderChain(remoteDir)) {
      await baiduJson(action.baiduBaseUrl, "/api/folders", {
        method: "POST",
        body: JSON.stringify({ path: folder }),
      }, 60_000);
    }
    const created = await baiduJson(action.baiduBaseUrl, "/api/transfers/upload-from-path", {
      method: "POST",
      body: JSON.stringify({ localPath: backupRelativePath, remotePath }),
    }, 60_000);
    const jobId = String(jobRecord(created.job).id || "");
    if (jobId.length === 0) throw new Error(`Baidu upload did not return a job id: ${JSON.stringify(compactJson(created))}`);
    uploadJobId = jobId;
    uploadDetail = await waitForBaiduTransfer(action.baiduBaseUrl, jobId, uploadTimeoutMs);
    const uploadedJob = jobRecord(uploadDetail.job);
    return {
      ok: true,
      result: {
        backupType: "pg_basebackup",
        volumeName: action.volumeName,
        backupBytes,
        backupSha256,
        localRelativePath: backupRelativePath,
        remotePath,
        remoteDir,
        month: parts.month,
        timestampPrefix: parts.stamp,
        baiduTransferJobId: jobId,
        baiduTransferStatus: String(uploadedJob.status || ""),
        baiduFsId: String(uploadedJob.fsId || ""),
        baiduResult: compactJson(uploadedJob.result ?? null),
      },
    };
  } finally {
    await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
    if (action.cleanupLocal && (uploadJobId.length === 0 || uploadDetail !== null)) await rm(backupPath, { force: true }).catch(() => undefined);
  }
}

async function executeScheduleAction(action: ScheduleAction, runId: string): Promise<{ ok: boolean; taskId: string | null; result: JsonValue }> {
  if (action.type === "dispatch") {
    const dispatched = await executeDispatchScheduleAction(action);
    return { ok: dispatched.ok, taskId: dispatched.taskId, result: dispatched.result };
  }
  const backup = await executePgdataBackupAction(action, runId);
  return { ok: backup.ok, taskId: null, result: backup.result };
}

/* ─── scheduled run execution ─────────────────────────────────────────── */

async function executeScheduledRun(runId: string): Promise<void> {
  if (ctx.activeScheduledRuns.has(runId)) return;
  ctx.activeScheduledRuns.add(runId);
  const started = Date.now();
  try {
    const queuedRuns = await sql()<ScheduledTaskRunRow[]>`
      SELECT *
      FROM unidesk_scheduled_task_runs
      WHERE id = ${runId}
      LIMIT 1
    `;
    const queuedRun = queuedRuns[0];
    if (queuedRun === undefined) return;
    const scheduleRow = await getScheduledTaskRow(queuedRun.schedule_id);
    if (scheduleRow === null) return;
    const runRows = await sql()<ScheduledTaskRunRow[]>`
      UPDATE unidesk_scheduled_task_runs
      SET status = 'running', started_at = now(), updated_at = now()
      WHERE id = ${runId} AND status = 'queued'
      RETURNING *
    `;
    if (runRows.length === 0) return;
    try {
      const action = normalizeScheduleAction(scheduleRow.action_json);
      const outcome = await executeScheduleAction(action, runId);
      const durationMs = Date.now() - started;
      const status = outcome.ok ? "succeeded" : "failed";
      const error = outcome.ok ? null : "scheduled action failed";
      await sql().begin(async (tx) => {
        await tx`
          UPDATE unidesk_scheduled_task_runs
          SET status = ${status},
              task_id = ${outcome.taskId},
              result = ${tx.json(outcome.result)},
              error = ${error},
              finished_at = now(),
              duration_ms = ${durationMs},
              updated_at = now()
          WHERE id = ${runId}
        `;
        await tx`
          UPDATE unidesk_scheduled_tasks
          SET last_run_at = now(), last_run_id = ${runId}, updated_at = now()
          WHERE id = ${scheduleRow.id}
        `;
      });
      await recordEvent(`scheduled_task_${status}`, "scheduler", { scheduleId: scheduleRow.id, runId, durationMs, result: compactJson(outcome.result) });
    } catch (error) {
      const durationMs = Date.now() - started;
      await sql().begin(async (tx) => {
        await tx`
          UPDATE unidesk_scheduled_task_runs
          SET status = 'failed',
              result = ${tx.json(errorToJson(error))},
              error = ${error instanceof Error ? error.message : String(error)},
              finished_at = now(),
              duration_ms = ${durationMs},
              updated_at = now()
          WHERE id = ${runId}
        `;
        await tx`
          UPDATE unidesk_scheduled_tasks
          SET last_run_at = now(), last_run_id = ${runId}, updated_at = now()
          WHERE id = ${scheduleRow.id}
        `;
      });
      await recordEvent("scheduled_task_failed", "scheduler", { scheduleId: scheduleRow.id, runId, durationMs, error: errorToJson(error) });
    }
  } finally {
    ctx.activeScheduledRuns.delete(runId);
  }
}

/* ─── CRUD ────────────────────────────────────────────────────────────── */

async function getScheduledTasks(limit: number): Promise<JsonValue[]> {
  const rows = await sql()<ScheduledTaskRow[]>`
    SELECT *
    FROM unidesk_scheduled_tasks
    ORDER BY enabled DESC, next_run_at ASC NULLS LAST, updated_at DESC
    LIMIT ${limit}
  `;
  const runRows = await sql()<ScheduledTaskRunRow[]>`
    SELECT *
    FROM unidesk_scheduled_task_runs
    ORDER BY updated_at DESC
    LIMIT ${Math.max(20, limit * 5)}
  `;
  const runsBySchedule = new Map<string, ScheduledTaskRunRow[]>();
  for (const run of runRows) {
    const list = runsBySchedule.get(run.schedule_id) ?? [];
    if (list.length < 5) list.push(run);
    runsBySchedule.set(run.schedule_id, list);
  }
  return rows.map((row) => scheduledTaskView(row, runsBySchedule.get(row.id) ?? []));
}

async function getScheduledTaskRuns(scheduleId: string | null, limit: number): Promise<JsonValue[]> {
  const rows = scheduleId === null
    ? await sql()<ScheduledTaskRunRow[]>`
      SELECT *
      FROM unidesk_scheduled_task_runs
      ORDER BY updated_at DESC
      LIMIT ${limit}
    `
    : await sql()<ScheduledTaskRunRow[]>`
      SELECT *
      FROM unidesk_scheduled_task_runs
      WHERE schedule_id = ${scheduleId}
      ORDER BY updated_at DESC
      LIMIT ${limit}
    `;
  return rows.map(scheduleRunView);
}

async function getScheduledTaskRow(scheduleId: string): Promise<ScheduledTaskRow | null> {
  const rows = await sql()<ScheduledTaskRow[]>`
    SELECT *
    FROM unidesk_scheduled_tasks
    WHERE id = ${scheduleId}
    LIMIT 1
  `;
  return rows[0] ?? null;
}

async function upsertScheduledTask(req: Request, scheduleIdFromPath: string | null): Promise<Response> {
  const body = await req.json().catch(() => ({})) as Record<string, unknown>;
  const id = normalizeScheduleId(scheduleIdFromPath ?? body.id);
  const schedule = normalizeScheduleSpec(body.schedule);
  const action = normalizeScheduleAction(body.action);
  const name = typeof body.name === "string" && body.name.trim().length > 0 ? body.name.trim() : id;
  const description = typeof body.description === "string" ? body.description : "";
  const enabled = typeof body.enabled === "boolean" ? body.enabled : true;
  const concurrencyPolicy = body.concurrencyPolicy === "parallel" ? "parallel" : "skip";
  const existing = await getScheduledTaskRow(id);
  const nextRunAt = existing?.next_run_at && JSON.stringify(existing.schedule_json) === JSON.stringify(schedule)
    ? rowIso(existing.next_run_at)
    : computeNextRunAt(schedule).toISOString();
  const scheduleJson = schedule as unknown as JsonValue;
  const actionJson = action as unknown as JsonValue;
  const rows = await sql()<ScheduledTaskRow[]>`
    INSERT INTO unidesk_scheduled_tasks (id, name, description, enabled, schedule_json, action_json, concurrency_policy, next_run_at, updated_at)
    VALUES (${id}, ${name}, ${description}, ${enabled}, ${sql().json(scheduleJson)}, ${sql().json(actionJson)}, ${concurrencyPolicy}, ${nextRunAt}, now())
    ON CONFLICT (id) DO UPDATE SET
      name = EXCLUDED.name,
      description = EXCLUDED.description,
      enabled = EXCLUDED.enabled,
      schedule_json = EXCLUDED.schedule_json,
      action_json = EXCLUDED.action_json,
      concurrency_policy = EXCLUDED.concurrency_policy,
      next_run_at = EXCLUDED.next_run_at,
      updated_at = now()
    RETURNING *
  `;
  await recordEvent(existing === null ? "scheduled_task_created" : "scheduled_task_updated", "scheduler", { scheduleId: id, name, enabled, schedule: scheduleJson, action: compactJson(action) });
  return jsonResponse({ ok: true, schedule: scheduledTaskView(rows[0]) }, existing === null ? 201 : 200);
}

async function patchScheduledTask(scheduleId: string, req: Request): Promise<Response> {
  const current = await getScheduledTaskRow(scheduleId);
  if (current === null) return jsonResponse({ ok: false, error: `scheduled task not found: ${scheduleId}` }, 404);
  const body = await req.json().catch(() => ({})) as Record<string, unknown>;
  const schedule = body.schedule === undefined ? normalizeScheduleSpec(current.schedule_json) : normalizeScheduleSpec(body.schedule);
  const action = body.action === undefined ? normalizeScheduleAction(current.action_json) : normalizeScheduleAction(body.action);
  const name = typeof body.name === "string" && body.name.trim().length > 0 ? body.name.trim() : current.name;
  const description = typeof body.description === "string" ? body.description : current.description;
  const enabled = typeof body.enabled === "boolean" ? body.enabled : current.enabled;
  const concurrencyPolicy = body.concurrencyPolicy === "parallel" || body.concurrencyPolicy === "skip" ? body.concurrencyPolicy : current.concurrency_policy;
  const scheduleChanged = body.schedule !== undefined && JSON.stringify(schedule) !== JSON.stringify(current.schedule_json);
  const nextRunAt = scheduleChanged || current.next_run_at === null ? computeNextRunAt(schedule).toISOString() : rowIso(current.next_run_at);
  const scheduleJson = schedule as unknown as JsonValue;
  const actionJson = action as unknown as JsonValue;
  const rows = await sql()<ScheduledTaskRow[]>`
    UPDATE unidesk_scheduled_tasks
    SET name = ${name},
        description = ${description},
        enabled = ${enabled},
        schedule_json = ${sql().json(scheduleJson)},
        action_json = ${sql().json(actionJson)},
        concurrency_policy = ${concurrencyPolicy},
        next_run_at = ${nextRunAt},
        updated_at = now()
    WHERE id = ${scheduleId}
    RETURNING *
  `;
  await recordEvent("scheduled_task_updated", "scheduler", { scheduleId, name, enabled, schedule: scheduleJson, action: compactJson(action) });
  return jsonResponse({ ok: true, schedule: scheduledTaskView(rows[0]) });
}

async function deleteScheduledTask(scheduleId: string): Promise<Response> {
  const rows = await sql()<Array<{ id: string }>>`
    DELETE FROM unidesk_scheduled_tasks
    WHERE id = ${scheduleId}
    RETURNING id
  `;
  if (rows.length === 0) return jsonResponse({ ok: false, error: `scheduled task not found: ${scheduleId}` }, 404);
  await recordEvent("scheduled_task_deleted", "scheduler", { scheduleId });
  return jsonResponse({ ok: true, deleted: scheduleId });
}

async function triggerScheduledTask(scheduleId: string, triggerType: "manual" | "schedule"): Promise<JsonValue | null> {
  const runId = `schedrun_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
  let createdRun: ScheduledTaskRunRow | null = null;
  await sql().begin(async (tx) => {
    const rows = await tx<ScheduledTaskRow[]>`
      SELECT *
      FROM unidesk_scheduled_tasks
      WHERE id = ${scheduleId}
      FOR UPDATE
    `;
    const scheduleRow = rows[0];
    if (scheduleRow === undefined) throw new Error(`scheduled task not found: ${scheduleId}`);
    if (triggerType === "schedule" && !scheduleRow.enabled) return;
    const runningRows = await tx<Array<{ count: string | number }>>`
      SELECT count(*)::int AS count
      FROM unidesk_scheduled_task_runs
      WHERE schedule_id = ${scheduleId}
        AND status IN ('queued', 'running')
    `;
    const runningCount = Number(runningRows[0]?.count ?? 0);
    const schedule = normalizeScheduleSpec(scheduleRow.schedule_json);
    const shouldAdvanceNext = triggerType === "schedule";
    if (shouldAdvanceNext) {
      await tx`
        UPDATE unidesk_scheduled_tasks
        SET next_run_at = ${computeNextRunAt(schedule).toISOString()}, updated_at = now()
        WHERE id = ${scheduleId}
      `;
    }
    if (scheduleRow.concurrency_policy !== "parallel" && runningCount > 0) {
      const skippedRows = await tx<ScheduledTaskRunRow[]>`
        INSERT INTO unidesk_scheduled_task_runs (id, schedule_id, trigger_type, status, result, error, finished_at, duration_ms)
        VALUES (${runId}, ${scheduleId}, ${triggerType}, 'skipped', ${tx.json({ reason: "previous run still active", runningCount })}, 'previous run still active', now(), 0)
        RETURNING *
      `;
      createdRun = skippedRows[0] ?? null;
      return;
    }
    const runRows = await tx<ScheduledTaskRunRow[]>`
      INSERT INTO unidesk_scheduled_task_runs (id, schedule_id, trigger_type, status)
      VALUES (${runId}, ${scheduleId}, ${triggerType}, 'queued')
      RETURNING *
    `;
    createdRun = runRows[0] ?? null;
  });
  const run = createdRun as ScheduledTaskRunRow | null;
  if (run === null) return null;
  if (run.status === "queued") {
    setTimeout(() => {
      executeScheduledRun(runId).catch((error) => logger("error", "scheduled_run_uncaught", { runId, error: errorToJson(error) }));
    }, 0);
  }
  await recordEvent("scheduled_task_triggered", "scheduler", { scheduleId, runId, triggerType, status: run.status });
  return scheduleRunView(run);
}

export async function scheduledTaskRoute(req: Request, url: URL): Promise<Response> {
  const prefix = "/api/schedules";
  const rest = url.pathname === prefix ? "" : url.pathname.slice(prefix.length + 1);
  const segments = rest.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
  if (url.pathname === prefix && req.method === "GET") {
    return jsonResponse({ ok: true, schedules: await getScheduledTasks(readLimit(url, 100)) });
  }
  if (url.pathname === prefix && req.method === "POST") return upsertScheduledTask(req, null);
  if (segments.length === 1 && segments[0] === "runs" && req.method === "GET") {
    return jsonResponse({ ok: true, runs: await getScheduledTaskRuns(null, readLimit(url, 100)) });
  }
  if (segments.length === 1 && req.method === "GET") {
    const schedule = await getScheduledTaskRow(segments[0]);
    if (schedule === null) return jsonResponse({ ok: false, error: `scheduled task not found: ${segments[0]}` }, 404);
    const runs = await sql()<ScheduledTaskRunRow[]>`
      SELECT *
      FROM unidesk_scheduled_task_runs
      WHERE schedule_id = ${segments[0]}
      ORDER BY updated_at DESC
      LIMIT 20
    `;
    return jsonResponse({ ok: true, schedule: scheduledTaskView(schedule, runs) });
  }
  if (segments.length === 1 && req.method === "PUT") return upsertScheduledTask(req, segments[0]);
  if (segments.length === 1 && req.method === "PATCH") return patchScheduledTask(segments[0], req);
  if (segments.length === 1 && req.method === "DELETE") return deleteScheduledTask(segments[0]);
  if (segments.length === 2 && segments[1] === "run" && req.method === "POST") {
    const run = await triggerScheduledTask(segments[0], "manual");
    if (run === null) return jsonResponse({ ok: false, error: `scheduled task was not triggered: ${segments[0]}` }, 409);
    return jsonResponse({ ok: true, run });
  }
  if (segments.length === 2 && segments[1] === "runs" && req.method === "GET") {
    return jsonResponse({ ok: true, runs: await getScheduledTaskRuns(segments[0], readLimit(url, 100)) });
  }
  return jsonResponse({ ok: false, error: "scheduled task route not found", path: url.pathname }, 404);
}

export async function recoverScheduledRuns(): Promise<void> {
  const rows = await sql()<ScheduledTaskRunRow[]>`
    UPDATE unidesk_scheduled_task_runs
    SET status = 'failed',
        error = 'backend-core restarted before scheduled run completed',
        result = jsonb_build_object('error', 'backend-core restarted before scheduled run completed'),
        finished_at = now(),
        updated_at = now()
    WHERE status IN ('queued', 'running')
    RETURNING *
  `;
  if (rows.length > 0) await recordEvent("scheduled_runs_recovered", "scheduler", { failedRunCount: rows.length });
  const schedules = await sql()<ScheduledTaskRow[]>`
    SELECT *
    FROM unidesk_scheduled_tasks
    WHERE next_run_at IS NULL
    LIMIT 200
  `;
  for (const schedule of schedules) {
    const nextRunAt = computeNextRunAt(normalizeScheduleSpec(schedule.schedule_json)).toISOString();
    await sql()`UPDATE unidesk_scheduled_tasks SET next_run_at = ${nextRunAt}, updated_at = now() WHERE id = ${schedule.id}`;
  }
}

export async function runDueScheduledTasks(): Promise<void> {
  if (!ctx.dbReady) return;
  const rows = await sql()<ScheduledTaskRow[]>`
    SELECT *
    FROM unidesk_scheduled_tasks
    WHERE enabled = true
      AND (next_run_at IS NULL OR next_run_at <= now())
    ORDER BY next_run_at ASC NULLS FIRST
    LIMIT 5
  `;
  for (const row of rows) {
    try {
      await triggerScheduledTask(row.id, "schedule");
    } catch (error) {
      logger("error", "scheduled_task_due_trigger_failed", { scheduleId: row.id, error: errorToJson(error) });
    }
  }
}
