<script lang="ts">
	import { untrack } from "svelte";
	import type { ReasoningEffort } from "@dispatch/transport-contract";
	import { isReasoningEffort } from "../../chat/reasoning-effort";
	import {
		approximateNextRunEpoch,
		badgeForStatus,
		type Badge,
		emptyForm,
		effortOptions,
		formatCountdown,
		formDiffers,
		formFromConfig,
		joinInterval,
		nextRunEpoch,
		patchFromForm,
		viewRuns,
		type HeartbeatFormState,
		type HeartbeatRunView,
	} from "../logic/view-model";
	import type {
		HeartbeatRun,
		LoadHeartbeatConfig,
		LoadHeartbeatNextRun,
		LoadHeartbeatRuns,
		SaveHeartbeatConfig,
		StopHeartbeatRun,
	} from "../logic/types";
	import type {
		LoadSystemPrompt,
		LoadSystemPromptVariables,
	} from "../../system-prompt";
	import PromptEditor from "./PromptEditor.svelte";

	let {
		models,
		loadConfig,
		saveConfig,
		loadRuns,
		stopRun,
		loadVariables,
		loadDefaultPrompt,
		loadNextRun,
		onOpenRun,
	}: {
		/** The available model names (for the config's model dropdown). */
		models: readonly string[];
		loadConfig: LoadHeartbeatConfig;
		saveConfig: SaveHeartbeatConfig;
		loadRuns: LoadHeartbeatRuns;
		stopRun: StopHeartbeatRun;
		/** Load the available system-prompt variables (palette in the prompt editor). */
		loadVariables: LoadSystemPromptVariables;
		/** Load the global system prompt — the default the heartbeat inherits when
		 *  its `systemPrompt` is empty (the workspace's regular prompt). */
		loadDefaultPrompt: LoadSystemPrompt;
		/** Load the server-authoritative next-run timestamp (the countdown source). */
		loadNextRun: LoadHeartbeatNextRun;
		/** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */
		onOpenRun: (run: HeartbeatRunView) => void;
	} = $props();

	const badgeClass: Record<Badge, string> = {
		success: "badge-success",
		warning: "badge-warning",
		error: "badge-error",
		neutral: "badge-ghost",
	};

	const effortOpts = effortOptions();

	// ── Config form ──────────────────────────────────────────────────────────
	let form = $state<HeartbeatFormState>(emptyForm());
	/** The last successfully loaded/saved config, to diff the form against. */
	let loadedConfig = $state<HeartbeatFormState>(emptyForm());
	let configLoading = $state(false);
	let configError = $state<string | null>(null);
	let saving = $state(false);
	let saveError = $state<string | null>(null);
	let justSaved = $state(false);
	let hasConfig = $state(false);
	let promptEditorOpen = $state(false);

	const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig);

	async function refreshConfig(): Promise<void> {
		configLoading = true;
		configError = null;
		const result = await loadConfig();
		configLoading = false;
		if (result === null) return;
		if (result.ok) {
			hasConfig = true;
			form = formFromConfig(result.config);
			loadedConfig = formFromConfig(result.config);
			saveError = null;
		} else {
			configError = result.error;
		}
	}

	async function handleSave(): Promise<void> {
		if (saving || !hasChanges) return;
		saving = true;
		saveError = null;
		justSaved = false;
		const result = await saveConfig(patchFromForm(form));
		saving = false;
		if (result === null) return;
		if (result.ok) {
			// Re-seed from the authoritative response so the form tracks the server.
			form = formFromConfig(result.config);
			loadedConfig = formFromConfig(result.config);
			justSaved = true;
		} else {
			saveError = result.error;
		}
	}

	// The enable toggle is the primary action — persist it immediately (don't
	// require a separate Save). Mirrors the codebase's save-on-change controls.
	async function handleToggleEnabled(): Promise<void> {
		if (saving) return;
		const next = !form.enabled;
		form = { ...form, enabled: next };
		saving = true;
		saveError = null;
		justSaved = false;
		const result = await saveConfig({ enabled: next });
		saving = false;
		if (result === null) return;
		if (result.ok) {
			form = formFromConfig(result.config);
			loadedConfig = formFromConfig(result.config);
			justSaved = true;
		} else {
			saveError = result.error;
			// Revert the toggle to the last-known state.
			form = { ...form, enabled: loadedConfig.enabled };
		}
	}

	// The inactive-only checkbox is a save-on-change control (like the enable
	// toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest
	// of the form. The heartbeat then skips a fire whenever the workspace has
	// active agents (a conversation whose status is "active" or "queued").
	async function handleToggleInactiveOnly(): Promise<void> {
		if (saving) return;
		const next = !form.inactiveOnly;
		form = { ...form, inactiveOnly: next };
		saving = true;
		saveError = null;
		justSaved = false;
		const result = await saveConfig({ inactiveOnly: next });
		saving = false;
		if (result === null) return;
		if (result.ok) {
			form = formFromConfig(result.config);
			loadedConfig = formFromConfig(result.config);
			justSaved = true;
		} else {
			saveError = result.error;
			// Revert the checkbox to the last-known state.
			form = { ...form, inactiveOnly: loadedConfig.inactiveOnly };
		}
	}

	// ── Runs list (polls while mounted) ───────────────────────────────────────
	let runs = $state<readonly HeartbeatRunView[]>([]);
	/** The raw backend runs (carry `triggeredAt`), kept for the next-run
	 *  approximation fallback (the view drops `triggeredAt` for display labels). */
	let rawRuns = $state<readonly HeartbeatRun[]>([]);
	/** True after the first successful load (gates the "No runs yet" empty state
	 *  WITHOUT flashing it before the initial fetch resolves). The per-poll
	 *  loading is intentionally INVISIBLE — it's near-instant and a visible
	 *  loading indicator caused the sidebar to flicker every poll (height shift). */
	let hasLoadedRuns = $state(false);
	let runsError = $state<string | null>(null);
	let stoppingId = $state<string | null>(null);
	let stopError = $state<string | null>(null);
	let pollHandle: ReturnType<typeof setInterval> | null = null;
	/** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */
	let refreshInFlight = false;

	// ── Next-run countdown ───────────────────────────────────────────────────
	/** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced
	 *  from the backend's `next-run` endpoint; falls back to an approximation
	 *  (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */
	let nextRunAt = $state<number | null>(null);
	/** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and
	 *  rely on the approximation. Reset only on remount. */
	let nextRunEndpointFailed = $state(false);

	async function refreshNextRun(): Promise<void> {
		if (nextRunEndpointFailed) return;
		const result = await loadNextRun();
		if (result === null) return;
		if (result.ok) {
			nextRunAt = nextRunEpoch(result.nextRunAt);
		} else {
			// Endpoint absent / errored → stop polling it + use the approximation.
			nextRunEndpointFailed = true;
		}
	}

	/** The fallback countdown source: latest run + interval (only when enabled +
	 *  ≥1 run). Recomputed reactively from the loaded config + raw runs. */
	const approxNextRun = $derived(
		approximateNextRunEpoch(
			rawRuns,
			joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes),
			loadedConfig.enabled,
		),
	);
	/** The effective next-run epoch: the server value if available, else the
	 *  approximation. Drives the countdown. */
	const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt);

	const RUN_POLL_MS = 4000;

	async function refreshRuns(): Promise<void> {
		if (refreshInFlight) return;
		refreshInFlight = true;
		const result = await loadRuns();
		refreshInFlight = false;
		if (result === null) return;
		if (result.ok) {
			rawRuns = result.runs;
			runs = viewRuns(result.runs);
			// Clear the error only on success so it stays visible (stable, no
			// flicker) during an in-flight retry rather than vanishing mid-poll.
			runsError = null;
			hasLoadedRuns = true;
		} else {
			runsError = result.error;
		}
	}

	async function handleStop(runId: string): Promise<void> {
		if (stoppingId !== null) return;
		stoppingId = runId;
		stopError = null;
		const result = await stopRun(runId);
		stoppingId = null;
		if (result === null) return;
		if (result.ok) {
			await refreshRuns();
		} else {
			stopError = result.error;
		}
	}

	// Load config + runs + next-run on mount, and poll them while the view is
	// alive so a running run's completion/stopped transition + the next-run timer
	// stay fresh without a manual refresh.
	$effect(() => {
		untrack(() => {
			void refreshConfig();
			void refreshRuns();
			void refreshNextRun();
		});
		pollHandle = setInterval(() => {
			void refreshRuns();
			void refreshNextRun();
		}, RUN_POLL_MS);
		return () => {
			if (pollHandle !== null) clearInterval(pollHandle);
			pollHandle = null;
		};
	});

	// A relative label ("5m ago") drifts as time passes; re-derive runs every
	// minute so the list stays fresh without a full re-fetch.
	let tick = $state(0);
	$effect(() => {
		const h = setInterval(() => {
			tick++;
		}, 60000);
		return () => clearInterval(h);
	});
	const runsView = $derived.by(() => {
		void tick; // depend on the ticker
		return runs;
	});

	// The countdown clock: ticks every second so the "next run in Xm Ys" stays
	// live. Pure countdown math is in `formatCountdown` (view-model); this only
	// advances `now`.
	let now = $state(Date.now());
	$effect(() => {
		const h = setInterval(() => {
			now = Date.now();
		}, 1000);
		return () => clearInterval(h);
	});
	const countdownMs = $derived(
		effectiveNextRun !== null ? effectiveNextRun - now : null,
	);
	const countdownLabel = $derived(formatCountdown(countdownMs));
</script>

<div class="flex flex-col gap-3">
	<!-- Enable / status header -->
	<section class="flex flex-col gap-1">
		<div class="flex items-center justify-between gap-2">
			<div class="flex items-center gap-2">
				<button
					type="button"
					role="switch"
					aria-checked={form.enabled}
					aria-label="Toggle heartbeat"
					class="toggle toggle-sm"
					class:toggle-primary={form.enabled}
					disabled={saving || configLoading}
					onclick={handleToggleEnabled}
				></button>
				<span class="text-xs font-semibold uppercase opacity-60">
					{#if configLoading}
						Loading…
					{:else if form.enabled}
						Enabled
					{:else}
						Disabled
					{/if}
				</span>
			</div>
			<button
				type="button"
				class="btn btn-ghost btn-xs"
				disabled={configLoading}
				onclick={() => refreshConfig()}
				aria-label="Refresh heartbeat config"
			>
				{#if configLoading}
					<span class="loading loading-spinner loading-xs"></span>
				{:else}
					Refresh
				{/if}
			</button>
		</div>
		{#if form.enabled && effectiveNextRun !== null}
			<p class="text-xs opacity-60" title="When the next heartbeat run fires">
				Next run in {countdownLabel}
			</p>
		{/if}
	</section>

	{#if configError}
		<p class="text-xs text-error">{configError}</p>
	{:else}
		<!-- Inactive-only (skip fires while the workspace has active agents) -->
		<section class="flex flex-col gap-1">
			<label class="flex items-start gap-2 text-sm">
				<input
					type="checkbox"
					class="checkbox checkbox-sm checkbox-primary mt-0.5"
					checked={form.inactiveOnly}
					disabled={saving || configLoading}
					onchange={handleToggleInactiveOnly}
					aria-label="Only run the heartbeat when the workspace is idle"
				/>
				<span class="flex flex-col gap-0.5">
					<span>Only run when idle</span>
					<span class="text-xs opacity-50">
						Skip heartbeat fires while agents are active in this workspace. When off, the
						heartbeat runs on every interval regardless of activity.
					</span>
				</span>
			</label>
		</section>

		<!-- Prompts (open the full-page editor) -->
		<section class="flex flex-col gap-1">
			<span class="text-xs font-semibold uppercase opacity-60">Prompts</span>
			<button
				type="button"
				class="btn btn-sm btn-outline"
				disabled={saving || configLoading}
				onclick={() => (promptEditorOpen = true)}
			>
				Edit prompts
			</button>
			<p class="text-xs opacity-50">
				Open the editor for the system + task prompts (with a variable palette).
			</p>
		</section>

		<!-- Model + reasoning effort -->
		<section class="flex flex-col gap-2">
			<div class="flex flex-col gap-1">
				<span class="text-xs font-semibold uppercase opacity-60">Model</span>
				<select
					class="select select-sm w-full"
					value={form.model}
					disabled={saving || configLoading}
					onchange={(e) => (form = { ...form, model: e.currentTarget.value })}
					aria-label="Heartbeat model"
				>
					{#if models.length === 0}
						<option value="">No models available</option>
					{:else}
						<option value="" disabled>Select a model</option>
						{#each models as model (model)}
							<option value={model}>{model}</option>
						{/each}
					{/if}
				</select>
			</div>

			<div class="flex flex-col gap-1">
				<span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
				<select
					class="select select-sm w-full"
					value={form.reasoningEffort}
					disabled={saving || configLoading}
					onchange={(e) => {
						const v = e.currentTarget.value;
						if (isReasoningEffort(v)) form = { ...form, reasoningEffort: v as ReasoningEffort };
					}}
					aria-label="Heartbeat reasoning effort"
				>
					{#each effortOpts as option (option.value)}
						<option value={option.value}>{option.label}</option>
					{/each}
				</select>
			</div>
		</section>

		<!-- Interval (hours + minutes) -->
		<section class="flex flex-col gap-1">
			<span class="text-xs font-semibold uppercase opacity-60">Interval</span>
			<div class="flex items-center gap-2">
				<input
					type="number"
					class="input input-bordered input-sm w-20"
					min="0"
					max="24"
					value={form.intervalHours}
					disabled={saving || configLoading}
					oninput={(e) => {
						const n = Number.parseInt(e.currentTarget.value, 10);
						form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n };
					}}
					onchange={(e) => {
						const clamped = Math.max(0, Math.min(24, form.intervalHours));
						form = { ...form, intervalHours: clamped };
						e.currentTarget.value = String(clamped);
					}}
					aria-label="Heartbeat interval hours"
				/>
				<span class="text-xs opacity-60">h</span>
				<input
					type="number"
					class="input input-bordered input-sm w-20"
					min="0"
					max="59"
					value={form.intervalMinutes}
					disabled={saving || configLoading}
					oninput={(e) => {
						const n = Number.parseInt(e.currentTarget.value, 10);
						form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n };
					}}
					onchange={(e) => {
						const clamped = Math.max(0, Math.min(59, form.intervalMinutes));
						form = { ...form, intervalMinutes: clamped };
						e.currentTarget.value = String(clamped);
					}}
					aria-label="Heartbeat interval minutes"
				/>
				<span class="text-xs opacity-60">m between runs</span>
			</div>
		</section>

		<!-- Save -->
		<section class="flex flex-col gap-1">
			<button
				type="button"
				class="btn btn-sm btn-primary"
				disabled={!hasChanges || saving || configLoading}
				onclick={handleSave}
			>
				{#if saving}
					<span class="loading loading-spinner loading-xs"></span>
					Saving…
				{:else}
					Save config
				{/if}
			</button>
			{#if saveError}
				<p class="text-xs text-error">{saveError}</p>
			{:else if justSaved}
				<p class="text-xs text-success">Saved.</p>
			{/if}
		</section>
	{/if}

	<!-- Runs list -->
	<section class="flex flex-col gap-1">
		<div class="flex items-center justify-between gap-2">
			<span class="text-xs font-semibold uppercase opacity-60">Runs</span>
			<button
				type="button"
				class="btn btn-ghost btn-xs"
				onclick={() => refreshRuns()}
				aria-label="Refresh heartbeat runs"
			>
				Refresh
			</button>
		</div>

		{#if runsError}
			<p class="text-xs text-error">{runsError}</p>
		{:else if runs.length > 0}
			<ul class="flex max-h-72 flex-col gap-1 overflow-y-auto">
				{#each runsView as run (run.id)}
					<li>
						<button
							type="button"
							class="flex w-full items-center justify-between gap-2 rounded-box bg-base-200 p-2 text-left hover:bg-base-300"
							onclick={() => onOpenRun(run)}
							aria-label="Open heartbeat run {run.id} chat"
						>
							<span class="flex min-w-0 flex-col gap-0.5">
								<span class="truncate font-mono text-xs opacity-70">{run.id}</span>
								<span class="text-xs opacity-60">
									{run.relativeLabel} · {run.timeLabel}
								</span>
							</span>
							<span class="flex items-center gap-1">
								{#if run.busy}
									<span class="loading loading-spinner loading-xs"></span>
								{/if}
								<span class="badge badge-sm {badgeClass[run.badge]}">{run.statusLabel}</span>
							</span>
						</button>
						{#if run.busy}
							<button
								type="button"
								class="btn btn-ghost btn-xs mt-0.5 text-xs"
								disabled={stoppingId === run.id}
								onclick={() => handleStop(run.id)}
							>
								{#if stoppingId === run.id}
									<span class="loading loading-spinner loading-xs"></span>
									Stopping…
								{:else}
									Stop
								{/if}
							</button>
						{/if}
					</li>
				{/each}
			</ul>
			{#if stopError}
				<p class="text-xs text-error">{stopError}</p>
			{/if}
		{:else if hasLoadedRuns}
			<!-- Loaded with zero runs (not the pre-first-load gap). No loading
			     indicator — polling is near-instant and a visible one flickered. -->
			<p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p>
		{/if}
	</section>
</div>

{#if promptEditorOpen}
	<PromptEditor
		systemPrompt={form.systemPrompt}
		taskPrompt={form.taskPrompt}
		{loadVariables}
		{loadDefaultPrompt}
		{saveConfig}
		onSaved={(systemPrompt, taskPrompt) => {
			// Sync the form + the diff baseline so the main Save button + formDiffers
			// stay accurate (the editor persisted the prompts already). `systemPrompt`
			// may be "" (inherit) — the form stores the raw override.
			form = { ...form, systemPrompt, taskPrompt };
			loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt };
			justSaved = true;
		}}
		onClose={() => (promptEditorOpen = false)}
	/>
{/if}
