import React from 'react';
import { useSelector } from 'react-redux';
import qs from 'query-string';
import { useLocation, useHistory } from 'react-router-dom';

import { resolveExtension } from '@plone/volto/helpers/Extensions/withBlockExtensions';
import config from '@plone/volto/registry';
import { usePrevious } from '@plone/volto/helpers/Utils/usePrevious';
import isEqual from 'lodash/isEqual';

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

const SEARCH_ENDPOINT_FIELDS = [
  'SearchableText',
  'b_size',
  'limit',
  'sort_on',
  'sort_order',
  'depth',
];

const PAQO = 'plone.app.querystring.operation';

/**
 * Based on URL state, gets an initial internal state for the search
 *
 * @function getInitialState
 *
 */
function getInitialState(
  data,
  facets,
  urlSearchText,
  id,
  sortOnParam,
  sortOrderParam,
) {
  const { types: facetWidgetTypes } =
    config.blocks.blocksConfig.search.extensions.facetWidgets;
  const facetSettings = data?.facets || [];

  return {
    query: [
      ...(data.query?.query || []),
      ...(facetSettings || [])
        .map((facet) => {
          if (!facet?.field) return null;

          const { valueToQuery } = resolveExtension(
            'type',
            facetWidgetTypes,
            facet,
          );

          const name = facet.field.value;
          const value = facets[name];

          return valueToQuery({ value, facet });
        })
        .filter((f) => !!f),
      ...(urlSearchText
        ? [
            {
              i: 'SearchableText',
              o: 'plone.app.querystring.operation.string.contains',
              v: urlSearchText,
            },
          ]
        : []),
    ],
    sort_on: sortOnParam || data.query?.sort_on,
    sort_order: sortOrderParam || data.query?.sort_order,
    b_size: data.query?.b_size,
    limit: data.query?.limit,
    depth: data.query?.depth,
    block: id,
  };
}

/**
 * "Normalizes" the search state to something that's serializable
 * (for querying) and used to compute data for the ListingBody
 *
 * @function normalizeState
 *
 */
function normalizeState({
  query, // base query
  facets, // facet values
  id, // block id
  searchText, // SearchableText
  sortOn,
  sortOrder,
  facetSettings, // data.facets extracted from block data
}) {
  const { types: facetWidgetTypes } =
    config.blocks.blocksConfig.search.extensions.facetWidgets;

  // Here, we are removing the QueryString of the Listing ones, which is present in the Facet
  // because we already initialize the facet with those values.
  const configuredFacets = facetSettings
    ? facetSettings.map((facet) => facet?.field?.value)
    : [];

  let copyOfQuery = query.query ? [...query.query] : [];

  const queryWithoutFacet = copyOfQuery.filter((query) => {
    return !configuredFacets.includes(query.i);
  });

  const params = {
    query: [
      ...(queryWithoutFacet || []),
      ...(facetSettings || []).map((facet) => {
        if (!facet?.field) return null;

        const { valueToQuery } = resolveExtension(
          'type',
          facetWidgetTypes,
          facet,
        );

        const name = facet.field.value;
        const value = facets[name];

        return valueToQuery({ value, facet });
      }),
    ].filter((o) => !!o),
    sort_on: sortOn || query.sort_on,
    sort_order: sortOrder || query.sort_order,
    b_size: query.b_size,
    limit: query.limit,
    depth: query.depth,
    block: id,
  };

  // Note Ideally the searchtext functionality should be restructured as being just
  // another facet. But right now it's the same. This means that if a searchText
  // is provided, it will override the SearchableText facet.
  // If there is no searchText, the SearchableText in the query remains in effect.
  // TODO eventually the searchText should be a distinct facet from SearchableText, and
  // the two conditions could be combined, in comparison to the current state, when
  // one overrides the other.
  if (searchText) {
    params.query = params.query.reduce(
      // Remove SearchableText from query
      (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]),
      [],
    );
    params.query.push({
      i: 'SearchableText',
      o: 'plone.app.querystring.operation.string.contains',
      v: searchText,
    });
  }

  return params;
}

const getSearchFields = (searchData) => {
  return Object.assign(
    {},
    ...SEARCH_ENDPOINT_FIELDS.map((k) => {
      return searchData[k] ? { [k]: searchData[k] } : {};
    }),
    searchData.query ? { query: serializeQuery(searchData['query']) } : {},
  );
};

/**
 * A hook that will mirror the search block state to a hash location
 */
const useHashState = () => {
  const location = useLocation();
  const history = useHistory();

  /**
   * Required to maintain parameter compatibility.
    With this we will maintain support for receiving hash (#) and search (?) type parameters.
  */
  const oldState = React.useMemo(() => {
    return {
      ...qs.parse(location.search),
      ...qs.parse(location.hash),
    };
  }, [location.hash, location.search]);

  // This creates a shallow copy. Why is this needed?
  const current = Object.assign(
    {},
    ...Array.from(Object.keys(oldState)).map((k) => ({ [k]: oldState[k] })),
  );

  const setSearchData = React.useCallback(
    (searchData) => {
      const newParams = qs.parse(location.search);

      let changed = false;

      Object.keys(searchData)
        .sort()
        .forEach((k) => {
          if (searchData[k]) {
            newParams[k] = searchData[k];
            if (oldState[k] !== searchData[k]) {
              changed = true;
            }
          }
        });

      if (changed) {
        history.push({
          search: qs.stringify(newParams),
        });
      }
    },
    [history, oldState, location.search],
  );

  return [current, setSearchData];
};

/**
 * A hook to make it possible to switch disable mirroring the search block
 * state to the window location. When using the internal state we "start from
 * scratch", as it's intended to be used in the edit page.
 */
const useSearchBlockState = (uniqueId, isEditMode) => {
  const [hashState, setHashState] = useHashState();
  const [internalState, setInternalState] = React.useState({});

  return isEditMode
    ? [internalState, setInternalState]
    : [hashState, setHashState];
};

// Simple compress/decompress the state in URL by replacing the lengthy string
const deserializeQuery = (q) => {
  return JSON.parse(q)?.map((kvp) => ({
    ...kvp,
    o: kvp.o.replace(/^paqo/, PAQO),
  }));
};
const serializeQuery = (q) => {
  return JSON.stringify(
    q?.map((kvp) => ({ ...kvp, o: kvp.o.replace(PAQO, 'paqo') })),
  );
};

const withSearch = (options) => (WrappedComponent) => {
  const { inputDelay = 1000 } = options || {};

  function WithSearch(props) {
    const { data, id, editable = false } = props;

    const [locationSearchData, setLocationSearchData] = useSearchBlockState(
      id,
      editable,
    );

    // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const urlQuery = locationSearchData.query
      ? deserializeQuery(locationSearchData.query)
      : [];
    const urlSearchText =
      locationSearchData.SearchableText ||
      urlQuery.find(({ i }) => i === 'SearchableText')?.v ||
      '';

    // TODO: refactor, should use only useLocationStateManager()!!!
    const [searchText, setSearchText] = React.useState(urlSearchText);
    // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const configuredFacets =
      data.facets?.map((facet) => facet?.field?.value) || [];

    // Here we are getting the initial value of the facet if Listing Query contains the same criteria as
    // facet.
    const queryData = data?.query?.query
      ? deserializeQuery(JSON.stringify(data?.query?.query))
      : [];

    let intializeFacetWithQueryValue = [];

    for (let value of configuredFacets) {
      const queryString = queryData.find((item) => item.i === value);
      if (queryString) {
        intializeFacetWithQueryValue = [
          ...intializeFacetWithQueryValue,
          { [queryString.i]: queryString.v },
        ];
      }
    }

    const multiFacets = data.facets
      ?.filter((facet) => facet?.multiple)
      .map((facet) => facet?.field?.value);
    const [facets, setFacets] = React.useState(
      Object.assign(
        {},
        ...urlQuery.map(({ i, v }) => ({ [i]: v })),
        // TODO: the 'o' should be kept. This would be a major refactoring of the facets
        ...intializeFacetWithQueryValue,
        // support for simple filters like ?Subject=something
        // TODO: since the move to hash params this is no longer working.
        // We'd have to treat the location.search and manage it just like the
        // hash, to support it. We can read it, but we'd have to reset it as
        // well, so at that point what's the difference to the hash?
        ...configuredFacets.map((f) =>
          locationSearchData[f]
            ? {
                [f]:
                  multiFacets.indexOf(f) > -1
                    ? [locationSearchData[f]]
                    : locationSearchData[f],
              }
            : {},
        ),
      ),
    );
    const previousUrlQuery = usePrevious(urlQuery);

    // During first render the previousUrlQuery is undefined and urlQuery
    // is empty so it resetting the facet when you are navigating but during reload we have urlQuery and we need
    // to set the facet at first render.
    const preventOverrideOfFacetState =
      previousUrlQuery === undefined && urlQuery.length === 0;

    React.useEffect(() => {
      if (
        !isEqual(urlQuery, previousUrlQuery) &&
        !preventOverrideOfFacetState
      ) {
        setFacets(
          Object.assign(
            {},
            ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets

            // support for simple filters like ?Subject=something
            // TODO: since the move to hash params this is no longer working.
            // We'd have to treat the location.search and manage it just like the
            // hash, to support it. We can read it, but we'd have to reset it as
            // well, so at that point what's the difference to the hash?
            ...configuredFacets.map((f) =>
              locationSearchData[f]
                ? {
                    [f]:
                      multiFacets.indexOf(f) > -1
                        ? [locationSearchData[f]]
                        : locationSearchData[f],
                  }
                : {},
            ),
          ),
        );
      }
    }, [
      urlQuery,
      configuredFacets,
      locationSearchData,
      multiFacets,
      previousUrlQuery,
      preventOverrideOfFacetState,
    ]);

    const [sortOn, setSortOn] = React.useState(data?.query?.sort_on);
    const [sortOrder, setSortOrder] = React.useState(data?.query?.sort_order);

    const [searchData, setSearchData] = React.useState(
      getInitialState(data, facets, urlSearchText, id),
    );

    const deepFacets = JSON.stringify(facets);
    const deepData = JSON.stringify(data);
    React.useEffect(() => {
      setSearchData(
        getInitialState(
          JSON.parse(deepData),
          JSON.parse(deepFacets),
          urlSearchText,
          id,
          sortOn,
          sortOrder,
        ),
      );
    }, [deepData, deepFacets, urlSearchText, id, sortOn, sortOrder]);

    const timeoutRef = React.useRef();
    const facetSettings = data?.facets;

    const deepQuery = JSON.stringify(data.query);
    const onTriggerSearch = React.useCallback(
      (
        toSearchText = undefined,
        toSearchFacets = undefined,
        toSortOn = undefined,
        toSortOrder = undefined,
      ) => {
        if (timeoutRef.current) clearTimeout(timeoutRef.current);
        timeoutRef.current = setTimeout(
          () => {
            const newSearchData = normalizeState({
              id,
              query: data.query || {},
              facets: toSearchFacets || facets,
              searchText: toSearchText ? toSearchText.trim() : '',
              sortOn: toSortOn || undefined,
              sortOrder: toSortOrder || sortOrder,
              facetSettings,
            });
            if (toSearchFacets) setFacets(toSearchFacets);
            if (toSortOn) setSortOn(toSortOn || undefined);
            if (toSortOrder) setSortOrder(toSortOrder);
            setSearchData(newSearchData);
            setLocationSearchData(getSearchFields(newSearchData));
          },
          toSearchFacets ? inputDelay / 3 : inputDelay,
        );
      },
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [
        // Use deep comparison of data.query
        deepQuery,
        facets,
        id,
        setLocationSearchData,
        searchText,
        sortOn,
        sortOrder,
        facetSettings,
      ],
    );

    const removeSearchQuery = () => {
      let newSearchData = { ...searchData };
      newSearchData.query = searchData.query.reduce(
        // Remove SearchableText from query
        (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]),
        [],
      );
      setSearchData(newSearchData);
      setLocationSearchData(getSearchFields(newSearchData));
    };

    const querystringResults = useSelector(
      (state) => state.querystringsearch.subrequests,
    );
    const totalItems =
      querystringResults[id]?.total || querystringResults[id]?.items?.length;

    return (
      <WrappedComponent
        {...props}
        searchData={searchData}
        facets={facets}
        setFacets={setFacets}
        setSortOn={setSortOn}
        setSortOrder={setSortOrder}
        sortOn={sortOn}
        sortOrder={sortOrder}
        searchedText={urlSearchText}
        searchText={searchText}
        removeSearchQuery={removeSearchQuery}
        setSearchText={setSearchText}
        onTriggerSearch={onTriggerSearch}
        totalItems={totalItems}
      />
    );
  }
  WithSearch.displayName = `WithSearch(${getDisplayName(WrappedComponent)})`;

  return WithSearch;
};

export default withSearch;
