import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import {
	Box,
	IconButton,
	Input,
	InputGroup,
	InputLeftAddon,
	InputLeftElement,
	InputRightElement,
	Tooltip,
	useColorModeValue,
} from "@chakra-ui/react";
import {
	ChevronDownIcon,
	ChevronUpIcon,
	CloseIcon,
	SearchIcon,
} from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import Autosuggest from "react-autosuggest";

import useCurrentUser from "../components/useCurrentUser";
import { logAndCapture } from "../util";

export const emptySearchQuery = {
	value: "",
	filterToZoneLabel: null,
	filterToItemKind: null,
	filterToCurrentUserOwnsOrWants: null,
};

export function searchQueryIsEmpty(query) {
	return Object.values(query).every((value) => !value);
}

const SUGGESTIONS_PLACEMENT_PROPS = {
	inline: {
		borderBottomRadius: "md",
	},
	top: {
		position: "absolute",
		bottom: "100%",
		borderTopRadius: "md",
	},
};

/**
 * SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
 * and contains the search field where the user types their query.
 *
 * It has some subtle keyboard interaction support, like DownArrow to go to the
 * first search result, and Escape to clear the search and go back to the
 * ItemsPanel. (The SearchPanel can also send focus back to here, with Escape
 * from anywhere, or UpArrow from the first result!)
 */
function SearchToolbar({
	query,
	searchQueryRef,
	firstSearchResultRef = null,
	onChange,
	autoFocus,
	showItemsLabel = false,
	background = null,
	boxShadow = null,
	suggestionsPlacement = "inline",
	...props
}) {
	const [suggestions, setSuggestions] = React.useState([]);
	const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
	const { isLoggedIn } = useCurrentUser();

	// NOTE: This query should always load ~instantly, from the client cache.
	const { data } = useQuery(gql`
		query SearchToolbarZones {
			allZones {
				id
				label
				depth
				isCommonlyUsedByItems
			}
		}
	`);
	const zones = data?.allZones || [];
	const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);

	let zoneLabels = itemZones.map((z) => z.label);
	zoneLabels = [...new Set(zoneLabels)];
	zoneLabels.sort();

	const onMoveFocusDownToResults = (e) => {
		if (firstSearchResultRef && firstSearchResultRef.current) {
			firstSearchResultRef.current.focus();
			e.preventDefault();
		}
	};

	const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
	const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");

	const renderSuggestion = React.useCallback(
		({ text }, { isHighlighted }) => (
			<Box
				fontWeight={isHighlighted ? "bold" : "normal"}
				background={isHighlighted ? highlightedBgColor : suggestionBgColor}
				padding="2"
				paddingLeft="2.5rem"
				fontSize="sm"
			>
				{text}
			</Box>
		),
		[suggestionBgColor, highlightedBgColor],
	);

	const renderSuggestionsContainer = React.useCallback(
		({ containerProps, children }) => {
			const { className, ...otherContainerProps } = containerProps;
			return (
				<ClassNames>
					{({ css, cx }) => (
						<Box
							{...otherContainerProps}
							boxShadow="md"
							overflow="auto"
							transition="all 0.4s"
							maxHeight="48"
							width="100%"
							className={cx(
								className,
								css`
									li {
										list-style: none;
									}
								`,
							)}
							{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
						>
							{children}
							{!children && advancedSearchIsOpen && (
								<Box
									padding="4"
									fontSize="sm"
									fontStyle="italic"
									textAlign="center"
								>
									No more filters available!
								</Box>
							)}
						</Box>
					)}
				</ClassNames>
			);
		},
		[advancedSearchIsOpen, suggestionsPlacement],
	);

	// When we change the query filters, clear out the suggestions.
	React.useEffect(() => {
		setSuggestions([]);
	}, [
		query.filterToItemKind,
		query.filterToZoneLabel,
		query.filterToCurrentUserOwnsOrWants,
	]);

	let queryFilterText = getQueryFilterText(query);
	if (showItemsLabel) {
		queryFilterText = queryFilterText ? (
			<>
				<Box as="span" fontWeight="600">
					Items:
				</Box>{" "}
				{queryFilterText}
			</>
		) : (
			<Box as="span" fontWeight="600">
				Items
			</Box>
		);
	}

	const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
		showAll: true,
	});

	// Once you remove the final suggestion available, close Advanced Search. We
	// have placeholder text available, sure, but this feels more natural!
	React.useEffect(() => {
		if (allSuggestions.length === 0) {
			setAdvancedSearchIsOpen(false);
		}
	}, [allSuggestions.length]);

	const focusBorderColor = useColorModeValue("green.600", "green.400");

	return (
		<Box position="relative" {...props}>
			<Autosuggest
				suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
				onSuggestionsFetchRequested={({ value }) => {
					// HACK: I'm not sure why, but apparently this gets called with value
					//       set to the _chosen suggestion_ after choosing it? Has that
					//       always happened? Idk? Let's just, gate around it, I guess?
					if (typeof value === "string") {
						setSuggestions(
							getSuggestions(value, query, zoneLabels, isLoggedIn),
						);
					}
				}}
				onSuggestionSelected={(e, { suggestion }) => {
					onChange({
						...query,
						// If the suggestion was from typing, remove the last word of the
						// query value. Or, if it was from Advanced Search, leave it alone!
						value: advancedSearchIsOpen
							? query.value
							: removeLastWord(query.value),
						filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
						filterToItemKind: suggestion.itemKind || query.filterToItemKind,
						filterToCurrentUserOwnsOrWants:
							suggestion.userOwnsOrWants ||
							query.filterToCurrentUserOwnsOrWants,
					});
				}}
				getSuggestionValue={(zl) => zl}
				alwaysRenderSuggestions={true}
				renderSuggestion={renderSuggestion}
				renderSuggestionsContainer={renderSuggestionsContainer}
				renderInputComponent={(inputProps) => (
					<InputGroup boxShadow={boxShadow} borderRadius="md">
						{queryFilterText ? (
							<InputLeftAddon>
								<SearchIcon color="gray.400" marginRight="3" />
								<Box fontSize="sm">{queryFilterText}</Box>
							</InputLeftAddon>
						) : (
							<InputLeftElement>
								<SearchIcon color="gray.400" />
							</InputLeftElement>
						)}
						<Input
							background={background}
							// TODO: How to improve a11y here?
							// eslint-disable-next-line jsx-a11y/no-autofocus
							autoFocus={autoFocus}
							{...inputProps}
						/>
						<InputRightElement
							width="auto"
							justifyContent="flex-end"
							paddingRight="2px"
							paddingY="2px"
						>
							{!searchQueryIsEmpty(query) && (
								<Tooltip label="Clear">
									<IconButton
										icon={<CloseIcon fontSize="0.6em" />}
										color="gray.400"
										variant="ghost"
										height="100%"
										marginLeft="1"
										aria-label="Clear search"
										onClick={() => {
											setSuggestions([]);
											onChange(emptySearchQuery);
										}}
									/>
								</Tooltip>
							)}
							<Tooltip label="Advanced search">
								<IconButton
									icon={
										advancedSearchIsOpen ? (
											<ChevronUpIcon fontSize="1.5em" />
										) : (
											<ChevronDownIcon fontSize="1.5em" />
										)
									}
									color="gray.400"
									variant="ghost"
									height="100%"
									aria-label="Open advanced search"
									onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
								/>
							</Tooltip>
						</InputRightElement>
					</InputGroup>
				)}
				inputProps={{
					placeholder: "Search all items…",
					focusBorderColor: focusBorderColor,
					value: query.value || "",
					ref: searchQueryRef,
					minWidth: 0,
					"data-test-id": "item-search-input",
					onChange: (e, { newValue, method }) => {
						// The Autosuggest tries to change the _entire_ value of the element
						// when navigating suggestions, which isn't actually what we want.
						// Only accept value changes that are typed by the user!
						if (method === "type") {
							onChange({ ...query, value: newValue });
						}
					},
					onKeyDown: (e) => {
						if (e.key === "Escape") {
							if (suggestions.length > 0) {
								setSuggestions([]);
								return;
							}
							onChange(emptySearchQuery);
							e.target.blur();
						} else if (e.key === "Enter") {
							// Pressing Enter doesn't actually submit because it's all on
							// debounce, but it can be a declaration that the query is done, so
							// filter suggestions should go away!
							if (suggestions.length > 0) {
								setSuggestions([]);
								return;
							}
						} else if (e.key === "ArrowDown") {
							if (suggestions.length > 0) {
								return;
							}
							onMoveFocusDownToResults(e);
						} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
							onChange({
								...query,
								filterToItemKind: null,
								filterToZoneLabel: null,
								filterToCurrentUserOwnsOrWants: null,
							});
						}
					},
				}}
			/>
		</Box>
	);
}

function getSuggestions(
	value,
	query,
	zoneLabels,
	isLoggedIn,
	{ showAll = false } = {},
) {
	if (!value && !showAll) {
		return [];
	}

	const words = (value || "").split(/\s+/);
	const lastWord = words[words.length - 1];
	if (lastWord.length < 2 && !showAll) {
		return [];
	}

	const suggestions = [];

	if (query.filterToItemKind == null) {
		if (
			wordMatches("NC", lastWord) ||
			wordMatches("Neocash", lastWord) ||
			showAll
		) {
			suggestions.push({ itemKind: "NC", text: "Neocash items" });
		}

		if (
			wordMatches("NP", lastWord) ||
			wordMatches("Neopoints", lastWord) ||
			showAll
		) {
			suggestions.push({ itemKind: "NP", text: "Neopoint items" });
		}

		if (
			wordMatches("PB", lastWord) ||
			wordMatches("Paintbrush", lastWord) ||
			showAll
		) {
			suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
		}
	}

	if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
		if (wordMatches("Items you own", lastWord) || showAll) {
			suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
		}

		if (wordMatches("Items you want", lastWord) || showAll) {
			suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
		}
	}

	if (query.filterToZoneLabel == null) {
		for (const zoneLabel of zoneLabels) {
			if (wordMatches(zoneLabel, lastWord) || showAll) {
				suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
			}
		}
	}

	return suggestions;
}

function wordMatches(target, word) {
	return target.toLowerCase().includes(word.toLowerCase());
}

function getQueryFilterText(query) {
	const textWords = [];

	if (query.filterToItemKind) {
		textWords.push(query.filterToItemKind);
	}

	if (query.filterToZoneLabel) {
		textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
	}

	if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
		if (!query.filterToItemKind && !query.filterToZoneLabel) {
			textWords.push("Items");
		} else if (query.filterToItemKind && !query.filterToZoneLabel) {
			textWords.push("items");
		}
		textWords.push("you own");
	} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
		if (!query.filterToItemKind && !query.filterToZoneLabel) {
			textWords.push("Items");
		} else if (query.filterToItemKind && !query.filterToZoneLabel) {
			textWords.push("items");
		}
		textWords.push("you want");
	}

	return textWords.join(" ");
}

/**
 * pluralizeZoneLabel hackily tries to convert a zone name to a plural noun!
 *
 * HACK: It'd be more reliable and more translatable to do this by just
 *       manually creating the plural for each zone. But, ehh! ¯\_ (ツ)_/¯
 */
function pluralizeZoneLabel(zoneLabel) {
	if (zoneLabel.endsWith("ss")) {
		return zoneLabel + "es";
	} else if (zoneLabel.endsWith("s")) {
		return zoneLabel;
	} else {
		return zoneLabel + "s";
	}
}

/**
 * removeLastWord returns a copy of the text, with the last word and any
 * preceding space removed.
 */
function removeLastWord(text) {
	// This regex matches the full text, and assigns the last word and any
	// preceding text to subgroup 2, and all preceding text to subgroup 1. If
	// there's no last word, we'll still match, and the full string will be in
	// subgroup 1, including any space - no changes made!
	const match = text.match(/^(.*?)(\s*\S+)?$/);
	if (!match) {
		logAndCapture(
			new Error(
				`Assertion failure: pattern should match any input text, ` +
					`but failed to match ${JSON.stringify(text)}`,
			),
		);
		return text;
	}

	return match[1];
}

export default SearchToolbar;