/** @import { SpanData, SpanTree } from './types.js' */
// This helps `pnpm check` pass in the test apps without having to include
// the ambient.d.ts file in each of their tsconfig.json files.
/** @import {} from './ambient.js' */

import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { defineConfig, test as base, devices } from '@playwright/test';
import { number_from_env } from '../../../test-utils/index.js';

/** @type {typeof import('./types.js')['test']} */
export const test = base.extend({
	app: ({ page }, use) => {
		// these are assumed to have been put in the global scope by the layout
		void use({
			goto: (url, opts) => page.evaluate(({ url, opts }) => goto(url, opts), { url, opts }),

			invalidate: (url) => page.evaluate((url) => invalidate(url), url),

			beforeNavigate: (fn) => page.evaluate((fn) => beforeNavigate(fn), fn),

			afterNavigate: () => page.evaluate(() => afterNavigate(() => {})),

			preloadCode: (pathname) => page.evaluate((pathname) => preloadCode(pathname), pathname),

			preloadData: (url) => page.evaluate((url) => preloadData(url), url)
		});
	},

	clicknav: async ({ page, javaScriptEnabled }, use) => {
		/**
		 * @param {string} selector
		 * @param {{ timeout?: number, waitForURL?: string }} [options]
		 */
		async function clicknav(selector, { timeout, waitForURL } = {}) {
			const element = page.locator(selector);
			if (javaScriptEnabled) {
				const promises = [page.waitForNavigation({ timeout }), element.click()];
				if (waitForURL) {
					promises.push(page.waitForURL(waitForURL, { timeout }));
				}
				await Promise.all(promises);
			} else {
				await element.click();
			}
		}

		await use(clicknav);
	},

	scroll_to: async ({ page }, use) => {
		/**
		 * @param {number} x
		 * @param {number} y
		 */
		async function scroll_to(x, y) {
			// The browser will do this for us, but we need to do it pre-emptively
			// so that we can check the scroll location.
			// Otherwise, we'd be checking a decimal number against an integer.
			x = Math.trunc(x);
			y = Math.trunc(y);
			const watcher = page.waitForFunction(
				/** @param {{ x: number, y: number }} opt */ (opt) =>
					// check if the scroll position reached the desired or maximum position
					window.scrollX ===
						Math.min(opt.x, document.documentElement.offsetWidth - window.innerWidth) &&
					window.scrollY ===
						Math.min(opt.y, document.documentElement.offsetHeight - window.innerHeight),
				{ x, y }
			);
			await page.evaluate(
				/** @param {{ x: number, y: number }} opt */ (opt) => window.scrollTo(opt.x, opt.y),
				{ x, y }
			);
			await watcher;
		}

		await use(scroll_to);
	},

	in_view: async ({ page }, use) => {
		/** @param {string} selector */
		async function in_view(selector) {
			const box = await page.locator(selector).boundingBox();
			const view = page.viewportSize();
			return !!box && !!view && box.y < view.height && box.y + box.height > 0;
		}

		await use(in_view);
	},

	get_computed_style: async ({ page }, use) => {
		/**
		 * @param {string} selector
		 * @param {string} prop
		 */
		function get_computed_style(selector, prop) {
			return page.$eval(
				selector,
				(node, prop) => window.getComputedStyle(node).getPropertyValue(prop),
				prop
			);
		}

		await use(get_computed_style);
	},

	page: async ({ page, javaScriptEnabled }, use) => {
		// automatically wait for kit started event after navigation functions if js is enabled
		const page_navigation_functions = /** @type {const} */ (['goto', 'goBack', 'reload']);
		page_navigation_functions.forEach((fn) => {
			const original_page_fn = page[fn];
			if (!original_page_fn) {
				throw new Error(`function does not exist on page: ${fn}`);
			}

			/** @param  {...any} args */
			async function modified_fn(...args) {
				try {
					// @ts-ignore
					const res = await original_page_fn.apply(page, args);
					if (javaScriptEnabled && args[1]?.wait_for_started !== false) {
						// the first navigation to a route triggers on-demand compilation in dev
						// mode, which can take a while on a cold/overloaded CI runner
						await page.locator('body.started').waitFor({ timeout: process.env.CI ? 30000 : 15000 });
					}
					return res;
				} catch (e) {
					// Exclude this function from the stack trace so that it points to the failing test
					// instead of this file.
					Error.captureStackTrace(/** @type {Error} */ (e), modified_fn);
					throw e;
				}
			}

			page[fn] = modified_fn;
		});

		await use(page);
	},

	// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
	read_errors: async ({}, use) => {
		/** @param {string} path */
		function read_errors(path) {
			const errors =
				fs.existsSync('test/errors.json') &&
				JSON.parse(fs.readFileSync('test/errors.json', 'utf8'));
			return errors[path];
		}

		await use(read_errors);
	},

	// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
	read_traces: async ({}, use) => {
		/** @param {string} test_id */
		function read_traces(test_id) {
			const raw = fs.readFileSync('test/spans.jsonl', 'utf8').split('\n').filter(Boolean);
			const traces = /** @type {SpanData[]} */ (raw.map((line) => JSON.parse(line)));

			return traces
				.filter((t) => t.parent_span_id === undefined && t.attributes.test_id === test_id)
				.map((root_trace) => {
					const child_traces = traces.filter((span) => span.trace_id === root_trace.trace_id);
					return build_span_tree(root_trace, child_traces);
				});
		}

		await use(read_traces);
	},

	// eslint-disable-next-line no-empty-pattern -- Playwright doesn't let us use `_` as a parameter name. It must be a destructured object
	start_server: async ({}, use) => {
		/**
		 * @type {http.Server | undefined}
		 */
		let server;

		/**
		 * @type {Set<import('net').Socket> | undefined}
		 */
		let sockets;

		/**
		 * @param {(req: http.IncomingMessage, res: http.ServerResponse) => void} handler
		 */
		async function start_server(handler) {
			if (server) {
				throw new Error('server already started');
			}
			server = http.createServer(handler);

			await new Promise((fulfil) => {
				/** @type {http.Server} */ (server).listen(0, 'localhost', () => {
					fulfil(undefined);
				});
			});

			const { port } = /** @type {import('net').AddressInfo} */ (server.address());
			if (!port) {
				throw new Error(`Could not find port from server ${JSON.stringify(server.address())}`);
			}
			sockets = new Set();
			server.on('connection', (socket) => {
				/** @type {Set<import('net').Socket>} */ (sockets).add(socket);
				socket.on('close', () => {
					/** @type {Set<import('net').Socket>} */ (sockets).delete(socket);
				});
			});
			return {
				port
			};
		}
		await use(start_server);

		if (server) {
			if (sockets) {
				sockets.forEach((socket) => {
					if (!socket.destroyed) {
						socket.destroy();
					}
				});
			}

			await new Promise((fulfil, reject) => {
				/** @type {http.Server} */ (server).close((err) => {
					if (err) {
						reject(err);
					} else {
						fulfil(undefined);
					}
				});
			});
		}
	},

	// make sure context fixture depends on start server, so setup/teardown order is
	// setup start_server
	// setup context
	// teardown context
	// teardown start_server
	async context({ context, start_server }, use) {
		// just here make sure start_server is referenced, don't call
		if (!start_server) {
			throw new Error('start_server fixture not present');
		}
		await use(context);
		try {
			await context.close();
		} catch (e) {
			console.error('failed to close context fixture', e);
		}
	}
});

const known_devices = {
	chromium: devices['Desktop Chrome'],
	firefox: devices['Desktop Firefox'],
	webkit: devices['Desktop Safari']
};
const test_browser = /** @type {keyof typeof known_devices} */ (
	process.env.KIT_E2E_BROWSER ?? 'chromium'
);

const test_browser_device = known_devices[test_browser]
	? {
			...known_devices[test_browser],
			channel: test_browser === 'chromium' ? 'chromium' : undefined
		}
	: undefined;

if (!test_browser_device) {
	throw new Error(
		`invalid test browser specified: KIT_E2E_BROWSER=${
			process.env.KIT_E2E_BROWSER
		}. Allowed values: ${Object.keys(known_devices).join(', ')}`
	);
}

export const config = defineConfig({
	forbidOnly: !!process.env.CI,
	// generous timeouts on CI
	timeout: process.env.CI ? 45000 : 15000,
	webServer: {
		command: process.env.DEV ? 'pnpm dev --force' : 'pnpm build && pnpm preview',
		port: process.env.DEV ? 5173 : 4173
	},
	retries: process.env.CI ? 2 : number_from_env('KIT_E2E_RETRIES', 0),
	projects: [
		{
			name: `${test_browser}-${process.env.DEV ? 'dev' : 'build'}`,
			use: {
				javaScriptEnabled: true
			}
		},
		{
			name: `${test_browser}-${process.env.DEV ? 'dev' : 'build'}-no-js`,
			use: {
				javaScriptEnabled: false
			}
		}
	],
	use: {
		...test_browser_device,
		screenshot: 'only-on-failure',
		trace: 'retain-on-failure'
	},
	workers: process.env.CI ? 2 : number_from_env('KIT_E2E_WORKERS', undefined),
	reporter: process.env.CI
		? [
				['dot'],
				[path.resolve(fileURLToPath(import.meta.url), '../github-flaky-warning-reporter.js')]
			]
		: 'list',
	testDir: 'test',
	testMatch: /(.+\.)?(test|spec)\.[jt]s/
});

/**
 * @param {SpanData} current_span
 * @param {SpanData[]} child_spans
 * @returns {SpanTree}
 */
function build_span_tree(current_span, child_spans) {
	const children = child_spans.filter((span) => span.parent_span_id === current_span.span_id);
	return {
		...current_span,
		children: children.map((child) => build_span_tree(child, child_spans))
	};
}
