import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte';

vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate } from '$app/navigation';

const docSummary = (id: string, title: string) => ({
	id,
	title,
	datePrecision: 'DAY' as const,
	receiverCount: 0
});

/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
	id,
	title,
	documentDate: '1880-01-01',
	metaDatePrecision: 'DAY',
	originalFilename: 'brief.pdf',
	receivers: [],
	tags: [],
	completionPercentage: 0,
	contributors: [],
	matchData: {
		titleOffsets: [],
		senderMatched: false,
		matchedReceiverIds: [],
		matchedTagIds: [],
		snippetOffsets: [],
		summaryOffsets: []
	},
	status: 'UPLOADED',
	metadataComplete: false,
	scriptType: 'UNKNOWN',
	createdAt: '2024-01-01T00:00:00',
	updatedAt: '2024-01-01T00:00:00'
});

const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
	id: 'g1',
	title: 'Briefe der Familie Raddatz',
	body: '',
	status: 'DRAFT' as const,
	type: 'JOURNEY' as const,
	persons: [],
	items: [],
	createdAt: '2024-01-01T00:00:00',
	updatedAt: '2024-01-01T00:00:00',
	...overrides
});

const defaultProps = (overrides: Record<string, unknown> = {}) => ({
	geschichte: makeGeschichte(),
	onSubmit: vi.fn().mockResolvedValue(undefined),
	submitting: false,
	...overrides
});

function mockCsrfFetch(responseFactory: () => object) {
	vi.stubGlobal(
		'fetch',
		vi.fn().mockResolvedValue({
			ok: true,
			json: vi.fn().mockResolvedValue(responseFactory())
		})
	);
}

afterEach(() => {
	cleanup();
	vi.unstubAllGlobals();
});

describe('JourneyEditor — empty state', () => {
	it('renders title input and intro textarea', async () => {
		render(JourneyEditor, defaultProps());
		await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
		await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
	});

	it('labels the title input and intro textarea for screen readers', async () => {
		render(JourneyEditor, defaultProps());
		await expect
			.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
			.toBeInTheDocument();
		await expect
			.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
			.toBeInTheDocument();
	});

	it('shows empty state message when items list is empty', async () => {
		render(JourneyEditor, defaultProps());
		await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
	});
});

describe('JourneyEditor — items in position order', () => {
	it('renders items sorted by position', async () => {
		const items = [
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
		];
		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		// Brief A (position 0) must appear before Brief B (position 1) in DOM order
		const briefA = page.getByText('Brief A').element();
		const briefB = page.getByText('Brief B').element();
		expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
	});
});

describe('JourneyEditor — publish surface', () => {
	it('publish button disabled when no items', async () => {
		render(JourneyEditor, defaultProps());
		await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
	});

	it('shows a visible hint while publishing is disabled', async () => {
		render(JourneyEditor, defaultProps());
		await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
	});

	it('publish stays disabled until title is non-empty', async () => {
		render(
			JourneyEditor,
			defaultProps({
				geschichte: makeGeschichte({
					title: '',
					items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
				})
			})
		);

		await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();

		const titleInput = page.getByPlaceholder(/Titel/);
		await userEvent.fill(titleInput, 'Meine Reise');

		await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
	});

	it('adding an item enables the publish button (canPublish becomes true)', async () => {
		const newItem = { id: 'i1', position: 0, note: 'Test' };
		mockCsrfFetch(() => newItem);

		render(JourneyEditor, defaultProps());

		// Publish should be disabled before adding item
		await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();

		// Add interlude
		await userEvent.click(page.getByText(m.journey_add_interlude()));
		await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
		await userEvent.click(
			page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
		);

		// After item add, publish becomes enabled — item was added and state is correct
		await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
	});

	it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
		const onSubmit = vi.fn().mockResolvedValue(undefined);
		render(
			JourneyEditor,
			defaultProps({
				onSubmit,
				geschichte: makeGeschichte({
					title: '  Meine Reise  ',
					items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
				})
			})
		);

		await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));

		await vi.waitFor(() => {
			expect(onSubmit).toHaveBeenCalledWith(
				expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
			);
		});
	});

	it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
		const onSubmit = vi.fn().mockResolvedValue(undefined);
		render(
			JourneyEditor,
			defaultProps({
				onSubmit,
				geschichte: makeGeschichte({
					status: 'PUBLISHED',
					items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
				})
			})
		);

		await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));

		await vi.waitFor(() => {
			expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
		});
	});

	it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
		render(
			JourneyEditor,
			defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
		);

		await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
	});
});

describe('JourneyEditor — add document', () => {
	it('calls POST with documentId when document selected from picker', async () => {
		const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
		vi.stubGlobal(
			'fetch',
			vi
				.fn()
				.mockResolvedValueOnce({
					// picker search results
					ok: true,
					json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
				})
				.mockResolvedValueOnce({
					// POST /items
					ok: true,
					json: vi.fn().mockResolvedValue(newItem)
				})
		);

		render(JourneyEditor, defaultProps());

		await userEvent.click(page.getByText(m.journey_add_document()));
		await userEvent.fill(page.getByRole('combobox'), 'Karl');
		// dropdown option appears after the typeahead debounce
		await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
		await userEvent.click(page.getByText(/Brief von Karl ·/));

		await vi.waitFor(() => {
			expect(globalThis.fetch).toHaveBeenCalledWith(
				expect.stringContaining('/items'),
				expect.objectContaining({ method: 'POST' })
			);
		});
	});
});

describe('JourneyEditor — add interlude', () => {
	it('calls POST with note on interlude confirm', async () => {
		const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
		mockCsrfFetch(() => newItem);

		render(JourneyEditor, defaultProps());

		await userEvent.click(page.getByText(m.journey_add_interlude()));
		await userEvent.fill(
			page.getByPlaceholder(m.journey_interlude_placeholder()),
			'Reise nach Wien'
		);
		await userEvent.click(
			page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
		);

		await vi.waitFor(() => {
			expect(globalThis.fetch).toHaveBeenCalledWith(
				expect.stringContaining('/items'),
				expect.objectContaining({
					method: 'POST',
					body: JSON.stringify({ note: 'Reise nach Wien' })
				})
			);
		});
	});

	it('moves keyboard focus into the new row after the interlude is added', async () => {
		const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
		mockCsrfFetch(() => newItem);

		render(JourneyEditor, defaultProps());

		await userEvent.click(page.getByText(m.journey_add_interlude()));
		await userEvent.fill(
			page.getByPlaceholder(m.journey_interlude_placeholder()),
			'Reise nach Wien'
		);
		await userEvent.click(
			page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
		);

		await vi.waitFor(() => {
			expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
		});
	});
});

describe('JourneyEditor — mutation error code routing', () => {
	it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: false,
				json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
			})
		);

		render(JourneyEditor, defaultProps());

		await userEvent.click(page.getByText(m.journey_add_interlude()));
		await userEvent.fill(
			page.getByPlaceholder(m.journey_interlude_placeholder()),
			'Reise nach Wien'
		);
		await userEvent.click(
			page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
		);

		await expect
			.element(page.getByText(m.error_journey_document_already_added()))
			.toBeInTheDocument();
		await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
	});
});

describe('JourneyEditor — remove with pending state', () => {
	it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
		const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
		let resolveFetch!: (value: unknown) => void;
		vi.stubGlobal(
			'fetch',
			vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(
			page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
		);

		// Row still present, marked as pending (text appears in the row AND the live region,
		// so scope the query to the row instead of using a page-wide locator)
		await expect.element(page.getByText('Brief A')).toBeInTheDocument();
		await vi.waitFor(() => {
			const row = document.querySelector('[data-block-id="i1"]');
			expect(row).toBeTruthy();
			expect(row!.textContent).toContain(m.journey_item_pending_remove());
			expect(row!.className).toContain('opacity-60');
		});

		resolveFetch({ ok: true });
		await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
	});

	it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
		const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		// Click remove (no note → direct remove)
		await userEvent.click(
			page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
		);

		await expect.element(page.getByRole('alert')).toBeInTheDocument();
		await expect.element(page.getByText('Brief A')).toBeInTheDocument();
	});

	it('removes the row on successful DELETE', async () => {
		const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
		vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(
			page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
		);

		await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
	});

	it('focuses a sensible target after a successful remove (not body)', async () => {
		const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
		vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(
			page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
		);

		await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
		await vi.waitFor(() => {
			expect(document.activeElement).not.toBe(document.body);
			expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
		});
	});
});

describe('JourneyEditor — reorder via move buttons', () => {
	it('move-up calls PUT reorder with swapped IDs', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi.fn().mockResolvedValue([
					{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
					{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
				])
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));

		await vi.waitFor(() => {
			expect(globalThis.fetch).toHaveBeenCalledWith(
				expect.stringContaining('/items/reorder'),
				expect.objectContaining({
					method: 'PUT',
					body: JSON.stringify({ itemIds: ['i2', 'i1'] })
				})
			);
		});
	});

	it('move-down calls PUT reorder with swapped IDs', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi.fn().mockResolvedValue([
					{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
					{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
				])
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));

		await vi.waitFor(() => {
			expect(globalThis.fetch).toHaveBeenCalledWith(
				expect.stringContaining('/items/reorder'),
				expect.objectContaining({
					method: 'PUT',
					body: JSON.stringify({ itemIds: ['i2', 'i1'] })
				})
			);
		});
	});

	it('renders the server-confirmed order after a successful reorder', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi.fn().mockResolvedValue([
					{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
					{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
				])
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));

		await vi.waitFor(() => {
			const briefB = page.getByText('Brief B').element();
			const briefA = page.getByText('Brief A').element();
			expect(
				briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
			).toBeTruthy();
		});
	});

	it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));

		await expect.element(page.getByRole('alert')).toBeInTheDocument();
		const briefA = page.getByText('Brief A').element();
		const briefB = page.getByText('Brief B').element();
		expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
	});

	it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
		const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));

		await expect.element(page.getByRole('alert')).toBeInTheDocument();
		const briefA = page.getByText('Brief A').element();
		const briefB = page.getByText('Brief B').element();
		expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
		expect(consoleError).toHaveBeenCalled();
		consoleError.mockRestore();
	});
});

describe('JourneyEditor — live announce region', () => {
	it('announces the move only after the reorder resolved, then clears', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi.fn().mockResolvedValue([
					{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
					{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
				])
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));

		const liveRegion = document.querySelector('[aria-live="polite"]');
		await vi.waitFor(() => {
			expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
		});

		await vi.waitFor(
			() => {
				expect((liveRegion?.textContent ?? '').trim()).toBe('');
			},
			{ timeout: 2000 }
		);
	});

	it('announces the error text instead of a success message when the move fails', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
			{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));

		const liveRegion = document.querySelector('[aria-live="polite"]');
		await vi.waitFor(() => {
			expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
		});
	});
});

describe('JourneyEditor — note patch body', () => {
	it('sends {"note":null} when note textarea is cleared and blurred', async () => {
		const items = [
			{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
		];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi
					.fn()
					.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
		await userEvent.clear(textarea);
		await textarea.element().dispatchEvent(new FocusEvent('blur'));

		await vi.waitFor(() => {
			expect(globalThis.fetch).toHaveBeenCalledWith(
				expect.stringContaining('/items/i1'),
				expect.objectContaining({
					method: 'PATCH',
					body: JSON.stringify({ note: null })
				})
			);
		});
	});
});

describe('JourneyEditor — duplicate document aria-disabled', () => {
	it('already-added document appears as aria-disabled in picker', async () => {
		const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
		vi.stubGlobal(
			'fetch',
			vi.fn().mockResolvedValue({
				ok: true,
				json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
			})
		);

		render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));

		await userEvent.click(page.getByText(m.journey_add_document()));
		await userEvent.fill(page.getByRole('combobox'), 'Karl');

		// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
		await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
		const option = page
			.getByText(/Brief von Karl ·/)
			.element()
			.closest('li')!;
		expect(option.getAttribute('aria-disabled')).toBe('true');
	});
});

describe('JourneyEditor — unsaved warning banner', () => {
	function triggerNavigationAttempt() {
		const calls = vi.mocked(beforeNavigate).mock.calls;
		if (calls.length === 0) return;
		const [callback] = calls[calls.length - 1];
		const cancel = vi.fn();
		(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
			cancel,
			to: { url: new URL('http://localhost/geschichten') }
		});
		return cancel;
	}

	it('banner is absent before any edit or navigation attempt', async () => {
		render(JourneyEditor, defaultProps());
		expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
	});

	it('banner appears when dirty and a navigation is attempted', async () => {
		render(JourneyEditor, defaultProps());

		// Mark dirty by editing the title
		const titleInput = page.getByPlaceholder(/Titel/);
		await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));

		// Simulate the user trying to navigate away
		const cancel = triggerNavigationAttempt();
		expect(cancel).toHaveBeenCalled();

		await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
	});

	it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
		const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
		render(
			JourneyEditor,
			defaultProps({
				onSubmit,
				geschichte: makeGeschichte({
					title: 'Titel',
					items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
				})
			})
		);

		// Mark dirty
		const titleInput = page.getByPlaceholder(/Titel/);
		await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));

		// Trigger navigation → banner appears
		triggerNavigationAttempt();
		await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();

		// Attempt save — onSubmit throws
		await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));

		// Banner must still be visible (isDirty was not cleared)
		await vi.waitFor(() => {
			expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
		});
	});

	it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
		// Regression guard for clearOnSuccess(): without it, a curator who edits the
		// title and saves successfully stays trapped — the page goto() gets cancelled
		// by the still-armed guard and the banner appears after a *successful* save.
		const onSubmit = vi.fn().mockResolvedValue(undefined);
		render(JourneyEditor, defaultProps({ onSubmit }));

		// Mark dirty
		const titleInput = page.getByPlaceholder(/Titel/);
		await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));

		// Dirty state blocks navigation
		expect(triggerNavigationAttempt()).toHaveBeenCalled();

		// Save succeeds
		await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
		await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());

		// Guard is disarmed again — navigation passes and no banner shows
		await vi.waitFor(() => {
			expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
		});
		expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
	});

	it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
		mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
		render(JourneyEditor, defaultProps());

		await userEvent.click(page.getByText(m.journey_add_interlude()));
		await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
		await userEvent.click(
			page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
		);
		// The new interlude row renders its note textarea once the POST resolved
		await expect
			.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
			.toBeInTheDocument();

		// The item was persisted by its own POST — navigating away loses nothing
		expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
	});
});

describe('JourneyEditor — selectedPersons marks dirty', () => {
	function getNavCallback() {
		const calls = vi.mocked(beforeNavigate).mock.calls;
		const [callback] = calls[calls.length - 1];
		return (cancel = vi.fn()) => {
			(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
				cancel,
				to: { url: new URL('http://localhost/geschichten') }
			});
			return cancel;
		};
	}

	it('removing a person chip marks the editor dirty', async () => {
		render(
			JourneyEditor,
			defaultProps({
				geschichte: makeGeschichte({
					persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
				})
			})
		);

		// Confirm navigation is NOT blocked initially (clean state)
		const triggerNav = getNavCallback();
		expect(triggerNav()).not.toHaveBeenCalled();

		// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
		await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));

		// After person removal, navigation should be blocked
		await vi.waitFor(() => {
			const cancel = triggerNav();
			expect(cancel).toHaveBeenCalled();
		});
	});
});

describe('JourneyEditor — person chips from GeschichteView', () => {
	it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
		render(
			JourneyEditor,
			defaultProps({
				geschichte: makeGeschichte({
					persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
				})
			})
		);

		await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
	});
});
