/** @import { AST } from 'svelte/compiler' */
import { existsSync } from 'node:fs';
import path from 'node:path';
import MagicString from 'magic-string';
import sharp from 'sharp';
import { parse } from 'svelte-parse-markup';
import { walk } from 'zimmerframe';

// TODO: expose this in vite-imagetools rather than duplicating it
const OPTIMIZABLE = /^[^?]+\.(avif|heif|gif|jpeg|jpg|png|tiff|webp)(\?.*)?$/;

/**
 * Creates the Svelte image plugin.
 * @param {import('vite').Plugin<void>} imagetools_plugin
 * @returns {import('vite').Plugin<void>}
 */
export function image_plugin(imagetools_plugin) {
	/** @type {import('vite').ResolvedConfig} */
	let vite_config;

	const name = 'vite-plugin-enhanced-img-markup';

	/** @type {import('vite').Plugin<void>} */
	const plugin = {
		name,
		configResolved(config) {
			vite_config = config;
			const svelteConfigPlugin = config.plugins.find((p) => p.name === 'vite-plugin-svelte:config');
			if (!svelteConfigPlugin) {
				throw new Error(
					'@sveltejs/enhanced-img requires @sveltejs/vite-plugin-svelte 6 or higher to be installed'
				);
			}
			const api = svelteConfigPlugin.api;
			const id_filter = (api.filter ?? api.idFilter).id; // TODO: idFilter was used by earlier versions of vite-plugin-svelte@6, remove when @7 is required
			// @ts-expect-error plugin.transform is defined below before configResolved is called
			plugin.transform.filter.id = {
				include: id_filter.include,
				// Exclude modules with query parameters (e.g. ?raw, ?url) — these are not
				// Svelte components to compile, so parsing them as markup would fail.
				exclude: [...id_filter.exclude, /\?/]
			};
		},
		transform: {
			order: 'pre', // puts it before vite-plugin-svelte:compile
			filter: {
				code: /<enhanced:img/ // code filter must match in addition to the id filter set in configResolved hook above
			},

			async handler(content, filename) {
				const plugin_context = this;
				const s = new MagicString(content);
				const ast = parse(content, { filename, modern: true });

				/**
				 * Import path to import name
				 * e.g. ./foo.png => __IMPORTED_ASSET_0__
				 * @type {Map<string, string>}
				 */
				const imports = new Map();

				/**
				 * @param {import('svelte/compiler').AST.RegularElement} node
				 * @param {AST.Text | AST.ExpressionTag} src_attribute
				 * @returns {Promise<void>}
				 */
				async function update_element(node, src_attribute) {
					if (src_attribute.type === 'ExpressionTag') {
						const start =
							'end' in src_attribute.expression
								? src_attribute.expression.end
								: src_attribute.expression.range?.[0];
						const end =
							'start' in src_attribute.expression
								? src_attribute.expression.start
								: src_attribute.expression.range?.[1];

						if (typeof start !== 'number' || typeof end !== 'number') {
							throw new Error('ExpressionTag has no range');
						}
						const src_var_name = content.substring(start, end).trim();

						s.update(node.start, node.end, dynamic_img_to_picture(content, node, src_var_name));
						return;
					}

					const original_url = src_attribute.raw.trim();
					let url = original_url;

					if (OPTIMIZABLE.test(url)) {
						const sizes = get_attr_value(node, 'sizes');
						const width = get_attr_value(node, 'width');
						url += url.includes('?') ? '&' : '?';
						if (sizes && 'raw' in sizes) {
							url += 'imgSizes=' + encodeURIComponent(sizes.raw) + '&';
						}
						if (width && 'raw' in width) {
							url += 'imgWidth=' + encodeURIComponent(width.raw) + '&';
						}
						url += 'enhanced';
					}

					// resolves the import so that we can build the entire picture template string and don't
					// need any logic blocks
					const resolved_id = (await plugin_context.resolve(url, filename))?.id;
					if (!resolved_id) {
						const query_index = url.indexOf('?');
						const file_path = query_index >= 0 ? url.substring(0, query_index) : url;
						if (existsSync(path.resolve(vite_config.publicDir, file_path))) {
							throw new Error(
								`Could not locate ${file_path}. Please move it to be located relative to the page in the routes directory or reference it beginning with /static/. See https://vitejs.dev/guide/assets for more details on referencing assets.`
							);
						}
						throw new Error(
							`Could not locate ${file_path}. See https://vitejs.dev/guide/assets for more details on referencing assets.`
						);
					}

					if (OPTIMIZABLE.test(url)) {
						const image = await process_id(resolved_id, plugin_context, imagetools_plugin);
						s.update(node.start, node.end, img_to_picture(content, node, image));
					} else {
						const metadata = await sharp(resolved_id).metadata();
						// this must come after the await so that we don't hand off processing between getting
						// the imports.size and incrementing the imports.size
						const name = imports.get(original_url) || '__IMPORTED_ASSET_' + imports.size + '__';
						if (!metadata.width || !metadata.height) {
							console.warn(`Could not determine intrinsic dimensions for ${resolved_id}`);
						}
						const new_markup = `<img ${serialize_img_attributes(content, node.attributes, {
							src: `{${name}}`,
							width: metadata.width,
							height: metadata.height
						})} />`;
						s.update(node.start, node.end, new_markup);
						imports.set(original_url, name);
					}
				}

				/**
				 * @type {Array<ReturnType<typeof update_element>>}
				 */
				const pending_ast_updates = [];

				walk(/** @type {import('svelte/compiler').AST.TemplateNode} */ (ast), null, {
					RegularElement(node, { next }) {
						if ('name' in node && node.name === 'enhanced:img') {
							// Compare node tag match
							const src = get_attr_value(node, 'src');

							if (!src || typeof src === 'boolean') return;

							pending_ast_updates.push(update_element(node, src));

							return;
						}

						next();
					}
				});

				await Promise.all(pending_ast_updates);

				// add imports
				if (imports.size) {
					let text = '';
					for (const [path, import_name] of imports.entries()) {
						text += `\timport ${import_name} from "${path}";\n`;
					}

					if (ast.instance) {
						// @ts-ignore
						s.appendLeft(ast.instance.content.start, text);
					} else {
						s.prepend(`<script>${text}</script>\n`);
					}
				}

				if (ast.css) {
					const css = content.substring(ast.css.start, ast.css.end);
					const modified = css.replaceAll('enhanced\\:img', 'img');
					if (modified !== css) {
						s.update(ast.css.start, ast.css.end, modified);
					}
				}

				return {
					code: s.toString(),
					map: s.generateMap({ hires: 'boundary' })
				};
			}
		}
	};
	return plugin;
}

/**
 * @param {string} resolved_id
 * @param {import('vite').Rollup.PluginContext} plugin_context
 * @param {import('vite').Plugin} imagetools_plugin
 * @returns {Promise<import('vite-imagetools').Picture>}
 */
async function process_id(resolved_id, plugin_context, imagetools_plugin) {
	if (!imagetools_plugin.load) {
		throw new Error('Invalid instance of vite-imagetools. Could not find load method.');
	}
	const hook = imagetools_plugin.load;
	const handler = typeof hook === 'object' ? hook.handler : hook;
	const module_info = await handler.call(plugin_context, resolved_id);
	if (!module_info) {
		throw new Error(`Could not load ${resolved_id}`);
	}
	const code = typeof module_info === 'string' ? module_info : module_info.code;
	return parse_object(code.replace('export default', '').replace(/;$/, '').trim());
}

/**
 * @param {string} str
 */
export function parse_object(str) {
	const updated = str
		.replaceAll(/{(\n\s*)?/gm, '{"')
		.replaceAll(':', '":')
		.replaceAll(/,(\n\s*)?([^ ])/g, ',"$2');
	try {
		return JSON.parse(updated);
	} catch {
		throw new Error(`Failed parsing string to object: ${str}`);
	}
}

/**
 * @param {import('../types/internal.js').TemplateNode} node
 * @param {string} attr
 * @returns {AST.Text | AST.ExpressionTag | undefined}
 */
function get_attr_value(node, attr) {
	if (!('type' in node) || !('attributes' in node)) return;
	const attribute = node.attributes.find(
		/** @param {any} v */ (v) => v.type === 'Attribute' && v.name === attr
	);

	if (!attribute || !('value' in attribute) || typeof attribute.value === 'boolean') return;

	// Check if value is an array and has at least one element
	if (Array.isArray(attribute.value)) {
		if (attribute.value.length > 0) return attribute.value[0];
		return;
	}

	// If it's not an array or is empty, return the value as is
	return attribute.value;
}

/**
 * @param {string} content
 * @param {import('../types/internal.js').Attribute[]} attributes
 * @param {{
 *   src: string,
 *   width?: string | number,
 *   height?: string | number
 * }} details
 */
function serialize_img_attributes(content, attributes, details) {
	const attribute_strings = attributes.map((attribute) => {
		if ('name' in attribute && attribute.name === 'src') {
			return `src=${details.src}`;
		}
		return content.substring(attribute.start, attribute.end);
	});

	/** @type {number | undefined} */
	let user_width;
	/** @type {number | undefined} */
	let user_height;
	for (const attribute of attributes) {
		if ('name' in attribute && 'value' in attribute) {
			const value = Array.isArray(attribute.value) ? attribute.value[0] : attribute.value;
			if (typeof value === 'object' && 'raw' in value) {
				if (attribute.name === 'width') user_width = parseInt(value.raw);
				if (attribute.name === 'height') user_height = parseInt(value.raw);
			}
		}
	}
	if (details.width && details.height) {
		if (!user_width && !user_height) {
			attribute_strings.push(`width=${details.width}`);
			attribute_strings.push(`height=${details.height}`);
		} else if (!user_width && user_height) {
			attribute_strings.push(
				`width=${Math.round(
					(stringToNumber(details.width) * user_height) / stringToNumber(details.height)
				)}`
			);
		} else if (!user_height && user_width) {
			attribute_strings.push(
				`height=${Math.round(
					(stringToNumber(details.height) * user_width) / stringToNumber(details.width)
				)}`
			);
		}
	}

	return attribute_strings.join(' ');
}

/**
 * @param {string|number} param
 */
function stringToNumber(param) {
	return typeof param === 'string' ? parseInt(param) : param;
}

/**
 * @param {string} content
 * @param {import('svelte/compiler').AST.RegularElement} node
 * @param {import('vite-imagetools').Picture} image
 */
function img_to_picture(content, node, image) {
	/** @type {import('../types/internal.js').Attribute[]} */
	const attributes = node.attributes;
	const index = attributes.findIndex(
		(attribute) => 'name' in attribute && attribute.name === 'sizes'
	);
	let sizes_string = '';
	if (index >= 0) {
		sizes_string = ' ' + content.substring(attributes[index].start, attributes[index].end);
		attributes.splice(index, 1);
	}

	let res = '<picture>';

	for (const [format, srcset] of Object.entries(image.sources)) {
		res += `<source srcset=${to_value(srcset)}${sizes_string} type="image/${format}" />`;
	}

	res += `<img ${serialize_img_attributes(content, attributes, {
		src: to_value(image.img.src),
		width: image.img.w,
		height: image.img.h
	})} />`;

	return res + '</picture>';
}

/**
 * @param {string} src
 */
function to_value(src) {
	// __VITE_ASSET__ needs to be contained in double quotes to work with Vite asset plugin
	return src.startsWith('__VITE_ASSET__') ? `{"${src}"}` : `"${src}"`;
}

/**
 * For images like `<img src={manually_imported} />`
 * @param {string} content
 * @param {import('svelte/compiler').AST.RegularElement} node
 * @param {string} src_var_name
 */
function dynamic_img_to_picture(content, node, src_var_name) {
	const attributes = node.attributes;
	/**
	 * @param attribute_name {string}
	 */
	function index(attribute_name) {
		return attributes.findIndex(
			(attribute) => 'name' in attribute && attribute.name === attribute_name
		);
	}
	const size_index = index('sizes');
	const width_index = index('width');
	const height_index = index('height');
	let sizes_string = '';
	if (size_index >= 0) {
		sizes_string =
			' ' + content.substring(attributes[size_index].start, attributes[size_index].end);
		attributes.splice(size_index, 1);
	}

	return `{#if typeof ${src_var_name} === 'string'}
	{#if import.meta.env.DEV && ${!width_index && !height_index}}
		{${src_var_name}} was not enhanced. Cannot determine dimensions.
	{:else}
		<img ${serialize_img_attributes(content, attributes, {
			src: `{${src_var_name}}`
		})} />
	{/if}
{:else}
	<picture>
		{#each Object.entries(${src_var_name}.sources) as [format, srcset]}
			<source {srcset}${sizes_string} type={'image/' + format} />
		{/each}
		<img ${serialize_img_attributes(content, attributes, {
			src: `{${src_var_name}.img.src}`,
			width: `{${src_var_name}.img.w}`,
			height: `{${src_var_name}.img.h}`
		})} />
	</picture>
{/if}`;
}
