import { fileURLToPath } from "node:url";
import type { AstroIntegration } from "astro";

/** Reusable error for invalid breakpoint values. */
class InvalidBreakpointValueError extends TypeError {
	constructor(name: string, value: string | number) {
		super(
			`Breakpoint "${name}" has an invalid value: "${value}". Expected a number or a float-parsable string value.`,
		);
	}
}

/**
 * Sort breakpoints by their numeric value.
 *
 * @param name1 Name of breakpoint 1.
 * @param value1 Value of breakpoint 1.
 * @param name2 Name of breakpoint 2.
 * @param value2 Value of breakpoint 2.
 * @returns Negative number if value1 < value2, 0 if equal or positive number
 *  if value1 > value2.
 */
function sortBreakpoints(
	[name1, value1]: [string, string | number],
	[name2, value2]: [string, string | number],
): number {
	// parseFloat() can handle strings with units like "1rem" or "2px".
	const numValue1 =
		typeof value1 === "number" ? value1 : Number.parseFloat(value1);
	if (Number.isNaN(numValue1)) {
		throw new InvalidBreakpointValueError(name1, value1);
	}

	const numValue2 =
		typeof value2 === "number" ? value2 : Number.parseFloat(value2);
	if (Number.isNaN(numValue2)) {
		throw new InvalidBreakpointValueError(name2, value2);
	}

	return numValue1 - numValue2;
}

/**
 * Generate a unique CSS ID for a given number.
 *
 * @param index The number to generate the CSS ID for.
 * @returns The generated CSS ID.
 */
function generateCSSId(index: number) {
	// Basically a base-36 number system calculation, but with the first
	// character not being a numeric, and also using underscores and dashes.

	// biome-ignore lint/style/noNonNullAssertion: Can't be null, 26 is the length of the string.
	let id = "useandompxbfghjklqvwyzrict"[index % 26]!;
	let i = Math.floor(index / 26);
	while (i > 0) {
		// biome-ignore lint/style/noNonNullAssertion: Can't be null, 38 is the length of the string.
		id += "useandom-2619834075px_bfghjklqvwyzrict"[i % 38]!;
		i = Math.floor(i / 38);
	}
	return id;
}

/**
 * Generate an SVG icon which shows the active Tailwind CSS breakpoint.
 *
 * @param breakpoints The breakpoints to generate the icon for.
 * @returns The generated SVG icon.
 */
function generateIcon(breakpoints: Record<string, string | number>) {
	const sortedBreakpointsArray = Object.entries(breakpoints);
	if (sortedBreakpointsArray.length === 0) {
		throw new TypeError(
			"No breakpoints defined. At least one breakpoint must be specified.",
		);
	}
	if (sortedBreakpointsArray.length === 1) {
		// If there is only 1 breakpoint, .sort() does not run
		// sortBreakpoints(). So, if there is an invalid value,
		// it will not be caught. That's why we check here.

		// biome-ignore lint/style/noNonNullAssertion: Can't be null, we checked the length above.
		const [name, value] = sortedBreakpointsArray[0]!;
		if (
			Number.isNaN(typeof value === "number" ? value : Number.parseFloat(value))
		) {
			throw new InvalidBreakpointValueError(name, value);
		}
	} else {
		sortedBreakpointsArray.sort(sortBreakpoints);

		const foundValues: (string | number)[] = [];
		for (const [name, value] of sortedBreakpointsArray) {
			if (foundValues.includes(value)) {
				throw new TypeError(
					`Duplicate breakpoint value detected: "${value}" is already assigned to another breakpoint. Check "${name}".`,
				);
			}
			foundValues.push(value);
		}
	}

	return (
		'<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" viewBox="-10 -10 20 20">' +
		"<style>" +
		"text{" +
		"-moz-osx-font-smoothing:grayscale;" +
		"-webkit-font-smoothing:antialiased;" +
		"-webkit-text-size-adjust:100%;" +
		"display:none;" +
		"dominant-baseline:middle;" +
		"font-family:var(--default-mono-font-family,var(--font-mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,&quot;Liberation Mono&quot;,&quot;Courier New&quot;,monospace));" +
		"font-feature-settings:var(--default-mono-font-feature-settings,var(--font-mono--font-feature-settings,normal));" +
		"font-variation-settings:var(--default-mono-font-variation-settings,var(--font-mono--font-variation-settings,normal));" +
		"text-anchor:middle" +
		"}" +
		"#_{display:inline}" +
		sortedBreakpointsArray
			.map(
				([, value], index) =>
					`@media (width>=${typeof value === "number" ? `${value}px` : value}){#${generateCSSId(index)}{display:inline}#${index === 0 ? "_" : generateCSSId(index - 1)}{display:none}}`,
			)
			.join("") +
		"</style>" +
		// biome-ignore lint/style/noNonNullAssertion: Can't be null, we checked the length above.
		`<text id="_" lengthAdjust="spacingAndGlyphs" textLength="20">&lt;${sortedBreakpointsArray[0]![0]}</text>` +
		sortedBreakpointsArray
			.map(
				([name], index) =>
					`<text id="${generateCSSId(index)}" lengthAdjust="spacingAndGlyphs" textLength="20">${name}</text>`,
			)
			.join("") +
		"</svg>"
	);
}

/**
 * Options for the `showTailwindCSSBreakpoint` Astro integration.
 */
export interface ShowTailwindCSSBreakpointOptions {
	/**
	 * Define the Tailwind CSS breakpoints to use.
	 * Key is the name of the breakpoint, value is the size of the breakpoint.
	 * If the value is a number, it will be converted to a string with
	 * the suffix "px".
	 * Make sure that all values are in the same unit (px, rem, em, etc.).
	 *
	 * By default, the breakpoints are:
	 * - `sm`: `"40rem"` (640px with a root font size of 16px)
	 * - `md`: `"48rem"` (768px with a root font size of 16px)
	 * - `lg`: `"64rem"` (1024px with a root font size of 16px)
	 * - `xl`: `"80rem"` (1280px with a root font size of 16px)
	 * - `2xl`: `"96rem"` (1536px with a root font size of 16px)
	 */
	breakpoints?: Record<string, string | number>;
}

/**
 * An Astro integration that adds a dev toolbar app to show the current
 * Tailwind CSS breakpoint.
 *
 * @param options The options for the integration.
 * @returns The integration.
 */
export default function showTailwindCSSBreakpoint(
	options: ShowTailwindCSSBreakpointOptions = {},
): AstroIntegration {
	// API Reference: https://docs.astro.build/en/reference/integrations-reference/
	return {
		name: "astro-show-tailwindcss-breakpoint",
		hooks: {
			"astro:config:setup": ({ addDevToolbarApp }) => {
				const {
					breakpoints = {
						// Default taken from https://tailwindcss.com/docs/responsive-design#overview
						sm: "40rem",
						md: "48rem",
						lg: "64rem",
						xl: "80rem",
						"2xl": "96rem",
					},
				} = options;

				addDevToolbarApp({
					id: "tailwindcss-breakpoint",
					name: "Tailwind CSS Breakpoint",
					icon: generateIcon(breakpoints),
					entrypoint: fileURLToPath(new URL("./app.js", import.meta.url)),
				});
			},
		},
	};
}
