<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
import { getErrorMessage } from '$lib/shared/errors';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead';
import GeschichteSidebar from './GeschichteSidebar.svelte';
import JourneyItemRow from './JourneyItemRow.svelte';
import JourneyAddBar from './JourneyAddBar.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';

type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];

interface Props {
	geschichte: GeschichteView;
	/** Must reject when the save failed — the editor keeps its dirty state then. */
	onSubmit: (payload: {
		title: string;
		body: string;
		status: 'DRAFT' | 'PUBLISHED';
		personIds: string[];
	}) => Promise<void>;
	submitting?: boolean;
}

let { geschichte, onSubmit, submitting = false }: Props = $props();

const unsaved = createUnsavedWarning();

let title = $state(geschichte.title ?? '');

let body = $state(geschichte.body ?? '');
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte.status ?? 'DRAFT');
let selectedPersons: PersonOption[] = $state(
	geschichte.persons ? Array.from(geschichte.persons).map(toPersonOption) : []
);
let items: JourneyItemView[] = $state(
	[...(geschichte.items ?? [])].sort((a, b) => a.position - b.position)
);

let titleTouched = $state(false);
let mutationError = $state('');
let pendingRemoveIds: string[] = $state([]);
let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleAnnounceReset() {
	if (announceTimer) clearTimeout(announceTimer);
	announceTimer = setTimeout(() => {
		liveAnnounce = '';
		announceTimer = null;
	}, 500);
}

const titleEmpty = $derived(title.trim().length === 0);
const showTitleError = $derived(titleEmpty && titleTouched);
const isDraft = $derived(status === 'DRAFT');
const alreadyAddedIds = $derived(
	new Set(items.filter((i) => i.document).map((i) => i.document!.id))
);
const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);

// Skip the initial run so mounting with pre-existing persons doesn't mark dirty.
let _personEffectMounted = false;
$effect(() => {
	void selectedPersons.length;
	if (!_personEffectMounted) {
		_personEffectMounted = true;
		return;
	}
	unsaved.markDirty();
});

let listEl: HTMLElement | null = $state(null);
let editorColEl: HTMLElement | null = $state(null);

const dragDrop = createBlockDragDrop<JourneyItemView>({
	getSortedBlocks: () => items,
	onReorder: handleReorder
});

$effect(() => {
	dragDrop.setListElement(listEl);
});

/** Maps a failed mutation response to a user-facing message via its backend error code. */
async function failureMessage(res: Response): Promise<string> {
	const code = (await res.json().catch(() => ({})))?.code;
	return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}

/** Moves keyboard focus to a control inside the row of the given item. */
async function focusRowControl(itemId: string, selector: string) {
	await tick();
	editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
}

async function handleReorder(itemIds: string[]) {
	const prev = [...items];
	items = itemIds.map((id) => items.find((i) => i.id === id)!);
	mutationError = '';
	try {
		const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/reorder`, {
			method: 'PUT',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ itemIds })
		});
		if (!res.ok) {
			items = prev;
			mutationError = await failureMessage(res);
			return;
		}
		const updated: JourneyItemView[] = await res.json();
		items = updated.sort((a, b) => a.position - b.position);
	} catch (e) {
		console.error('Journey reorder failed', e);
		items = prev;
		mutationError = m.journey_mutation_error_reload();
	}
}

/** Pessimistic append shared by both add paths — items update only on API success. */
async function appendItem(body: { documentId?: string; note?: string }) {
	mutationError = '';
	try {
		const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(body)
		});
		if (!res.ok) {
			mutationError = await failureMessage(res);
			return;
		}
		const newItem: JourneyItemView = await res.json();
		items = [...items, newItem];
		// Move-up is disabled on the first row — fall back to the remove button then.
		await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
	} catch (e) {
		console.error('Journey item append failed', e);
		mutationError = m.journey_mutation_error_reload();
	}
}

async function handleAddDocument(doc: DocumentOption) {
	await appendItem({ documentId: doc.id });
}

async function handleAddInterlude(text: string) {
	await appendItem({ note: text });
}

async function handleRemove(itemId: string) {
	const idx = items.findIndex((i) => i.id === itemId);
	mutationError = '';
	pendingRemoveIds = [...pendingRemoveIds, itemId];
	liveAnnounce = m.journey_item_pending_remove();
	scheduleAnnounceReset();
	try {
		const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
			method: 'DELETE'
		});
		if (!res.ok) {
			mutationError = await failureMessage(res);
			return;
		}
		items = items.filter((i) => i.id !== itemId);
		await tick();
		if (items.length === 0 || idx <= 0) {
			editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
		} else {
			await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
		}
	} catch (e) {
		console.error('Journey item remove failed', e);
		mutationError = m.journey_mutation_error_reload();
	} finally {
		pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
	}
}

async function handleNotePatch(itemId: string, note: string | null) {
	const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
		method: 'PATCH',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ note: note })
	});
	// Carry the backend error code's message so the row can show the specific
	// reason (e.g. JOURNEY_NOTE_TOO_LONG) instead of a generic alert.
	if (!res.ok) throw new Error(await failureMessage(res));
	const updated: JourneyItemView = await res.json();
	items = items.map((i) => (i.id === itemId ? updated : i));
}

async function handleMoveUp(index: number) {
	if (index === 0) return;
	const total = items.length;
	const ids = items.map((i) => i.id);
	[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
	await handleReorder(ids);
	// Announce only after the server confirmed (or rejected) the reorder —
	// announcing beforehand would claim success for a move that rolled back.
	liveAnnounce = mutationError
		? mutationError
		: m.journey_item_moved({ position: index + 1, total, newPosition: index });
	scheduleAnnounceReset();
}

async function handleMoveDown(index: number) {
	if (index === items.length - 1) return;
	const total = items.length;
	const ids = items.map((i) => i.id);
	[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
	await handleReorder(ids);
	liveAnnounce = mutationError
		? mutationError
		: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
	scheduleAnnounceReset();
}

async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
	titleTouched = true;
	if (titleEmpty) return;
	try {
		await onSubmit({
			title: title.trim(),
			body,
			status: nextStatus,
			personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
		});
		unsaved.clearOnSuccess();
	} catch {
		// onSubmit signalled failure — keep dirty flag so the banner stays
	}
}
</script>

<!-- Screen-reader live region for move announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>

{#if unsaved.showUnsavedWarning}
	<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}

<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
	<!-- Editor column -->
	<div bind:this={editorColEl} class="flex flex-col gap-4">
		<!-- Title -->
		<div>
			<input
				type="text"
				bind:value={title}
				maxlength="255"
				oninput={() => unsaved.markDirty()}
				onblur={() => (titleTouched = true)}
				placeholder={m.geschichte_editor_title_placeholder()}
				aria-label={m.journey_title_aria_label()}
				aria-invalid={showTitleError}
				aria-describedby={showTitleError ? 'journey-title-error' : undefined}
				class="block w-full rounded border {showTitleError
					? 'border-danger'
					: 'border-line'} bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
			/>
			{#if showTitleError}
				<p id="journey-title-error" class="mt-1 text-sm text-danger" role="alert">
					{m.geschichte_editor_title_required()}
				</p>
			{/if}
		</div>

		<!-- Intro textarea -->
		<div>
			<textarea
				bind:value={body}
				maxlength="4000"
				oninput={() => unsaved.markDirty()}
				placeholder={m.journey_intro_placeholder()}
				aria-label={m.journey_intro_aria_label()}
				rows={3}
				class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
			></textarea>
			<p class="mt-1 font-sans text-xs text-ink-3">{m.journey_intro_save_hint()}</p>
		</div>

		<!-- Item list -->
		{#if showPublishedEmptyWarning}
			<p
				class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
			>
				{m.journey_published_empty_warning()}
			</p>
		{/if}

		{#if mutationError}
			<p
				class="rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
				role="alert"
			>
				{mutationError}
			</p>
		{/if}

		{#if items.length === 0}
			<p class="font-sans text-sm text-ink-3">{m.journey_empty_state()}</p>
		{:else}
			<!-- pointer events managed by createBlockDragDrop; keyboard reorder available via move-up/down buttons on each item -->
			<ol
				bind:this={listEl}
				onpointermove={(e) => dragDrop.handlePointerMove(e)}
				onpointerup={() => dragDrop.handlePointerUp()}
				class="m-0 flex list-none flex-col gap-2 p-0"
			>
				{#each items as item, i (item.id)}
					<!-- pointerdown initiates drag; the drag handle button inside is the semantic interactive element -->
					<li
						data-block-wrapper
						onpointerdown={(e) => dragDrop.handleGripDown(e, item.id)}
						class="transition-all duration-150 {dragDrop.draggedBlockId === item.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-focus-ring/40' : ''}"
						style={dragDrop.draggedBlockId === item.id
							? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
							: ''}
					>
						{#if dragDrop.dropTargetIdx === i}
							<div class="mb-1 h-1 rounded-full bg-accent transition-all"></div>
						{/if}
						<JourneyItemRow
							item={item}
							index={i}
							total={items.length}
							pendingRemove={pendingRemoveIds.includes(item.id)}
							onMoveUp={() => handleMoveUp(i)}
							onMoveDown={() => handleMoveDown(i)}
							onRemove={() => handleRemove(item.id)}
							onNotePatch={(note) => handleNotePatch(item.id, note)}
						/>
					</li>
				{/each}
			</ol>
		{/if}

		<JourneyAddBar
			alreadyAddedIds={alreadyAddedIds}
			onAddDocument={handleAddDocument}
			onAddInterlude={handleAddInterlude}
		/>
	</div>

	<!-- Sidebar -->
	<GeschichteSidebar status={status} bind:selectedPersons={selectedPersons} />
</div>

<!-- Save bar -->
<div
	class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
>
	<p class="font-sans text-xs text-ink-3">
		{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
	</p>
	<div class="flex flex-col items-start gap-1 sm:items-end">
		<div class="flex gap-2">
			{#if isDraft}
				<button
					type="button"
					onclick={() => save('DRAFT')}
					disabled={submitting || titleEmpty}
					class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
				>
					{m.geschichte_editor_save_draft()}
				</button>
				<button
					type="button"
					onclick={() => save('PUBLISHED')}
					disabled={submitting || !canPublish}
					title={canPublish ? undefined : m.journey_publish_disabled_title()}
					class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
				>
					{m.geschichte_editor_publish()}
				</button>
			{:else}
				<button
					type="button"
					onclick={() => save('DRAFT')}
					disabled={submitting}
					class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
				>
					{m.geschichte_editor_unpublish()}
				</button>
				<button
					type="button"
					onclick={() => save('PUBLISHED')}
					disabled={submitting || titleEmpty}
					class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
				>
					{m.geschichte_editor_save()}
				</button>
			{/if}
		</div>
		{#if isDraft && !canPublish}
			<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
		{/if}
	</div>
</div>
