import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom';

const mockPage = {
	url: new URL('http://localhost/stammbaum'),
	state: {},
	data: { canWrite: false } as { canWrite: boolean }
};

vi.mock('$app/state', () => ({
	get page() {
		return mockPage;
	}
}));

const replaceState = vi.fn();
vi.mock('$app/navigation', () => ({
	replaceState: (...args: unknown[]) => replaceState(...args),
	// StammbaumSidePanel (rendered transitively) imports invalidateAll/goto, so
	// the mock must provide every export the module graph uses.
	invalidateAll: vi.fn(),
	goto: vi.fn()
}));

// The page reads window.matchMedia('(max-width: 767px)').matches at init to
// decide whether to centre a tapped person above the bottom sheet (#703 AC8).
// Make the mock query-aware so only the mobile breakpoint flips; other media
// queries (e.g. prefers-reduced-motion) keep their benign default.
const originalMatchMedia = window.matchMedia;
function mockMatchMedia(isMobile: boolean) {
	window.matchMedia = ((query: string) => ({
		matches: query.includes('max-width') ? isMobile : false,
		media: query,
		onchange: null,
		addEventListener: () => {},
		removeEventListener: () => {},
		addListener: () => {},
		removeListener: () => {},
		dispatchEvent: () => false
	})) as unknown as typeof window.matchMedia;
}

afterEach(() => {
	cleanup();
	window.matchMedia = originalMatchMedia;
});

async function loadComponent() {
	return (await import('./+page.svelte')).default;
}

const sampleNodes = [
	{ id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
	{ id: 'p-2', firstName: 'Bert', lastName: 'Schmidt', displayName: 'Bert Schmidt' }
];

// Typed family nodes for the AC8 tests — familyMember is required on the DTO.
const familyNodes = [
	{ id: 'p-1', displayName: 'Anna Schmidt', familyMember: true },
	{ id: 'p-2', displayName: 'Bert Schmidt', familyMember: true }
];

describe('stammbaum page', () => {
	it('shows the empty state when there are no family nodes', async () => {
		mockPage.url = new URL('http://localhost/stammbaum');
		const Stammbaum = await loadComponent();
		render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });

		await expect
			.element(page.getByRole('heading', { name: /noch keine familienmitglieder/i }))
			.toBeVisible();
		await expect
			.element(page.getByRole('link', { name: /zur personenliste/i }))
			.toHaveAttribute('href', '/persons');
	});

	it('hides zoom controls when there are no nodes', async () => {
		mockPage.url = new URL('http://localhost/stammbaum');
		const Stammbaum = await loadComponent();
		render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } });

		await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument();
		await expect
			.element(page.getByRole('button', { name: /verkleinern/i }))
			.not.toBeInTheDocument();
	});

	it('renders the page heading and zoom controls when nodes are present', async () => {
		mockPage.url = new URL('http://localhost/stammbaum');
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
		});

		await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible();
		await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible();
		await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeVisible();
	});

	it('preselects a node when the URL has a focus query param matching an existing node', async () => {
		mockPage.url = new URL('http://localhost/stammbaum?focus=p-1');
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
		});

		await expect.element(page.getByRole('complementary')).toBeVisible();
	});

	it('does not preselect when the focus param does not match any node', async () => {
		mockPage.url = new URL('http://localhost/stammbaum?focus=missing');
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
		});

		await expect.element(page.getByRole('complementary')).not.toBeInTheDocument();
	});

	it('clamps zoom-out at MIN_ZOOM (0.25), reflected in the mirrored ?z param', async () => {
		mockPage.url = new URL('http://localhost/stammbaum');
		replaceState.mockClear();
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
		});

		const zoomOut = page.getByRole('button', { name: /verkleinern/i });
		// Default z=1; well over (1 - 0.25) / 0.1 = 8 steps to reach the floor.
		for (let i = 0; i < 15; i++) await zoomOut.click();

		await vi.waitFor(() => {
			const url = replaceState.mock.calls.at(-1)![0] as URL;
			expect(url.searchParams.get('z')).toBe('0.25');
		});
	});

	it('mirrors the view into ?cx&cy&z when zoomed (US-PANEL-002 AC2)', async () => {
		mockPage.url = new URL('http://localhost/stammbaum');
		replaceState.mockClear();
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } }
		});

		await page.getByRole('button', { name: /vergrößern/i }).click();
		await vi.waitFor(() => expect(replaceState).toHaveBeenCalled());
		const url = replaceState.mock.calls.at(-1)![0] as URL;
		expect(url.searchParams.get('z')).toBeTruthy();
		expect(url.searchParams.has('cx')).toBe(true);
		expect(url.searchParams.has('cy')).toBe(true);
	});

	// AC8 — the tapped person must clear the bottom sheet on a phone, but the
	// desktop side panel is a flex sibling that never overlaps the canvas, so no
	// centring should fire there. These tests prove the matchMedia gate around
	// selectPerson, not just the recentreAbove geometry (covered in panZoom.test).
	// We assert on the rendered viewBox — a pure function of the view state — so a
	// recentre is observed as a shifted origin, with no dependence on the noisy
	// mount-time URL-mirror timing.
	function svgViewBox(): string {
		const svg = document.querySelector('svg[aria-label="Stammbaum"]');
		if (!svg) throw new Error('No Stammbaum svg rendered');
		return svg.getAttribute('viewBox') ?? '';
	}

	it('recentres the tapped person when matchMedia reports mobile (#703 AC8)', async () => {
		mockMatchMedia(true);
		mockPage.url = new URL('http://localhost/stammbaum');
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
		});
		const before = await vi.waitFor(() => {
			const vb = svgViewBox();
			expect(vb).toBeTruthy();
			return vb;
		});

		await page.getByRole('button', { name: 'Anna Schmidt' }).click();

		// The mobile tap recentres the canvas, lifting the anchor above the sheet,
		// so the viewBox origin shifts.
		await vi.waitFor(() => expect(svgViewBox()).not.toBe(before));
	});

	it('does not recentre on tap when matchMedia reports desktop (#703 AC8)', async () => {
		mockMatchMedia(false);
		mockPage.url = new URL('http://localhost/stammbaum');
		const Stammbaum = await loadComponent();
		render(Stammbaum, {
			props: { data: { nodes: familyNodes, edges: [], initialView: DEFAULT_VIEW } }
		});
		const before = await vi.waitFor(() => {
			const vb = svgViewBox();
			expect(vb).toBeTruthy();
			return vb;
		});

		await page.getByRole('button', { name: 'Anna Schmidt' }).click();

		// The tap registers — the desktop side panel opens — but no recentre fires,
		// so the viewBox is unchanged.
		await expect.element(page.getByRole('complementary')).toBeVisible();
		expect(svgViewBox()).toBe(before);
	});
});
