<script lang="ts">
	import { onMount, onDestroy } from 'svelte';
	import { goto, beforeNavigate } from '$app/navigation';
	import type { HookKinds, PluginWorkloadInput } from '$lib/types';
	import type { RedeployTrigger } from '$lib/api';
	import * as api from '$lib/api';
	import ForgeHero from '$lib/components/ForgeHero.svelte';
	import TriggerKindForm, {
		createTriggerKindFormState,
		isTriggerFormValid,
		buildTriggerInput
	} from '$lib/components/TriggerKindForm.svelte';
	import SourceKindPicker from '$lib/components/workload/SourceKindPicker.svelte';
	import ImageSourceForm from '$lib/components/workload/ImageSourceForm.svelte';
	import ComposeSourceForm from '$lib/components/workload/ComposeSourceForm.svelte';
	import StaticSourceForm from '$lib/components/workload/StaticSourceForm.svelte';
	import DockerfileSourceForm from '$lib/components/workload/DockerfileSourceForm.svelte';
	import AppManifest, { type ManifestRow } from '$lib/components/workload/AppManifest.svelte';
	import WizardRail from '$lib/components/wizard/WizardRail.svelte';
	import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
	import {
		emptyImageState,
		emptyComposeState,
		emptyStaticState,
		emptyDockerfileState,
		seedImageState,
		seedComposeState,
		seedStaticState,
		seedDockerfileState,
		imageToConfig,
		composeToConfig,
		staticToConfig,
		dockerfileToConfig,
		stringifyConfig,
		type ImageFormState,
		type ComposeFormState,
		type StaticFormState,
		type DockerfileFormState,
		type GitSourceState
	} from '$lib/workload/sourceForms';
	import { t } from '$lib/i18n';

	// `triggers` is no longer hardcoded into the workload row — the
	// kind list is kept on `kinds` only for reference but the wizard
	// now composes a standalone Trigger record (or picks one) and
	// binds it after the workload is created.
	let kinds = $state<HookKinds>({ sources: [], triggers: [] });
	let name = $state('');
	let sourceKind = $state('image');
	let sourceConfig = $state('{}');
	let publicSubdomain = $state('');
	let publicDomain = $state('');
	let publicPort = $state(0);

	// Trigger UX modes — three branches:
	//   inline → create a new trigger inline; bind after workload create.
	//   pick   → select an existing trigger; bind after workload create.
	//   skip   → create the workload without any binding.
	type TriggerMode = 'inline' | 'pick' | 'skip';
	let triggerMode = $state<TriggerMode>('inline');
	let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));

	// Existing-trigger picker. `existingTriggers` is loaded lazily on
	// mount; if the request fails the operator can still create one
	// inline or skip altogether — we don't block the wizard.
	let existingTriggers = $state<RedeployTrigger[]>([]);
	let pickedTriggerId = $state('');

	// ── Source-config form state ──────────────────────────────────────
	// One state object per source kind, modelled by the shared (pure,
	// unit-tested) `sourceForms.ts` module. The form bodies live in
	// dedicated components under $lib/components/workload/ that bind these
	// objects; serialization to the `source_config` POST body is done here
	// via the module's serializers so the shape stays byte-identical to the
	// legacy inline helpers. The advancedJson toggle swaps the chosen form
	// component for the raw-JSON editor.
	let advancedJson = $state(false);
	let imageState = $state<ImageFormState>(emptyImageState());
	let composeState = $state<ComposeFormState>(emptyComposeState());
	let staticState = $state<StaticFormState>(emptyStaticState());
	let dockerfileState = $state<DockerfileFormState>(emptyDockerfileState());

	// ── Discovery transient status (lifted out of the source forms) ───
	// StaticSourceForm / DockerfileSourceForm (and the StaticDiscoveryWizard
	// they embed) unmount whenever the operator flips Advanced JSON or
	// switches source kind, which would normally reset the loaded folder
	// tree + the detect/test pills + the mode-override sentinel. Holding that
	// transient status HERE (the page does not unmount on those toggles) and
	// binding it down via the wizard's optional bindable props makes it
	// persist for the session: re-opening the form shows the already-loaded
	// tree and the detect/test results again. The picker open-flags + items
	// stay local to the wizard (modals are transient; re-opening reloads).
	//
	// Static and dockerfile keep SEPARATE status objects because they are
	// distinct sources sharing only the git field VALUES — never their
	// discovery results. The git field values themselves already persist via
	// the staticState/dockerfileState objects above; this is only the status.
	let staticDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
	let staticDetectError = $state('');
	let staticTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
	let staticTestError = $state('');
	let staticTree = $state<api.FolderEntry[]>([]);
	let staticModeUserOverride = $state(false);
	let staticTreeLoaded = $state(false);

	let dockerfileDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
	let dockerfileDetectError = $state('');
	let dockerfileTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
	let dockerfileTestError = $state('');

	const useComposeForm = $derived(sourceKind === 'compose' && !advancedJson);
	const useImageForm = $derived(sourceKind === 'image' && !advancedJson);
	const useStaticForm = $derived(sourceKind === 'static' && !advancedJson);
	const useDockerfileForm = $derived(sourceKind === 'dockerfile' && !advancedJson);

	// Image-ref conflict detection. The ImageSourceForm runs the debounced
	// /api/discovery/image/conflicts lookup and writes its results back into
	// this triplet (bound below). The submit gate reads them: the first
	// submit while conflicts are present is blocked + acknowledged; a second
	// click proceeds.
	let imageConflicts = $state<api.ImageConflict[]>([]);
	let imageConflictAcknowledged = $state(false);
	let imageConflictBlocked = $state(false);

	// Registry list for the image registry_name picker. Loaded lazily on
	// mount; failure to load is non-fatal — the field falls back to a plain
	// text input via the empty-list path so the form still works.
	let registries = $state<{ name: string; url: string }[]>([]);

	let loading = $state(true);
	let submitting = $state(false);
	let error = $state('');

	// Unsaved-changes guard state. `submitted` flips true around a
	// successful create so the navigation guard does NOT prompt when we
	// programmatically route to the new workload's detail page. It stays
	// true thereafter (the page is being torn down anyway).
	let submitted = $state(false);

	// Cache the schema sample per kind so flipping the dropdown back and
	// forth doesn't re-fetch. Plugins can grow new fields server-side
	// without a frontend rebuild — that's the whole point of routing the
	// initial form body through /api/hooks/kinds/{kind}/schema instead of
	// hardcoding samples in this file.
	const schemaCache = new Map<string, string>();
	async function fetchSampleJSON(kind: string): Promise<string> {
		if (!kind) return '{}';
		const cached = schemaCache.get(kind);
		if (cached !== undefined) return cached;
		try {
			const res = await api.getHookKindSchema(kind);
			const text = JSON.stringify(res.sample ?? {}, null, 2);
			schemaCache.set(kind, text);
			return text;
		} catch {
			return sourceConfigSample(kind) || '{}';
		}
	}

	// Seed the per-kind state object from a source_config JSON blob. Thin
	// wrappers over the shared `sourceForms.ts` seeders — only the chosen
	// kind's object is reseeded so switching kinds preserves the others.
	function seedFormState(kind: string, jsonText: string): void {
		if (kind === 'compose') composeState = seedComposeState(jsonText);
		else if (kind === 'image') imageState = seedImageState(jsonText);
		else if (kind === 'static') staticState = seedStaticState(jsonText);
		else if (kind === 'dockerfile') dockerfileState = seedDockerfileState(jsonText);
	}

	// Serialize the per-kind state object back to the canonical JSON string
	// for the Advanced-JSON editor view. env/volumes (image) and storage_* /
	// unknown keys (static/dockerfile) are preserved from the current
	// sourceConfig via the module serializers.
	function formStateToJSON(kind: string): string {
		if (kind === 'compose') return stringifyConfig(composeToConfig(composeState));
		if (kind === 'image') return stringifyConfig(imageToConfig(imageState, sourceConfig));
		if (kind === 'static') return stringifyConfig(staticToConfig(staticState, sourceConfig));
		if (kind === 'dockerfile') return stringifyConfig(dockerfileToConfig(dockerfileState, sourceConfig));
		return sourceConfig;
	}

	onMount(async () => {
		try {
			kinds = await api.listHookKinds();
			if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
				sourceKind = kinds.sources[0];
			}
			sourceConfig = await fetchSampleJSON(sourceKind);
			seedFormState(sourceKind, sourceConfig);
			// Best-effort registry list for the picker. If the user
			// is on an installation without registries configured,
			// the field gracefully renders as a free-text input.
			try {
				registries = (await api.listRegistries()).map((r) => ({ name: r.name, url: r.url }));
			} catch {
				registries = [];
			}
			// Best-effort fetch of existing triggers — feeds the
			// "Pick existing" mode. Failure leaves the picker empty
			// and the operator can still create one inline.
			try {
				existingTriggers = await api.listTriggers();
			} catch {
				existingTriggers = [];
			}
		} catch (e) {
			error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
		} finally {
			loading = false;
		}
	});

	function sourceConfigSample(kind: string): string {
		switch (kind) {
			case 'image':
				return JSON.stringify(
					{
						image: 'registry.example.com/owner/app',
						registry_name: '',
						port: 8080,
						healthcheck: '/healthz',
						env: {},
						volumes: [],
						cpu_limit: 0,
						memory_limit: 0,
						default_tag: 'latest'
					},
					null,
					2
				);
			case 'compose':
				return JSON.stringify(
					{
						compose_yaml: 'services:\n  web:\n    image: nginx:alpine\n    ports:\n      - "80"\n',
						compose_project_name: ''
					},
					null,
					2
				);
			case 'static':
				return JSON.stringify(
					{
						provider: 'gitea',
						base_url: 'https://git.example.com',
						repo_owner: 'owner',
						repo_name: 'pages',
						branch: 'main',
						folder_path: '',
						mode: 'static',
						render_markdown: false
					},
					null,
					2
				);
			case 'dockerfile':
				return JSON.stringify(
					{
						provider: 'gitea',
						base_url: 'https://git.example.com',
						repo_owner: 'owner',
						repo_name: 'myservice',
						branch: 'main',
						context_path: '',
						dockerfile_path: 'Dockerfile',
						port: 8080
					},
					null,
					2
				);
			default:
				return '{}';
		}
	}

	// Tracks the previously-selected sourceKind across an onSourceChange
	// transition. Used to detect static ↔ dockerfile (both git-clone
	// sources sharing the static* state vars for provider/repo/branch/
	// token) so we can preserve those fields across the switch instead
	// of overwriting them with sample placeholders.
	//
	// Initialized with the same literal as `sourceKind` (intentional
	// mirror, not a snapshot-of-state — the value is updated manually
	// inside onSourceChange, so no Svelte reactivity is needed).
	let prevSourceKind = $state<string>('image');

	async function onSourceChange() {
		const previous = prevSourceKind;
		const current = sourceKind;
		prevSourceKind = current;

		const wasGit = previous === 'static' || previous === 'dockerfile';
		const isGit = current === 'static' || current === 'dockerfile';

		// Cross-git transition: snapshot the shared git-discovery slice
		// before fetching the new sample so we can restore after seed.
		// Without this, switching static → dockerfile (or back) clobbers
		// the user's already-typed provider/baseURL/repo/branch/token
		// with the placeholder values baked into the sample blob. The
		// slice lives on whichever git state object was active before.
		let gitSnapshot: GitSourceState | null = null;
		if (wasGit && isGit) {
			const prevState = previous === 'dockerfile' ? dockerfileState : staticState;
			gitSnapshot = {
				provider: prevState.provider,
				baseURL: prevState.baseURL,
				repoOwner: prevState.repoOwner,
				repoName: prevState.repoName,
				branch: prevState.branch,
				accessToken: prevState.accessToken
			};
		}

		sourceConfig = await fetchSampleJSON(current);
		// Switching INTO any kind seeds its form state from the sample so
		// the operator sees something sensible immediately. Switching OUT
		// leaves the other kinds' state alone — re-entry restores it.
		seedFormState(current, sourceConfig);
		advancedJson = false;

		// Restore the shared git fields if we were just on a git source and
		// are switching to another git source. Their values are still valid;
		// the new kind's seed only matters for kind-specific fields
		// (folder/mode for static; context/dockerfile/port for dockerfile).
		if (gitSnapshot) {
			const target = current === 'dockerfile' ? dockerfileState : staticState;
			target.provider = gitSnapshot.provider;
			target.baseURL = gitSnapshot.baseURL;
			target.repoOwner = gitSnapshot.repoOwner;
			target.repoName = gitSnapshot.repoName;
			target.branch = gitSnapshot.branch;
			target.accessToken = gitSnapshot.accessToken;
			// The TARGET kind's git fields were just overwritten with the other
			// source's values, so any persisted detect/test result (or loaded
			// tree) for the target no longer corresponds to what's shown.
			// Invalidate it — same "fields changed → invalidate downstream"
			// philosophy the wizard's own selectRepo/selectBranch follow. The
			// SOURCE kind's status is left intact so flipping back restores it.
			invalidateDiscoveryStatus(current);
			// Keep sourceConfig in sync so the Advanced JSON view reflects
			// the actual (preserved) state, not the sample placeholders.
			sourceConfig = formStateToJSON(current);
		}
	}

	// Reset the lifted transient discovery status for a git source kind. Used
	// when a cross-git switch overwrites that kind's repo coordinates, so a
	// stale "connection OK" pill / loaded tree doesn't linger against fields
	// the user never probed under this kind.
	function invalidateDiscoveryStatus(kind: string): void {
		if (kind === 'static') {
			staticDetectStatus = 'idle';
			staticDetectError = '';
			staticTestStatus = 'idle';
			staticTestError = '';
			staticTree = [];
			staticTreeLoaded = false;
		} else if (kind === 'dockerfile') {
			dockerfileDetectStatus = 'idle';
			dockerfileDetectError = '';
			dockerfileTestStatus = 'idle';
			dockerfileTestError = '';
		}
	}

	// Toggle between the kind-aware form and the raw JSON editor.
	// Direction matters: going to Advanced JSON commits the form fields
	// into sourceConfig first so the user sees the same data they were
	// editing; going back to the form re-seeds from whatever they typed
	// in the JSON so manual JSON edits aren't lost.
	function toggleAdvancedJSON() {
		if (!advancedJson) {
			sourceConfig = formStateToJSON(sourceKind);
			advancedJson = true;
		} else {
			seedFormState(sourceKind, sourceConfig);
			advancedJson = false;
		}
	}

	// JSON validity hints — non-blocking, just a small status pill in the
	// editor footer. Server still gets the parse error if the user submits.
	function jsonOk(s: string): boolean {
		try {
			JSON.parse(s);
			return true;
		} catch {
			return false;
		}
	}
	const sourceValid = $derived(jsonOk(sourceConfig));

	const sourceLines = $derived(sourceConfig.split('\n').length);
	const sourceBytes = $derived(new Blob([sourceConfig]).size);

	// Trigger-step validity. Inline mode requires a complete kind+name+config;
	// pick mode requires a chosen trigger; skip mode is always valid.
	const triggerStepValid = $derived.by(() => {
		if (triggerMode === 'skip') return true;
		if (triggerMode === 'pick') return !!pickedTriggerId;
		return isTriggerFormValid(triggerForm);
	});

	// ── Manifest summary (Review step) ──────────────────────────────
	// A read-only spec-sheet of the whole workload, rendered below the
	// public-face inputs on the final step. All value-derivation lives here
	// (the page owns the state); AppManifest is pure presentation. Each row
	// is { label, value, mono? } — mono marks machine-readable literals
	// (image refs, repo paths, branches, ports, FQDNs).

	// Source row value — the kind's 1-2 key identifiers. The kind itself is
	// shown as a badge in the manifest header, so this is just the values.
	const sourceSummary = $derived.by((): { value: string; mono: boolean } => {
		const dash = ' · ';
		if (sourceKind === 'image') {
			const ref = imageState.ref.trim();
			const tag = imageState.defaultTag.trim() || 'latest';
			const reg = imageState.registryName.trim() || $t('apps.new.manifest.registryPublic');
			const refTag = ref ? `${ref}:${tag}` : '';
			return { value: refTag ? `${refTag}${dash}${reg}` : reg, mono: true };
		}
		if (sourceKind === 'compose') {
			const project = composeState.projectName.trim();
			return {
				value: project || $t('apps.new.composeProjectPlaceholder'),
				mono: !!project
			};
		}
		if (sourceKind === 'static') {
			const repo = gitRepoRef(staticState.repoOwner, staticState.repoName, staticState.branch);
			const folder = staticState.folderPath.trim() || $t('apps.new.manifest.folderRoot');
			return { value: `${repo}${dash}${folder}${dash}${staticState.mode}`, mono: true };
		}
		if (sourceKind === 'dockerfile') {
			const repo = gitRepoRef(
				dockerfileState.repoOwner,
				dockerfileState.repoName,
				dockerfileState.branch
			);
			const dfPath = dockerfileState.dockerfilePath.trim() || 'Dockerfile';
			const port =
				typeof dockerfileState.port === 'number' && dockerfileState.port > 0
					? `:${dockerfileState.port}`
					: '';
			return { value: `${repo}${dash}${dfPath}${port}`, mono: true };
		}
		return { value: sourceKind, mono: true };
	});

	// owner/repo@branch — the canonical git source identifier used by both
	// static and dockerfile sources. Falls back gracefully on empty fields.
	function gitRepoRef(owner: string, name: string, branch: string): string {
		const o = owner.trim();
		const n = name.trim();
		const b = branch.trim() || 'main';
		const slug = o && n ? `${o}/${n}` : o || n || '—';
		return `${slug}@${b}`;
	}

	// Trigger row value — inline reads the inline form's kind+name; pick reads
	// the chosen existing trigger; skip is "Manual only".
	const triggerSummary = $derived.by((): string => {
		if (triggerMode === 'skip') return $t('apps.new.manifest.triggerManual');
		if (triggerMode === 'pick') {
			const picked = existingTriggers.find((tr) => tr.id === pickedTriggerId);
			if (picked) return `${picked.name} · ${picked.kind}`;
			return $t('apps.new.triggers.pickPlaceholder');
		}
		const nm = triggerForm.name.trim();
		return nm ? `${nm} · ${triggerForm.kind}` : triggerForm.kind;
	});

	// Public-face row value — FQDN + port, or "Internal only" when nothing set.
	const faceSummary = $derived.by((): { value: string; mono: boolean } => {
		const sub = publicSubdomain.trim();
		const dom = publicDomain.trim();
		const hasPort = typeof publicPort === 'number' && publicPort > 0;
		if (!sub && !dom && !hasPort) {
			return { value: $t('apps.new.manifest.internalOnly'), mono: false };
		}
		const host = [sub, dom].filter(Boolean).join('.');
		const port = hasPort ? `:${publicPort}` : '';
		return { value: `${host || '—'}${port}`, mono: true };
	});

	const manifestRows = $derived<ManifestRow[]>([
		{ label: $t('apps.new.manifest.name'), value: name.trim() || $t('apps.new.manifest.unnamed') },
		{ label: $t('apps.new.manifest.source'), value: sourceSummary.value, mono: sourceSummary.mono },
		{ label: $t('apps.new.manifest.trigger'), value: triggerSummary },
		{ label: $t('apps.new.manifest.publicFace'), value: faceSummary.value, mono: faceSummary.mono }
	]);

	// ── Wizard step state ───────────────────────────────────────────
	// The form is split into four progressive steps. `currentStep` is
	// 1-based; `maxReached` tracks the furthest validly-reached step so
	// the rail can jump back (never skip forward past an invalid step).
	// Per-step validity reuses the exact conditions the final submit gate
	// enforced before the wizard split, so nothing regresses.
	const STEP_COUNT = 4;
	let currentStep = $state(1);
	let maxReached = $state(1);

	const step1Valid = $derived(name.trim() !== '' && sourceKind !== '');
	// Cleared <input type=number> binds to null in Svelte 5 (not 0), and
	// `null > 0` is false — guard against null/NaN/non-positive so a blank
	// port field doesn't pass. Mirrors the legacy dockerfilePortValid check.
	const dockerfilePortValid = $derived(
		typeof dockerfileState.port === 'number' &&
			Number.isFinite(dockerfileState.port) &&
			dockerfileState.port > 0
	);
	// Per-step validity reuses the EXACT conditions the page enforced before
	// the extraction (owner+name for git sources; +port for dockerfile; the
	// raw-JSON path gates on parseability). The stricter sourceForms.ts
	// predicates additionally require base_url, so we keep these inline to
	// avoid regressing the gate.
	const step2Valid = $derived(
		!(
			(!useComposeForm && !useImageForm && !useStaticForm && !useDockerfileForm && !sourceValid) ||
			(useImageForm && !imageState.ref.trim()) ||
			(useStaticForm && (!staticState.repoOwner.trim() || !staticState.repoName.trim())) ||
			(useDockerfileForm &&
				(!dockerfileState.repoOwner.trim() ||
					!dockerfileState.repoName.trim() ||
					!dockerfilePortValid))
		)
	);
	const step3Valid = $derived(triggerStepValid);

	function stepValid(step: number): boolean {
		if (step === 1) return step1Valid;
		if (step === 2) return step2Valid;
		if (step === 3) return step3Valid;
		return true;
	}

	// Active step's validity — gates the Next button.
	const canAdvance = $derived(stepValid(currentStep));

	const stepLabels = $derived([
		$t('apps.new.wizard.stepBasics'),
		$t('apps.new.wizard.stepConfigure'),
		$t('apps.new.wizard.stepTrigger'),
		$t('apps.new.wizard.stepReview')
	]);

	function goToStep(step: number) {
		// Rail navigation: only into an already-reached step.
		if (step < 1 || step > maxReached) return;
		currentStep = step;
	}

	function goNext() {
		if (currentStep >= STEP_COUNT || !canAdvance) return;
		currentStep += 1;
		if (currentStep > maxReached) maxReached = currentStep;
	}

	function goBack() {
		if (currentStep > 1) currentStep -= 1;
	}

	// ── Unsaved-changes guard ─────────────────────────────────────────
	// `dirty` is intentionally conservative (not over-eager): it trips
	// once the operator has invested meaningful effort — named the
	// workload, advanced past the Basics step, or named an inline
	// trigger. The seeded source-config placeholders (e.g. owner/app)
	// deliberately do NOT count, so simply landing on the page and
	// switching source kinds won't nag on leave.
	const dirty = $derived(
		name.trim() !== '' || currentStep > 1 || triggerForm.name.trim() !== ''
	);

	// SvelteKit in-app navigation guard. A successful create sets
	// `submitted` first so the goto(`/apps/{id}`) is never blocked. Rather
	// than the browser's native confirm, cancel the navigation and open the
	// app's ConfirmDialog; on confirm, re-issue the nav with `confirmedLeave`
	// set so the second beforeNavigate pass lets it through. Hard unloads
	// (tab close / reload / external) can't show custom UI, so those fall
	// through to the native beforeunload prompt below.
	let leaveConfirmOpen = $state(false);
	let pendingNavUrl = $state<string | null>(null);
	let confirmedLeave = $state(false);

	beforeNavigate((nav) => {
		if (submitted || submitting || confirmedLeave || !dirty) return;
		if (nav.willUnload || !nav.to) return;
		nav.cancel();
		pendingNavUrl = nav.to.url.href;
		leaveConfirmOpen = true;
	});

	function confirmLeave() {
		leaveConfirmOpen = false;
		confirmedLeave = true;
		const url = pendingNavUrl;
		pendingNavUrl = null;
		if (url) void goto(url);
	}

	function cancelLeave() {
		leaveConfirmOpen = false;
		pendingNavUrl = null;
	}

	// Browser-level guard for tab close / reload / external navigation,
	// which beforeNavigate does not cover. Setting returnValue triggers
	// the native "Leave site?" prompt. Removed on destroy.
	function onBeforeUnload(event: BeforeUnloadEvent) {
		if (submitted || submitting || !dirty) return;
		event.preventDefault();
		// Legacy browsers require returnValue to be set to a string.
		event.returnValue = '';
	}

	onMount(() => {
		window.addEventListener('beforeunload', onBeforeUnload);
	});
	onDestroy(() => {
		if (typeof window !== 'undefined') {
			window.removeEventListener('beforeunload', onBeforeUnload);
		}
	});

	async function submit(e: Event) {
		e.preventDefault();
		// Enter pressed inside a non-final step advances the wizard rather
		// than submitting a half-filled form.
		if (currentStep < STEP_COUNT) {
			goNext();
			return;
		}
		error = '';
		submitting = true;
		try {
			let parsedSource: unknown;
			if (useComposeForm) {
				// All four form paths route through the shared sourceForms.ts
				// serializers so the POSTed source_config is byte-identical to
				// the legacy inline helpers (key order + preserve/scrub rules
				// included). env/volumes (image) and storage_* / unknown keys
				// (static/dockerfile) are preserved from `sourceConfig`.
				parsedSource = composeToConfig(composeState);
			} else if (useImageForm) {
				parsedSource = imageToConfig(imageState, sourceConfig);
				// Conflict guard: if the resolved image ref already belongs to
				// other workloads and the operator hasn't acknowledged that
				// fact, intercept the first click so they get a chance to
				// review. A second click (with imageConflictAcknowledged
				// flipped true) goes through unchanged.
				if (imageConflicts.length > 0 && !imageConflictAcknowledged) {
					imageConflictAcknowledged = true;
					imageConflictBlocked = true;
					submitting = false;
					return;
				}
				imageConflictBlocked = false;
			} else if (useStaticForm) {
				parsedSource = staticToConfig(staticState, sourceConfig);
			} else if (useDockerfileForm) {
				parsedSource = dockerfileToConfig(dockerfileState, sourceConfig);
			} else {
				try {
					parsedSource = JSON.parse(sourceConfig);
				} catch {
					throw new Error($t('apps.new.errors.sourceConfigInvalid'));
				}
			}

			// Triggers no longer ride on the workload row; the backend
			// still accepts the legacy fields but the new code path
			// passes a manual placeholder + empty config and binds a
			// real Trigger record after creation.
			const body: PluginWorkloadInput = {
				name: name.trim(),
				source_kind: sourceKind,
				source_config: parsedSource,
				trigger_kind: '',
				trigger_config: {}
			};
			if (publicSubdomain || publicDomain || publicPort > 0) {
				body.public_faces = [
					{
						subdomain: publicSubdomain,
						domain: publicDomain,
						target_service: '',
						target_port: publicPort,
						access_list_id: 0,
						enable_ssl: true
					}
				];
			}

			const created = await api.createPluginWorkload(body);
			// The workload now exists — mark submitted so the navigation
			// guard does not prompt on the goto below (or on any redirect
			// the bind step might trigger).
			submitted = true;

			// Bind a trigger to the freshly-created workload. Keep going
			// to the detail page even on bind failure — the operator can
			// retry from the workload's Triggers panel without losing
			// their work.
			if (triggerMode === 'inline' || triggerMode === 'pick') {
				try {
					if (triggerMode === 'inline') {
						const inline = buildTriggerInput(triggerForm);
						await api.bindTriggerToWorkload(created.id, { inline });
					} else if (pickedTriggerId) {
						await api.bindTriggerToWorkload(created.id, {
							trigger_id: pickedTriggerId
						});
					}
				} catch (be) {
					const msg = be instanceof Error ? be.message : $t('apps.new.errors.triggerBindUnknown');
					// Surface the bind failure to the user, then still
					// route to the detail page where they can retry.
					try {
						sessionStorage.setItem(
							`tinyforge.bindError.${created.id}`,
							$t('apps.new.triggers.bindError', { error: msg })
						);
					} catch {
						// session storage may be disabled — ignore.
					}
				}
			}
			goto(`/apps/${created.id}`);
		} catch (e) {
			error = e instanceof Error ? e.message : $t('apps.new.errors.createFailed');
		} finally {
			submitting = false;
		}
	}
</script>

<svelte:head>
	<title>{$t('apps.new.pageTitle')}</title>
</svelte:head>

<div class="forge">
	{#snippet newLede()}
		{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
		{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
	{/snippet}

	<ForgeHero
		backHref="/apps"
		backLabel={$t('apps.new.backLabel')}
		eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
		title={$t('apps.new.title')}
		size="lg"
		lede_html={newLede}
	/>

	{#if loading}
		<div class="loading-line">
			<span class="spinner" aria-hidden="true"></span>
			<span>{$t('apps.new.loadingKinds')}</span>
		</div>
	{:else}
		<div class="wizard-shell">
			<WizardRail
				steps={stepLabels.map((label) => ({ label }))}
				current={currentStep}
				{maxReached}
				onselect={goToStep}
			/>
		<form onsubmit={submit} class="form" novalidate>
			<span class="reg reg-tl" aria-hidden="true"></span>
			<span class="reg reg-tr" aria-hidden="true"></span>
			<span class="reg reg-bl" aria-hidden="true"></span>
			<span class="reg reg-br" aria-hidden="true"></span>

			{#if error}
				<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
			{/if}

			{#if currentStep === 1}
			<div class="wizard-step">
			<div class="field">
				<label for="app-name" class="field-label">
					<span class="num">01</span>
					<span class="lbl">{$t('apps.new.fieldName')}</span>
					<span class="req">{$t('apps.new.fieldNameRequired')}</span>
				</label>
				<input
					id="app-name"
					type="text"
					bind:value={name}
					required
					placeholder={$t('apps.new.fieldNamePlaceholder')}
					class="input"
					autocomplete="off"
					spellcheck="false"
				/>
				<p class="hint">{$t('apps.new.fieldNameHint')}</p>
			</div>

			<div class="field">
				<div class="field-label">
					<span class="num">02</span>
					<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
					<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
				</div>
				<SourceKindPicker
					kinds={kinds.sources}
					bind:value={sourceKind}
					onchange={onSourceChange}
					ariaLabel={$t('apps.new.fieldSourcePlugin')}
				/>
				<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
			</div>
			</div>
			{/if}

			{#if currentStep === 2}
			<div class="wizard-step">
			<div class="field">
				<div class="field-label">
					<span class="num">03</span>
					<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
					<span class="req">
					{useComposeForm
						? $t('apps.new.fieldConfigYaml')
						: useImageForm || useStaticForm || useDockerfileForm
							? $t('apps.new.fieldConfigForm')
							: $t('apps.new.fieldConfigJson')}
				</span>
				</div>
				{#if useComposeForm}
					<ComposeSourceForm bind:form={composeState} onAdvanced={toggleAdvancedJSON} />
				{:else if useImageForm}
					<!-- Image source form. Owns inspect + conflict-lookup
					     async UX internally; writes the conflict triplet back
					     here so the submit gate can run the two-click flow. -->
					<ImageSourceForm
						bind:form={imageState}
						{registries}
						bind:submitting
						bind:conflicts={imageConflicts}
						bind:conflictAcknowledged={imageConflictAcknowledged}
						bind:conflictBlocked={imageConflictBlocked}
						onAdvanced={toggleAdvancedJSON}
					/>
				{:else if useStaticForm}
					<!-- Static source form. Provider + repo + mode in
					     dedicated controls; access_token treated as a
					     password input. Augmented with inline discovery:
					     provider auto-detect, test-connection probe, and
					     repo / branch / folder pickers fed by the
					     /api/discovery/git/* endpoints. -->
					<StaticSourceForm
						bind:form={staticState}
						bind:modeUserOverride={staticModeUserOverride}
						bind:treeLoaded={staticTreeLoaded}
						bind:tree={staticTree}
						bind:detectStatus={staticDetectStatus}
						bind:detectError={staticDetectError}
						bind:testStatus={staticTestStatus}
						bind:testError={staticTestError}
						onAdvanced={toggleAdvancedJSON}
					/>
				{:else if useDockerfileForm}
					<!-- Dockerfile source form. Shares the provider + repo +
					     branch + token git-discovery wiring with the static
					     source (StaticDiscoveryWizard in its compact variant —
					     same handlers, no folder tree). The build-step controls
					     (context path, dockerfile path, port) are the only
					     dockerfile-specific UI. -->
					<DockerfileSourceForm
						bind:form={dockerfileState}
						bind:detectStatus={dockerfileDetectStatus}
						bind:detectError={dockerfileDetectError}
						bind:testStatus={dockerfileTestStatus}
						bind:testError={dockerfileTestError}
						onAdvanced={toggleAdvancedJSON}
					/>
				{:else}
					<div class="editor">
						<div class="editor-head">
							<span class="dot"></span><span class="dot"></span><span class="dot"></span>
							<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
							<span class="spacer"></span>
							{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static' || sourceKind === 'dockerfile'}
								<button
									type="button"
									class="editor-chip"
									onclick={toggleAdvancedJSON}
									title={$t('apps.new.switchToFormTitle')}
								>
									{$t('apps.new.backToForm')}
								</button>
							{/if}
							<button
								type="button"
								class="editor-chip"
								onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
							>
								{$t('apps.new.resetSample')}
							</button>
						</div>
						<textarea
							id="app-source-config"
							bind:value={sourceConfig}
							rows="12"
							spellcheck="false"
							class="code-area"
							aria-label={$t('apps.new.sourceConfigJsonAria')}
						></textarea>
						<div class="editor-foot">
							<span class="foot-status" class:bad={!sourceValid}>
								<span class="foot-dot" aria-hidden="true"></span>
								{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
							</span>
							<span class="sep">·</span>
							<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
							<span class="sep">·</span>
							<span>{sourceBytes} B</span>
						</div>
					</div>
				{/if}
				<!-- The repo + branch EntityPicker mounts now live inside
				     StaticDiscoveryWizard (one mount each), so the static and
				     dockerfile forms share them automatically. -->
			</div>
			</div>
			{/if}

			{#if currentStep === 3}
			<div class="wizard-step">
			<fieldset class="field group">
				<legend class="field-label as-legend">
					<span class="num">04</span>
					<span class="lbl">{$t('apps.new.triggers.section')}</span>
					<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
				</legend>
				<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>

				<!-- Mode selector — three short cards. The "active" card
				     reveals its sub-form below. Skipping is explicit so
				     users can ship the workload now and wire triggers
				     later from the detail page. -->
				<div
					class="trig-mode-row"
					role="radiogroup"
					aria-label={$t('apps.new.triggers.section')}
				>
					<button
						type="button"
						role="radio"
						aria-checked={triggerMode === 'inline'}
						class="trig-mode-card"
						class:active={triggerMode === 'inline'}
						onclick={() => (triggerMode = 'inline')}
					>
						<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
						<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
						<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
					</button>
					<button
						type="button"
						role="radio"
						aria-checked={triggerMode === 'pick'}
						class="trig-mode-card"
						class:active={triggerMode === 'pick'}
						onclick={() => (triggerMode = 'pick')}
					>
						<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
						<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
						<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
					</button>
					<button
						type="button"
						role="radio"
						aria-checked={triggerMode === 'skip'}
						class="trig-mode-card"
						class:active={triggerMode === 'skip'}
						onclick={() => (triggerMode = 'skip')}
					>
						<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
						<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
						<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
					</button>
				</div>

				{#if triggerMode === 'inline'}
					<div class="trig-sub">
						<TriggerKindForm
							bind:state={triggerForm}
							idPrefix="app-trig"
							showName={true}
							showWebhook={true}
							showKindPicker={true}
						/>
					</div>
				{:else if triggerMode === 'pick'}
					<div class="trig-sub">
						{#if existingTriggers.length === 0}
							<div class="note muted-note">
								<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
								<p>{$t('apps.new.triggers.pickEmpty')}</p>
							</div>
						{:else}
							<label class="sub" for="app-trig-pick">
								<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
								<select
									id="app-trig-pick"
									class="input"
									bind:value={pickedTriggerId}
								>
									<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
									{#each existingTriggers as tr (tr.id)}
										<option value={tr.id}>
											{tr.name} · {tr.kind}{tr.webhook_enabled
												? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
												: ''}
										</option>
									{/each}
								</select>
								<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
							</label>
						{/if}
					</div>
				{:else}
					<div class="trig-sub">
						<div class="note muted-note">
							<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
							<p>{$t('apps.new.triggers.skippedNote')}</p>
						</div>
					</div>
				{/if}
			</fieldset>
			</div>
			{/if}

			{#if currentStep === 4}
			<div class="wizard-step">
			<fieldset class="field group">
				<legend class="field-label as-legend">
					<span class="num">05</span>
					<span class="lbl">{$t('apps.new.faceLabel')}</span>
					<span class="opt">{$t('apps.new.faceOptional')}</span>
				</legend>
				<div class="row three">
					<label class="sub" for="app-public-subdomain">
						<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
						<input
							id="app-public-subdomain"
							type="text"
							class="input"
							bind:value={publicSubdomain}
							placeholder={$t('apps.new.faceSubdomainPlaceholder')}
							autocomplete="off"
						/>
					</label>
					<label class="sub" for="app-public-domain">
						<span class="sub-label">{$t('apps.new.faceDomain')}</span>
						<input
							id="app-public-domain"
							type="text"
							class="input"
							bind:value={publicDomain}
							placeholder={$t('apps.new.faceDomainPlaceholder')}
							autocomplete="off"
						/>
					</label>
					<label class="sub" for="app-public-port">
						<span class="sub-label">{$t('apps.new.facePort')}</span>
						<input
							id="app-public-port"
							type="number"
							class="input"
							bind:value={publicPort}
							min="0"
							max="65535"
						/>
					</label>
				</div>
				<p class="hint">{$t('apps.new.faceHint')}</p>
			</fieldset>
			<AppManifest rows={manifestRows} {sourceKind} />
			</div>
			{/if}

			{#if currentStep === STEP_COUNT && useImageForm && imageConflictBlocked && imageConflicts.length > 0}
				<div class="note conflict-blocked-note" role="status" aria-live="polite">
					<span class="note-tag">{$t('apps.new.imageConflictTag')}</span>
					<p>{$t('apps.new.imageConflictBlockedSubmit')}</p>
				</div>
			{/if}

			<div class="wizard-nav">
				{#if currentStep > 1}
					<button type="button" class="forge-btn-ghost" onclick={goBack}>
						<span class="arrow back" aria-hidden="true">←</span>
						<span>{$t('apps.new.wizard.back')}</span>
					</button>
				{:else}
					<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
				{/if}

				{#if currentStep < STEP_COUNT}
					<button type="button" class="forge-btn" onclick={goNext} disabled={!canAdvance}>
						<span>{$t('apps.new.wizard.next')}</span>
						<span class="arrow" aria-hidden="true">→</span>
					</button>
				{:else}
					<button
						class="forge-btn"
						class:btn-warn={useImageForm && imageConflictBlocked && imageConflicts.length > 0}
						type="submit"
						disabled={submitting || !step1Valid || !step2Valid || !step3Valid}
					>
						<span>
							{#if submitting}
								{$t('apps.new.submitting')}
							{:else if useImageForm && imageConflictBlocked && imageConflicts.length > 0}
								{$t('apps.new.submitAnyway')}
							{:else}
								{$t('apps.new.submit')}
							{/if}
						</span>
						<span class="arrow" aria-hidden="true">→</span>
					</button>
				{/if}
			</div>
		</form>
		</div>
	{/if}

	<ConfirmDialog
		open={leaveConfirmOpen}
		title={$t('apps.new.unsavedChangesTitle')}
		message={$t('apps.new.unsavedChanges')}
		confirmLabel={$t('apps.new.unsavedChangesConfirm')}
		onconfirm={confirmLeave}
		oncancel={cancelLeave}
	/>
</div>

<style>
	.forge {
		--accent: var(--forge-accent);
		--accent-soft: var(--forge-accent-soft);
		display: flex;
		flex-direction: column;
		gap: 1rem;
		max-width: 880px;
		margin: 0 auto;
	}

	.loading-line {
		display: inline-flex;
		align-items: center;
		gap: 0.6rem;
		color: var(--text-secondary);
		font-family: var(--forge-mono);
		font-size: 0.78rem;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}
	.spinner {
		width: 12px;
		height: 12px;
		border-radius: 50%;
		border: 1.5px solid var(--border-primary);
		border-top-color: var(--accent);
		animation: spin 0.9s linear infinite;
	}
	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}

	/* ── Form shell with registration corners ─────── */
	.form {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-2xl);
		padding: 1.75rem;
	}

	/* ── Wizard: step rail + progressive steps ─────── */
	.wizard-shell {
		display: flex;
		align-items: flex-start;
		gap: var(--space-8);
	}
	.wizard-shell > .form {
		flex: 1;
		min-width: 0;
	}
	/* One step visible at a time; preserve the form's inter-field rhythm. */
	.wizard-step {
		display: flex;
		flex-direction: column;
		gap: 1.5rem;
	}
	/* Staggered entrance — one orchestrated reveal as each step lands. */
	.wizard-step > * {
		animation: step-rise 360ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
	}
	.wizard-step > *:nth-child(2) {
		animation-delay: 60ms;
	}
	.wizard-step > *:nth-child(3) {
		animation-delay: 120ms;
	}
	.wizard-step > *:nth-child(n + 4) {
		animation-delay: 180ms;
	}
	@keyframes step-rise {
		from {
			opacity: 0;
			transform: translateY(8px);
		}
		to {
			opacity: 1;
			transform: none;
		}
	}
	@media (prefers-reduced-motion: reduce) {
		.wizard-step > * {
			animation: none;
		}
	}
	.wizard-nav {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 1rem;
		padding-top: 1rem;
		border-top: 1px solid var(--border-secondary);
	}
	.arrow.back {
		margin-right: 0.1rem;
	}
	@media (max-width: 820px) {
		.wizard-shell {
			flex-direction: column;
			gap: var(--space-5);
		}
		.wizard-shell > .form {
			width: 100%;
		}
	}
	.reg {
		position: absolute;
		width: 10px;
		height: 10px;
		border-color: var(--color-brand-500);
		border-style: solid;
		border-width: 0;
		pointer-events: none;
	}
	.reg-tl {
		top: -1px;
		left: -1px;
		border-top-width: 2px;
		border-left-width: 2px;
		border-top-left-radius: var(--radius-2xl);
	}
	.reg-tr {
		top: -1px;
		right: -1px;
		border-top-width: 2px;
		border-right-width: 2px;
		border-top-right-radius: var(--radius-2xl);
	}
	.reg-bl {
		bottom: -1px;
		left: -1px;
		border-bottom-width: 2px;
		border-left-width: 2px;
		border-bottom-left-radius: var(--radius-2xl);
	}
	.reg-br {
		bottom: -1px;
		right: -1px;
		border-bottom-width: 2px;
		border-right-width: 2px;
		border-bottom-right-radius: var(--radius-2xl);
	}

	/* ── Alert ─────────────────────────────────────── */
	.alert {
		display: flex;
		gap: 0.7rem;
		align-items: center;
		padding: 0.7rem 0.9rem;
		background: var(--color-danger-light);
		color: var(--color-danger-dark);
		border: 1px solid var(--color-danger);
		border-left-width: 4px;
		border-radius: var(--radius-lg);
		font-size: 0.875rem;
	}
	.alert-tag {
		font-family: var(--forge-mono);
		font-weight: 700;
		font-size: 0.65rem;
		letter-spacing: 0.16em;
		padding: 0.15rem 0.4rem;
		background: var(--color-danger);
		color: #fff;
		border-radius: var(--radius-sm);
	}
	:global([data-theme='dark']) .alert {
		background: color-mix(in srgb, var(--color-danger) 14%, transparent);
		color: #fca5a5;
	}

	/* ── Fields ────────────────────────────────────── */
	.field {
		display: flex;
		flex-direction: column;
		gap: 0.55rem;
		margin: 0;
		padding: 0;
		border: 0;
	}
	.field.group {
		display: flex;
		flex-direction: column;
		gap: 0.7rem;
	}
	.field-label {
		display: flex;
		align-items: center;
		gap: 0.55rem;
		margin: 0;
		padding: 0;
	}
	.field-label.as-legend {
		float: none;
		width: 100%;
	}
	.num {
		display: inline-flex;
		width: 26px;
		height: 26px;
		justify-content: center;
		align-items: center;
		background: var(--text-primary);
		color: var(--surface-card);
		border-radius: var(--radius-sm);
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		font-weight: 700;
	}
	.lbl {
		font-family: var(--font-family-sans);
		font-weight: 600;
		font-size: 1.1rem;
		letter-spacing: -0.01em;
		line-height: 1;
		color: var(--text-primary);
	}
	.req,
	.opt {
		font-family: var(--forge-mono);
		font-size: 0.58rem;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.14em;
	}
	.req {
		color: var(--color-danger);
	}
	.opt {
		color: var(--text-tertiary);
	}

	.input {
		width: 100%;
		background: var(--surface-input);
		border: 1px solid var(--border-input);
		border-radius: var(--radius-lg);
		padding: 0.6rem 0.8rem;
		font-size: 0.92rem;
		color: var(--text-primary);
		font-family: inherit;
		outline: none;
		transition: border-color 120ms ease, box-shadow 120ms ease;
	}
	.input:focus {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--accent-soft);
	}
	.input:focus-visible {
		outline: none;
	}

	.hint {
		font-size: 0.78rem;
		color: var(--text-tertiary);
		margin: 0;
		line-height: 1.45;
	}

	.row {
		display: grid;
		grid-template-columns: 1fr 1fr;
		gap: 0.9rem;
	}
	.row.three {
		grid-template-columns: 1fr 1fr 1fr;
	}
	@media (max-width: 600px) {
		.row,
		.row.three {
			grid-template-columns: 1fr;
		}
	}
	.sub {
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
	}
	.sub-label {
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		font-weight: 600;
		letter-spacing: 0.14em;
		text-transform: uppercase;
		color: var(--text-secondary);
	}

	/* ── Code editor frame ─────────────────────────── */
	.editor {
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		overflow: hidden;
		background: var(--surface-input);
		transition: border-color 150ms ease, box-shadow 150ms ease;
	}
	.editor:focus-within {
		border-color: var(--border-focus);
		box-shadow: 0 0 0 3px var(--accent-soft);
	}
	.editor-head {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.55rem 0.8rem;
		background: var(--surface-card-hover);
		border-bottom: 1px solid var(--border-primary);
		font-family: var(--forge-mono);
		font-size: 0.7rem;
		color: var(--text-tertiary);
	}
	.editor-head .dot {
		width: 8px;
		height: 8px;
		border-radius: 50%;
		background: var(--border-input);
	}
	.editor-head .dot:nth-of-type(1) {
		background: #ef4444aa;
	}
	.editor-head .dot:nth-of-type(2) {
		background: #f59e0baa;
	}
	.editor-head .dot:nth-of-type(3) {
		background: #10b981aa;
	}
	.editor-title {
		margin-left: 0.4rem;
		color: var(--text-secondary);
		font-weight: 600;
		letter-spacing: 0.02em;
	}
	.spacer {
		flex: 1;
	}
	.editor-chip {
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-md);
		padding: 0.22rem 0.55rem;
		font-family: var(--forge-mono);
		font-size: 0.6rem;
		font-weight: 600;
		letter-spacing: 0.1em;
		text-transform: uppercase;
		color: var(--text-secondary);
		cursor: pointer;
		transition: all 120ms ease;
	}
	.editor-chip:hover {
		border-color: var(--color-brand-400);
		color: var(--text-primary);
		background: var(--surface-card-hover);
	}
	.editor-chip:focus-visible {
		outline: 2px solid var(--border-focus);
		outline-offset: 2px;
	}

	.code-area {
		display: block;
		width: 100%;
		border: 0;
		background: transparent;
		padding: 0.85rem 1rem;
		font-family: var(--forge-mono);
		font-size: 0.82rem;
		line-height: 1.55;
		color: var(--text-primary);
		resize: vertical;
		outline: none;
		tab-size: 2;
	}
	.code-area::placeholder {
		color: var(--text-tertiary);
	}

	.editor-foot {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.45rem 0.8rem;
		border-top: 1px solid var(--border-primary);
		background: var(--surface-card-hover);
		font-family: var(--forge-mono);
		font-size: 0.62rem;
		color: var(--text-tertiary);
	}
	.foot-status {
		display: inline-flex;
		align-items: center;
		gap: 0.35rem;
		color: var(--color-success-dark);
		letter-spacing: 0.1em;
		font-weight: 600;
	}
	.foot-status .foot-dot {
		width: 6px;
		height: 6px;
		border-radius: 50%;
		background: var(--color-success);
	}
	.foot-status.bad {
		color: var(--color-danger-dark);
	}
	.foot-status.bad .foot-dot {
		background: var(--color-danger);
	}
	:global([data-theme='dark']) .foot-status {
		color: #86efac;
	}
	:global([data-theme='dark']) .foot-status.bad {
		color: #fca5a5;
	}
	.sep {
		opacity: 0.5;
	}

	/* Wizard nav uses the app-wide primary button (`.forge-btn`, defined in
	   app.css) so Next/Create match every other primary action in the app.
	   The only local additions are the arrow-nudge affordance and the
	   conflict-state `.btn-warn` variant preserved from the legacy button. */
	.forge-btn .arrow {
		transition: transform 150ms ease;
	}
	.forge-btn:hover:not(:disabled) .arrow {
		transform: translateX(2px);
	}
	.forge-btn:focus-visible {
		outline: 2px solid var(--border-focus);
		outline-offset: 2px;
	}
	/* Variant used on the second submit click when image-conflicts are
	   present but the operator chose to proceed anyway. The colour shift
	   plus label change ("Forge anyway") signals that the click did
	   register without forcing a modal. */
	.forge-btn.btn-warn {
		background: var(--color-warning, var(--color-danger));
		color: #fff;
	}
	.forge-btn.btn-warn:hover:not(:disabled) {
		filter: brightness(1.05);
	}

	/* Three-column row grid — used by the public-face step. (The image /
	   dockerfile form variants of this grid moved into their components.) */
	.row.three {
		display: grid;
		grid-template-columns: 1fr 1fr 1fr;
		gap: 0.9rem;
	}
	@media (max-width: 720px) {
		.row.three {
			grid-template-columns: 1fr 1fr;
		}
	}
	@media (max-width: 480px) {
		.row.three {
			grid-template-columns: 1fr;
		}
	}

	/* ── Trigger mode picker ──────────────────────────
	   Three short cards (NEW / PICK / SKIP). The active
	   card lights up its tag in brand colour and reveals
	   the matching sub-form below in a soft inset panel. */
	.trig-mode-row {
		display: grid;
		grid-template-columns: repeat(3, minmax(0, 1fr));
		gap: 0.55rem;
	}
	@media (max-width: 720px) {
		.trig-mode-row {
			grid-template-columns: 1fr;
		}
	}
	.trig-mode-card {
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
		padding: 0.75rem 0.85rem;
		text-align: left;
		background: var(--surface-card);
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		cursor: pointer;
		transition: border-color 150ms ease, background 150ms ease,
			transform 150ms ease;
	}
	.trig-mode-card:hover {
		border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
		transform: translateY(-1px);
	}
	.trig-mode-card.active {
		border-color: var(--forge-accent);
		background: var(--forge-accent-soft);
		box-shadow: inset 0 0 0 1px var(--forge-accent);
	}
	.trig-mode-tag {
		display: inline-flex;
		align-self: flex-start;
		padding: 0.18rem 0.5rem;
		background: var(--text-primary);
		color: var(--surface-card);
		font-family: var(--forge-mono);
		font-size: 0.58rem;
		font-weight: 700;
		letter-spacing: 0.18em;
		border-radius: var(--radius-sm);
		line-height: 1;
	}
	.trig-mode-card.active .trig-mode-tag {
		background: var(--forge-accent);
	}
	.trig-mode-name {
		font-weight: 600;
		font-size: 0.92rem;
		color: var(--text-primary);
		letter-spacing: -0.01em;
	}
	.trig-mode-hint {
		font-size: 0.7rem;
		color: var(--text-tertiary);
		line-height: 1.45;
	}
	.trig-sub {
		margin-top: 0.2rem;
		padding: 0.95rem 1rem;
		border: 1px solid var(--border-primary);
		border-radius: var(--radius-lg);
		background: var(--surface-input);
	}
	.note {
		display: flex;
		gap: 0.7rem;
		align-items: flex-start;
		padding: 0.65rem 0.85rem;
		background: var(--surface-card-hover);
		border: 1px dashed var(--border-primary);
		border-radius: var(--radius-lg);
	}
	.muted-note {
		background: transparent;
	}
	.note-tag {
		padding: 0.16rem 0.4rem;
		background: var(--text-primary);
		color: var(--surface-card);
		font-family: var(--forge-mono);
		font-size: 0.56rem;
		font-weight: 700;
		letter-spacing: 0.18em;
		border-radius: var(--radius-sm);
		flex: 0 0 auto;
		line-height: 1.4;
	}
	.note p {
		margin: 0;
		font-size: 0.83rem;
		color: var(--text-secondary);
		line-height: 1.5;
	}

	.conflict-blocked-note {
		border-color: var(--color-warning, var(--forge-accent));
	}
	.conflict-blocked-note .note-tag {
		background: var(--color-warning, var(--forge-accent));
	}
</style>
