import type { CurrentReadable, CurrentWritable } from './current-value.js';
import { resolveMaterial, type FragMaterial, type ResolvedMaterial } from './material.js';
import {
	toMotionGPUErrorReport,
	type MotionGPUErrorPhase,
	type MotionGPUErrorReport
} from './error-report.js';
import { createRenderer } from './renderer.js';
import { buildRendererPipelineSignature } from './recompile-policy.js';
import { assertUniformValueForType } from './uniforms.js';
import type { FrameRegistry } from './frame-registry.js';
import type {
	AnyPass,
	ColorPipelineOptions,
	FrameInvalidationToken,
	PendingStorageWrite,
	Renderer,
	RenderTargetDefinitionMap,
	StorageBufferDefinitionMap,
	TextureMap,
	TextureValue,
	UniformType,
	UniformValue
} from './types.js';

export interface MotionGPURuntimeLoopOptions {
	canvas: HTMLCanvasElement;
	registry: FrameRegistry;
	size: CurrentWritable<{ width: number; height: number }>;
	dpr: CurrentReadable<number>;
	maxDelta: CurrentReadable<number>;
	getMaterial: () => FragMaterial;
	getRenderTargets: () => RenderTargetDefinitionMap;
	getPasses: () => AnyPass[];
	getClearColor: () => [number, number, number, number];
	getColor?: () => ColorPipelineOptions | undefined;
	getAdapterOptions: () => GPURequestAdapterOptions | undefined;
	getDeviceDescriptor: () => GPUDeviceDescriptor | undefined;
	getOnError: () => ((report: MotionGPUErrorReport) => void) | undefined;
	reportError: (report: MotionGPUErrorReport | null) => void;
	getErrorHistoryLimit?: () => number | undefined;
	getOnErrorHistory?: () => ((history: MotionGPUErrorReport[]) => void) | undefined;
	reportErrorHistory?: (history: MotionGPUErrorReport[]) => void;
}

export interface MotionGPURuntimeLoop {
	requestFrame: () => void;
	invalidate: (token?: FrameInvalidationToken) => void;
	advance: () => void;
	destroy: () => void;
}

function getRendererRetryDelayMs(attempt: number): number {
	return Math.min(8000, 250 * 2 ** Math.max(0, attempt - 1));
}

const ERROR_CLEAR_GRACE_MS = 750;

export function createMotionGPURuntimeLoop(
	options: MotionGPURuntimeLoopOptions
): MotionGPURuntimeLoop {
	const { canvas: canvasElement, registry, size } = options;
	let frameId: number | null = null;
	let renderer: Renderer | null = null;
	let isDisposed = false;

	// Observed CSS dimensions provided by ResizeObserver.
	// -1 means no observation has been received yet — the render loop falls
	// back to getBoundingClientRect() until the first callback fires.
	let observedCssWidth = -1;
	let observedCssHeight = -1;

	let resizeObserver: ResizeObserver | null = null;
	try {
		// Wrapped in try/catch so a ReferenceError in environments without
		// ResizeObserver (bare Node.js) is handled gracefully. Tests can stub
		// this via vi.stubGlobal('ResizeObserver', mock).
		resizeObserver = new ResizeObserver((entries) => {
			const entry = entries[entries.length - 1];
			if (!entry) {
				return;
			}

			const boxSize = entry.contentBoxSize?.[0];
			if (boxSize) {
				observedCssWidth = Math.max(0, Math.floor(boxSize.inlineSize));
				observedCssHeight = Math.max(0, Math.floor(boxSize.blockSize));
			} else {
				// Fallback for browsers without contentBoxSize support.
				observedCssWidth = Math.max(0, Math.floor(entry.contentRect.width));
				observedCssHeight = Math.max(0, Math.floor(entry.contentRect.height));
			}

			if (!isDisposed) {
				scheduleFrame();
			}
		});
		resizeObserver.observe(canvasElement);
	} catch {
		// ResizeObserver may not support the canvas element in certain environments.
		resizeObserver = null;
	}
	let previousTime = performance.now() / 1000;
	let activeRendererSignature = '';
	let failedRendererSignature: string | null = null;
	let failedRendererAttempts = 0;
	let nextRendererRetryAt = 0;
	let rendererRebuildPromise: Promise<void> | null = null;

	const runtimeUniforms: Record<string, UniformValue> = {};
	const runtimeTextures: TextureMap = {};
	let activeUniforms: Record<string, UniformValue> = {};
	let activeTextures: Record<string, { source?: TextureValue }> = {};
	let uniformKeys: string[] = [];
	let uniformKeySet = new Set<string>();
	let uniformTypes = new Map<string, UniformType>();
	let textureKeys: string[] = [];
	let textureKeySet = new Set<string>();
	let activeMaterialSignature = '';
	let currentCssWidth = -1;
	let currentCssHeight = -1;
	const renderUniforms: Record<string, UniformValue> = {};
	const renderTextures: TextureMap = {};
	const canvasSize = { width: 0, height: 0 };
	let storageBufferKeys: string[] = [];
	let storageBufferKeySet = new Set<string>();
	let storageBufferDefinitions: StorageBufferDefinitionMap = {};
	const pendingStorageWrites: PendingStorageWrite[] = [];
	let shouldContinueAfterFrame = false;
	let activeErrorKey: string | null = null;
	let errorHistory: MotionGPUErrorReport[] = [];
	let errorClearReadyAtMs = 0;
	let lastFrameTimestampMs = performance.now();

	const resolveNowMs = (nowMs?: number): number => {
		if (typeof nowMs === 'number' && Number.isFinite(nowMs)) {
			return nowMs;
		}

		return lastFrameTimestampMs;
	};

	const getHistoryLimit = (): number => {
		const value = options.getErrorHistoryLimit?.() ?? 0;
		if (!Number.isFinite(value) || value <= 0) {
			return 0;
		}

		return Math.floor(value);
	};

	const publishErrorHistory = (): void => {
		options.reportErrorHistory?.(errorHistory);
		const onErrorHistory = options.getOnErrorHistory?.();
		if (!onErrorHistory) {
			return;
		}

		try {
			onErrorHistory(errorHistory);
		} catch {
			// User-provided error history handlers must not break runtime error recovery.
		}
	};

	const syncErrorHistory = (): void => {
		const limit = getHistoryLimit();
		if (limit <= 0) {
			if (errorHistory.length === 0) {
				return;
			}
			errorHistory = [];
			publishErrorHistory();
			return;
		}

		if (errorHistory.length <= limit) {
			return;
		}

		errorHistory.splice(0, errorHistory.length - limit);
		publishErrorHistory();
	};

	const setError = (error: unknown, phase: MotionGPUErrorPhase, nowMs?: number): void => {
		const report = toMotionGPUErrorReport(error, phase);
		errorClearReadyAtMs = resolveNowMs(nowMs) + ERROR_CLEAR_GRACE_MS;
		const reportKey = JSON.stringify({
			phase: report.phase,
			title: report.title,
			message: report.message,
			rawMessage: report.rawMessage
		});
		if (activeErrorKey === reportKey) {
			return;
		}
		activeErrorKey = reportKey;
		const historyLimit = getHistoryLimit();
		if (historyLimit > 0) {
			errorHistory.push(report);
			if (errorHistory.length > historyLimit) {
				errorHistory.splice(0, errorHistory.length - historyLimit);
			}
			publishErrorHistory();
		}
		options.reportError(report);
		const onError = options.getOnError();
		if (!onError) {
			return;
		}

		try {
			onError(report);
		} catch {
			// User-provided error handlers must not break runtime error recovery.
		}
	};

	const maybeClearError = (nowMs?: number): void => {
		if (activeErrorKey === null) {
			return;
		}
		if (resolveNowMs(nowMs) < errorClearReadyAtMs) {
			return;
		}

		activeErrorKey = null;
		errorClearReadyAtMs = 0;
		options.reportError(null);
	};

	const shouldRecreateRendererAfterError = (error: unknown): boolean => {
		return toMotionGPUErrorReport(error, 'render').code === 'WEBGPU_DEVICE_LOST';
	};

	const scheduleFrame = (): void => {
		if (isDisposed || frameId !== null) {
			return;
		}

		frameId = requestAnimationFrame(renderFrame);
	};

	const requestFrame = (): void => {
		scheduleFrame();
	};

	const invalidate = (token?: FrameInvalidationToken): void => {
		registry.invalidate(token);
		requestFrame();
	};

	const advance = (): void => {
		registry.advance();
		requestFrame();
	};

	const resetRuntimeMaps = (): void => {
		for (const key of Object.keys(runtimeUniforms)) {
			if (!uniformKeySet.has(key)) {
				delete runtimeUniforms[key];
			}
		}

		for (const key of Object.keys(runtimeTextures)) {
			if (!textureKeySet.has(key)) {
				delete runtimeTextures[key];
			}
		}
	};

	const resetRenderPayloadMaps = (): void => {
		for (const key of Object.keys(renderUniforms)) {
			if (!uniformKeySet.has(key)) {
				delete renderUniforms[key];
			}
		}

		for (const key of Object.keys(renderTextures)) {
			if (!textureKeySet.has(key)) {
				delete renderTextures[key];
			}
		}
	};

	const syncMaterialRuntimeState = (materialState: ResolvedMaterial): void => {
		const signatureChanged = activeMaterialSignature !== materialState.signature;
		const defaultsChanged =
			activeUniforms !== materialState.uniforms || activeTextures !== materialState.textures;

		if (!signatureChanged && !defaultsChanged) {
			return;
		}

		activeUniforms = materialState.uniforms;
		activeTextures = materialState.textures;
		if (!signatureChanged) {
			return;
		}

		// Build uniformKeys and uniformTypes in one pass to avoid iterating entries twice.
		const layoutEntries = materialState.uniformLayout.entries;
		const nextUniformKeys: string[] = [];
		const nextUniformTypes = new Map<string, UniformType>();
		for (const entry of layoutEntries) {
			nextUniformKeys.push(entry.name);
			nextUniformTypes.set(entry.name, entry.type);
		}
		uniformKeys = nextUniformKeys;
		uniformTypes = nextUniformTypes;
		textureKeys = materialState.textureKeys;
		uniformKeySet = new Set(uniformKeys);
		textureKeySet = new Set(textureKeys);
		storageBufferKeys = materialState.storageBufferKeys;
		storageBufferKeySet = new Set(storageBufferKeys);
		storageBufferDefinitions = (options.getMaterial().storageBuffers ??
			{}) as StorageBufferDefinitionMap;
		resetRuntimeMaps();
		resetRenderPayloadMaps();
		activeMaterialSignature = materialState.signature;
	};

	const resolveActiveMaterial = (): ResolvedMaterial => {
		return resolveMaterial(options.getMaterial());
	};

	const setUniform = (name: string, value: UniformValue): void => {
		if (!uniformKeySet.has(name)) {
			throw new Error(`Unknown uniform "${name}". Declare it in material.uniforms first.`);
		}
		const expectedType = uniformTypes.get(name);
		if (!expectedType) {
			throw new Error(`Unknown uniform type for "${name}"`);
		}
		assertUniformValueForType(expectedType, value);
		runtimeUniforms[name] = value;
	};

	const setTexture = (name: string, value: TextureValue): void => {
		if (!textureKeySet.has(name)) {
			throw new Error(`Unknown texture "${name}". Declare it in material.textures first.`);
		}
		runtimeTextures[name] = value;
	};

	const writeStorageBuffer = (
		name: string,
		data: ArrayBufferView,
		writeOptions?: { offset?: number }
	): void => {
		if (!storageBufferKeySet.has(name)) {
			throw new Error(
				`Unknown storage buffer "${name}". Declare it in material.storageBuffers first.`
			);
		}
		const definition = storageBufferDefinitions[name];
		if (!definition) {
			throw new Error(`Missing definition for storage buffer "${name}".`);
		}
		const offset = writeOptions?.offset ?? 0;
		if (offset < 0 || offset + data.byteLength > definition.size) {
			throw new Error(
				`Storage buffer "${name}" write out of bounds: offset=${offset}, dataSize=${data.byteLength}, bufferSize=${definition.size}.`
			);
		}
		pendingStorageWrites.push({ name, data, offset });
	};

	const readStorageBuffer = (name: string): Promise<ArrayBuffer> => {
		if (!storageBufferKeySet.has(name)) {
			throw new Error(
				`Unknown storage buffer "${name}". Declare it in material.storageBuffers first.`
			);
		}
		if (!renderer) {
			return Promise.reject(
				new Error(`Cannot read storage buffer "${name}": renderer not initialized.`)
			);
		}
		const gpuBuffer = renderer.getStorageBuffer?.(name);
		if (!gpuBuffer) {
			return Promise.reject(new Error(`Storage buffer "${name}" not allocated on GPU.`));
		}
		const device = renderer.getDevice?.();
		if (!device) {
			return Promise.reject(new Error('Cannot read storage buffer: GPU device unavailable.'));
		}
		const definition = storageBufferDefinitions[name];
		if (!definition) {
			return Promise.reject(new Error(`Missing definition for storage buffer "${name}".`));
		}
		const stagingBuffer = device.createBuffer({
			size: definition.size,
			usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
		});
		const commandEncoder = device.createCommandEncoder();
		commandEncoder.copyBufferToBuffer(gpuBuffer, 0, stagingBuffer, 0, definition.size);
		device.queue.submit([commandEncoder.finish()]);
		return stagingBuffer.mapAsync(GPUMapMode.READ).then(
			() => {
				try {
					return stagingBuffer.getMappedRange().slice(0);
				} finally {
					stagingBuffer.unmap();
					stagingBuffer.destroy();
				}
			},
			(error) => {
				stagingBuffer.destroy();
				throw error;
			}
		);
	};

	const renderFrame = (timestamp: number): void => {
		frameId = null;
		if (isDisposed) {
			return;
		}
		lastFrameTimestampMs = timestamp;
		syncErrorHistory();

		let materialState: ResolvedMaterial;
		try {
			materialState = resolveActiveMaterial();
		} catch (error) {
			setError(error, 'initialization', timestamp);
			scheduleFrame();
			return;
		}

		shouldContinueAfterFrame = false;

		const color = options.getColor?.();
		const rendererSignature = buildRendererPipelineSignature({
			materialSignature: materialState.signature,
			...(color !== undefined ? { color } : {})
		});
		syncMaterialRuntimeState(materialState);

		if (failedRendererSignature && failedRendererSignature !== rendererSignature) {
			failedRendererSignature = null;
			failedRendererAttempts = 0;
			nextRendererRetryAt = 0;
		}

		if (!renderer || activeRendererSignature !== rendererSignature) {
			if (
				failedRendererSignature === rendererSignature &&
				performance.now() < nextRendererRetryAt
			) {
				scheduleFrame();
				return;
			}

			if (!rendererRebuildPromise) {
				rendererRebuildPromise = (async () => {
					try {
						const nextRenderer = await createRenderer({
							canvas: canvasElement,
							fragmentWgsl: materialState.fragmentWgsl,
							fragmentLineMap: materialState.fragmentLineMap,
							fragmentSource: materialState.fragmentSource,
							includeSources: materialState.includeSources,
							defineBlockSource: materialState.defineBlockSource,
							materialSource: materialState.source,
							materialSignature: materialState.signature,
							uniformLayout: materialState.uniformLayout,
							textureKeys: materialState.textureKeys,
							textureDefinitions: materialState.textures,
							storageBufferKeys: materialState.storageBufferKeys,
							storageBufferDefinitions,
							storageTextureKeys: materialState.storageTextureKeys,
							getRenderTargets: options.getRenderTargets,
							getPasses: options.getPasses,
							...(color !== undefined ? { color } : {}),
							getClearColor: options.getClearColor,
							getDpr: () => options.dpr.current,
							adapterOptions: options.getAdapterOptions(),
							deviceDescriptor: options.getDeviceDescriptor(),
							requestRender: scheduleFrame
						});

						if (isDisposed) {
							nextRenderer.destroy();
							return;
						}

						renderer?.destroy();
						renderer = nextRenderer;
						activeRendererSignature = rendererSignature;
						failedRendererSignature = null;
						failedRendererAttempts = 0;
						nextRendererRetryAt = 0;
						maybeClearError(performance.now());
					} catch (error) {
						failedRendererSignature = rendererSignature;
						failedRendererAttempts += 1;
						const retryDelayMs = getRendererRetryDelayMs(failedRendererAttempts);
						nextRendererRetryAt = performance.now() + retryDelayMs;
						setError(error, 'initialization');
					} finally {
						rendererRebuildPromise = null;
						scheduleFrame();
					}
				})();
			}

			return;
		}

		const time = timestamp / 1000;
		const rawDelta = Math.max(0, time - previousTime);
		const delta = Math.min(rawDelta, options.maxDelta.current);
		previousTime = time;

		// Use ResizeObserver-supplied dimensions when available; otherwise fall
		// back to getBoundingClientRect() (e.g. before the first RO callback fires).
		let width: number;
		let height: number;
		if (observedCssWidth >= 0) {
			width = observedCssWidth;
			height = observedCssHeight;
		} else {
			const rect = canvasElement.getBoundingClientRect();
			width = Math.max(0, Math.floor(rect.width));
			height = Math.max(0, Math.floor(rect.height));
		}

		if (width !== currentCssWidth || height !== currentCssHeight) {
			currentCssWidth = width;
			currentCssHeight = height;
			size.set({ width, height });
		}

		try {
			registry.run({
				time,
				delta,
				setUniform,
				setTexture,
				writeStorageBuffer,
				readStorageBuffer,
				invalidate,
				advance,
				renderMode: registry.getRenderMode(),
				autoRender: registry.getAutoRender(),
				canvas: canvasElement
			});

			const shouldRenderFrame = registry.shouldRender();
			shouldContinueAfterFrame =
				registry.getRenderMode() === 'always' ||
				(registry.getRenderMode() === 'on-demand' && shouldRenderFrame);

			if (shouldRenderFrame) {
				for (const key of uniformKeys) {
					const runtimeValue = runtimeUniforms[key];
					renderUniforms[key] =
						runtimeValue === undefined ? (activeUniforms[key] as UniformValue) : runtimeValue;
				}

				for (const key of textureKeys) {
					const runtimeValue = runtimeTextures[key];
					renderTextures[key] =
						runtimeValue === undefined ? (activeTextures[key]?.source ?? null) : runtimeValue;
				}

				canvasSize.width = width;
				canvasSize.height = height;
				renderer.render({
					time,
					delta,
					renderMode: registry.getRenderMode(),
					uniforms: renderUniforms,
					textures: renderTextures,
					canvasSize,
					pendingStorageWrites: pendingStorageWrites.length > 0 ? pendingStorageWrites : undefined
				});
				// Clear in-place after synchronous render() completes — avoids
				// the splice(0) copy and eliminates the conditional spread object.
				if (pendingStorageWrites.length > 0) {
					pendingStorageWrites.length = 0;
				}
			} else if (pendingStorageWrites.length > 0) {
				renderer.flushStorageWrites(pendingStorageWrites);
				pendingStorageWrites.length = 0;
			}

			maybeClearError(timestamp);
		} catch (error) {
			setError(error, 'render', timestamp);
			if (renderer && shouldRecreateRendererAfterError(error)) {
				renderer.destroy();
				renderer = null;
				activeRendererSignature = '';
				failedRendererSignature = null;
				failedRendererAttempts = 0;
				nextRendererRetryAt = 0;
				shouldContinueAfterFrame = true;
			}
		} finally {
			registry.endFrame();
		}

		if (shouldContinueAfterFrame) {
			scheduleFrame();
		}
	};

	(async () => {
		try {
			const initialMaterial = resolveActiveMaterial();
			syncMaterialRuntimeState(initialMaterial);
			activeRendererSignature = '';
			scheduleFrame();
		} catch (error) {
			setError(error, 'initialization');
			scheduleFrame();
		}
	})();

	return {
		requestFrame,
		invalidate,
		advance,
		destroy: () => {
			isDisposed = true;
			resizeObserver?.disconnect();
			resizeObserver = null;
			if (frameId !== null) {
				cancelAnimationFrame(frameId);
				frameId = null;
			}
			renderer?.destroy();
			registry.clear();
		}
	};
}
