import * as fuzzy from 'fuzzy';
import get from 'lodash/get';
import uniqBy from 'lodash/uniqBy';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import {
  currentBackend,
  expandSearchEntries,
  getEntryField,
  mergeExpandedEntries,
} from '@staticcms/core/backend';
import Autocomplete from '@staticcms/core/components/common/autocomplete/Autocomplete';
import Field from '@staticcms/core/components/common/field/Field';
import Pill from '@staticcms/core/components/common/pill/Pill';
import CircularProgress from '@staticcms/core/components/common/progress/CircularProgress';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { fileSearch, sortByScore } from '@staticcms/core/lib/util/search.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import {
  addFileTemplateFields,
  compileStringTemplate,
  expandPath,
  extractTemplateVars,
} from '@staticcms/core/lib/widgets/stringTemplate';
import { selectCollection } from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';

import type {
  Entry,
  EntryData,
  ObjectValue,
  RelationField,
  WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
import type { ListChildComponentProps } from 'react-window';

function Option({ index, style, data }: ListChildComponentProps<{ options: ReactNode[] }>) {
  return <div style={style}>{data.options[index]}</div>;
}

export interface HitOption {
  data: EntryData;
  value: string;
  label: string;
}

export interface Option {
  value: string;
  label: string;
}

function getSelectedOptions(value: HitOption[] | undefined | null): HitOption[] | null;
function getSelectedOptions(value: string[] | undefined | null): string[] | null;
function getSelectedOptions(value: string[] | HitOption[] | undefined | null) {
  if (!value || !Array.isArray(value)) {
    return null;
  }

  return value;
}

function uniqOptions(initial: HitOption[], current: HitOption[]): HitOption[] {
  return uniqBy(initial.concat(current), o => o.value);
}

function getSelectedValue(value: string, options: HitOption[], isMultiple: boolean): string | null;
function getSelectedValue(
  value: string[],
  options: HitOption[],
  isMultiple: boolean,
): string[] | null;
function getSelectedValue(
  value: string | string[] | null | undefined,
  options: HitOption[],
  isMultiple: boolean,
): string | string[] | null;
function getSelectedValue(
  value: string | string[] | null | undefined,
  options: HitOption[],
  isMultiple: boolean,
): string | string[] | null {
  if (isMultiple && Array.isArray(value)) {
    const selectedOptions = getSelectedOptions(value);
    if (selectedOptions === null) {
      return null;
    }

    const selected = selectedOptions
      .map(i => options.find(o => o.value === i))
      .filter(Boolean)
      .map(option => (typeof option === 'string' ? option : option?.value)) as string[];

    return selected;
  } else {
    return options.find(option => option.value === value)?.value ?? null;
  }
}

const DEFAULT_OPTIONS_LIMIT = 20;

const RelationControl: FC<WidgetControlProps<string | string[], RelationField>> = ({
  value,
  field,
  duplicate,
  config,
  locale,
  label,
  errors,
  hasErrors,
  disabled,
  forSingleList,
  onChange,
}) => {
  const [internalRawValue, setInternalValue] = useState(value);
  const internalValue = useMemo(
    () => (duplicate ? value : internalRawValue),
    [internalRawValue, duplicate, value],
  );
  const [initialOptions, setInitialOptions] = useState<HitOption[]>([]);

  const searchCollectionSelector = useMemo(
    () => selectCollection(field.collection),
    [field.collection],
  );
  const searchCollection = useAppSelector(searchCollectionSelector);

  const isMultiple = useMemo(() => {
    return field.multiple ?? false;
  }, [field.multiple]);

  const parseNestedFields = useCallback(
    (hit: Entry, field: string): string => {
      const hitData =
        locale != null && hit.i18n != null && hit.i18n[locale] != null
          ? hit.i18n[locale].data
          : hit.data;

      const templateVars = extractTemplateVars(field);
      // return non template fields as is
      if (templateVars.length <= 0) {
        return get(hitData, field) as string;
      }
      const data = addFileTemplateFields(hit.path, hitData);
      return compileStringTemplate(field, null, hit.slug, data);
    },
    [locale],
  );

  const parseHitOptions = useCallback(
    (hits: Entry[]) => {
      const valueField = field.value_field;
      const displayField = field.display_fields || [field.value_field];

      const options = hits.reduce((acc, hit) => {
        const valuesPaths = expandPath({ data: hit.data, path: valueField });
        for (let i = 0; i < valuesPaths.length; i++) {
          const value = parseNestedFields(hit, valuesPaths[i]) as string;

          const label = displayField
            .map(key => {
              const displayPaths = expandPath({ data: hit.data, path: key });
              const path = displayPaths[i] ?? displayPaths[0];
              if (isNullish(path) || isEmpty(path)) {
                return value;
              }
              return parseNestedFields(hit, displayPaths[i] ?? displayPaths[0]);
            })
            .join(' ');

          acc.push({ data: hit.data, value, label });
        }

        return acc;
      }, [] as HitOption[]);

      return options;
    },
    [field.display_fields, field.value_field, parseNestedFields],
  );

  const [options, setOptions] = useState<HitOption[]>([]);
  const [entries, setEntries] = useState<Entry[] | null>(null);
  const loading = useMemo(() => !entries, [entries]);

  const filterOptions = useCallback(
    (inputValue: string) => {
      if (!entries) {
        return;
      }

      const searchFields = field.search_fields;
      const file = field.file;
      const limit = field.options_length || DEFAULT_OPTIONS_LIMIT;

      let hits: Entry<ObjectValue>[];

      if (file) {
        hits = fileSearch(
          entries.find(e => e.slug === file),
          searchFields,
          inputValue,
        );
      } else {
        const expandedEntries = expandSearchEntries(entries, searchFields);
        hits = mergeExpandedEntries(
          fuzzy
            .filter(inputValue, expandedEntries, {
              extract: entry => {
                return getEntryField(entry.field, entry);
              },
            })
            .sort(sortByScore)
            .map(f => f.original),
        );
      }

      let options = uniqBy(parseHitOptions(hits), o => o.value);

      if (limit !== undefined && limit > 0) {
        options = options.slice(0, limit);
      }

      setOptions(options);
    },
    [entries, field.file, field.options_length, field.search_fields, parseHitOptions],
  );

  useEffect(() => {
    if (!loading || !searchCollection) {
      return;
    }

    const getOptions = async () => {
      const backend = currentBackend(config);

      const options = await backend.listAllEntries(searchCollection);
      setEntries(options);

      const hitOptions = parseHitOptions(options);

      if (value) {
        const byValue = hitOptions.reduce((acc, option) => {
          acc[option.value] = option;
          return acc;
        }, {} as Record<string, HitOption>);

        const newFilteredValue =
          typeof value === 'string'
            ? value in byValue
              ? [value]
              : []
            : value.filter(v => v && v in byValue);

        const newInitialOptions = newFilteredValue.map(v => byValue[v]);

        setInitialOptions(newInitialOptions);
      }

      setOptions(hitOptions);
    };

    getOptions();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [searchCollection, config, loading, parseHitOptions]);

  const uniqueOptions = useMemo(() => {
    let uOptions = uniqOptions(initialOptions, options);

    const limit = field.options_length || DEFAULT_OPTIONS_LIMIT;
    if (limit !== undefined && limit > 0) {
      uOptions = uOptions.slice(0, limit);
    }

    return uOptions;
  }, [field.options_length, initialOptions, options]);

  const uniqueOptionsByValue = useMemo(
    () =>
      uniqueOptions.reduce((acc, option) => {
        acc[option.value] = option;
        return acc;
      }, {} as Record<string, HitOption>),
    [uniqueOptions],
  );

  const selectedValue = useMemo(() => {
    let selected = getSelectedValue(internalValue, uniqueOptions, isMultiple);
    if (isMultiple && !selected) {
      selected = [];
    }
    return selected;
  }, [internalValue, isMultiple, uniqueOptions]);

  const ref = useRef<HTMLButtonElement | null>(null);

  const handleChange = useCallback(
    (newValue: string | string[] | undefined) => {
      if (!newValue) {
        setInternalValue(newValue);
        onChange(newValue);
        return;
      }

      if (Array.isArray(newValue)) {
        const newFilteredValue = newValue.filter(v => v && v in uniqueOptionsByValue);
        const newInitialOptions = newFilteredValue.map(v => uniqueOptionsByValue[v]);
        setInitialOptions(newInitialOptions);
        setInternalValue(newFilteredValue);
        onChange(newFilteredValue);
      } else {
        if (!(newValue in uniqueOptionsByValue)) {
          setInternalValue(null);
          onChange(null);
          return;
        }
        setInitialOptions([uniqueOptionsByValue[newValue]]);
        setInternalValue(newValue);
        onChange(newValue);
      }
    },
    [onChange, uniqueOptionsByValue],
  );

  const isRequired = useMemo(() => field.required ?? true, [field.required]);

  return (
    <Field
      inputRef={ref}
      label={label}
      errors={errors}
      noPadding={!hasErrors}
      hint={field.hint}
      forSingleList={forSingleList}
      cursor="text"
      disabled={disabled}
    >
      <Autocomplete
        label={
          <>
            {Array.isArray(selectedValue) && selectedValue.length > 0 ? (
              <div className="flex flex-wrap gap-2 p-2 pr-0 w-relation-widget-label">
                {selectedValue.map(selectValue => {
                  const option = uniqueOptionsByValue[selectValue];
                  return (
                    <Pill key={selectValue} noWrap disabled={disabled}>
                      {option?.label ?? selectValue}
                    </Pill>
                  );
                })}
              </div>
            ) : null}
            {loading ? (
              <CircularProgress
                key="loading-indicator"
                className="absolute inset-y-0 right-4 flex items-center pr-2"
                data-testid="relation-loading-indicator"
                size="small"
              />
            ) : null}
          </>
        }
        ref={ref}
        value={selectedValue}
        options={uniqueOptions}
        disabled={disabled}
        required={isRequired}
        displayValue={item => {
          if (!item || Array.isArray(item)) {
            return '';
          }

          const option = uniqueOptionsByValue[item];
          if (!option) {
            return '';
          }

          return option.label;
        }}
        onQuery={filterOptions}
        onChange={handleChange}
      />
    </Field>
  );
};

export default RelationControl;
