/** @import { BuildOptions } from 'esbuild' */
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve, posix } from 'node:path';
import { fileURLToPath } from 'node:url';
import { builtinModules } from 'node:module';
import process from 'node:process';
import esbuild from 'esbuild';
import toml from '@iarna/toml';

/**
 * @typedef {{
 *   build?: { publish?: string }
 *   functions?: { node_bundler?: 'zisi' | 'esbuild' }
 * } & toml.JsonMap} NetlifyConfig
 */

const name = '@sveltejs/adapter-netlify';
const files = fileURLToPath(new URL('./files', import.meta.url).href);

const edge_set_in_env_var =
	process.env.NETLIFY_SVELTEKIT_USE_EDGE === 'true' ||
	process.env.NETLIFY_SVELTEKIT_USE_EDGE === '1';

const FUNCTION_PREFIX = 'sveltekit-';

/** @type {import('./index.js').default} */
export default function ({ split = false, edge = edge_set_in_env_var } = {}) {
	return {
		name,
		/** @param {import('@sveltejs/kit').Builder} builder */
		async adapt(builder) {
			if (!builder.routes) {
				throw new Error(
					'@sveltejs/adapter-netlify >=2.x (possibly installed through @sveltejs/adapter-auto) requires @sveltejs/kit version 1.5 or higher. ' +
						'Either downgrade the adapter or upgrade @sveltejs/kit'
				);
			}

			if (existsSync(`${builder.config.kit.files.assets}/_headers`)) {
				throw new Error(
					`The _headers file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory`
				);
			}

			if (existsSync(`${builder.config.kit.files.assets}/_redirects`)) {
				throw new Error(
					`The _redirects file should be placed in the project root rather than the ${builder.config.kit.files.assets} directory`
				);
			}

			const netlify_config = get_netlify_config();

			// "build" is the default publish directory when Netlify detects SvelteKit
			const publish = get_publish_directory(netlify_config, builder) || 'build';

			// empty out existing build directories
			builder.rimraf(publish);
			builder.rimraf('.netlify/edge-functions');
			builder.rimraf('.netlify/server');
			builder.rimraf('.netlify/package.json');
			builder.rimraf('.netlify/serverless.js');

			if (existsSync('.netlify/functions-internal')) {
				for (const file of readdirSync('.netlify/functions-internal')) {
					if (file.startsWith(FUNCTION_PREFIX)) {
						builder.rimraf(join('.netlify/functions-internal', file));
					}
				}
			}

			builder.log.minor(`Publishing to "${publish}"`);

			builder.log.minor('Copying assets...');
			const publish_dir = `${publish}${builder.config.kit.paths.base}`;
			builder.writeClient(publish_dir);
			builder.writePrerendered(publish_dir);

			builder.log.minor('Writing custom headers...');
			const headers_file = join(publish, '_headers');
			builder.copy('_headers', headers_file);
			appendFileSync(
				headers_file,
				`\n\n/${builder.getAppPath()}/immutable/*\n  cache-control: public\n  cache-control: immutable\n  cache-control: max-age=31536000\n`
			);

			if (edge) {
				if (split) {
					throw new Error('Cannot use `split: true` alongside `edge: true`');
				}

				await generate_edge_functions({ builder });
			} else {
				generate_lambda_functions({ builder, split, publish });
			}
		},

		supports: {
			read: () => true,
			instrumentation: () => true
		}
	};
}
/**
 * @param { object } params
 * @param {import('@sveltejs/kit').Builder} params.builder
 */
async function generate_edge_functions({ builder }) {
	const tmp = builder.getBuildDirectory('netlify-tmp');
	builder.rimraf(tmp);
	builder.mkdirp(tmp);

	builder.mkdirp('.netlify/edge-functions');

	builder.log.minor('Generating Edge Function...');
	const relativePath = posix.relative(tmp, builder.getServerDirectory());

	builder.copy(`${files}/edge.js`, `${tmp}/entry.js`, {
		replace: {
			'0SERVER': `${relativePath}/index.js`,
			MANIFEST: './manifest.js'
		}
	});

	const manifest = builder.generateManifest({
		relativePath
	});

	writeFileSync(`${tmp}/manifest.js`, `export const manifest = ${manifest};\n`);

	/** @type {{ assets: Set<string> }} */
	// we have to prepend the file:// protocol because Windows doesn't support absolute path imports
	const { assets } = (await import(`file://${tmp}/manifest.js`)).manifest;

	const path = '/*';
	// We only need to specify paths without the trailing slash because
	// Netlify will handle the optional trailing slash for us
	const excluded = [
		// Contains static files
		`/${builder.getAppPath()}/immutable/*`,
		`/${builder.getAppPath()}/version.json`,
		...builder.prerendered.paths,
		...Array.from(assets).flatMap((asset) => {
			if (asset.endsWith('/index.html')) {
				const dir = asset.replace(/\/index\.html$/, '');
				return [
					`${builder.config.kit.paths.base}/${asset}`,
					`${builder.config.kit.paths.base}/${dir}`
				];
			}
			return `${builder.config.kit.paths.base}/${asset}`;
		}),
		// Should not be served by SvelteKit at all
		'/.netlify/*'
	];

	/** @type {import('@netlify/edge-functions').Manifest} */
	const edge_manifest = {
		functions: [
			{
				function: 'render',
				path,
				excludedPath: /** @type {`/${string}`[]} */ (excluded)
			}
		],
		version: 1
	};

	/** @type {BuildOptions} */
	const esbuild_config = {
		bundle: true,
		format: 'esm',
		platform: 'browser',
		sourcemap: 'linked',
		target: 'es2020',
		loader: {
			'.wasm': 'copy',
			'.woff': 'copy',
			'.woff2': 'copy',
			'.ttf': 'copy',
			'.eot': 'copy',
			'.otf': 'copy'
		},
		// Node built-ins are allowed, but must be prefixed with `node:`
		// https://docs.netlify.com/edge-functions/api/#runtime-environment
		external: builtinModules.map((id) => `node:${id}`),
		alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`]))
	};
	await Promise.all([
		esbuild.build({
			entryPoints: [`${tmp}/entry.js`],
			outfile: '.netlify/edge-functions/render.js',
			...esbuild_config
		}),
		builder.hasServerInstrumentationFile() &&
			esbuild.build({
				entryPoints: [`${builder.getServerDirectory()}/instrumentation.server.js`],
				outfile: '.netlify/edge/instrumentation.server.js',
				...esbuild_config
			})
	]);

	if (builder.hasServerInstrumentationFile()) {
		builder.instrument({
			entrypoint: '.netlify/edge-functions/render.js',
			instrumentation: '.netlify/edge/instrumentation.server.js',
			start: '.netlify/edge/start.js'
		});
	}

	writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest));
}
/**
 * @param { object } params
 * @param {import('@sveltejs/kit').Builder} params.builder
 * @param { string } params.publish
 * @param { boolean } params.split
 */
function generate_lambda_functions({ builder, publish, split }) {
	builder.mkdirp('.netlify/functions-internal/.svelte-kit');

	builder.writeServer('.netlify/server');

	const replace = {
		'0SERVER': './server/index.js' // digit prefix prevents CJS build from using this as a variable name, which would also get replaced
	};

	builder.copy(files, '.netlify', { replace, filter: (name) => !name.endsWith('edge.js') });

	builder.log.minor('Generating serverless functions...');

	if (split) {
		const seen = new Set();

		for (let i = 0; i < builder.routes.length; i++) {
			const route = builder.routes[i];
			if (route.prerender === true) continue;

			const routes = [route];

			/** @type {string[]} */
			const parts = [];

			// The parts should conform to URLPattern syntax
			// https://docs.netlify.com/build/functions/get-started/?fn-language=ts&data-tab=TypeScript#route-requests
			for (const segment of route.segments) {
				if (segment.rest) {
					parts.push('*');
				} else if (segment.dynamic) {
					// URLPattern requires params to start with letters
					parts.push(`:param${parts.length}`);
				} else {
					parts.push(segment.content);
				}
			}

			// Netlify handles trailing slashes for us, so we don't need to include them in the pattern
			const pattern = `/${parts.join('/')}`;
			const name =
				FUNCTION_PREFIX + (parts.join('-').replace(/[:.]/g, '_').replace('*', '__rest') || 'index');

			// skip routes with identical patterns, they were already folded into another function
			if (seen.has(pattern)) continue;

			const patterns = [pattern, `${pattern === '/' ? '' : pattern}/__data.json`];
			patterns.forEach((p) => seen.add(p));

			// figure out which lower priority routes should be considered fallbacks
			for (let j = i + 1; j < builder.routes.length; j += 1) {
				const other = builder.routes[j];
				if (other.prerender === true) continue;

				if (matches(route.segments, other.segments)) {
					routes.push(other);
				}
			}

			generate_serverless_function({
				builder,
				routes,
				patterns,
				name
			});
		}

		generate_serverless_function({
			builder,
			routes: [],
			patterns: ['/*'],
			name: `${FUNCTION_PREFIX}catch-all`,
			exclude: Array.from(seen)
		});
	} else {
		generate_serverless_function({
			builder,
			routes: undefined,
			patterns: ['/*'],
			name: `${FUNCTION_PREFIX}render`
		});
	}

	// Copy user's custom _redirects file if it exists
	if (existsSync('_redirects')) {
		builder.log.minor('Copying user redirects...');
		const redirects_file = join(publish, '_redirects');
		builder.copy('_redirects', redirects_file);
	}
}

function get_netlify_config() {
	if (!existsSync('netlify.toml')) return null;

	try {
		return /** @type {NetlifyConfig} */ (toml.parse(readFileSync('netlify.toml', 'utf-8')));
	} catch (err) {
		if (err instanceof Error) {
			err.message = `Error parsing netlify.toml: ${err.message}`;
		}
		throw err;
	}
}

/**
 * @param {NetlifyConfig | null} netlify_config
 * @param {import('@sveltejs/kit').Builder} builder
 **/
function get_publish_directory(netlify_config, builder) {
	if (netlify_config) {
		if (!netlify_config.build?.publish) {
			builder.log.minor('No publish directory specified in netlify.toml, using default');
			return;
		}

		if (resolve(netlify_config.build.publish) === process.cwd()) {
			throw new Error(
				'The publish directory cannot be set to the site root. Please change it to another value such as "build" in netlify.toml.'
			);
		}
		return netlify_config.build.publish;
	}

	builder.log.warn(
		'No netlify.toml found. Using default publish directory. Consult https://svelte.dev/docs/kit/adapter-netlify#usage for more details'
	);
}

/**
 * @typedef {{ rest: boolean, dynamic: boolean, content: string }} RouteSegment
 */

/**
 * @param {RouteSegment[]} a
 * @param {RouteSegment[]} b
 * @returns {boolean}
 */
function matches(a, b) {
	if (a[0] && b[0]) {
		if (b[0].rest) {
			if (b.length === 1) return true;

			const next_b = b.slice(1);

			for (let i = 0; i < a.length; i += 1) {
				if (matches(a.slice(i), next_b)) return true;
			}

			return false;
		}

		if (!b[0].dynamic) {
			if (!a[0].dynamic && a[0].content !== b[0].content) return false;
		}

		if (a.length === 1 && b.length === 1) return true;
		return matches(a.slice(1), b.slice(1));
	} else if (a[0]) {
		return a.length === 1 && a[0].rest;
	} else {
		return b.length === 1 && b[0].rest;
	}
}

/**
 *
 * @param {{
 *   builder: import('@sveltejs/kit').Builder,
 *   routes: import('@sveltejs/kit').RouteDefinition[] | undefined,
 *   patterns: string[],
 *   name: string,
 *   exclude?: string[]
 * }} opts
 */
function generate_serverless_function({ builder, routes, patterns, name, exclude }) {
	const manifest = builder.generateManifest({
		relativePath: '../server',
		routes
	});

	const fn = generate_serverless_function_module(manifest);
	const config = generate_config_export(patterns, exclude);

	if (builder.hasServerInstrumentationFile()) {
		writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn);
		builder.instrument({
			entrypoint: `.netlify/functions-internal/${name}.mjs`,
			instrumentation: '.netlify/server/instrumentation.server.js',
			start: `.netlify/functions-start/${name}.start.mjs`,
			module: {
				generateText: generate_traced_module(config)
			}
		});
	} else {
		writeFileSync(`.netlify/functions-internal/${name}.mjs`, `${fn}\n${config}`);
	}
}

/**
 * @param {string} manifest
 * @returns {string}
 */
function generate_serverless_function_module(manifest) {
	return `\
import { init } from '../serverless.js';

export default init(${manifest});
`;
}

/**
 * @param {string[]} patterns
 * @param {string[]} [exclude]
 * @returns {string}
 */
function generate_config_export(patterns, exclude = []) {
	// TODO: add a human friendly name for the function https://docs.netlify.com/build/frameworks/frameworks-api/#configuration-options-2
	return `\
export const config = {
	path: [${patterns.map((s) => JSON.stringify(s)).join(', ')}],
	excludedPath: [${['/.netlify/*', ...exclude].map((s) => JSON.stringify(s)).join(', ')}],
	preferStatic: true
};
`;
}

/**
 * @param {string} config
 * @returns {(opts: { instrumentation: string; start: string }) => string}
 */
function generate_traced_module(config) {
	return ({ instrumentation, start }) => {
		return `\
import './${instrumentation}';
const { default: _0 } = await import('./${start}');
export { _0 as default };

${config}`;
	};
}
