import React from "react";
import gql from "graphql-tag";
import produce, { enableMapSet } from "immer";
import { useQuery, useApolloClient } from "@apollo/client";
import { useNavigate, useLocation, useSearchParams } from "react-router-dom";

import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { useSavedOutfit } from "../loaders/outfits";

enableMapSet();

export const OutfitStateContext = React.createContext(null);

function useOutfitState() {
	const apolloClient = useApolloClient();
	const navigate = useNavigate();

	const urlOutfitState = useParseOutfitUrl();
	const [localOutfitState, dispatchToOutfit] = React.useReducer(
		outfitStateReducer(apolloClient),
		urlOutfitState,
	);

	// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
	// about the outfit. We'll use it to initialize the local state.
	const {
		isLoading: outfitLoading,
		error: outfitError,
		data: outfitData,
		status: outfitStatus,
	} = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null });

	const creator = outfitData?.creator;
	const updatedAt = outfitData?.updatedAt;

	// We memoize this to make `outfitStateWithoutExtras` an even more reliable
	// stable object!
	const savedOutfitState = React.useMemo(
		() => getOutfitStateFromOutfitData(outfitData),
		[outfitData],
	);

	// When the saved outfit data comes in for the first time, we reset the local
	// outfit state to match. (We don't reset it on subsequent outfit data
	// updates, e.g. when an outfit saves and the response comes back from the
	// server, because then we could be in a loop of replacing the local state
	// with the persisted state if the user makes changes in the meantime!)
	//
	// HACK: I feel like not having species/color is one of the best ways to tell
	// if we're replacing an incomplete outfit state… but it feels a bit fragile
	// and not-quite-what-we-mean.
	//
	// TODO: I forget the details of why we have both resetting the local state,
	// and a thing where we fallback between the different kinds of outfit state.
	// Probably something about SSR when we were on Next.js? Could be simplified?
	React.useEffect(() => {
		if (
			outfitStatus === "success" &&
			localOutfitState.speciesId == null &&
			localOutfitState.colorId == null
		) {
			dispatchToOutfit({
				type: "resetToSavedOutfitData",
				savedOutfitData: outfitData,
			});
		}
	}, [outfitStatus, outfitData, localOutfitState]);

	// Choose which customization state to use. We want it to match the outfit in
	// the URL immediately, without having to wait for any effects, to avoid race
	// conditions!
	//
	// The reducer is generally the main source of truth for live changes!
	//
	// But if:
	//   - it's not initialized yet (e.g. the first frame of navigating to an
	//     outfit from Your Outfits), or
	//   - it's for a different outfit than the URL says (e.g. clicking Back
	//     or Forward to switch between saved outfits),
	//
	// Then use saved outfit data or the URL query string instead, because that's
	// a better representation of the outfit in the URL. (If the saved outfit
	// data isn't loaded yet, then this will be a customization state with
	// partial data, and that's okay.)
	console.debug(
		`[useOutfitState] Outfit states:\n- Local: %o\n- Saved: %o\n- URL: %o`,
		localOutfitState,
		savedOutfitState,
		urlOutfitState,
	);
	let outfitState;
	if (
		urlOutfitState.id === localOutfitState.id &&
		localOutfitState.speciesId != null &&
		localOutfitState.colorId != null
	) {
		// Use the reducer state: they're both for the same saved outfit, or both
		// for an unsaved outfit (null === null). But we don't use it when it's
		// *only* got the ID, and no other fields yet.
		console.debug(
			"[useOutfitState] Choosing local outfit state",
			localOutfitState,
		);
		outfitState = localOutfitState;
	} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
		// Use the saved outfit state: it's for the saved outfit the URL points to.
		console.debug(
			"[useOutfitState] Choosing saved outfit state",
			savedOutfitState,
		);
		outfitState = savedOutfitState;
	} else {
		// Use the URL state: it's more up-to-date than any of the others. (Worst
		// case, it's empty except for ID, which is fine while the saved outfit
		// data loads!)
		console.debug(
			"[useOutfitState] Choosing URL outfit state",
			urlOutfitState,
			savedOutfitState,
		);
		outfitState = urlOutfitState;
	}

	// When unpacking the customization state, we call `Array.from` on our item
	// IDs. It's more convenient to manage them as a Set in state, but most
	// callers will find it more convenient to access them as arrays! e.g. for
	// `.map()`.
	const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } =
		outfitState;
	const wornItemIds = Array.from(outfitState.wornItemIds);
	const closetedItemIds = Array.from(outfitState.closetedItemIds);
	const allItemIds = [...wornItemIds, ...closetedItemIds];

	const {
		loading: itemsLoading,
		error: itemsError,
		data: itemsData,
	} = useQuery(
		gql`
			query OutfitStateItems(
				$allItemIds: [ID!]!
				$speciesId: ID!
				$colorId: ID!
				$altStyleId: ID
			) {
				items(ids: $allItemIds) {
					# TODO: De-dupe this from SearchPanel?
					id
					name
					thumbnailUrl
					isNc
					isPb
					currentUserOwnsThis
					currentUserWantsThis

					appearanceOn(
						speciesId: $speciesId
						colorId: $colorId
						altStyleId: $altStyleId
					) {
						# This enables us to quickly show the item when the user clicks it!
						...ItemAppearanceForOutfitPreview

						# This is used to group items by zone, and to detect conflicts when
						# wearing a new item.
						layers {
							zone {
								id
								label
							}
						}
						restrictedZones {
							id
							label
							isCommonlyUsedByItems
						}
					}
				}

				# NOTE: We skip this query if items is empty for perf reasons. If
				#       you're adding more fields, consider changing that condition!
			}
			${itemAppearanceFragment}
		`,
		{
			variables: { allItemIds, speciesId, colorId, altStyleId },
			context: { sendAuth: true },
			// Skip if this outfit has no items, as an optimization; or if we don't
			// have the species/color ID loaded yet because we're waiting on the
			// saved outfit to load.
			skip: allItemIds.length === 0 || speciesId == null || colorId == null,
		},
	);

	const resultItems = itemsData?.items || [];

	// Okay, time for some big perf hacks! Lower down in the app, we use
	// React.memo to avoid re-rendering Item components if the items haven't
	// updated. In simpler cases, we just make the component take the individual
	// item fields as props... but items are complex and that makes it annoying
	// :p Instead, we do these tricks to reuse physical item objects if they're
	// still deep-equal to the previous version. This is because React.memo uses
	// object identity to compare its props, so now when it checks whether
	// `oldItem === newItem`, the answer will be `true`, unless the item really
	// _did_ change!
	const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
	let items = resultItems.map((item) => {
		const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
		if (
			cachedItemObject &&
			JSON.stringify(cachedItemObject) === JSON.stringify(item)
		) {
			return cachedItemObject;
		}
		return item;
	});
	if (
		items.length === cachedItemObjects.length &&
		items.every((_, index) => items[index] === cachedItemObjects[index])
	) {
		// Even reuse the entire array if none of the items changed!
		items = cachedItemObjects;
	}
	React.useEffect(() => {
		setCachedItemObjects(items);
	}, [items, setCachedItemObjects]);

	const itemsById = {};
	for (const item of items) {
		itemsById[item.id] = item;
	}

	const zonesAndItems = getZonesAndItems(
		itemsById,
		wornItemIds,
		closetedItemIds,
	);
	const incompatibleItems = items
		.filter((i) => i.appearanceOn.layers.length === 0)
		.sort((a, b) => a.name.localeCompare(b.name));

	const url = buildOutfitUrl(outfitState);

	const outfitStateWithExtras = {
		id,
		creator,
		updatedAt,
		zonesAndItems,
		incompatibleItems,
		name,
		wornItemIds,
		closetedItemIds,
		allItemIds,
		speciesId,
		colorId,
		pose,
		altStyleId,
		appearanceId,
		url,

		// We use this plain outfit state objects in `useOutfitSaving`! Unlike the
		// full `outfitState` object, which we rebuild each render,
		// `outfitStateWithoutExtras` will mostly only change when there is an
		// actual change to outfit state.
		outfitStateWithoutExtras: outfitState,
		savedOutfitState,
	};

	// Keep the URL up-to-date.
	const path = buildOutfitPath(outfitState);
	React.useEffect(() => {
		console.debug(`[useOutfitState] Navigating to latest outfit path:`, path);
		navigate(path, { replace: true });
	}, [path, navigate]);

	return {
		loading: outfitLoading || itemsLoading,
		error: outfitError || itemsError,
		outfitState: outfitStateWithExtras,
		dispatchToOutfit,
	};
}

const outfitStateReducer = (apolloClient) => (baseState, action) => {
	console.info("[useOutfitState] Action:", action);
	switch (action.type) {
		case "rename":
			return produce(baseState, (state) => {
				state.name = action.outfitName;
			});
		case "setSpeciesAndColor":
			return produce(baseState, (state) => {
				state.speciesId = action.speciesId;
				state.colorId = action.colorId;
				state.pose = action.pose;
				state.altStyleId = null;
				state.appearanceId = null;
			});
		case "wearItem":
			return produce(baseState, (state) => {
				const { wornItemIds, closetedItemIds } = state;
				const { itemId, itemIdsToReconsider = [] } = action;

				// Move conflicting items to the closet.
				//
				// We do this by looking them up in the Apollo Cache, which is going to
				// include the relevant item data because the `useOutfitState` hook
				// queries for it!
				//
				// (It could be possible to mess up the timing by taking an action
				// while worn items are still partially loading, but I think it would
				// require a pretty weird action sequence to make that happen... like,
				// doing a search and it loads before the worn item data does? Anyway,
				// Apollo will throw in that case, which should just essentially reject
				// the action.)
				let conflictingIds;
				try {
					conflictingIds = findItemConflicts(itemId, state, apolloClient);
				} catch (e) {
					console.error(e);
					return;
				}
				for (const conflictingId of conflictingIds) {
					wornItemIds.delete(conflictingId);
					closetedItemIds.add(conflictingId);
				}

				// Move this item from the closet to the worn set.
				closetedItemIds.delete(itemId);
				wornItemIds.add(itemId);

				reconsiderItems(itemIdsToReconsider, state, apolloClient);
			});
		case "unwearItem":
			return produce(baseState, (state) => {
				const { wornItemIds, closetedItemIds } = state;
				const { itemId, itemIdsToReconsider = [] } = action;

				// Move this item from the worn set to the closet.
				wornItemIds.delete(itemId);
				closetedItemIds.add(itemId);

				reconsiderItems(
					// Don't include the unworn item in items to reconsider!
					itemIdsToReconsider.filter((x) => x !== itemId),
					state,
					apolloClient,
				);
			});
		case "removeItem":
			return produce(baseState, (state) => {
				const { wornItemIds, closetedItemIds } = state;
				const { itemId, itemIdsToReconsider = [] } = action;

				// Remove this item from both the worn set and the closet.
				wornItemIds.delete(itemId);
				closetedItemIds.delete(itemId);

				reconsiderItems(
					// Don't include the removed item in items to reconsider!
					itemIdsToReconsider.filter((x) => x !== itemId),
					state,
					apolloClient,
				);
			});
		case "setPose":
			return produce(baseState, (state) => {
				state.pose = action.pose;
				// Usually only the `pose` is specified, but `PosePickerSupport` can
				// also specify a corresponding `appearanceId`, to get even more
				// particular about which version of the pose to show if more than one.
				state.appearanceId = action.appearanceId || null;
			});
		case "setStyle":
			return produce(baseState, (state) => {
				state.altStyleId = action.altStyleId;
			});
		case "resetToSavedOutfitData":
			return getOutfitStateFromOutfitData(action.savedOutfitData);
		case "handleOutfitSaveResponse":
			return produce(baseState, (state) => {
				const { outfitData } = action;

				// If this is a save result for a different outfit, ignore it.
				if (state.id != null && outfitData.id != state.id) {
					return;
				}

				// Otherwise, update the local outfit to match the fields the server
				// controls: it chooses the ID for new outfits, and it can choose a
				// different name if ours was already in use.
				state.id = outfitData.id;
				state.name = outfitData.name;

				// The server also tries to lock the outfit to a specific appearanceId
				// for the given species/color/pose. Accept that change too—but only if
				// we haven't already changed species/color/pose since then!
				if (
					state.speciesId == outfitData.speciesId &&
					state.colorId == outfitData.colorId &&
					state.pose == outfitData.pose
				) {
					state.appearanceId = outfitData.appearanceId;
				}
			});
		default:
			throw new Error(`unexpected action ${JSON.stringify(action)}`);
	}
};

const EMPTY_CUSTOMIZATION_STATE = {
	id: null,
	name: null,
	speciesId: null,
	colorId: null,
	pose: null,
	appearanceId: null,
	wornItemIds: [],
	closetedItemIds: [],
};

function useParseOutfitUrl() {
	// Get params from both the `?a=1` and `#a=1` parts of the URL, because DTI
	// has historically used both!
	const location = useLocation();
	const [justSearchParams] = useSearchParams();
	const hashParams = new URLSearchParams(location.hash.slice(1));

	// Merge them into one URLSearchParams object.
	const mergedParams = new URLSearchParams();
	for (const [key, value] of justSearchParams) {
		mergedParams.append(key, value);
	}
	for (const [key, value] of hashParams) {
		mergedParams.append(key, value);
	}

	// We memoize this to make `outfitStateWithoutExtras` an even more reliable
	// stable object!
	const memoizedOutfitState = React.useMemo(
		() => readOutfitStateFromSearchParams(location.pathname, mergedParams),
		// TODO: This hook is reliable as-is, I think… but is there a simpler way
		// to make it obvious that it is?
		// eslint-disable-next-line react-hooks/exhaustive-deps
		[location.pathname, mergedParams.toString()],
	);

	return memoizedOutfitState;
}

function readOutfitStateFromSearchParams(pathname, searchParams) {
	// For the /outfits/:id page, ignore the query string, and just wait for the
	// outfit data to load in!
	const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/);
	if (pathnameMatch) {
		return {
			...EMPTY_CUSTOMIZATION_STATE,
			id: pathnameMatch[1],
		};
	}

	// Otherwise, parse the query string, and fill in default values for anything
	// not specified.
	return {
		id: searchParams.get("outfit"),
		name: searchParams.get("name"),
		speciesId: searchParams.get("species") || "1",
		colorId: searchParams.get("color") || "8",
		pose: searchParams.get("pose") || "HAPPY_FEM",
		altStyleId: searchParams.get("style") || null,
		appearanceId: searchParams.get("state") || null,
		wornItemIds: new Set(searchParams.getAll("objects[]")),
		closetedItemIds: new Set(searchParams.getAll("closet[]")),
	};
}

function getOutfitStateFromOutfitData(outfit) {
	if (!outfit) {
		return EMPTY_CUSTOMIZATION_STATE;
	}

	return {
		id: outfit.id,
		name: outfit.name,
		speciesId: outfit.speciesId,
		colorId: outfit.colorId,
		pose: outfit.pose,
		appearanceId: outfit.appearanceId,
		altStyleId: outfit.altStyleId,
		wornItemIds: new Set(outfit.wornItemIds),
		closetedItemIds: new Set(outfit.closetedItemIds),
	};
}

function findItemConflicts(itemIdToAdd, state, apolloClient) {
	const { wornItemIds, speciesId, colorId, altStyleId } = state;

	const itemIds = [itemIdToAdd, ...wornItemIds];
	const data = apolloClient.readQuery({
		query: gql`
			query OutfitStateItemConflicts(
				$itemIds: [ID!]!
				$speciesId: ID!
				$colorId: ID!
				$altStyleId: ID
			) {
				items(ids: $itemIds) {
					id
					appearanceOn(
						speciesId: $speciesId
						colorId: $colorId
						altStyleId: $altStyleId
					) {
						layers {
							zone {
								id
							}
						}

						restrictedZones {
							id
						}
					}
				}
			}
		`,
		variables: {
			itemIds,
			speciesId,
			colorId,
			altStyleId,
		},
	});
	if (data == null) {
		throw new Error(
			`[findItemConflicts] Cache lookup failed for: ` +
				`items=${itemIds.join(",")}, speciesId=${speciesId}, ` +
				`colorId=${colorId}, altStyleId=${altStyleId}`,
		);
	}

	const { items } = data;
	const itemToAdd = items.find((i) => i.id === itemIdToAdd);
	if (!itemToAdd.appearanceOn) {
		return [];
	}
	const wornItems = Array.from(wornItemIds).map((id) =>
		items.find((i) => i.id === id),
	);

	const itemToAddZoneSets = getItemZones(itemToAdd);

	const conflictingIds = [];
	for (const wornItem of wornItems) {
		if (!wornItem.appearanceOn) {
			continue;
		}

		const wornItemZoneSets = getItemZones(wornItem);

		const itemsConflict =
			setsIntersect(
				itemToAddZoneSets.occupies,
				wornItemZoneSets.occupiesOrRestricts,
			) ||
			setsIntersect(
				wornItemZoneSets.occupies,
				itemToAddZoneSets.occupiesOrRestricts,
			);

		if (itemsConflict) {
			conflictingIds.push(wornItem.id);
		}
	}

	return conflictingIds;
}

function getItemZones(item) {
	const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
	const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
	const occupiesOrRestricts = new Set([...occupies, ...restricts]);
	return { occupies, occupiesOrRestricts };
}

function setsIntersect(a, b) {
	for (const el of a) {
		if (b.has(el)) {
			return true;
		}
	}
	return false;
}

/**
 * Try to add these items back to the outfit, if there would be no conflicts.
 * We use this in Search to try to restore these items after the user makes
 * changes, e.g., after they try on another Background we want to restore the
 * previous one!
 *
 * This mutates state.wornItemIds directly, on the assumption that we're in an
 * immer block, in which case mutation is the simplest API!
 */
function reconsiderItems(itemIdsToReconsider, state, apolloClient) {
	for (const itemIdToReconsider of itemIdsToReconsider) {
		const conflictingIds = findItemConflicts(
			itemIdToReconsider,
			state,
			apolloClient,
		);
		if (conflictingIds.length === 0) {
			state.wornItemIds.add(itemIdToReconsider);
		}
	}
}

// TODO: Get this out of here, tbh...
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
	const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
	const closetedItems = closetedItemIds
		.map((id) => itemsById[id])
		.filter((i) => i);

	// Loop over all the items, grouping them by zone, and also gathering all the
	// zone metadata.
	const allItems = [...wornItems, ...closetedItems];
	const itemsByZone = new Map();
	const zonesById = new Map();
	for (const item of allItems) {
		if (!item.appearanceOn) {
			continue;
		}

		for (const layer of item.appearanceOn.layers) {
			const zoneId = layer.zone.id;
			zonesById.set(zoneId, layer.zone);

			if (!itemsByZone.has(zoneId)) {
				itemsByZone.set(zoneId, []);
			}
			itemsByZone.get(zoneId).push(item);
		}
	}

	// Convert `itemsByZone` into an array of item groups.
	let zonesAndItems = Array.from(itemsByZone.entries()).map(
		([zoneId, items]) => ({
			zoneId,
			zoneLabel: zonesById.get(zoneId).label,
			items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
		}),
	);

	// Sort groups by the zone label's alphabetically, and tiebreak by the zone
	// ID. (That way, "Markings (#6)" sorts before "Markings (#16)".) We do this
	// before the data simplification step, because it's useful to have
	// consistent ordering for the algorithm that might choose to skip zones!
	zonesAndItems.sort((a, b) => {
		if (a.zoneLabel !== b.zoneLabel) {
			return a.zoneLabel.localeCompare(b.zoneLabel);
		} else {
			return a.zoneId - b.zoneId;
		}
	});

	// Data simplification step! Try to remove zone groups that aren't helpful.
	const groupsWithConflicts = zonesAndItems.filter(
		({ items }) => items.length > 1,
	);
	const itemIdsWithConflicts = new Set(
		groupsWithConflicts
			.map(({ items }) => items)
			.flat()
			.map((item) => item.id),
	);
	const itemIdsWeHaveSeen = new Set();
	zonesAndItems = zonesAndItems.filter(({ items }) => {
		// We need all groups with more than one item. If there's only one, we get
		// to think harder :)
		if (items.length > 1) {
			items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
			return true;
		}

		const item = items[0];

		// Has the item been seen a group we kept, or an upcoming group with
		// multiple conflicting items? If so, skip this group. If not, keep it.
		if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
			return false;
		} else {
			itemIdsWeHaveSeen.add(item.id);
			return true;
		}
	});

	// Finally, for groups with the same label, append the ID number.
	//
	// First, loop over the groups, to count how many times each zone label is
	// used. Then, loop over them again, appending the ID number if count > 1.
	const labelCounts = new Map();
	for (const itemZoneGroup of zonesAndItems) {
		const { zoneLabel } = itemZoneGroup;

		const count = labelCounts.get(zoneLabel) ?? 0;
		labelCounts.set(zoneLabel, count + 1);
	}
	for (const itemZoneGroup of zonesAndItems) {
		const { zoneId, zoneLabel } = itemZoneGroup;

		if (labelCounts.get(zoneLabel) > 1) {
			itemZoneGroup.zoneLabel += ` (#${zoneId})`;
		}
	}

	return zonesAndItems;
}

function buildOutfitPath(outfitState, { withoutOutfitId = false } = {}) {
	const { id } = outfitState;

	if (id && !withoutOutfitId) {
		return `/outfits/${id}`;
	}

	return "/outfits/new?" + buildOutfitQueryString(outfitState);
}

export function buildOutfitUrl(outfitState, options = {}) {
	const origin =
		typeof window !== "undefined"
			? window.location.origin
			: "https://impress.openneo.net";

	return origin + buildOutfitPath(outfitState, options);
}

function buildOutfitQueryString(outfitState) {
	const {
		name,
		speciesId,
		colorId,
		pose,
		altStyleId,
		appearanceId,
		wornItemIds,
		closetedItemIds,
	} = outfitState;

	const params = new URLSearchParams({
		name: name || "",
		species: speciesId || "",
		color: colorId || "",
		pose: pose || "",
	});
	if (altStyleId != null) {
		params.append("style", altStyleId);
	}
	if (appearanceId != null) {
		// `state` is an old name for compatibility with old-style DTI URLs. It
		// refers to "PetState", the database table name for pet appearances.
		params.append("state", appearanceId);
	}
	for (const itemId of wornItemIds) {
		params.append("objects[]", itemId);
	}
	for (const itemId of closetedItemIds) {
		params.append("closet[]", itemId);
	}

	return params.toString();
}

/**
 * Whether the two given outfit states represent identical customizations.
 */
export function outfitStatesAreEqual(a, b) {
	return buildOutfitQueryString(a) === buildOutfitQueryString(b);
}

export default useOutfitState;