import "./AppMenu.css";

import { getLocalizedString } from "@olenbetong/appframe-core";
import type { DataObject } from "@olenbetong/appframe-data";
import { useData, useLoading } from "@olenbetong/appframe-react";
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";

import { AppMenuHeader } from "./AppMenuHeader.js";
import { AppMenuList } from "./AppMenuList.js";
import { AppMenuRail } from "./AppMenuRail.js";
import type { AppMenuUser } from "./AppMenuUserMenu.js";
import {
	type AppMenuArticle,
	countItems,
	FAVORITES,
	filterCategory,
	groupEntries,
	groupLabel,
	type Selection,
	toAppEntries,
} from "./menuEntries.js";
import type { AppMenuCategory, AppMenuLink, AppMenuLinkGroup } from "./types.js";
import { useFavorites } from "./useFavorites.js";

export type { AppMenuArticle } from "./menuEntries.js";

export type AppMenuProps = {
	/** Data object returning the articles to list. See `AppMenuArticle` for the required fields. */
	dataObject: DataObject<AppMenuArticle>;
	/** Whether the menu is open. The menu is a controlled component. */
	open: boolean;
	/** Called when the menu requests to be closed (backdrop click, Escape, app selected). */
	onClose: () => void;
	/**
	 * Extra categories listed below the app categories in the rail, showing a
	 * fixed set of links instead of apps. See `createDeveloperCategories`.
	 */
	categories?: AppMenuCategory[];
	/** Data object over `aviw_General_SidebarUserInfo`. Enables the user menu in the header. */
	userDataObject?: DataObject<AppMenuUser>;
	/** Overrides the default user menu entries. */
	userMenuItems?: AppMenuLink[];
	/**
	 * Called when the favorite toggle next to an app is activated. Without this
	 * handler no favorite toggles are rendered.
	 *
	 * `id` is the version-less ArticleId. The menu updates its own state
	 * optimistically and rolls back if the promise rejects.
	 */
	onToggleFavorite?: (id: string, favorite: boolean) => void | Promise<unknown>;
	/**
	 * Link shown at the start of the content header, e.g. the portal/front page,
	 * which isn't part of the app list. Omit it when already on that page.
	 */
	homeLink?: AppMenuLink;
	/** Shown below the user's name. Defaults to `af.userSession.domain`. */
	userSubtitle?: string;
	/**
	 * Shows a highlighted "install update" button in the menu header. Pair with
	 * `useServiceWorkerUpdate()`.
	 */
	hasUpdate?: boolean;
	/** Called when the install update button is pressed. */
	onInstallUpdate?: () => void;
	id?: string;
	className?: string;
};

/**
 * Two pane application menu. The left rail holds a local search field and the
 * list of app categories (`MenuGroup`); the right pane lists the apps in the
 * selected category, or every app when no category is selected.
 *
 * On narrow viewports only one pane is visible at a time, defaulting to the app
 * list, with a button to switch to the category list.
 */
export function AppMenu({
	dataObject,
	open,
	onClose,
	categories = [],
	userDataObject,
	userMenuItems,
	userSubtitle,
	onToggleFavorite,
	homeLink,
	hasUpdate = false,
	onInstallUpdate,
	id,
	className,
}: AppMenuProps) {
	let dialogRef = useRef<HTMLDialogElement>(null);
	let searchRef = useRef<HTMLInputElement>(null);
	let [search, setSearch] = useState("");
	let [selection, setSelection] = useState<Selection>(null);
	let [mobilePane, setMobilePane] = useState<"apps" | "groups">("apps");
	let [hasLoaded, setHasLoaded] = useState(false);
	/*
	 * True once the initial category has been decided, either by the menu picking
	 * the favorites when the apps arrive, or by the user picking one themselves.
	 * Without it the menu would jump to the favorites group the moment the first
	 * favorite is added.
	 */
	let [hasResolvedSelection, setHasResolvedSelection] = useState(false);

	let isLoading = useLoading(dataObject);
	let articles = useData<AppMenuArticle>(dataObject);
	let { applyOverrides, toggleFavorite } = useFavorites(onToggleFavorite);

	// Sync the native dialog with the `open` prop. Using showModal() puts the
	// dialog in the top layer so it renders above the toolbar and page content.
	useEffect(() => {
		let dialog = dialogRef.current;
		if (!dialog) return;

		if (open && !dialog.open) {
			dialog.showModal();
			requestAnimationFrame(() => searchRef.current?.focus());
		} else if (!open && dialog.open) {
			dialog.close();
		}
	}, [open]);

	// Reset transient state whenever the menu is closed.
	useEffect(() => {
		if (!open) {
			setSearch("");
			setSelection(null);
			setMobilePane("apps");
			setHasResolvedSelection(false);
		}
	}, [open]);

	// Load the app list the first time the menu is opened.
	useEffect(() => {
		if (open && !hasLoaded) {
			setHasLoaded(true);
			dataObject.refreshDataSource();
			userDataObject?.refreshDataSource();
		}
	}, [open, hasLoaded, dataObject, userDataObject]);

	let entries = useMemo(() => applyOverrides(toAppEntries(articles)), [articles, applyOverrides]);
	let term = search.trim().toLowerCase();

	let searchMatches = useMemo(() => {
		if (!term) return entries;
		return entries.filter((entry) => entry.title.toLowerCase().includes(term) || entry.id.toLowerCase().includes(term));
	}, [entries, term]);

	let groups = useMemo(() => groupEntries(searchMatches), [searchMatches]);
	let visibleCategories = useMemo(
		() => categories.map((category) => filterCategory(category, term)).filter((category) => countItems(category) > 0),
		[categories, term],
	);

	let hasFavorites = groups.length > 0 && groups[0][0] === FAVORITES;

	/*
	 * The favorites are the most likely target, so select them by default. The
	 * article list loads asynchronously, so this can't be done when opening the
	 * menu — it has to wait until the apps have arrived. It only ever runs once
	 * per open, so favoriting an app doesn't move the user to another group.
	 */
	useEffect(() => {
		if (!open || hasResolvedSelection || entries.length === 0) return;

		setHasResolvedSelection(true);
		if (hasFavorites) setSelection({ type: "group", key: FAVORITES });
	}, [open, hasResolvedSelection, entries.length, hasFavorites]);

	// A selection that no longer has matches falls back to "all apps" so the pane
	// never ends up empty while results exist elsewhere.
	let activeSelection: Selection = null;
	if (selection?.type === "group" && groups.some(([group]) => group === selection.key)) activeSelection = selection;
	else if (selection?.type === "category" && visibleCategories.some((category) => category.id === selection.id)) {
		activeSelection = selection;
	}

	let activeCategoryId = activeSelection?.type === "category" ? activeSelection.id : null;
	let activeCategory = visibleCategories.find((category) => category.id === activeCategoryId);
	let activeGroupKey = activeSelection?.type === "group" ? activeSelection.key : null;

	// Everything the right hand pane renders, normalized to titled link groups.
	// The favorites are left out of the "all apps" pane, since those apps are
	// already listed in their own group.
	let paneGroups: AppMenuLinkGroup[] = useMemo(() => {
		if (activeCategory) return activeCategory.groups;

		return groups
			.filter(([group]) => (activeGroupKey === null ? group !== FAVORITES : group === activeGroupKey))
			.map(([group, items]) => ({
				title: groupLabel(group),
				items: items.map((entry) => ({
					id: entry.id,
					label: entry.title,
					href: entry.href,
					isFavorite: entry.isFavorite,
				})),
			}));
	}, [activeCategory, activeGroupKey, groups]);

	let paneItemCount = paneGroups.reduce((total, group) => total + group.items.length, 0);
	let hasApps = searchMatches.length > 0;
	let railGroups = groups.map(([key, items]) => ({ key, count: items.length }));

	function select(next: Selection) {
		setSelection(next);
		setHasResolvedSelection(true);
		setMobilePane("apps");
	}

	return (
		<dialog
			ref={dialogRef}
			id={id}
			className={clsx("ob-app-menu", className)}
			aria-label={getLocalizedString("Applications")}
			data-pane={mobilePane}
			onCancel={(event) => {
				// Let React own the open state instead of the browser's default close.
				event.preventDefault();
				onClose();
			}}
			onKeyDown={(event) => {
				// A `type="search"` input consumes Escape to clear itself, so the
				// dialog's own cancel event never fires when the search has focus.
				if (event.key === "Escape") {
					event.preventDefault();
					onClose();
				}
			}}
			onClick={(event) => {
				// Clicks on the dialog element itself are backdrop clicks; anything
				// inside the menu is caught by the inner wrapper below.
				if (event.target === dialogRef.current) onClose();
			}}
		>
			<div className="ob-app-menu__panel">
				<AppMenuRail
					searchRef={searchRef}
					search={search}
					onSearchChange={setSearch}
					groups={railGroups}
					appCount={searchMatches.length}
					categories={visibleCategories}
					selection={activeSelection}
					onSelect={select}
				/>

				<div className="ob-app-menu__content">
					{isLoading && !hasApps && !activeCategory && (
						<p className="ob-app-menu__message">{getLocalizedString("Loading...")}</p>
					)}

					{!isLoading && paneItemCount === 0 && (
						<p className="ob-app-menu__message">{getLocalizedString("No applications found")}</p>
					)}

					{paneItemCount > 0 && (
						<AppMenuList
							groups={paneGroups}
							itemCount={paneItemCount}
							onClose={onClose}
							onToggleFavorite={toggleFavorite}
						/>
					)}

					<AppMenuHeader
						homeLink={homeLink}
						mobilePane={mobilePane}
						onToggleMobilePane={() => setMobilePane(mobilePane === "apps" ? "groups" : "apps")}
						hasUpdate={hasUpdate}
						onInstallUpdate={onInstallUpdate}
						userDataObject={userDataObject}
						userMenuItems={userMenuItems}
						userSubtitle={userSubtitle}
						onClose={onClose}
					/>
				</div>
			</div>
		</dialog>
	);
}
