import clsx from "clsx";
import groupBy from "lodash/groupBy";
import {
  ChangeEvent,
  CSSProperties,
  FC,
  SelectHTMLAttributes,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";

import {
  FeatureChoiceOption,
  HtmlSelectOption,
  HtmlSelectOptionGroup,
} from "@dndbeyond/character-rules-engine";
import Check from "@dndbeyond/fontawesome-cache/svgs/regular/check.svg";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";

import styles from "./styles.module.css";

export interface SelectOption {
  value: string | number;
  label: string;
  groupLabel?: string;
  index: number;
}

export interface SelectProps
  extends Omit<
    SelectHTMLAttributes<HTMLDivElement>,
    "value" | "onChange" | "name" | "size"
  > {
  options: (FeatureChoiceOption | HtmlSelectOption | HtmlSelectOptionGroup)[];
  value: string | number | null;
  onChange?: (value: string | number) => void;
  onChangeConfirm?: (
    newValue: string,
    oldValue: string,
    accept: () => void,
    reject: () => void
  ) => void;
  placeholder?: string;
  searchThreshold?: number | null;
  searchPlaceholder?: string;
  isHighlighted?: boolean;
  name: string;
  optionsWidth?: "medium" | "large" | "x-large";
  hidePlaceholderOption?: boolean;
  size?: "small" | "medium";
  resetAfterChoice?: boolean;
}

export const Select: FC<SelectProps> = ({
  className,
  placeholder = "-- Choose an Option --",
  name,
  onChange,
  onChangeConfirm,
  options,
  value,
  disabled,
  searchThreshold = 10,
  searchPlaceholder = "Search...",
  isHighlighted,
  optionsWidth = "medium",
  hidePlaceholderOption,
  size = "medium",
  resetAfterChoice,
  ...props
}) => {
  const buttonRef = useRef<HTMLButtonElement>(null!);
  const searchRef = useRef<HTMLInputElement>(null!);
  const optionsRef = useRef<HTMLDivElement>(null!);
  const optionsListRef = useRef<HTMLDivElement>(null!);
  const isSearchActive = useRef(false);
  const { ref: selectRef, isVisible, setIsVisible } = useIsVisible(false);
  const [internalValue, setInternalValue] = useState(value || "");
  const [query, setQuery] = useState("");
  const [focused, setFocused] = useState(0);
  const [buttonPosition, setButtonPosition] = useState<DOMRect | null>(null);

  const getOptions = () => {
    let counter = 1;
    const optionArr: SelectOption[] = [];

    options.forEach((option: FeatureChoiceOption | HtmlSelectOptionGroup) => {
      if ("optGroupLabel" in option) {
        option.options.forEach((opt: HtmlSelectOption) => {
          optionArr.push({
            label: opt.label || "",
            value: opt.value,
            groupLabel: option.optGroupLabel,
            index: counter,
          });
          counter++;
        });
      } else {
        optionArr.push({
          label: option.label || "",
          value: option.value,
          index: counter,
        });
        counter++;
      }
    });

    return optionArr;
  };

  const [results, setResults] = useState(getOptions());
  const currentSelection = results.find(
    (option) => String(option.value) === String(internalValue)
  );

  const scrollIntoView = useCallback(() => {
    if (!optionsRef.current) return;

    const optionList = optionsRef.current.querySelector(
      `.${styles.optionList}`
    );
    if (!optionList) return;

    let targetElement: Element | null = null;

    // Look for the focused option first (including placeholder at index 0)
    if (focused >= 0) {
      targetElement = optionList.querySelector(`[data-index="${focused}"]`);
    }
    // If no focused option, look for the currently selected option
    else {
      targetElement = optionList.querySelector(`[aria-selected="true"]`);
    }

    // Scroll the target element into view within the dropdown container
    if (targetElement) {
      targetElement.scrollIntoView({
        behavior: "auto",
        block: "nearest",
        inline: "nearest",
      });
    }
  }, [focused]);

  const buildOptionsHtml = (options: SelectOption[]) => [
    ...options.map((option) => (
      <label
        className={clsx([
          styles.option,
          focused === option.index && styles.focused,
        ])}
        aria-selected={
          (value === null && query === "" && option.index === 0) ||
          String(internalValue) === String(option.value)
        }
        key={option.value}
        data-index={option.index}
        onMouseEnter={() => setFocused(option.index)}
      >
        <Check className={styles.check} aria-label={option.label} />
        <input
          type="radio"
          name={name}
          value={option.value}
          onClick={() => handleClickOption(option.value)}
          onChange={handleChange}
          checked={value === option.value}
        />
        {option.label}
      </label>
    )),
  ];

  const getOptionsList = () => {
    // Group items by groupLabel
    const grouped = groupBy(results, "groupLabel");
    // Turn grouped object into an array with groups and values
    const entries = Object.entries(grouped);
    // Map over grouped items to render
    return entries.map(([groupName, options]) => {
      // If there is a valid groupName
      if (groupName !== "undefined") {
        return (
          <div className={styles.optionGroup} key={groupName}>
            <label className={styles.groupLabel}>{groupName}</label>
            {buildOptionsHtml(options)}
          </div>
        );
      }
      // If the groupName is "undefined"
      return buildOptionsHtml(options);
    });
  };

  const getOptionsPosition = (): CSSProperties | undefined => {
    const maxHeight = 300; // Maximum height of the options dropdown
    // If the button exists, set styles based on its position
    if (buttonPosition && optionsListRef.current) {
      // This logic checks to see if the options should be above or below the button
      const buttonBottom = buttonPosition.y + buttonPosition.height;
      // Get search height to account for that in the height calculation
      const searchHeight = searchRef.current?.scrollHeight || 0;
      // Get the height of the options list
      const optionsListHeight = optionsListRef.current.scrollHeight;
      // Calculate the height of the options list (capped at maxHeight)
      const optionsHeight =
        optionsListHeight + searchHeight > maxHeight
          ? maxHeight - searchHeight
          : optionsListHeight + searchHeight;
      // Determine if there is enoughy room below the button to show the entire options list
      const isEnoughRoomBelow =
        buttonBottom + optionsHeight < window.innerHeight;
      const selectStyle = selectRef.current?.style;

      selectStyle?.setProperty("--options-width", `${buttonPosition.width}px`);
      selectStyle?.setProperty(
        "--options-top",
        isEnoughRoomBelow
          ? `${buttonBottom}px`
          : `${buttonPosition.y - optionsHeight + 8}px`
      );
      selectStyle?.setProperty("--options-height", `${optionsHeight}px`);
    }

    return undefined;
  };

  const handleToggleVisibility = () => {
    const isHidden = isVisible;
    setIsVisible(!isHidden);

    if (!isHidden) {
      // Get the position of the button when it is clicked
      if (buttonRef.current)
        setButtonPosition(buttonRef.current.getBoundingClientRect());

      // Reset state and scroll to selected option
      setFocused(0);
      setQuery("");
      setResults(getOptions());
      searchRef.current?.focus();

      // Use setTimeout to ensure the DOM is updated before scrolling
      setTimeout(() => {
        scrollIntoView();
      }, 0);
    }
  };

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    if (onChangeConfirm) {
      // If a confirm modal is needed, use that handler
      onChangeConfirm(
        e.target.value,
        String(internalValue),
        () => {
          if (onChange) {
            onChange(e.target.value);
          }
          setInternalValue(e.target.value);
        },
        () => {}
      );
    }
    // If no confirm modal is needed, use the normal handler
    else {
      if (onChange) onChange?.(e.target.value);
      setInternalValue(e.target.value);
    }
    // Reset state
    handleReset();
  };

  const handleClickOption = (value: string | number) => {
    if (value === currentSelection?.value) {
      handleReset();
    }
  };

  const handleReset = () => {
    if (resetAfterChoice) setInternalValue("");
    setIsVisible(false);
    setQuery("");
    setResults(getOptions());
  };

  const handleSearch = (e) => {
    // Reset results to default
    if (!e.target.value) {
      setResults(getOptions());
    } else {
      const filteredOptions = getOptions()
        .filter((opt) =>
          opt.label.toLowerCase().includes(e.target.value.toLowerCase())
        )
        .map((opt, index) => ({
          ...opt,
          index,
        }));
      setResults(filteredOptions);
    }
    setQuery(e.target.value);
  };

  const handleKeypress = (e) => {
    // Get the currently focused element in the DOM
    const activeElement = document.activeElement as HTMLElement;

    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        if (isVisible) {
          // Go to the next option
          if (focused < results.length) {
            setFocused(focused + 1);
          }
          // If there are no more options, go back to the top (placeholder at index 0)
          else {
            setFocused(0);
          }
        }
        // If the button is focused and the dropdown is closed, open it
        else if (activeElement === buttonRef.current) {
          setIsVisible(true);
          setFocused(currentSelection?.index || 0);
        }
        scrollIntoView();
        break;

      case "ArrowUp":
        e.preventDefault();
        if (isVisible) {
          // Go to the previous option
          if (focused > 0) {
            setFocused(focused - 1);
          }
          // If there are no previous options, go back to the bottom
          else {
            setFocused(results.length);
          }
        }
        // If the button is focused and the dropdown is closed, open it
        else if (activeElement === buttonRef.current) {
          setIsVisible(true);
          setFocused(currentSelection?.index || results.length);
        }
        scrollIntoView();
        break;

      case "Enter":
        e.preventDefault();
        // If the dropdown is open, select the focused option
        if (isVisible) {
          if (focused === 0) {
            // Handle the placeholder option (empty value)
            handleChange({
              target: { value: "" },
            } as ChangeEvent<HTMLInputElement>);
            buttonRef.current.focus();
          } else {
            const focusedOption = results.find((opt) => opt.index === focused);
            if (focusedOption) {
              handleChange({
                target: { value: focusedOption.value.toString() },
              } as ChangeEvent<HTMLInputElement>);
              buttonRef.current.focus();
            }
          }
        }
        // If the button is focused and the dropdown is closed, open it
        else if (activeElement === buttonRef.current) {
          handleToggleVisibility();
        }
        scrollIntoView();
        break;

      case "Escape":
        handleReset();
        break;

      case "Tab":
        if (isVisible) {
          // If the search is focused when tab is pressed go to item
          if (document.activeElement === searchRef.current) {
            e.preventDefault();
            setFocused(currentSelection?.index || 0);
            scrollIntoView();
            searchRef.current.blur();
          } else {
            setFocused(0);
            searchRef.current?.focus();
            handleToggleVisibility();
          }
        }
        break;

      default:
        break;
    }
  };

  useEffect(() => {
    const selectElement = selectRef.current;
    if (!selectElement) return;

    selectElement.addEventListener("keydown", handleKeypress);

    return () => {
      selectElement.removeEventListener("keydown", handleKeypress);
    };
  });

  useEffect(
    () => {
      // keep the search active is searchThreshold is set and results are more than the threshold - this prevents the search from going away if the search results are less than the threshold after a query is entered
      isSearchActive.current =
        !!searchThreshold &&
        searchThreshold >= 0 &&
        results.length > searchThreshold;
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  useEffect(() => {
    // If the select is visible, prevent the page from scrolling. This prevents us from having to
    // recalculate the position of the option group on scroll.
    document.body.style.overflowY = isVisible ? "hidden" : "unset";
    getOptionsPosition();

    // Scroll to the selected item when the dropdown becomes visible
    if (isVisible) {
      setTimeout(() => {
        scrollIntoView();
      }, 0);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isVisible, scrollIntoView]);

  const optionsKey = useMemo(
    () => options.map((o) => ("value" in o ? o.value : o.optGroupLabel)).join(","),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [options]
  );

  useEffect(() => {
    if (!isSearchActive.current) {
      setResults(getOptions());
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [optionsKey]);

  useEffect(() => {
    if (currentSelection) {
      setFocused(currentSelection.index);
    }
  }, [currentSelection]);

  useEffect(() => {
    setInternalValue(value || "");
  }, [value]);

  return (
    <div
      ref={selectRef}
      className={clsx([
        styles.select,
        isHighlighted && styles.highlighted,
        className,
      ])}
      onClick={(e) => {
        e.stopPropagation();
        e.nativeEvent.stopImmediatePropagation();
      }}
      {...props}
    >
      <button
        className={clsx([styles.button, styles[size]])}
        id={`${name}Button`}
        onClick={(e) => handleToggleVisibility()}
        role="combobox"
        aria-haspopup="listbox"
        aria-controls={`${name}Dropdown`}
        aria-labelledby={`${name}Label`}
        aria-expanded={isVisible}
        tabIndex={0}
        disabled={disabled || false}
        ref={buttonRef}
      >
        <span className={styles.buttonText}>
          {currentSelection ? currentSelection?.label : placeholder}
        </span>
        <ChevronDown />
      </button>
      <div
        ref={optionsRef}
        className={clsx([styles.options, styles[optionsWidth]])}
        role="group"
        id={`${name}Dropdown`}
      >
        {isSearchActive.current && (
          <input
            className={styles.search}
            type="search"
            value={query}
            onChange={handleSearch}
            placeholder={searchPlaceholder}
            ref={searchRef}
          />
        )}
        <div ref={optionsListRef} className={styles.optionList} role="menu">
          {!!searchThreshold && query.length > 0 && results.length < 1 && (
            <label className={styles.notFound}>
              No results found for <strong>{query}</strong>
            </label>
          )}
          {!query && !hidePlaceholderOption && (
            <label
              className={clsx([styles.option, focused === 0 && styles.focused])}
              aria-selected={!internalValue && query === ""}
              data-index={0}
              onMouseEnter={() => setFocused(0)}
            >
              <Check className={styles.check} />
              <input
                type="radio"
                name={name}
                value={""}
                onClick={() => handleClickOption("")}
                onChange={handleChange}
                checked={value === ""}
              />
              {placeholder}
            </label>
          )}
          {getOptionsList()}
        </div>
      </div>
    </div>
  );
};
