import type { TextureUpdateMode } from './types.js';

/**
 * Options controlling bitmap decode behavior.
 */
export interface TextureDecodeOptions {
	/**
	 * Controls color-space conversion during decode.
	 */
	colorSpaceConversion?: 'default' | 'none';
	/**
	 * Controls alpha premultiplication during decode.
	 */
	premultiplyAlpha?: 'default' | 'none' | 'premultiply';
	/**
	 * Controls bitmap orientation during decode.
	 */
	imageOrientation?: 'none' | 'flipY';
}

/**
 * Options controlling URL-based texture loading and decode behavior.
 */
export interface TextureLoadOptions {
	/**
	 * Desired texture color space.
	 */
	colorSpace?: 'srgb' | 'linear';
	/**
	 * Fetch options forwarded to `fetch`.
	 */
	requestInit?: RequestInit;
	/**
	 * Decode options forwarded to `createImageBitmap`.
	 */
	decode?: TextureDecodeOptions;
	/**
	 * Optional cancellation signal for this request.
	 */
	signal?: AbortSignal;
	/**
	 * Optional runtime update strategy metadata attached to loaded textures.
	 */
	update?: TextureUpdateMode;
	/**
	 * Optional runtime flip-y metadata attached to loaded textures.
	 */
	flipY?: boolean;
	/**
	 * Optional runtime premultiplied-alpha metadata attached to loaded textures.
	 */
	premultipliedAlpha?: boolean;
	/**
	 * Optional runtime mipmap metadata attached to loaded textures.
	 */
	generateMipmaps?: boolean;
}

/**
 * Loaded texture payload returned by URL loaders.
 */
export interface LoadedTexture {
	/**
	 * Source URL.
	 */
	url: string;
	/**
	 * Decoded bitmap source.
	 */
	source: ImageBitmap;
	/**
	 * Bitmap width in pixels.
	 */
	width: number;
	/**
	 * Bitmap height in pixels.
	 */
	height: number;
	/**
	 * Effective color space.
	 */
	colorSpace: 'srgb' | 'linear';
	/**
	 * Effective runtime update strategy.
	 */
	update?: TextureUpdateMode;
	/**
	 * Effective runtime flip-y metadata.
	 */
	flipY?: boolean;
	/**
	 * Effective runtime premultiplied-alpha metadata.
	 */
	premultipliedAlpha?: boolean;
	/**
	 * Effective runtime mipmap metadata.
	 */
	generateMipmaps?: boolean;
	/**
	 * Releases bitmap resources.
	 */
	dispose: () => void;
}

interface NormalizedTextureLoadOptions {
	colorSpace: 'srgb' | 'linear';
	requestInit?: RequestInit;
	decode: Required<TextureDecodeOptions>;
	signal?: AbortSignal;
	update?: TextureUpdateMode;
	flipY?: boolean;
	premultipliedAlpha?: boolean;
	generateMipmaps?: boolean;
}

interface TextureResourceCacheEntry {
	key: string;
	refs: number;
	controller: AbortController;
	settled: boolean;
	blobPromise: Promise<Blob>;
}

const resourceCache = new Map<string, TextureResourceCacheEntry>();

function createAbortError(): Error {
	try {
		return new DOMException('Texture request was aborted', 'AbortError');
	} catch {
		const error = new Error('Texture request was aborted');
		(error as Error & { name: string }).name = 'AbortError';
		return error;
	}
}

/**
 * Checks whether error represents abort cancellation.
 */
export function isAbortError(error: unknown): boolean {
	return (
		error instanceof Error &&
		(error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))
	);
}

function toBodyFingerprint(body: BodyInit | null | undefined): string | null {
	if (body == null) {
		return null;
	}

	if (typeof body === 'string') {
		return `string:${body}`;
	}

	if (body instanceof URLSearchParams) {
		return `urlsearchparams:${body.toString()}`;
	}

	if (typeof FormData !== 'undefined' && body instanceof FormData) {
		const entries = Array.from(body.entries()).map(([key, value]) => `${key}:${String(value)}`);
		return `formdata:${entries.join('&')}`;
	}

	if (body instanceof Blob) {
		return `blob:${body.type}:${body.size}`;
	}

	if (body instanceof ArrayBuffer) {
		return `arraybuffer:${body.byteLength}`;
	}

	if (ArrayBuffer.isView(body)) {
		return `view:${body.byteLength}`;
	}

	return `opaque:${Object.prototype.toString.call(body)}`;
}

function normalizeRequestInit(requestInit: RequestInit | undefined): Record<string, unknown> {
	if (!requestInit) {
		return {};
	}

	const headers = new Headers(requestInit.headers);
	const headerEntries = Array.from(headers.entries()).sort(([a], [b]) => a.localeCompare(b));
	const normalized: Record<string, unknown> = {};

	normalized.method = (requestInit.method ?? 'GET').toUpperCase();
	normalized.mode = requestInit.mode ?? null;
	normalized.cache = requestInit.cache ?? null;
	normalized.credentials = requestInit.credentials ?? null;
	normalized.redirect = requestInit.redirect ?? null;
	normalized.referrer = requestInit.referrer ?? null;
	normalized.referrerPolicy = requestInit.referrerPolicy ?? null;
	normalized.integrity = requestInit.integrity ?? null;
	normalized.keepalive = requestInit.keepalive ?? false;
	normalized.priority = requestInit.priority ?? null;
	normalized.headers = headerEntries;
	normalized.body = toBodyFingerprint(requestInit.body);

	return normalized;
}

function normalizeTextureLoadOptions(options: TextureLoadOptions): NormalizedTextureLoadOptions {
	const colorSpace = options.colorSpace ?? 'srgb';

	const normalized: NormalizedTextureLoadOptions = {
		colorSpace,
		decode: {
			colorSpaceConversion:
				options.decode?.colorSpaceConversion ?? (colorSpace === 'linear' ? 'none' : 'default'),
			premultiplyAlpha: options.decode?.premultiplyAlpha ?? 'default',
			imageOrientation: options.decode?.imageOrientation ?? 'none'
		}
	};

	if (options.requestInit !== undefined) {
		normalized.requestInit = options.requestInit;
	}
	if (options.signal !== undefined) {
		normalized.signal = options.signal;
	}
	if (options.update !== undefined) {
		normalized.update = options.update;
	}
	if (options.flipY !== undefined) {
		normalized.flipY = options.flipY;
	}
	if (options.premultipliedAlpha !== undefined) {
		normalized.premultipliedAlpha = options.premultipliedAlpha;
	}
	if (options.generateMipmaps !== undefined) {
		normalized.generateMipmaps = options.generateMipmaps;
	}

	return normalized;
}

/**
 * Builds deterministic resource cache key from full URL IO config.
 */
export function buildTextureResourceCacheKey(
	url: string,
	options: TextureLoadOptions = {}
): string {
	const normalized = normalizeTextureLoadOptions(options);
	return JSON.stringify({
		url,
		colorSpace: normalized.colorSpace,
		requestInit: normalizeRequestInit(normalized.requestInit),
		decode: normalized.decode
	});
}

/**
 * Clears the internal texture resource cache.
 */
export function clearTextureBlobCache(): void {
	for (const entry of resourceCache.values()) {
		if (!entry.settled) {
			entry.controller.abort();
		}
	}
	resourceCache.clear();
}

function acquireTextureBlob(
	url: string,
	options: TextureLoadOptions
): {
	entry: TextureResourceCacheEntry;
	release: () => void;
} {
	const key = buildTextureResourceCacheKey(url, options);
	const existing = resourceCache.get(key);
	if (existing) {
		existing.refs += 1;
		let released = false;
		return {
			entry: existing,
			release: () => {
				if (released) {
					return;
				}
				released = true;
				existing.refs = Math.max(0, existing.refs - 1);
				if (existing.refs === 0) {
					if (!existing.settled) {
						existing.controller.abort();
					}
					resourceCache.delete(key);
				}
			}
		};
	}

	const normalized = normalizeTextureLoadOptions(options);
	const controller = new AbortController();
	const requestInit = {
		...(normalized.requestInit ?? {}),
		signal: controller.signal
	} satisfies RequestInit;
	const entry: TextureResourceCacheEntry = {
		key,
		refs: 1,
		controller,
		settled: false,
		blobPromise: fetch(url, requestInit)
			.then(async (response) => {
				if (!response.ok) {
					throw new Error(`Texture request failed (${response.status}) for ${url}`);
				}
				return response.blob();
			})
			.then((blob) => {
				entry.settled = true;
				return blob;
			})
			.catch((error) => {
				resourceCache.delete(key);
				throw error;
			})
	};

	resourceCache.set(key, entry);
	let released = false;
	return {
		entry,
		release: () => {
			if (released) {
				return;
			}
			released = true;
			entry.refs = Math.max(0, entry.refs - 1);
			if (entry.refs === 0) {
				if (!entry.settled) {
					entry.controller.abort();
				}
				resourceCache.delete(key);
			}
		}
	};
}

async function awaitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
	if (!signal) {
		return promise;
	}

	if (signal.aborted) {
		throw createAbortError();
	}

	return new Promise<T>((resolve, reject) => {
		const onAbort = (): void => {
			reject(createAbortError());
		};

		signal.addEventListener('abort', onAbort, { once: true });

		promise.then(resolve, reject).finally(() => {
			signal.removeEventListener('abort', onAbort);
		});
	});
}

/**
 * Loads a single texture from URL and converts it to an `ImageBitmap`.
 *
 * @param url - Texture URL.
 * @param options - Loading options.
 * @returns Loaded texture object.
 * @throws {Error} When runtime does not support `createImageBitmap` or request fails.
 */
export async function loadTextureFromUrl(
	url: string,
	options: TextureLoadOptions = {}
): Promise<LoadedTexture> {
	if (typeof createImageBitmap !== 'function') {
		throw new Error('createImageBitmap is not available in this runtime');
	}

	const normalized = normalizeTextureLoadOptions(options);
	const { entry, release } = acquireTextureBlob(url, options);
	let bitmap: ImageBitmap | null = null;

	try {
		const blob = await awaitWithAbort(entry.blobPromise, normalized.signal);

		const bitmapOptions: ImageBitmapOptions = {
			colorSpaceConversion: normalized.decode.colorSpaceConversion,
			premultiplyAlpha: normalized.decode.premultiplyAlpha,
			imageOrientation: normalized.decode.imageOrientation
		};
		const allDefaults =
			bitmapOptions.colorSpaceConversion === 'default' &&
			bitmapOptions.premultiplyAlpha === 'default' &&
			bitmapOptions.imageOrientation === 'none';

		bitmap = allDefaults
			? await createImageBitmap(blob)
			: await createImageBitmap(blob, bitmapOptions);

		if (normalized.signal?.aborted) {
			bitmap.close();
			throw createAbortError();
		}

		let disposed = false;
		const loaded: LoadedTexture = {
			url,
			source: bitmap,
			width: bitmap.width,
			height: bitmap.height,
			colorSpace: normalized.colorSpace,
			dispose: () => {
				if (disposed) {
					return;
				}
				disposed = true;
				bitmap?.close();
				bitmap = null;
			}
		};

		if (normalized.update !== undefined) {
			loaded.update = normalized.update;
		}
		if (normalized.flipY !== undefined) {
			loaded.flipY = normalized.flipY;
		}
		if (normalized.premultipliedAlpha !== undefined) {
			loaded.premultipliedAlpha = normalized.premultipliedAlpha;
		}
		if (normalized.generateMipmaps !== undefined) {
			loaded.generateMipmaps = normalized.generateMipmaps;
		}

		return loaded;
	} catch (error) {
		if (bitmap) {
			bitmap.close();
		}
		throw error;
	} finally {
		release();
	}
}

/**
 * Loads many textures in parallel from URLs.
 *
 * @param urls - Texture URLs.
 * @param options - Shared loading options.
 * @returns Promise resolving to loaded textures in input order.
 */
export async function loadTexturesFromUrls(
	urls: string[],
	options: TextureLoadOptions = {}
): Promise<LoadedTexture[]> {
	const settled = await Promise.allSettled(urls.map((url) => loadTextureFromUrl(url, options)));
	const loaded: LoadedTexture[] = [];
	let firstError: unknown = null;

	for (const entry of settled) {
		if (entry.status === 'fulfilled') {
			loaded.push(entry.value);
			continue;
		}

		firstError ??= entry.reason;
	}

	if (firstError) {
		for (const texture of loaded) {
			texture.dispose();
		}
		throw firstError;
	}

	return loaded;
}
