// Shared cron CLI formatting, parsing, delivery preview, and warning helpers.
import {
  resolveExpiresAtMsFromDurationMs,
  timestampMsToIsoString,
} from "@openclaw/normalization-core/number-coercion";
import {
  normalizeLowercaseStringOrEmpty,
  normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import { resolveCronStaggerMs } from "../../cron/stagger.js";
import type { CronDeliveryPreview, CronJob, CronSchedule } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
import {
  isOffsetlessIsoDateTime,
  parseOffsetlessIsoDateTimeInTimeZone,
} from "../../infra/format-time/parse-offsetless-zoned-datetime.js";
import { formatTimestamp } from "../../logging/timestamps.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js";

export function parseCronCommandArgv(value: unknown): string[] | undefined {
  if (typeof value !== "string") {
    return undefined;
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(value);
  } catch {
    throw new Error("--command-argv must be a JSON array of strings");
  }
  if (
    !Array.isArray(parsed) ||
    parsed.length === 0 ||
    parsed.some((entry) => typeof entry !== "string" || entry.length === 0)
  ) {
    throw new Error("--command-argv must be a non-empty JSON array of non-empty strings");
  }
  return parsed;
}

export function parseCronCommandEnv(values: unknown): Record<string, string> | undefined {
  const rawValues = Array.isArray(values) ? values : typeof values === "string" ? [values] : [];
  if (rawValues.length === 0) {
    return undefined;
  }
  const env: Record<string, string> = {};
  for (const raw of rawValues) {
    if (typeof raw !== "string") {
      throw new Error("--command-env must be KEY=VALUE");
    }
    const idx = raw.indexOf("=");
    const key = idx > 0 ? raw.slice(0, idx).trim() : "";
    if (!key) {
      throw new Error("--command-env must be KEY=VALUE");
    }
    env[key] = raw.slice(idx + 1);
  }
  return env;
}

export const getCronChannelOptions = () => {
  // Keep help truthful even before the plugin registry is bootstrapped.
  const pluginIds = listChannelPlugins()
    .map((plugin) => plugin.id)
    .filter(Boolean);
  return pluginIds.length > 0 ? ["last", ...pluginIds].join("|") : "last|<channel-id>";
};

function toLocalIsoTime(value: unknown): string | undefined {
  return typeof value === "number" && Number.isFinite(value)
    ? formatTimestamp(new Date(value), { style: "long" })
    : undefined;
}

/**
 * CLI-only display enrichment for `cron runs` history entries: adds a short
 * `cause` alias for `errorReason` plus readable local-offset ISO mirrors of the
 * numeric timestamps (matching the diagnostic log `time` format). Stored data
 * and the gateway protocol stay unchanged; raw numeric fields are preserved.
 */
function enrichCronRunEntriesForDisplay(value: unknown): unknown {
  if (!value || typeof value !== "object") {
    return value;
  }
  const record = value as Record<string, unknown>;
  const entries = record.entries;
  if (!Array.isArray(entries)) {
    return value;
  }
  const nextEntries = entries.map((entry) => {
    if (!entry || typeof entry !== "object") {
      return entry;
    }
    const item = entry as Record<string, unknown>;
    if (item.action !== "finished") {
      return item;
    }
    const extra: Record<string, unknown> = {};
    const cause = typeof item.errorReason === "string" ? item.errorReason.trim() : "";
    if (cause) {
      extra.cause = cause;
    }
    const tsIso = toLocalIsoTime(item.ts);
    if (tsIso) {
      extra.tsIso = tsIso;
    }
    const runAtIso = toLocalIsoTime(item.runAtMs);
    if (runAtIso) {
      extra.runAtIso = runAtIso;
    }
    const nextRunAtIso = toLocalIsoTime(item.nextRunAtMs);
    if (nextRunAtIso) {
      extra.nextRunAtIso = nextRunAtIso;
    }
    return Object.keys(extra).length > 0 ? Object.assign({}, item, extra) : item;
  });
  return { ...record, entries: nextEntries };
}

export function printCronJson(value: unknown) {
  defaultRuntime.writeJson(enrichCronRunEntriesForDisplay(value));
}

/**
 * Enrich a CronJob (or list response) with a computed `status` field
 * derived from enabled + state.runningAtMs + state.lastRunStatus.
 * This mirrors the human-readable status shown by `cron list` / `cron show`.
 */
export function enrichCronJsonWithStatus(value: unknown): unknown {
  if (!value || typeof value !== "object") {
    return value;
  }
  const obj = value as Record<string, unknown>;

  // Single job object (has 'state' and 'enabled')
  if ("state" in obj && "enabled" in obj) {
    return { ...obj, status: computeStatus(obj as unknown as CronJob) };
  }

  // List response (has 'jobs' array)
  if ("jobs" in obj && Array.isArray(obj.jobs)) {
    const enrichedJobs = (obj.jobs as CronJob[]).map((job) => {
      const status = computeStatus(job);
      return Object.assign({}, job, { status });
    });
    return { ...obj, jobs: enrichedJobs };
  }

  return value;
}

function computeStatus(job: CronJob): string {
  if (!job.enabled) {
    return "disabled";
  }
  const state = job.state ?? {};
  if (state.runningAtMs) {
    return "running";
  }
  return state.lastRunStatus ?? state.lastStatus ?? "idle";
}

export function handleCronCliError(err: unknown) {
  defaultRuntime.error(danger(String(err)));
  defaultRuntime.exit(1);
}

export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
  // Old/offline gateways should not make successful cron mutations fail after the fact.
  try {
    const res = (await callGatewayFromCli("cron.status", opts, {})) as {
      enabled?: boolean;
      storePath?: string;
      storage?: string;
      sqlitePath?: string;
    };
    if (res?.enabled === true) {
      return;
    }
    const store =
      typeof res?.sqlitePath === "string"
        ? res.sqlitePath
        : typeof res?.storePath === "string"
          ? res.storePath
          : "";
    defaultRuntime.error(
      [
        "warning: cron scheduler is disabled in the Gateway; jobs are saved but will not run automatically.",
        "Re-enable with `cron.enabled: true` (or remove `cron.enabled: false`) and restart the Gateway.",
        store ? `store: ${store}` : "",
      ]
        .filter(Boolean)
        .join("\n"),
    );
  } catch {
    // Ignore status failures (older gateway, offline, etc.)
  }
}

export function parseDurationMs(input: string): number | null {
  const raw = input.trim();
  if (!raw) {
    return null;
  }
  const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/i);
  if (!match) {
    return null;
  }
  const n = Number.parseFloat(match[1] ?? "");
  if (!Number.isFinite(n) || n <= 0) {
    return null;
  }
  const unit = normalizeLowercaseStringOrEmpty(match[2] ?? "");
  const factor =
    unit === "ms"
      ? 1
      : unit === "s"
        ? 1000
        : unit === "m"
          ? 60_000
          : unit === "h"
            ? 3_600_000
            : 86_400_000;
  const result = Math.floor(n * factor);
  if (!Number.isFinite(result)) {
    // A finite mantissa can still overflow to Infinity for a large unit (e.g. a long
    // pure-digit string with "d"); reject it instead of returning Infinity ms.
    return null;
  }
  return result;
}

export function parseCronStaggerMs(params: {
  staggerRaw: string;
  useExact: boolean;
}): number | undefined {
  if (params.useExact) {
    return 0;
  }
  if (!params.staggerRaw) {
    return undefined;
  }
  const parsed = parseDurationMs(params.staggerRaw);
  if (!parsed) {
    throw new Error("Invalid --stagger; use e.g. 30s, 1m, 5m");
  }
  return parsed;
}

export function parseCronToolsAllow(input: unknown): string[] | undefined {
  const raw = Array.isArray(input)
    ? input.map((value) => String(value)).join(" ")
    : typeof input === "string"
      ? input
      : "";
  const tools = raw
    .split(/[,\s]+/u)
    .map((tool) => normalizeOptionalString(tool))
    .filter((tool): tool is string => Boolean(tool));
  return tools.length > 0 ? tools : undefined;
}

export function parseCronFallbacks(input: unknown): string[] | undefined {
  if (input === undefined) {
    return undefined;
  }
  const raw = Array.isArray(input)
    ? input.map((value) => String(value)).join(" ")
    : typeof input === "string"
      ? input
      : "";
  return raw
    .split(/[,\s]+/u)
    .map((fallback) => normalizeOptionalString(fallback))
    .filter((fallback): fallback is string => Boolean(fallback));
}

/**
 * Parse a one-shot `--at` value into an ISO string (UTC).
 *
 * When `tz` is provided and the input is an offset-less datetime
 * (e.g. `2026-03-23T23:00:00`), the datetime is interpreted in
 * that IANA timezone instead of UTC.
 */
export function parseAt(input: string, tz?: string): string | null {
  const raw = input.trim();
  if (!raw) {
    return null;
  }

  // If a timezone is provided and the input looks like an offset-less ISO datetime,
  // resolve it in the given IANA timezone so users get the time they expect.
  if (tz && isOffsetlessIsoDateTime(raw)) {
    return parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
  }

  const absolute = parseAbsoluteTimeMs(raw);
  if (absolute !== null) {
    return timestampMsToIsoString(absolute) ?? null;
  }
  const durationInput = raw.startsWith("+") ? raw.slice(1) : raw;
  const dur = parseDurationMs(durationInput);
  if (dur !== null) {
    const expiresAt = resolveExpiresAtMsFromDurationMs(dur);
    return timestampMsToIsoString(expiresAt) ?? null;
  }
  return null;
}

const CRON_ID_PAD = 36;
const CRON_NAME_PAD = 24;
const CRON_SCHEDULE_PAD = 32;
const CRON_NEXT_PAD = 10;
const CRON_LAST_PAD = 10;
const CRON_STATUS_PAD = 9;
const CRON_TARGET_PAD = 9;
const CRON_DELIVERY_PAD = 64;
const CRON_AGENT_PAD = 10;
const CRON_MODEL_PAD = 20;

const stringifyCell = (value: unknown, fallback = "-") => {
  if (typeof value === "string") {
    return value;
  }
  if (typeof value === "number" || typeof value === "boolean") {
    return String(value);
  }
  return fallback;
};

const pad = (value: unknown, width: number) => stringifyCell(value).padEnd(width);

const truncate = (value: string, width: number) => {
  if (value.length <= width) {
    return value;
  }
  if (width <= 3) {
    return value.slice(0, width);
  }
  return `${value.slice(0, width - 3)}...`;
};

const formatIsoMinute = (iso: string) => {
  const parsed = parseAbsoluteTimeMs(iso);
  const d = new Date(parsed ?? Number.NaN);
  if (Number.isNaN(d.getTime())) {
    return "-";
  }
  const isoStr = d.toISOString();
  return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
};

const formatSpan = (ms: number) => {
  if (ms < 60_000) {
    return "<1m";
  }
  if (ms < 3_600_000) {
    return `${Math.round(ms / 60_000)}m`;
  }
  if (ms < 86_400_000) {
    return `${Math.round(ms / 3_600_000)}h`;
  }
  return `${Math.round(ms / 86_400_000)}d`;
};

const formatRelative = (ms: number | null | undefined, nowMs: number) => {
  if (!ms) {
    return "-";
  }
  const delta = ms - nowMs;
  const label = formatSpan(Math.abs(delta));
  return delta >= 0 ? `in ${label}` : `${label} ago`;
};

const formatSchedule = (schedule: CronSchedule | undefined) => {
  if (schedule?.kind === "at") {
    return `at ${formatIsoMinute(schedule.at)}`;
  }
  if (schedule?.kind === "every") {
    return `every ${formatDurationHuman(schedule.everyMs)}`;
  }
  if (schedule?.kind !== "cron") {
    return "-";
  }
  const base = schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
  const staggerMs = resolveCronStaggerMs(schedule);
  if (staggerMs <= 0) {
    return `${base} (exact)`;
  }
  return `${base} (stagger ${formatDurationHuman(staggerMs)})`;
};

export function coerceCronDeliveryPreviews(value: unknown): Map<string, CronDeliveryPreview> {
  const previews =
    value && typeof value === "object"
      ? (value as { deliveryPreviews?: unknown }).deliveryPreviews
      : undefined;
  if (!previews || typeof previews !== "object") {
    return new Map();
  }
  return new Map(
    Object.entries(previews as Record<string, unknown>).flatMap(([jobId, preview]) => {
      if (!preview || typeof preview !== "object") {
        return [];
      }
      const record = preview as { label?: unknown; detail?: unknown };
      if (typeof record.label !== "string" || typeof record.detail !== "string") {
        return [];
      }
      return [[jobId, { label: record.label, detail: record.detail }]];
    }),
  );
}

export function printCronList(
  jobs: CronJob[],
  runtime: RuntimeEnv = defaultRuntime,
  opts?: { deliveryPreviews?: Map<string, CronDeliveryPreview> },
) {
  if (jobs.length === 0) {
    runtime.log("No cron jobs.");
    return;
  }

  const rich = isRich();
  const header = [
    pad("ID", CRON_ID_PAD),
    pad("Name", CRON_NAME_PAD),
    pad("Schedule", CRON_SCHEDULE_PAD),
    pad("Next", CRON_NEXT_PAD),
    pad("Last", CRON_LAST_PAD),
    pad("Status", CRON_STATUS_PAD),
    pad("Target", CRON_TARGET_PAD),
    pad("Delivery", CRON_DELIVERY_PAD),
    pad("Agent ID", CRON_AGENT_PAD),
    pad("Model", CRON_MODEL_PAD),
  ].join(" ");

  runtime.log(rich ? theme.heading(header) : header);
  const now = Date.now();

  for (const job of jobs) {
    const state = job.state ?? {};
    const idLabel = pad(job.id, CRON_ID_PAD);
    const nameLabel = pad(truncate(stringifyCell(job.name), CRON_NAME_PAD), CRON_NAME_PAD);
    const scheduleLabel = pad(
      truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD),
      CRON_SCHEDULE_PAD,
    );
    const nextLabel = pad(
      job.enabled ? formatRelative(state.nextRunAtMs, now) : "-",
      CRON_NEXT_PAD,
    );
    const lastLabel = pad(formatRelative(state.lastRunAtMs, now), CRON_LAST_PAD);
    const statusRaw = computeStatus(job);
    const statusLabel = pad(statusRaw, CRON_STATUS_PAD);
    const targetLabel = pad(job.sessionTarget ?? "-", CRON_TARGET_PAD);
    const deliveryPreview = opts?.deliveryPreviews?.get(job.id);
    const deliveryText = deliveryPreview
      ? `${deliveryPreview.label} (${deliveryPreview.detail})`
      : "-";
    const deliveryLabel = pad(truncate(deliveryText, CRON_DELIVERY_PAD), CRON_DELIVERY_PAD);
    const agentLabel = pad(truncate(job.agentId ?? "-", CRON_AGENT_PAD), CRON_AGENT_PAD);
    const modelLabel = pad(
      truncate(
        (job.payload?.kind === "agentTurn" ? job.payload.model : undefined) ?? "-",
        CRON_MODEL_PAD,
      ),
      CRON_MODEL_PAD,
    );

    const coloredStatus = (() => {
      if (statusRaw === "ok") {
        return colorize(rich, theme.success, statusLabel);
      }
      if (statusRaw === "error") {
        return colorize(rich, theme.error, statusLabel);
      }
      if (statusRaw === "running") {
        return colorize(rich, theme.warn, statusLabel);
      }
      if (statusRaw === "skipped") {
        return colorize(rich, theme.muted, statusLabel);
      }
      return colorize(rich, theme.muted, statusLabel);
    })();

    const coloredTarget =
      job.sessionTarget === "main"
        ? colorize(rich, theme.accent, targetLabel)
        : colorize(rich, theme.accentBright, targetLabel);
    const coloredAgent = job.agentId
      ? colorize(rich, theme.info, agentLabel)
      : colorize(rich, theme.muted, agentLabel);

    const line = [
      colorize(rich, theme.accent, idLabel),
      colorize(rich, theme.info, nameLabel),
      colorize(rich, theme.info, scheduleLabel),
      colorize(rich, theme.muted, nextLabel),
      colorize(rich, theme.muted, lastLabel),
      coloredStatus,
      coloredTarget,
      deliveryPreview
        ? colorize(rich, theme.info, deliveryLabel)
        : colorize(rich, theme.muted, deliveryLabel),
      coloredAgent,
      job.payload?.kind === "agentTurn" && job.payload.model
        ? colorize(rich, theme.info, modelLabel)
        : colorize(rich, theme.muted, modelLabel),
    ].join(" ");

    runtime.log(line.trimEnd());
  }
}

export function printCronShow(
  job: CronJob,
  runtime: RuntimeEnv = defaultRuntime,
  opts?: { deliveryPreview?: CronDeliveryPreview },
) {
  const preview = opts?.deliveryPreview ?? { label: "-", detail: "unavailable" };
  runtime.log(`id: ${job.id}`);
  runtime.log(`name: ${job.name}`);
  runtime.log(`enabled: ${job.enabled ? "yes" : "no"}`);
  runtime.log(`schedule: ${formatSchedule(job.schedule)}`);
  runtime.log(`session: ${job.sessionTarget ?? "-"}`);
  runtime.log(`agent: ${job.agentId ?? "-"}`);
  runtime.log(`model: ${job.payload.kind === "agentTurn" ? (job.payload.model ?? "-") : "-"}`);
  runtime.log(`delivery: ${preview.label} (${preview.detail})`);
  runtime.log(`next: ${formatRelative(job.state.nextRunAtMs, Date.now())}`);
  runtime.log(`last: ${formatRelative(job.state.lastRunAtMs, Date.now())}`);
  runtime.log(`status: ${computeStatus(job)}`);
  runtime.log(`diagnostic: ${job.state.lastDiagnosticSummary ?? "-"}`);
}
