import React from "react";
import LRU from "lru-cache";
import { Box, Grid, useToast } from "@chakra-ui/react";

import { loadImage, logAndCapture, safeImageUrl } from "../util";
import usePreferArchive from "./usePreferArchive";

// Import EaselJS and TweenJS as strings to run in a global context!
// The bundled scripts are built to attach themselves to `window.createjs`, and
// `window.createjs` is where the Neopets movie libraries expects to find them!
//
// TODO: Is there a nicer way to do this within esbuild? Would be nice to have
// builds of these libraries that just play better in the first place...
import easelSource from "easeljs/lib/easeljs.min.js";
import tweenSource from "tweenjs/lib/tweenjs.min.js";
new Function(easelSource).call(window);
new Function(tweenSource).call(window);

function OutfitMovieLayer({
	libraryUrl,
	width,
	height,
	placeholderImageUrl = null,
	isPaused = false,
	onLoad = null,
	onError = null,
	onLowFps = null,
	canvasProps = {},
}) {
	const [preferArchive] = usePreferArchive();
	const [stage, setStage] = React.useState(null);
	const [library, setLibrary] = React.useState(null);
	const [movieClip, setMovieClip] = React.useState(null);
	const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
	const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
	const canvasRef = React.useRef(null);
	const hasShownErrorMessageRef = React.useRef(false);
	const toast = useToast();

	// Set the canvas's internal dimensions to be higher, if the device has high
	// DPI like retina. But we'll keep the layout width/height as expected!
	const internalWidth = width * window.devicePixelRatio;
	const internalHeight = height * window.devicePixelRatio;

	const callOnLoadIfNotYetCalled = React.useCallback(() => {
		setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
			if (!alreadyHasCalledOnLoad && onLoad) {
				onLoad();
			}
			return true;
		});
	}, [onLoad]);

	const updateStage = React.useCallback(() => {
		if (!stage) {
			return;
		}

		try {
			stage.update();
		} catch (e) {
			// If rendering the frame fails, log it and proceed. If it's an
			// animation, then maybe the next frame will work? Also alert the user,
			// just as an FYI. (This is pretty uncommon, so I'm not worried about
			// being noisy!)
			if (!hasShownErrorMessageRef.current) {
				console.error(`Error rendering movie clip ${libraryUrl}`);
				logAndCapture(e);
				toast({
					status: "warning",
					title:
						"Hmm, we're maybe having trouble playing one of these animations.",
					description:
						"If it looks wrong, try pausing and playing, or reloading the " +
						"page. Sorry!",
					duration: 10000,
					isClosable: true,
				});
				// We do this via a ref, not state, because I want to guarantee that
				// future calls see the new value. With state, React's effects might
				// not happen in the right order for it to work!
				hasShownErrorMessageRef.current = true;
			}
		}
	}, [stage, toast, libraryUrl]);

	// This effect gives us a `stage` corresponding to the canvas element.
	React.useLayoutEffect(() => {
		const canvas = canvasRef.current;

		if (!canvas) {
			return;
		}

		if (canvas.getContext("2d") == null) {
			console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
			toast({
				status: "warning",
				title: "Oops, too many animations!",
				description:
					`Your device is out of memory, so we can't show any more ` +
					`animations. Try removing some items, or using another device.`,
				duration: null,
				isClosable: true,
			});
			return;
		}

		setStage((stage) => {
			if (stage && stage.canvas === canvas) {
				return stage;
			}

			return new window.createjs.Stage(canvas);
		});

		return () => {
			setStage(null);

			if (canvas) {
				// There's a Safari bug where it doesn't reliably garbage-collect
				// canvas data. Clean it up ourselves, rather than leaking memory over
				// time! https://stackoverflow.com/a/52586606/107415
				// https://bugs.webkit.org/show_bug.cgi?id=195325
				canvas.width = 0;
				canvas.height = 0;
			}
		};
	}, [libraryUrl, toast]);

	// This effect gives us the `library` and `movieClip`, based on the incoming
	// `libraryUrl`.
	React.useEffect(() => {
		let canceled = false;

		const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
		movieLibraryPromise
			.then((library) => {
				if (canceled) {
					return;
				}

				setLibrary(library);

				const movieClip = buildMovieClip(library, libraryUrl);
				setMovieClip(movieClip);
			})
			.catch((e) => {
				console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
				if (onError) {
					onError(e);
				}
			});

		return () => {
			canceled = true;
			movieLibraryPromise.cancel();
			setLibrary(null);
			setMovieClip(null);
		};
	}, [libraryUrl, preferArchive, onError]);

	// This effect puts the `movieClip` on the `stage`, when both are ready.
	React.useEffect(() => {
		if (!stage || !movieClip) {
			return;
		}

		stage.addChild(movieClip);

		// Render the movie's first frame. If it's animated and we're not paused,
		// then another effect will perform subsequent updates.
		updateStage();

		// This is when we trigger `onLoad`: once we're actually showing it!
		callOnLoadIfNotYetCalled();
		setMovieIsLoaded(true);

		return () => stage.removeChild(movieClip);
	}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);

	// This effect updates the `stage` according to the `library`'s framerate,
	// but only if there's actual animation to do - i.e., there's more than one
	// frame to show, and we're not paused.
	React.useEffect(() => {
		if (!stage || !movieClip || !library) {
			return;
		}

		if (isPaused || !hasAnimations(movieClip)) {
			return;
		}

		const targetFps = library.properties.fps;

		let lastFpsLoggedAtInMs = performance.now();
		let numFramesSinceLastLogged = 0;
		const intervalId = setInterval(() => {
			const now = performance.now();
			const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
			const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
			const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
			const roundedFps = Math.round(fps * 100) / 100;

			// If the page is visible, render the next frame, and track that we did.
			// And if it's been 2 seconds since the last time we logged the FPS,
			// compute and log the FPS during those two seconds. (Checking the page
			// visibility is both an optimization to avoid rendering the movie, but
			// also makes "low FPS" tracking more accurate: browsers already throttle
			// intervals when the page is hidden, so a low FPS is *expected*, and
			// wouldn't indicate a performance problem like a low FPS normally would.)
			if (!document.hidden) {
				updateStage();
				numFramesSinceLastLogged++;

				if (timeSinceLastFpsLoggedAtInSec > 2) {
					console.debug(
						`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
					);
					if (onLowFps && fps < 2) {
						onLowFps(fps);
					}

					lastFpsLoggedAtInMs = now;
					numFramesSinceLastLogged = 0;
				}
			}
		}, 1000 / targetFps);

		const onVisibilityChange = () => {
			// When the page switches from hidden to visible, reset the FPS counter
			// state, to start counting from When Visibility Came Back, rather than
			// from when we last counted, which could be a long time ago.
			if (!document.hidden) {
				lastFpsLoggedAtInMs = performance.now();
				numFramesSinceLastLogged = 0;
				console.debug(
					`[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
				);
			} else {
				console.debug(
					`[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
				);
			}
		};
		document.addEventListener("visibilitychange", onVisibilityChange);

		return () => {
			clearInterval(intervalId);
			document.removeEventListener("visibilitychange", onVisibilityChange);
		};
	}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);

	// This effect keeps the `movieClip` scaled correctly, based on the canvas
	// size and the `library`'s natural size declaration. (If the canvas size
	// changes on window resize, then this will keep us responsive, so long as
	// the parent updates our width/height props on window resize!)
	React.useEffect(() => {
		if (!stage || !movieClip || !library) {
			return;
		}

		movieClip.scaleX = internalWidth / library.properties.width;
		movieClip.scaleY = internalHeight / library.properties.height;

		// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
		// to `false`, so that we don't advance by a frame. This keeps us
		// really-paused if we're paused, and avoids skipping ahead by a frame if
		// we're playing.
		stage.tickOnUpdate = false;
		updateStage();
		stage.tickOnUpdate = true;
	}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);

	return (
		<Grid templateAreas="single-shared-area">
			<canvas
				ref={canvasRef}
				width={internalWidth}
				height={internalHeight}
				style={{
					width: width,
					height: height,
					gridArea: "single-shared-area",
				}}
				data-is-loaded={movieIsLoaded}
				{...canvasProps}
			/>
			{/* While the movie is loading, we show our image version as a
			 *  placeholder, because it generally loads much faster.
			 *  TODO: Show a loading indicator for this partially-loaded state? */}
			{placeholderImageUrl && (
				<Box
					as="img"
					src={safeImageUrl(placeholderImageUrl)}
					width={width}
					height={height}
					gridArea="single-shared-area"
					opacity={movieIsLoaded ? 0 : 1}
					transition="opacity 0.2s"
					onLoad={callOnLoadIfNotYetCalled}
				/>
			)}
		</Grid>
	);
}

function loadScriptTag(src) {
	let script;
	let canceled = false;
	let resolved = false;

	const scriptTagPromise = new Promise((resolve, reject) => {
		script = document.createElement("script");
		script.onload = () => {
			if (canceled) return;
			resolved = true;
			resolve(script);
		};
		script.onerror = (e) => {
			if (canceled) return;
			reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
		};
		script.src = src;
		document.body.appendChild(script);
	});

	scriptTagPromise.cancel = () => {
		if (resolved) return;
		script.src = "";
		canceled = true;
	};

	return scriptTagPromise;
}

const MOVIE_LIBRARY_CACHE = new LRU(10);

export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
	const cancelableResourcePromises = [];
	const cancelAllResources = () =>
		cancelableResourcePromises.forEach((p) => p.cancel());

	// Most of the logic for `loadMovieLibrary` is inside this async function.
	// But we want to attach more fields to the promise before returning it; so
	// we declare this async function separately, then call it, then edit the
	// returned promise!
	const createMovieLibraryPromise = async () => {
		// First, check the LRU cache. This will enable us to quickly return movie
		// libraries, without re-loading and re-parsing and re-executing.
		const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
		if (cachedLibrary) {
			return cachedLibrary;
		}

		// Then, load the script tag. (Make sure we set it up to be cancelable!)
		const scriptPromise = loadScriptTag(
			safeImageUrl(librarySrc, { preferArchive }),
		);
		cancelableResourcePromises.push(scriptPromise);
		await scriptPromise;

		// These library JS files are interesting in their operation. It seems like
		// the idea is, it pushes an object to a global array, and you need to snap
		// it up and see it at the end of the array! And I don't really see a way to
		// like, get by a name or ID that we know by this point. So, here we go, just
		// try to grab it once it arrives!
		//
		// I'm not _sure_ this method is reliable, but it seems to be stable so far
		// in Firefox for me. The things I think I'm observing are:
		//   - Script execution order should match insert order,
		//   - Onload execution order should match insert order,
		//   - BUT, script executions might be batched before onloads.
		//   - So, each script grabs the _first_ composition from the list, and
		//     deletes it after grabbing. That way, it serves as a FIFO queue!
		// I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
		// the race anymore? But fingers crossed!
		if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
			throw new Error(
				`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
			);
		}
		const [compositionId, composition] = Object.entries(
			window.AdobeAn.compositions,
		)[0];
		if (Object.keys(window.AdobeAn.compositions).length > 1) {
			console.warn(
				`Grabbing composition ${compositionId}, but there are >1 here: `,
				Object.keys(window.AdobeAn.compositions).length,
			);
		}
		delete window.AdobeAn.compositions[compositionId];
		const library = composition.getLibrary();

		// One more loading step as part of loading this library is loading the
		// images it uses for sprites.
		//
		// TODO: I guess the manifest has these too, so if we could use our DB cache
		//       to get the manifest to us faster, then we could avoid a network RTT
		//       on the critical path by preloading these images before the JS file
		//       even gets to us?
		const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
		const manifestImages = new Map(
			library.properties.manifest.map(({ id, src }) => [
				id,
				loadImage(librarySrcDir + "/" + src, {
					crossOrigin: "anonymous",
					preferArchive,
				}),
			]),
		);

		// Wait for the images, and make sure they're cancelable while we do.
		const manifestImagePromises = manifestImages.values();
		cancelableResourcePromises.push(...manifestImagePromises);
		await Promise.all(manifestImagePromises);

		// Finally, once we have the images loaded, the library object expects us to
		// mutate it (!) to give it the actual image and sprite sheet objects from
		// the loaded images. That's how the MovieClip's internal JS objects will
		// access the loaded data!
		const images = composition.getImages();
		for (const [id, image] of manifestImages.entries()) {
			images[id] = await image;
		}
		const spriteSheets = composition.getSpriteSheet();
		for (const { name, frames } of library.ssMetadata) {
			const image = await manifestImages.get(name);
			spriteSheets[name] = new window.createjs.SpriteSheet({
				images: [image],
				frames,
			});
		}

		MOVIE_LIBRARY_CACHE.set(librarySrc, library);

		return library;
	};

	const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
		// When any part of the movie library fails, we also cancel the other
		// resources ourselves, to avoid stray throws for resources that fail after
		// the parent catches the initial failure. We re-throw the initial failure
		// for the parent to handle, though!
		cancelAllResources();
		throw e;
	});

	// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
	// load as part of it. That should effectively halt the async function above
	// (anything not yet loaded will stop loading), and ensure that stray
	// failures don't trigger uncaught promise rejection warnings.
	movieLibraryPromise.cancel = cancelAllResources;

	return movieLibraryPromise;
}

export function buildMovieClip(library, libraryUrl) {
	let constructorName;
	try {
		const fileName = decodeURI(libraryUrl).split("/").pop();
		const fileNameWithoutExtension = fileName.split(".")[0];
		constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
		if (constructorName.match(/^[0-9]/)) {
			constructorName = "_" + constructorName;
		}
	} catch (e) {
		throw new Error(
			`Movie libraryUrl ${JSON.stringify(
				libraryUrl,
			)} did not match expected format: ${e.message}`,
		);
	}

	const LibraryMovieClipConstructor = library[constructorName];
	if (!LibraryMovieClipConstructor) {
		throw new Error(
			`Expected JS movie library ${libraryUrl} to contain a constructor ` +
				`named ${constructorName}, but it did not: ${Object.keys(library)}`,
		);
	}
	const movieClip = new LibraryMovieClipConstructor();

	return movieClip;
}

/**
 * Recursively scans the given MovieClip (or child createjs node), to see if
 * there are any animated areas.
 */
export function hasAnimations(createjsNode) {
	return (
		// Some nodes have simple animation frames.
		createjsNode.totalFrames > 1 ||
		// Tweens are a form of animation that can happen separately from frames.
		// They expect timer ticks to happen, and they change the scene accordingly.
		createjsNode?.timeline?.tweens?.length >= 1 ||
		// And some nodes have _children_ that are animated.
		(createjsNode.children || []).some(hasAnimations)
	);
}

export default OutfitMovieLayer;