import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonTypeahead from './PersonTypeahead.svelte';

const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));

const PERSONS = [
	{
		id: '1',
		firstName: 'Max',
		lastName: 'Mustermann',
		displayName: 'Max Mustermann',
		personType: 'PERSON'
	},
	{
		id: '2',
		firstName: 'Anna',
		lastName: 'Musterfrau',
		displayName: 'Anna Musterfrau',
		personType: 'PERSON'
	}
];

function mockFetchWithPersons(persons = PERSONS) {
	// The directory endpoint (/api/persons?…) now returns a paged { items } envelope; the
	// correspondents endpoint still returns a bare array. Branch the mock on the URL.
	vi.stubGlobal(
		'fetch',
		vi.fn().mockImplementation((url: string) =>
			Promise.resolve({
				ok: true,
				json: vi
					.fn()
					.mockResolvedValue(url.includes('/correspondents') ? persons : { items: persons })
			})
		)
	);
}

function mockFetchFailure() {
	vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
}

function hiddenInput(name: string) {
	return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
}

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

// ─── Rendering ────────────────────────────────────────────────────────────────

describe('PersonTypeahead – rendering', () => {
	it('renders the label and text input', async () => {
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		await expect.element(page.getByText('Absender')).toBeInTheDocument();
		await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
		await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
	});

	it('pre-fills the visible input from initialName', async () => {
		render(PersonTypeahead, {
			name: 'senderId',
			label: 'Absender',
			initialName: 'Max Mustermann'
		});
		// The $effect that syncs initialName runs after mount — poll until the value appears
		await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
	});

	it('renders a hidden input with the correct name attribute', async () => {
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		await tick();
		expect(hiddenInput('senderId')).toBeTruthy();
	});

	it('hidden input starts with the provided value', async () => {
		render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
		await tick();
		expect(hiddenInput('senderId')?.value).toBe('42');
	});
});

// ─── Search ───────────────────────────────────────────────────────────────────

describe('PersonTypeahead – search', () => {
	it('opens the dropdown with results after typing', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
		await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
		await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
	});

	it('shows loading indicator while fetching', async () => {
		vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Ma');
		await waitForDebounce();
		await expect.element(page.getByText('Suche...')).toBeInTheDocument();
	});

	it('shows no dropdown when the search returns empty results', async () => {
		mockFetchWithPersons([]);
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('XYZ');
		await waitForDebounce();
		await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
	});

	it('shows no results when fetch fails', async () => {
		mockFetchFailure();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Ma');
		await waitForDebounce();
		await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
	});
});

// ─── Selection ────────────────────────────────────────────────────────────────

describe('PersonTypeahead – selection', () => {
	it('fills the visible input and closes dropdown on click', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		document.querySelector<HTMLElement>('[role="option"]')!.click();
		await tick();
		await expect.element(input).toHaveValue('Max Mustermann');
		await expect
			.element(page.getByRole('option', { name: 'Max Mustermann' }))
			.not.toBeInTheDocument();
		await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
	});

	it('sets the hidden input value to the selected person id', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		document.querySelector<HTMLElement>('[role="option"]')!.click();
		await tick();
		await tick();
		expect(hiddenInput('senderId')?.value).toBe('1');
	});

	it('calls onchange with the person id on selection', async () => {
		mockFetchWithPersons();
		const onchange = vi.fn();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		document.querySelector<HTMLElement>('[role="option"]')!.click();
		await tick();
		expect(onchange).toHaveBeenCalledWith('1');
	});

	it('selects a result with Enter key', async () => {
		mockFetchWithPersons([
			{
				id: '1',
				firstName: 'Max',
				lastName: 'Mustermann',
				displayName: 'Max Mustermann',
				personType: 'PERSON'
			}
		]);
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Ma');
		await waitForDebounce();
		document.querySelector<HTMLElement>('[role="option"]')!.click();
		await tick();
		await expect.element(input).toHaveValue('Max Mustermann');
	});
});

// ─── Clearing ─────────────────────────────────────────────────────────────────

describe('PersonTypeahead – clearing a selection', () => {
	it('clears the hidden value when user edits the visible input after a selection', async () => {
		mockFetchWithPersons();
		const onchange = vi.fn();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
		const input = page.getByPlaceholder('Namen tippen...');

		await input.fill('Mu');
		await waitForDebounce();
		document.querySelector<HTMLElement>('[role="option"]')!.click();
		await tick();
		expect(onchange).toHaveBeenCalledWith('1');
		onchange.mockClear();

		await input.fill('x');
		await waitForDebounce();
		expect(onchange).toHaveBeenCalledWith('');
		await tick();
		expect(hiddenInput('senderId')?.value).toBe('');
	});
});

// ─── Correspondent mode ───────────────────────────────────────────────────────

describe('PersonTypeahead – correspondent mode', () => {
	it('fetches correspondents immediately on focus when restrictToCorrespondentsOf is set', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, {
			name: 'receiverId',
			label: 'Empfänger',
			restrictToCorrespondentsOf: 'person-a-id'
		});

		(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
		await waitForDebounce();

		const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
		expect(fetchMock).toHaveBeenCalledWith(
			expect.stringContaining('/api/persons/person-a-id/correspondents')
		);
	});

	it('shows correspondent results immediately on focus without typing', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, {
			name: 'receiverId',
			label: 'Empfänger',
			restrictToCorrespondentsOf: 'person-a-id'
		});

		(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
		await waitForDebounce();

		await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
	});

	it('uses correspondents endpoint with q param when typing', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, {
			name: 'receiverId',
			label: 'Empfänger',
			restrictToCorrespondentsOf: 'person-a-id'
		});

		await page.getByPlaceholder('Namen tippen...').fill('Anna');
		await waitForDebounce();

		const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
		expect(fetchMock).toHaveBeenCalledWith(
			expect.stringContaining('/api/persons/person-a-id/correspondents?q=Anna')
		);
	});

	it('uses normal persons endpoint when restrictToCorrespondentsOf is not set', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });

		await page.getByPlaceholder('Namen tippen...').fill('Anna');
		await waitForDebounce();

		const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
		expect(fetchMock).toHaveBeenCalledWith(
			expect.stringContaining('/api/persons?review=true&q=Anna')
		);
	});
});

// ─── resetKey ─────────────────────────────────────────────────────────────────

describe('PersonTypeahead – resetKey', () => {
	// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
	// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
	// navigation (prop update on a live instance) cannot be isolated at this level.
	it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
		mockFetchWithPersons([]);
		const { rerender } = render(PersonTypeahead, {
			name: 'senderId',
			label: 'Absender',
			initialName: '',
			resetKey: 0
		});
		const input = page.getByPlaceholder('Namen tippen...');

		// User types something without selecting a person
		await input.fill('Max');
		await waitForDebounce();
		await expect.element(input).toHaveValue('Max');

		// Navigation resets: initialName stays '', but resetKey increments
		await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
		await expect.element(input).toHaveValue('');
	});
});

// ─── Click outside ────────────────────────────────────────────────────────────

describe('PersonTypeahead – click outside', () => {
	it('closes the dropdown when clicking outside the component', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
		document.body.click();
		await tick();
		await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
	});
});

// ─── ARIA roles ───────────────────────────────────────────────────────────────

describe('PersonTypeahead – ARIA roles', () => {
	it('dropdown uses role="listbox" container and role="option" items', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		// Container must be a listbox
		await expect.element(page.getByRole('listbox')).toBeInTheDocument();

		// Items must be options, not buttons
		const options = page.getByRole('option');
		await expect.element(options.first()).toBeInTheDocument();
		await expect.element(page.getByRole('option', { name: 'Max Mustermann' })).toBeInTheDocument();
		await expect.element(page.getByRole('option', { name: 'Anna Musterfrau' })).toBeInTheDocument();
	});

	it('input has aria-expanded="false" when dropdown is closed', async () => {
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await expect.element(input).toHaveAttribute('aria-expanded', 'false');
	});

	it('input has aria-expanded="true" when dropdown is open', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		await expect.element(input).toHaveAttribute('aria-expanded', 'true');
	});

	it('input has aria-controls pointing to the listbox id', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		const ariaControls = await input.element().getAttribute('aria-controls');
		expect(ariaControls).toBeTruthy();

		// The listbox with that id must exist
		const listbox = document.getElementById(ariaControls!);
		expect(listbox).not.toBeNull();
		expect(listbox!.getAttribute('role')).toBe('listbox');
	});

	it('input has aria-haspopup="listbox"', async () => {
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await expect.element(input).toHaveAttribute('aria-haspopup', 'listbox');
	});
});

// ─── Keyboard navigation ──────────────────────────────────────────────────────

// CDP-based userEvent.keyboard does not reliably trigger Svelte 5 onkeydown
// handlers. Dispatch native KeyboardEvent directly on the DOM element instead.
async function pressKey(input: ReturnType<typeof page.getByPlaceholder>, key: string) {
	const el = (await input.element()) as HTMLInputElement;
	el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
	await tick();
}

describe('PersonTypeahead – keyboard navigation', () => {
	it('ArrowDown moves highlight to the first option', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		await pressKey(input, 'ArrowDown');

		// First option should be highlighted (aria-selected="true")
		const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
		await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
	});

	it('ArrowDown then ArrowDown moves highlight to the second option', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		await pressKey(input, 'ArrowDown');
		await pressKey(input, 'ArrowDown');

		const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' });
		await expect.element(secondOption).toHaveAttribute('aria-selected', 'true');
	});

	it('ArrowUp from first wraps to last option', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		await pressKey(input, 'ArrowDown'); // highlight first
		await pressKey(input, 'ArrowUp'); // wrap to last

		const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' });
		await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
	});

	it('ArrowDown from last wraps to first option', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		await pressKey(input, 'ArrowDown'); // highlight first (index 0)
		await pressKey(input, 'ArrowDown'); // highlight second (index 1 = last)
		await pressKey(input, 'ArrowDown'); // wrap to first (index 0)

		const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
		await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
	});

	it('Enter selects the highlighted option', async () => {
		mockFetchWithPersons([
			{
				id: '1',
				firstName: 'Max',
				lastName: 'Mustermann',
				displayName: 'Max Mustermann',
				personType: 'PERSON'
			}
		]);
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Ma');
		await waitForDebounce();

		await pressKey(input, 'ArrowDown');
		await pressKey(input, 'Enter');

		await expect.element(input).toHaveValue('Max Mustermann');
		await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
	});

	it('Escape closes the dropdown without selecting', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();
		await expect.element(page.getByRole('listbox')).toBeInTheDocument();

		await pressKey(input, 'Escape');

		await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
		// Value unchanged — nothing was selected
		await expect.element(input).toHaveValue('Mu');
	});

	it('active option id is set as aria-activedescendant on the input', async () => {
		mockFetchWithPersons();
		render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
		const input = page.getByPlaceholder('Namen tippen...');
		await input.fill('Mu');
		await waitForDebounce();

		// No active option before pressing ArrowDown
		const beforeNav = await input.element().getAttribute('aria-activedescendant');
		expect(beforeNav).toBeFalsy();

		await pressKey(input, 'ArrowDown');

		const afterNav = await input.element().getAttribute('aria-activedescendant');
		expect(afterNav).toBeTruthy();
	});
});
