import "./FilterBuilder.css";

import { Button, Checkbox, Dialog, Heading } from "@digdir/designsystemet-react";
import { XMarkIcon } from "@navikt/aksel-icons";
import {
	canEditSavedFilter,
	canSetCrossDomain,
	canShareSavedFilter,
	createEmptyFilter,
	findSavedFilter,
	type FilterTree,
	getLocalizedString,
	isEmptyFilter,
	pruneFilter,
	type SavedFilter,
} from "@olenbetong/appframe-core";
import { type NamedFilterType, setNamedFilter, useFilterConversion, useSavedFilters } from "@olenbetong/appframe-react";
import type { SaveFilterInput } from "@olenbetong/appframe-react";
import type { DataObject } from "@olenbetong/appframe-data";
import clsx from "clsx";
import { type ReactNode, useCallback, useEffect, useState } from "react";

import { FilterEditor } from "./FilterEditor.js";
import { FilterNameDialog } from "./FilterNameDialog.js";
import { FilterShareDialog } from "./FilterShareDialog.js";
import { SavedFilterTree } from "./SavedFilterTree.js";
import type { FilterEditorSize, GetFieldOptions } from "./types.js";
import type { FieldMetadata } from "@olenbetong/appframe-core";

export type FilterBuilderProps = {
	/** The fields the user can filter on. Fields with `excludeFromFilter` are hidden. */
	fields: FieldMetadata[];
	/**
	 * The view the filter applies to. Used both for conversion and, unless
	 * `dbObjectId` is set, as the key saved filters are stored under.
	 */
	viewName?: string;
	/** Overrides `viewName` as the key saved filters are stored under. */
	dbObjectId?: string;
	/** Loads the filters saved for a report instead of for a view. */
	reportId?: string;
	getFieldOptions?: GetFieldOptions;
	/**
	 * `"dialog"` wraps the builder in a modal, `"inline"` renders it in place.
	 * @default "dialog"
	 */
	variant?: "dialog" | "inline";
	/** Whether the dialog is open. Ignored when `variant` is `"inline"`. */
	open?: boolean;
	/** Called when the dialog is dismissed, and after the filter is applied. */
	onClose?: () => void;
	/** The filter tree. Use together with `onChange` for a controlled component. */
	value?: FilterTree | null;
	/** The initial filter tree when uncontrolled. */
	defaultValue?: FilterTree | null;
	/** Called on every edit. */
	onChange?: (filter: FilterTree) => void;
	/**
	 * Called when the user applies the filter. The tree is pruned, so incomplete
	 * conditions are dropped and an empty filter is reported as `null`.
	 */
	onApply?: (filter: FilterTree | null, filterString: string) => void;
	/**
	 * Applies the filter to a data object when the user confirms. The filter is
	 * written to a named slot, so it composes with other filter providers on the
	 * same data object instead of overwriting them.
	 */
	dataObject?: DataObject<any>;
	/**
	 * Parameter the filter is applied to.
	 * @default "filterObject"
	 */
	filterType?: NamedFilterType;
	/**
	 * Name of the slot the filter is applied to.
	 * @default "filterBuilder"
	 */
	slotName?: string;
	/**
	 * Shows the filter string below the tree.
	 * @default true
	 */
	showFilterString?: boolean;
	/** Called whenever the filter string is reconverted. */
	onFilterStringChange?: (filterString: string) => void;
	/** Called when a saved filter is selected, and with `null` for "New Filter". */
	onSelect?: (filter: SavedFilter | null) => void;
	/**
	 * Extra columns written along with the filter when it is saved. The report
	 * launcher stores its header text, its hide-criteria flag and its parameter
	 * values here.
	 */
	saveValues?: SavedFilterValues;
	/**
	 * Hides the OK/Cancel row, for callers that supply their own actions. The
	 * saved-filter toolbar stays.
	 */
	hideActions?: boolean;
	/**
	 * Replaces the OK/Cancel buttons, so a caller's own actions share the row
	 * with the buttons acting on the selected saved filter.
	 */
	actions?: ReactNode;
	/**
	 * Wraps the criteria editor, for callers that put it beside content of their
	 * own — the report launcher puts it in a tab next to the report parameters.
	 * Whatever is returned fills the column next to the saved-filter list.
	 */
	renderMain?: (editor: ReactNode) => ReactNode;
	/** Hides the saved-filter list, leaving just the criteria editor. */
	hideSavedFilters?: boolean;
	className?: string;
	"data-size"?: FilterEditorSize;
};

/** The parts of a saved filter the builder writes on behalf of a caller. */
export type SavedFilterValues = Pick<SaveFilterInput, "headerText" | "hideCriteria" | "parameters">;

/**
 * The web port of the Windows filter builder: the saved-filter list, the
 * criteria editor and the save/share toolbar.
 *
 * Saved filters are stored as criteria strings, so selecting one round-trips
 * through the conversion endpoint to rebuild the tree, and saving converts the
 * tree back.
 *
 * @example
 * ```jsx
 * <FilterBuilder
 *   open={open}
 *   fields={dsPersons.getFields()}
 *   viewName="aviw_Personnel_Persons"
 *   dataObject={dsPersons}
 *   onClose={() => setOpen(false)}
 * />
 * ```
 */
export function FilterBuilder({
	fields,
	viewName,
	dbObjectId,
	reportId,
	getFieldOptions,
	variant = "dialog",
	open = true,
	onClose,
	value,
	defaultValue,
	onChange,
	onApply,
	dataObject,
	filterType = "filterObject",
	slotName = "filterBuilder",
	showFilterString = true,
	onFilterStringChange,
	onSelect,
	saveValues,
	hideActions,
	actions,
	renderMain,
	hideSavedFilters,
	className,
	"data-size": size = "sm",
}: FilterBuilderProps) {
	let resolvedId = dbObjectId ?? viewName;
	let { mine, shared, filters, loading, save, rename, remove, setCrossDomain } = useSavedFilters({
		dbObjectId: reportId ? undefined : resolvedId,
		reportId,
	});

	let [uncontrolled, setUncontrolled] = useState<FilterTree>(() => defaultValue ?? createEmptyFilter());
	let isControlled = value !== undefined;
	let filter = isControlled ? (value ?? createEmptyFilter()) : uncontrolled;

	let [selectedId, setSelectedId] = useState<number | null>(null);
	let [dirty, setDirty] = useState(false);
	let [saving, setSaving] = useState(false);
	let [sharing, setSharing] = useState(false);
	let [error, setError] = useState<string | null>(null);

	let { filterString, isConverting, parseFilterString } = useFilterConversion(filter, { viewName });
	let selected = findSavedFilter(filters, selectedId);

	useEffect(() => {
		onFilterStringChange?.(filterString);
	}, [filterString, onFilterStringChange]);

	let update = useCallback(
		(next: FilterTree) => {
			if (!isControlled) setUncontrolled(next);
			onChange?.(next);
			setDirty(true);
		},
		[isControlled, onChange],
	);

	function applyFilter(next: FilterTree, markClean: boolean) {
		if (!isControlled) setUncontrolled(next);
		onChange?.(next);
		if (markClean) setDirty(false);
	}

	async function handleSelect(filterId: number | null) {
		setSelectedId(filterId);
		setError(null);

		if (filterId === null) {
			applyFilter(createEmptyFilter(), true);
			onSelect?.(null);

			return;
		}

		let savedFilter = findSavedFilter(filters, filterId);
		if (!savedFilter) return;

		onSelect?.(savedFilter);

		// Filters are stored as criteria strings, so the tree has to be rebuilt.
		let parsed = await parseFilterString(savedFilter.criteria);
		applyFilter(parsed ?? createEmptyFilter(), true);
	}

	async function handleSave(name: string) {
		setSaving(false);
		setError(null);

		try {
			// Saving under an existing name of the user's own replaces that
			// filter rather than creating a duplicate.
			let existing = mine.find((candidate) => candidate.name === name);
			let saved = await save({
				...saveValues,
				filterId: existing?.filterId ?? null,
				name,
				criteria: filterString,
				crossDomain: existing?.crossDomain ?? undefined,
			});

			if (saved) setSelectedId(saved.filterId);
			setDirty(false);
		} catch (cause) {
			setError(String((cause as Error)?.message ?? cause));
		}
	}

	async function handleOverwrite() {
		if (!selected) return;

		setError(null);

		try {
			await save({ ...saveValues, filterId: selected.filterId, name: selected.name, criteria: filterString });
			setDirty(false);
		} catch (cause) {
			setError(String((cause as Error)?.message ?? cause));
		}
	}

	function handleRename(savedFilter: SavedFilter) {
		let name = globalThis.prompt?.(getLocalizedString("Filter name"), savedFilter.name);
		if (name?.trim()) void rename(savedFilter.filterId, name.trim());
	}

	function handleDelete(savedFilter: SavedFilter) {
		let message = getLocalizedString("Delete the filter {0}?").replace("{0}", savedFilter.name);
		if (!globalThis.confirm?.(message)) return;

		void remove(savedFilter.filterId).then(() => {
			if (savedFilter.filterId === selectedId) void handleSelect(null);
		});
	}

	function handleApply() {
		let pruned = pruneFilter(filter);

		if (dataObject) {
			setNamedFilter(dataObject, slotName, pruned, { type: filterType });
		}

		onApply?.(pruned, filterString);
		onClose?.();
	}

	let isEmpty = isEmptyFilter(filter);
	let canOverwrite = Boolean(selected) && canEditSavedFilter(selected as SavedFilter) && dirty && !isEmpty;

	let editor = (
		<FilterEditor
			fields={fields}
			value={filter}
			viewName={viewName}
			getFieldOptions={getFieldOptions}
			showFilterString={showFilterString}
			data-size={size}
			onChange={update}
		/>
	);

	let content = (
		<div
			className={clsx("ObFilterBuilder-root", hideSavedFilters && "ObFilterBuilder-root--noSavedFilters", className)}
			data-size={size}
		>
			{!hideSavedFilters && (
				<div className="ObFilterBuilder-sidebar">
					<SavedFilterTree
						mine={mine}
						shared={shared}
						value={selectedId}
						dirty={dirty}
						loading={loading}
						data-size={size}
						onChange={(filterId) => void handleSelect(filterId)}
						onRename={handleRename}
						onDelete={handleDelete}
					/>
				</div>
			)}
			<div className="ObFilterBuilder-main">{renderMain ? renderMain(editor) : editor}</div>
			{error && (
				<p className="ObFilterBuilder-error" role="alert">
					{error}
				</p>
			)}
			<div className="ObFilterBuilder-footer">
				{!hideSavedFilters && (
					<div className="ObFilterBuilder-toolbar">
						<Button
							type="button"
							variant="secondary"
							data-size={size}
							disabled={!selected || !canShareSavedFilter(selected)}
							onClick={() => setSharing(true)}
						>
							{getLocalizedString("Sharing...")}
						</Button>
						<Button
							type="button"
							variant="secondary"
							data-size={size}
							disabled={isEmpty || isConverting}
							onClick={() => setSaving(true)}
						>
							{getLocalizedString("Save as")}
						</Button>
						<Button
							type="button"
							variant="secondary"
							data-size={size}
							disabled={!canOverwrite || isConverting}
							onClick={() => void handleOverwrite()}
						>
							{getLocalizedString("Save")}
						</Button>
						<Checkbox
							data-size={size}
							label={getLocalizedString("All Domains")}
							checked={selected?.crossDomain ?? false}
							disabled={!selected || !canSetCrossDomain(selected)}
							onChange={(event) => {
								if (selected) void setCrossDomain(selected.filterId, event.currentTarget.checked);
							}}
						/>
					</div>
				)}
				{!hideActions && (
					<div className="ObFilterBuilder-actions">
						{actions ?? (
							<>
								<Button type="button" data-size={size} onClick={handleApply}>
									{getLocalizedString("OK")}
								</Button>
								<Button type="button" variant="secondary" data-size={size} onClick={onClose}>
									{getLocalizedString("Cancel")}
								</Button>
							</>
						)}
					</div>
				)}
			</div>
			<FilterNameDialog
				open={saving}
				defaultName={selected?.name ?? ""}
				existingNames={mine.map((candidate) => candidate.name)}
				data-size={size}
				onCancel={() => setSaving(false)}
				onSubmit={(name) => void handleSave(name)}
			/>
			<FilterShareDialog
				open={sharing}
				filterId={selectedId}
				filterName={selected?.name}
				data-size={size}
				onClose={() => setSharing(false)}
			/>
		</div>
	);

	if (variant === "inline") return content;

	return (
		<Dialog
			open={open}
			closedby="closerequest"
			closeButton={false}
			data-size={size}
			className="ObFilterBuilder-dialog"
			onClose={onClose}
		>
			{/*
			 * `Dialog`'s own close button is a floated first child of the dialog,
			 * which a flex layout takes out of its float and stacks above the
			 * heading. Rendering it in the header instead keeps it aligned.
			 */}
			<Dialog.Block className="ObFilterBuilder-dialogHeader">
				<Heading level={2} data-size="xs">
					{getLocalizedString("Edit filter")}
				</Heading>
				<Button
					type="button"
					variant="tertiary"
					data-color="neutral"
					data-size={size}
					icon
					aria-label={getLocalizedString("Close")}
					onClick={onClose}
				>
					<XMarkIcon aria-hidden />
				</Button>
			</Dialog.Block>
			<Dialog.Block className="ObFilterBuilder-dialogBody">{content}</Dialog.Block>
		</Dialog>
	);
}
