import { Check, ChevronDown, Loader2 } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';

/**
 * One row in the list. Choices are always ids, because that is what the form
 * posts.
 */
export type ComboboxOption = {
    value: number;
    label: string;
    /** A second line, such as a customer number and phone, used for searching too. */
    note?: string | null;
    /** A short right-aligned annotation, such as a job's status. */
    meta?: string | null;
};

/**
 * Searching a list too long to send with the page — the seven thousand
 * customers, for instance. The picker asks for what has been typed rather than
 * being handed everything, which is the difference between a list that covers
 * the customer database and one that covers the newest few hundred rows of it.
 */
export type ComboboxRemote = {
    url: string;
    /**
     * Parameters sent with every request, such as the customer whose jobs are
     * being listed.
     */
    params?: Record<string, string | number | null | undefined>;
    /** The parameter the typed text travels in. */
    queryParam?: string;
    /** How much has to be typed before the first search. */
    minChars?: number;
};

/**
 * Roughly the height the open list needs, so it can tell when the space below a
 * field is too short to hold it.
 */
const LIST_HEIGHT = 272;

type ComboboxProps = {
    id: string;
    name: string;
    options: ComboboxOption[];
    value: number | null;
    /**
     * The chosen value, and the row it came from — which is how a caller reads
     * the extra detail a search returned, such as a customer's contact.
     */
    onChange: (value: number | null, option: ComboboxOption | null) => void;
    placeholder?: string;
    /**
     * Adds a row at the top of the list that clears the field. Everything on a
     * ticket is optional, so every picker needs a way back to nothing.
     */
    clearLabel?: string;
    disabled?: boolean;
    /** Shown instead of the list when nothing matches what was typed. */
    emptyLabel?: string;
    /** Searches the server as you type instead of filtering what it was given. */
    remote?: ComboboxRemote | null;
    className?: string;
};

/**
 * A searchable picker for the CRM forms.
 *
 * Some of these lists are hundreds of rows long — customers, jobs, the whole
 * staff list — and a native select makes you scroll through all of them to find
 * one. This filters as you type, matches the second line too, and still posts a
 * plain named value so it drops straight into an Inertia form. The selected id
 * travels in a hidden input, which is what the form submits; the visible input
 * only ever holds text.
 *
 * With `remote` it does not filter a list at all: it asks the server, which is
 * the only way a picker can offer all eight thousand jobs rather than the ones
 * that happened to fit in the page.
 *
 * Nothing is committed until a row is chosen, so a half-typed name can never
 * post as a value: leaving the field restores the label of whatever is actually
 * selected.
 */
export function Combobox({
    id,
    name,
    options,
    value,
    onChange,
    placeholder = 'Start typing to search',
    clearLabel,
    disabled = false,
    emptyLabel = 'No matches',
    remote = null,
    className,
}: ComboboxProps) {
    const [open, setOpen] = React.useState(false);
    const [query, setQuery] = React.useState('');
    const [active, setActive] = React.useState(0);
    const [dropUp, setDropUp] = React.useState(false);
    const [found, setFound] = React.useState<ComboboxOption[]>([]);
    const [searching, setSearching] = React.useState(false);
    // A search that fails must look different from one that simply found
    // nothing, or a broken connection reads as "this customer does not exist".
    const [failure, setFailure] = React.useState<string | null>(null);
    const [attempt, setAttempt] = React.useState(0);
    const wrapper = React.useRef<HTMLDivElement>(null);
    const listId = `${id}-listbox`;
    const rowId = (index: number) => `${id}-option-${index}`;

    const selected = options.find((option) => option.value === value) ?? null;

    // Params are usually written inline as an object literal, so they are
    // compared by their contents rather than by identity: otherwise every
    // render would look like a new search.
    const paramsKey = JSON.stringify(remote?.params ?? {});
    const queryParam = remote?.queryParam ?? 'search';
    const minChars = remote?.minChars ?? 0;
    const remoteUrl = remote?.url ?? null;

    /**
     * One search per pause in typing, cancelled the moment the text or the
     * parameters change again, so a fast typist never sees an old answer land on
     * top of a new question.
     */
    React.useEffect(() => {
        if (remoteUrl === null || !open || disabled) {
            return;
        }

        const term = query.trim();

        if (term.length < minChars) {
            setFound([]);

            return;
        }

        const controller = new AbortController();
        const timer = window.setTimeout(async () => {
            setSearching(true);
            setFailure(null);

            try {
                const url = new URL(remoteUrl, window.location.origin);
                url.searchParams.set(queryParam, term);

                for (const [key, param] of Object.entries(
                    JSON.parse(paramsKey) as Record<
                        string,
                        string | number | null
                    >,
                )) {
                    if (param !== null && param !== undefined) {
                        url.searchParams.set(key, String(param));
                    }
                }

                const response = await fetch(url, {
                    headers: { Accept: 'application/json' },
                    signal: controller.signal,
                });

                if (!response.ok) {
                    throw new Error(
                        `Search failed with status ${response.status}`,
                    );
                }

                const payload = (await response.json()) as {
                    options?: ComboboxOption[];
                };

                if (!controller.signal.aborted) {
                    setFound(payload.options ?? []);
                }
            } catch (error) {
                // Aborted searches are the normal case, not a failure.
                if (!controller.signal.aborted) {
                    setFound([]);
                    setFailure(
                        error instanceof Error &&
                            error.message.startsWith('Search failed')
                            ? 'Search is not available right now.'
                            : 'Could not reach the server.',
                    );
                }
            } finally {
                if (!controller.signal.aborted) {
                    setSearching(false);
                }
            }
        }, 250);

        return () => {
            window.clearTimeout(timer);
            controller.abort();
        };
    }, [
        remoteUrl,
        paramsKey,
        queryParam,
        minChars,
        open,
        disabled,
        query,
        attempt,
    ]);

    const matches = React.useMemo(() => {
        if (remoteUrl === null) {
            const term = query.trim().toLowerCase();

            if (term === '') {
                return options;
            }

            return options.filter((option) =>
                `${option.label} ${option.note ?? ''} ${option.meta ?? ''}`
                    .toLowerCase()
                    .includes(term),
            );
        }

        // What the server found, with whatever is already selected kept at the
        // top: a search that no longer matches the chosen row must not make the
        // choice disappear.
        return [
            ...(selected === null ? [] : [selected]),
            ...found.filter((option) => option.value !== value),
        ];
    }, [remoteUrl, options, query, found, selected, value]);

    // The clearing row, when there is one, is the first row of the list.
    const rows: Array<ComboboxOption | null> =
        clearLabel === undefined ? matches : [null, ...matches];

    // The highlight is clamped rather than reset, so typing narrows the list
    // without the selection jumping to the top on every keystroke.
    const cursor = Math.min(active, rows.length - 1);

    const openList = () => {
        if (disabled) {
            return;
        }

        setFailure(null);
        setQuery('');
        setActive(
            Math.max(
                0,
                rows.findIndex((row) =>
                    row === null ? value === null : row.value === value,
                ),
            ),
        );
        setOpen(true);
    };

    const close = () => {
        setOpen(false);
        setQuery('');
        setActive(0);
    };

    const choose = (row: ComboboxOption | null) => {
        onChange(row === null ? null : row.value, row);
        close();
    };

    // Clicking anywhere else is a dismissal. Rows themselves are kept from
    // taking focus, so choosing one does not read as leaving the field.
    React.useEffect(() => {
        if (!open) {
            return;
        }

        const onPointerDown = (event: MouseEvent) => {
            if (
                wrapper.current &&
                !wrapper.current.contains(event.target as Node)
            ) {
                close();
            }
        };

        document.addEventListener('mousedown', onPointerDown);

        return () => document.removeEventListener('mousedown', onPointerDown);
    }, [open]);

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

        document
            .getElementById(rowId(cursor))
            ?.scrollIntoView({ block: 'nearest' });
    }, [open, cursor]);

    // These pickers sit well down a long form, so the list drops below the
    // field only while there is room. Near the bottom of the window it opens
    // upwards instead of running off the screen.
    React.useEffect(() => {
        if (!open) {
            setDropUp(false);

            return;
        }

        const measure = () => {
            const rect = wrapper.current?.getBoundingClientRect();

            if (!rect) {
                return;
            }

            const below = window.innerHeight - rect.bottom;

            setDropUp(below < LIST_HEIGHT && rect.top > below);
        };

        measure();
        window.addEventListener('resize', measure);
        window.addEventListener('scroll', measure, true);

        return () => {
            window.removeEventListener('resize', measure);
            window.removeEventListener('scroll', measure, true);
        };
    }, [open]);

    const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
        if (event.key === 'ArrowDown') {
            event.preventDefault();

            if (open) {
                setActive(Math.min(cursor + 1, rows.length - 1));
            } else {
                openList();
            }

            return;
        }

        if (event.key === 'ArrowUp') {
            event.preventDefault();

            if (open) {
                setActive(Math.max(cursor - 1, 0));
            } else {
                openList();
            }

            return;
        }

        if (event.key === 'Enter') {
            if (!open) {
                return;
            }

            event.preventDefault();

            const row = rows[cursor];

            if (row !== undefined) {
                choose(row);
            }

            return;
        }

        if (event.key === 'Escape') {
            if (open) {
                event.preventDefault();
                event.stopPropagation();
                close();
            }

            return;
        }

        if (event.key === 'Tab') {
            close();
        }
    };

    return (
        <div ref={wrapper} className={cn('relative', className)}>
            {/* What the form actually posts. */}
            <input
                type="hidden"
                name={name}
                value={value === null ? '' : String(value)}
            />

            <input
                id={id}
                type="text"
                role="combobox"
                autoComplete="off"
                spellCheck={false}
                aria-expanded={open}
                aria-controls={listId}
                aria-autocomplete="list"
                aria-busy={searching}
                aria-activedescendant={open ? rowId(cursor) : undefined}
                disabled={disabled}
                className={cn(
                    'h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-8 pl-3 text-sm shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30',
                    'placeholder:text-muted-foreground',
                    'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
                    'disabled:cursor-not-allowed disabled:opacity-50',
                )}
                placeholder={selected?.label ?? placeholder}
                value={open ? query : (selected?.label ?? '')}
                onChange={(event) => {
                    setQuery(event.target.value);
                    // Typing highlights the first match, not the row that clears
                    // the field, so Enter chooses a real answer rather than
                    // emptying the picker.
                    setActive(clearLabel === undefined ? 0 : 1);
                    setOpen(true);
                }}
                onFocus={openList}
                onKeyDown={onKeyDown}
            />

            {searching ? (
                <Loader2
                    aria-hidden
                    className="absolute top-1/2 right-2.5 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground"
                />
            ) : (
                <ChevronDown
                    aria-hidden
                    className="pointer-events-none absolute top-1/2 right-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
                />
            )}

            {open ? (
                <div
                    id={listId}
                    role="listbox"
                    className={cn(
                        'absolute z-50 max-h-64 w-full min-w-[16rem] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
                        dropUp ? 'bottom-full mb-1' : 'top-full mt-1',
                    )}
                >
                    {rows.length === 0 ? (
                        <div className="px-3 py-2 text-sm text-muted-foreground">
                            <p>
                                {searching
                                    ? 'Searching…'
                                    : (failure ?? emptyLabel)}
                            </p>
                            {failure !== null && !searching ? (
                                <button
                                    type="button"
                                    onClick={() => setAttempt((n) => n + 1)}
                                    className="mt-1 text-xs font-medium text-foreground underline underline-offset-2"
                                >
                                    Try again
                                </button>
                            ) : null}
                        </div>
                    ) : (
                        rows.map((row, index) => {
                            const isSelected =
                                row === null
                                    ? value === null
                                    : row.value === value;

                            return (
                                <button
                                    key={row === null ? 'clear' : row.value}
                                    id={rowId(index)}
                                    type="button"
                                    role="option"
                                    aria-selected={isSelected}
                                    // Keeps the text input focused, so the list
                                    // stays open while a row is clicked.
                                    onMouseDown={(event) =>
                                        event.preventDefault()
                                    }
                                    onClick={() => choose(row)}
                                    className={cn(
                                        'flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
                                        index === cursor
                                            ? 'bg-accent text-accent-foreground'
                                            : 'text-foreground',
                                    )}
                                >
                                    <Check
                                        aria-hidden
                                        className={cn(
                                            'mt-0.5 size-3.5 shrink-0',
                                            isSelected
                                                ? 'opacity-100'
                                                : 'opacity-0',
                                        )}
                                    />

                                    <span className="flex min-w-0 flex-1 flex-col">
                                        <span className="truncate">
                                            {row === null
                                                ? (clearLabel ?? 'None')
                                                : row.label}
                                        </span>
                                        {row?.note ? (
                                            <span className="truncate text-xs text-muted-foreground">
                                                {row.note}
                                            </span>
                                        ) : null}
                                    </span>

                                    {row?.meta ? (
                                        <span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
                                            {row.meta}
                                        </span>
                                    ) : null}
                                </button>
                            );
                        })
                    )}
                </div>
            ) : null}
        </div>
    );
}
