import React from "react";
import {
	Box,
	Flex,
	Grid,
	Heading,
	Link,
	useColorModeValue,
} from "@chakra-ui/react";
import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react";
import { WarningIcon } from "@chakra-ui/icons";

import { buildImpress2020Url } from "./impress-2020-config";

import ErrorGrundoImg from "./images/error-grundo.png";
import ErrorGrundoImg2x from "./images/error-grundo@2x.png";

/**
 * Delay hides its content at first, then shows it after the given delay.
 *
 * This is useful for loading states: it can be disruptive to see a spinner or
 * skeleton element for only a brief flash, we'd rather just show them if
 * loading is genuinely taking a while!
 *
 * 300ms is a pretty good default: that's about when perception shifts from "it
 * wasn't instant" to "the process took time".
 * https://developers.google.com/web/fundamentals/performance/rail
 */
export function Delay({ children, ms = 300 }) {
	const [isVisible, setIsVisible] = React.useState(false);

	React.useEffect(() => {
		const id = setTimeout(() => setIsVisible(true), ms);
		return () => clearTimeout(id);
	}, [ms, setIsVisible]);

	return (
		<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
			{children}
		</Box>
	);
}

/**
 * Heading1 is a large, page-title-ish heading, with our DTI-brand-y Delicious
 * font and some special typographical styles!
 */
export function Heading1({ children, ...props }) {
	return (
		<Heading
			as="h1"
			size="2xl"
			fontFamily="Delicious, sans-serif"
			fontWeight="800"
			{...props}
		>
			{children}
		</Heading>
	);
}

/**
 * Heading2 is a major subheading, with our DTI-brand-y Delicious font and some
 * special typographical styles!!
 */
export function Heading2({ children, ...props }) {
	return (
		<Heading
			as="h2"
			size="xl"
			fontFamily="Delicious, sans-serif"
			fontWeight="700"
			{...props}
		>
			{children}
		</Heading>
	);
}

/**
 * Heading2 is a minor subheading, with our DTI-brand-y Delicious font and some
 * special typographical styles!!
 */
export function Heading3({ children, ...props }) {
	return (
		<Heading
			as="h3"
			size="lg"
			fontFamily="Delicious, sans-serif"
			fontWeight="700"
			{...props}
		>
			{children}
		</Heading>
	);
}

/**
 * ErrorMessage is a simple error message for simple errors!
 */
export function ErrorMessage({ children, ...props }) {
	return (
		<Box color="red.400" {...props}>
			{children}
		</Box>
	);
}

export function useCommonStyles() {
	return {
		brightBackground: useColorModeValue("white", "gray.700"),
		bodyBackground: useColorModeValue("gray.50", "gray.800"),
	};
}

/**
 * safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
 */
export function safeImageUrl(
	urlString,
	{ crossOrigin = null, preferArchive = false } = {},
) {
	if (urlString == null) {
		return urlString;
	}

	let url;
	try {
		url = new URL(
			urlString,
			// A few item thumbnail images incorrectly start with "/". When that
			// happens, the correct URL is at images.neopets.com.
			//
			// So, we provide "http://images.neopets.com" as the base URL when
			// parsing. Most URLs are absolute and will ignore it, but relative URLs
			// will resolve relative to that base.
			"http://images.neopets.com",
		);
	} catch (e) {
		logAndCapture(
			new Error(
				`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
			),
		);
		return buildImpress2020Url("/__error__URL-was-not-parseable__");
	}

	// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
	// proxy if we need CORS headers.
	if (
		url.origin === "http://images.neopets.com" ||
		url.origin === "https://images.neopets.com"
	) {
		url.protocol = "https:";
		if (preferArchive) {
			const archiveUrl = new URL(
				`/api/readFromArchive`,
				window.location.origin,
			);
			archiveUrl.search = new URLSearchParams({ url: url.toString() });
			url = archiveUrl;
		} else if (crossOrigin) {
			// NOTE: Previously we would rewrite this to our proxy that adds an
			// `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
			// openneo.net), but images.neopets.com now includes this header for us!
			//
			// So, do nothing!
		}
	} else if (
		url.origin === "http://pets.neopets.com" ||
		url.origin === "https://pets.neopets.com"
	) {
		url.protocol = "https:";
		if (crossOrigin) {
			url.host = "pets.neopets-asset-proxy.openneo.net";
		}
	}

	if (url.protocol !== "https:" && url.hostname !== "localhost") {
		logAndCapture(
			new Error(
				`safeImageUrl was provided an unsafe URL, but we don't know how to ` +
					`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
			),
		);
		return buildImpress2020Url("/__error__URL-was-not-HTTPS__");
	}

	return url.toString();
}

/**
 * useDebounce helps make a rapidly-changing value change less! It waits for a
 * pause in the incoming data before outputting the latest value.
 *
 * We use it in search: when the user types rapidly, we don't want to update
 * our query and send a new request every keystroke. We want to wait for it to
 * seem like they might be done, while still feeling responsive!
 *
 * Adapted from https://usehooks.com/useDebounce/
 */
export function useDebounce(
	value,
	delay,
	{ waitForFirstPause = false, initialValue = null, forceReset = null } = {},
) {
	// State and setters for debounced value
	const [debouncedValue, setDebouncedValue] = React.useState(
		waitForFirstPause ? initialValue : value,
	);

	React.useEffect(
		() => {
			// Update debounced value after delay
			const handler = setTimeout(() => {
				setDebouncedValue(value);
			}, delay);

			// Cancel the timeout if value changes (also on delay change or unmount)
			// This is how we prevent debounced value from updating if value is changed ...
			// .. within the delay period. Timeout gets cleared and restarted.
			return () => {
				clearTimeout(handler);
			};
		},
		[value, delay], // Only re-call effect if value or delay changes
	);

	// The `forceReset` option helps us decide whether to set the value
	// immediately! We'll update it in an effect for consistency and clarity, but
	// also return it immediately rather than wait a tick.
	const shouldForceReset = forceReset && forceReset(debouncedValue, value);
	React.useEffect(() => {
		if (shouldForceReset) {
			setDebouncedValue(value);
		}
	}, [shouldForceReset, value]);

	return shouldForceReset ? value : debouncedValue;
}

/**
 * useFetch uses `fetch` to fetch the given URL, and returns the request state.
 *
 * Our limited API is designed to match the `use-http` library!
 */
export function useFetch(url, { responseType, skip, ...fetchOptions }) {
	// Just trying to be clear about what you'll get back ^_^` If we want to
	// fetch non-binary data later, extend this and get something else from res!
	if (responseType !== "arrayBuffer") {
		throw new Error(`unsupported responseType ${responseType}`);
	}

	const [response, setResponse] = React.useState({
		loading: skip ? false : true,
		error: null,
		data: null,
	});

	// We expect this to be a simple object, so this helps us only re-send the
	// fetch when the options have actually changed, rather than e.g. a new copy
	// of an identical object!
	const fetchOptionsAsJson = JSON.stringify(fetchOptions);

	React.useEffect(() => {
		if (skip) {
			return;
		}

		let canceled = false;

		fetch(url, JSON.parse(fetchOptionsAsJson))
			.then(async (res) => {
				if (canceled) {
					return;
				}

				const arrayBuffer = await res.arrayBuffer();
				setResponse({ loading: false, error: null, data: arrayBuffer });
			})
			.catch((error) => {
				if (canceled) {
					return;
				}

				setResponse({ loading: false, error, data: null });
			});

		return () => {
			canceled = true;
		};
	}, [skip, url, fetchOptionsAsJson]);

	return response;
}

/**
 * useLocalStorage is like React.useState, but it persists the value in the
 * device's `localStorage`, so it comes back even after reloading the page.
 *
 * Adapted from https://usehooks.com/useLocalStorage/.
 */
let storageListeners = [];
export function useLocalStorage(key, initialValue) {
	const loadValue = React.useCallback(() => {
		if (typeof localStorage === "undefined") {
			return initialValue;
		}
		try {
			const item = localStorage.getItem(key);
			return item ? JSON.parse(item) : initialValue;
		} catch (error) {
			console.error(error);
			return initialValue;
		}
	}, [key, initialValue]);

	const [storedValue, setStoredValue] = React.useState(loadValue);

	const setValue = React.useCallback(
		(value) => {
			try {
				setStoredValue(value);
				window.localStorage.setItem(key, JSON.stringify(value));
				storageListeners.forEach((l) => l());
			} catch (error) {
				console.error(error);
			}
		},
		[key],
	);

	const reloadValue = React.useCallback(() => {
		setStoredValue(loadValue());
	}, [loadValue, setStoredValue]);

	// Listen for changes elsewhere on the page, and update here too!
	React.useEffect(() => {
		storageListeners.push(reloadValue);
		return () => {
			storageListeners = storageListeners.filter((l) => l !== reloadValue);
		};
	}, [reloadValue]);

	// Listen for changes in other tabs, and update here too! (This does not
	// catch same-page updates!)
	React.useEffect(() => {
		window.addEventListener("storage", reloadValue);
		return () => window.removeEventListener("storage", reloadValue);
	}, [reloadValue]);

	return [storedValue, setValue];
}

export function loadImage(
	rawSrc,
	{ crossOrigin = null, preferArchive = false } = {},
) {
	const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
	const image = new Image();
	let canceled = false;
	let resolved = false;

	const promise = new Promise((resolve, reject) => {
		image.onload = () => {
			if (canceled) return;
			resolved = true;
			resolve(image);
		};
		image.onerror = () => {
			if (canceled) return;
			reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
		};
		if (crossOrigin) {
			image.crossOrigin = crossOrigin;
		}
		image.src = src;
	});

	promise.cancel = () => {
		// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
		//       resolved images. That's because our approach to cancelation
		//       mutates the Image object we already returned, which could be
		//       surprising if the caller is using the Image and expected the
		//       `cancel` call to only cancel any in-flight network requests.
		//       (e.g. we cancel a DTI movie when it unloads from the page, but
		//       it might stick around in the movie cache, and we want those images
		//       to still work!)
		if (resolved) return;
		image.src = "";
		canceled = true;
	};

	return promise;
}

/**
 * loadable is a wrapper for `@loadable/component`, with extra error handling.
 * Loading the page will often fail if you keep a session open during a deploy,
 * because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
 */
export function loadable(load, options) {
	return loadableLibrary(
		() =>
			load().catch((e) => {
				console.error("Error loading page, reloading:", e);
				window.location.reload();
				// Return a component that renders nothing, while we reload!
				return () => null;
			}),
		options,
	);
}

/**
 * logAndCapture will print an error to the console, and send it to Sentry.
 *
 * This is useful when there's a graceful recovery path, but it's still a
 * genuinely unexpected error worth logging.
 */
export function logAndCapture(e) {
	console.error(e);
	Sentry.captureException(e);
}

export function getGraphQLErrorMessage(error) {
	// If this is a GraphQL Bad Request error, show the message of the first
	// error the server returned. Otherwise, just use the normal error message!
	return (
		error?.networkError?.result?.errors?.[0]?.message || error?.message || null
	);
}

export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
	// Log the detailed error to the console, so we can have a good debug
	// experience without the parent worrying about it!
	React.useEffect(() => {
		if (error) {
			console.error(error);
		}
	}, [error]);

	return (
		<Flex justify="center" marginTop="8">
			<Grid
				templateAreas='"icon title" "icon description" "icon details"'
				templateColumns="auto minmax(0, 1fr)"
				maxWidth="500px"
				marginX="8"
				columnGap="4"
			>
				<Box gridArea="icon" marginTop="2">
					<Box
						borderRadius="full"
						boxShadow="md"
						overflow="hidden"
						width="100px"
						height="100px"
					>
						<img
							src={ErrorGrundoImg}
							srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
							alt="Distressed Grundo programmer"
							width={100}
							height={100}
						/>
					</Box>
				</Box>
				<Box gridArea="title" fontSize="lg" marginBottom="1">
					{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
					{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
					{variant === "not-found" && <>Oops, page not found 😖</>}
				</Box>
				<Box gridArea="description" marginBottom="2">
					{variant === "unexpected" && (
						<>
							There was an error displaying this page. I'll get info about it
							automatically, but you can tell me more at{" "}
							<Link href="mailto:matchu@openneo.net" color="green.400">
								matchu@openneo.net
							</Link>
							!
						</>
					)}
					{variant === "network" && (
						<>
							There was an error displaying this page. Check your internet
							connection and try again—and if you keep having trouble, please
							tell me more at{" "}
							<Link href="mailto:matchu@openneo.net" color="green.400">
								matchu@openneo.net
							</Link>
							!
						</>
					)}
					{variant === "not-found" && (
						<>
							We couldn't find this page. Maybe it's been deleted? Check the URL
							and try again—and if you keep having trouble, please tell me more
							at{" "}
							<Link href="mailto:matchu@openneo.net" color="green.400">
								matchu@openneo.net
							</Link>
							!
						</>
					)}
				</Box>
				{error && (
					<Box gridArea="details" fontSize="xs" opacity="0.8">
						<WarningIcon
							marginRight="1.5"
							marginTop="-2px"
							aria-label="Error message"
						/>
						"{getGraphQLErrorMessage(error)}"
					</Box>
				)}
			</Grid>
		</Flex>
	);
}

export function TestErrorSender() {
	React.useEffect(() => {
		if (window.location.href.includes("send-test-error-for-sentry")) {
			throw new Error("Test error for Sentry");
		}
	});

	return null;
}