import clsx from 'clsx';
import React, {
  ChangeEvent,
  FormEvent,
  useEffect,
  useRef,
  useState,
} from 'react';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import { noop } from '../../../helpers/utils';
import { useDebounce } from '../../../hooks/useDebounce/useDebounce';
import { Button, ButtonContext } from '../../Buttons';
import { IconName } from '../../Icons';
import { BaseFormControl, BaseInputEvents } from '../Form.models';
import { FormElementContainer } from '../FormElementContainer';
import classes from './CustomTags.scss';

interface SuggestionsHook {
  readonly suggestions: string[];
  readonly setVal: React.Dispatch<string>;
  readonly val: string;
  readonly cursorPos: number | undefined;
  readonly setCursorPos: React.Dispatch<
    React.SetStateAction<number | undefined>
  >;
}

export interface CustomTagsProps extends BaseFormControl, BaseInputEvents {
  /** Input element type (default: 'text')*/
  type?: string;
  /** If set, sets the form control value */
  value?: string[];
  /** Input placeholder */
  placeholder?: string;
  /** Whether or not the control should start focused (default: false) */
  autoFocus?: boolean;
  /** Whether or not the values displays inline or as rows */
  displayAsRows?: boolean;
  /** Determines how often liveSuggestionsResolver will be called. Timer is in milliseconds (default: 200)*/
  liveSuggestionsDelay?: number;
  /** If set, enables live suggestions. Emits the currently typed input value and expects an array of suggestions to be displayed */
  liveSuggestionsResolver?: (value: string) => Promise<string[]> | string[];
}

/**
 * Form control that allows custom tags to be entered
 * @example
 *  <CustomTags
 *  name="customTags"
 *  value={['Tag1', 'Tag2']}
 *  label={'Custom Tags'}
 *  onChange={e => console.log(e)}
 *  />
 */
export const CustomTags: React.FC<CustomTagsProps> = ({
  id,
  name,
  type = 'text',
  value,
  disabled = false,
  placeholder,
  error = undefined,
  autoFocus = false,
  onChange = noop,
  displayAsRows = false,
  onFocus,
  onBlur = noop,
  liveSuggestionsDelay = 200,
  liveSuggestionsResolver,
  className = '',
  ...rest
}) => {
  const [currentTags, setCurrentTags] = useState<string[]>([]); // Current tags the user has selected
  const [shouldAnimate, setShouldAnimate] = useState<boolean>(false);
  const ref = useRef<FormEvent<HTMLInputElement>>();

  useEffect(() => {
    setCurrentTags(value ?? []);
  }, [value]);

  const styles = {
    flexDirection: displayAsRows ? 'column' : 'row',
  } as React.CSSProperties;

  useEffect(() => {
    // Only emit if there is a current event
    if (ref.current) {
      // TODO: remove event object and emit only currentTags array
      onChange({
        ...ref.current,

        currentTarget: { value: currentTags as unknown as string },
      } as React.ChangeEvent<HTMLInputElement>);

      // Resets event data
      ref.current = undefined;
    }
  }, [currentTags, onChange]);

  // live suggestions
  const textInput = useRef<HTMLInputElement>(null);
  const { suggestions, setVal, val, cursorPos, setCursorPos } = useSuggestions(
    currentTags,
    liveSuggestionsDelay,
    liveSuggestionsResolver,
  );

  // errors
  const [errorMessage, setErrorMessage] = useState<string | undefined>(error);
  useEffect(() => {
    switch (true) {
      case error !== undefined:
        setErrorMessage(error);
        break;
      case currentTags.includes(val.trim()):
        setErrorMessage('Duplicate value');
        break;
      default:
        setErrorMessage(undefined);
        break;
    }
  }, [currentTags, error, val]);

  /**
   * Checks if a string contains only whitespaces
   * @param str value
   * @returns true if string is empty
   */
  const isStringEmpty = (str: string): boolean => {
    return !str || /^\s*$/.test(str);
  };

  /**
   * Adds a tag to currently selected list
   * @param e Select FormEvent
   */
  function addTag(e: FormEvent<HTMLInputElement>): void {
    setShouldAnimate(true);

    const newTag = e.currentTarget.value.trim();
    // If the new tag is an empty string OR a duplicate, don't add the tag
    if (newTag === '' || currentTags.includes(newTag)) {
      return;
    }

    // Set event data
    ref.current = e;

    setCurrentTags((prevState) => [...prevState, newTag].sort());

    // Value was successfully added, reset input, highlighted suggestion, and any error message
    setVal('');
    setCursorPos(undefined);
    setErrorMessage(undefined);
  }

  /**
   * Removes tags from currently selected tags
   * @param idx index of tag to be filtered
   * @param e Event
   */
  function removeTag(idx: number, e: unknown): void {
    // Set event data
    ref.current = e as FormEvent<HTMLInputElement>;

    setCurrentTags((prevState) =>
      prevState.filter((_: string, i: number) => i !== idx),
    );
  }

  /**
   * Called when a suggestion is clicked
   * @param e click event
   * @param suggestion item that was clicked
   */
  const onSuggestionClickedHandler = (
    e: React.MouseEvent<HTMLLIElement, MouseEvent>,
    suggestion: string,
  ): void => {
    setVal(suggestion);

    e.persist();
    addTag({
      ...e,
      currentTarget: { value: suggestion },
    } as unknown as FormEvent<HTMLInputElement>);

    // focus back on the input after clicking new suggestion
    textInput.current?.focus();
  };

  /**
   * Called when a key is pressed in the input
   * @param e keyboard event
   */
  const onKeyPressHandler = (
    e: React.KeyboardEvent<HTMLInputElement>,
  ): void => {
    if (
      e.key === 'ArrowUp' &&
      suggestions.length > 0 &&
      liveSuggestionsResolver !== undefined
    ) {
      e.preventDefault(); // prevents cursor in input from moving
      // remove cursor position if pos is at the top
      if (cursorPos === undefined || cursorPos === 0) {
        setCursorPos(undefined);
      } else {
        // select previous item
        setCursorPos((prevState: number | undefined) => {
          if (prevState === undefined) {
            return undefined;
          } else {
            return prevState - 1;
          }
        });
      }
    }

    if (
      e.key === 'ArrowDown' &&
      suggestions.length > 0 &&
      liveSuggestionsResolver !== undefined
    ) {
      e.preventDefault(); // prevents cursor in input from moving
      // if no cursor pos, start from the top and select the first item
      if (cursorPos === undefined) {
        setCursorPos(0);

        // continue traversing down the list until the last item is reached
      } else if (cursorPos < suggestions.length - 1) {
        setCursorPos((prevState: number | undefined) => {
          if (prevState === undefined) {
            return 0;
          } else {
            return prevState + 1;
          }
        });
      }
    }

    if (e.key === 'Enter') {
      // if there is a highlighted item, emit that value instead of whats in the input
      if (cursorPos !== undefined) {
        e.persist();

        //@ts-expect-error we're not passing a full EventTarget object here
        addTag({ ...e, currentTarget: { value: suggestions[cursorPos] } });
      } else {
        e.persist();
        addTag(e);
      }
    }
  };

  /**
   * Called when the Add button is clicked
   * @param e MouseEvent
   */
  const onPlusClick = (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
  ): void => {
    addTag({
      ...e,
      currentTarget: {
        value: textInput.current?.value ?? '',
      },
    } as unknown as FormEvent<HTMLInputElement>);
  };

  /**
   * Called when a user focuses out of the input
   * @param e Input form event
   */
  const onBlurHandler = (e: ChangeEvent<HTMLInputElement>): void => {
    onBlur(e);

    // reset input if value is only whitespaces
    if (isStringEmpty(e?.currentTarget?.value)) {
      setVal('');
      return;
    }

    addTag({
      ...e,
      currentTarget: {
        value: textInput.current?.value ?? '',
      },
    } as ChangeEvent<HTMLInputElement>);
  };

  return (
    <FormElementContainer
      {...rest}
      className={clsx(classes.container, 'custom-tags-container', className)}
      error={errorMessage}
      dataTestFieldType="CustomTags"
      htmlFor={id ?? name}
    >
      <div className={clsx(classes.tagsWrapper)} style={styles}>
        <div className={clsx(classes.inputWrapper)}>
          <input
            className={clsx({
              [classes.hasError]: errorMessage !== undefined,
            })}
            id={id ?? name}
            name={name}
            ref={textInput}
            value={val}
            type={type}
            disabled={disabled}
            placeholder={disabled ? undefined : placeholder}
            autoFocus={autoFocus}
            autoComplete={'off'}
            onChange={(e) => setVal(e.currentTarget.value)}
            onKeyDown={onKeyPressHandler}
            onBlur={onBlurHandler}
            onFocus={onFocus}
          />
          {val !== '' && suggestions.length > 0 && (
            <ul className={clsx(classes.autoComplete)}>
              {suggestions.map((suggestion, idx) => (
                <li
                  key={suggestion}
                  onMouseDown={(event) =>
                    onSuggestionClickedHandler(event, suggestion)
                  }
                  className={clsx({
                    [classes.selected]: cursorPos === idx,
                  })}
                >
                  {suggestion}
                </li>
              ))}
            </ul>
          )}
          <Button
            className={clsx({
              [classes.plusButton]: true,
              [classes.hasError]: !!errorMessage,
            })}
            type="button"
            icon={IconName.Plus}
            onButtonClicked={onPlusClick}
            buttonContext={ButtonContext.None}
            disabled={disabled || !val}
            dataTestId="tags-add"
          />
        </div>
        <TransitionGroup component={null}>
          {currentTags?.map((tag, idx) => (
            <CSSTransition
              key={tag}
              timeout={{ enter: 1000, exit: 10 }}
              classNames={{
                enter: clsx(shouldAnimate && classes.customTagEnter),
                enterActive: clsx(
                  shouldAnimate && classes.customTagEnterActive,
                ),
                exit: classes.customTagExit,
                exitActive: classes.customTagExitActive,
              }}
              onEntered={() => {
                setShouldAnimate(false);
              }}
            >
              <span
                className={clsx({
                  [classes.selectedItem]: true,
                  [classes.disabled]: disabled,
                })}
                data-test-id="tag"
              >
                <span title={tag}>{tag}</span>
                <Button
                  type="button"
                  icon={IconName.X}
                  onButtonClicked={(e) => {
                    e.persist();
                    removeTag(idx, e);
                  }}
                  disabled={disabled}
                  buttonContext={ButtonContext.Icon}
                  dataTestId="tags-delete"
                ></Button>
              </span>
            </CSSTransition>
          ))}
        </TransitionGroup>
      </div>
    </FormElementContainer>
  );
};

const useSuggestions = (
  currentTags: string[],
  liveSuggestionsDelay: number,
  liveSuggestionsResolver?: (value: string) => Promise<string[]> | string[],
): SuggestionsHook => {
  async function fetchData(): Promise<void> {
    if (liveSuggestionsResolver !== undefined) {
      try {
        // isLoading = true;
        const tempSuggestions = await liveSuggestionsResolver(debouncedValue);
        // Only set suggestion if current value still matches the value we requested the suggestions for
        if (valRef.current === debouncedValue) {
          setSuggestions(
            tempSuggestions.filter((item) => !currentTags.includes(item)),
          );
          setCursorPos(undefined);
        }
      } catch (error) {
        // eslint-disable-next-line no-console
        console.log(error);
      } finally {
        // isLoading = false;
      }
    }
  }

  const [suggestions, setSuggestions] = useState<string[]>([]);
  const [debouncedValue, setVal, val] = useDebounce<string>(
    '',
    liveSuggestionsDelay,
  );
  // Storing the current val in a reference, so we can get the actual current value later
  const valRef = useRef<string>(val);
  valRef.current = val;

  const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);

  useEffect(() => {
    // if liveSuggestionsResolver is not set, exit
    if (liveSuggestionsResolver === undefined) {
      return;
    }

    // if debouncedValue is empty, reset suggestions
    if (debouncedValue === '') {
      setSuggestions([]);
      setCursorPos(undefined);

      // only send request if debouncedValue is not an empty string
    } else {
      fetchData();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [debouncedValue, liveSuggestionsResolver]); // do not allow tags to trigger

  return { suggestions, setVal, val, cursorPos, setCursorPos } as const;
};
