import { debounce, noop } from "lodash";
import PropTypes from "prop-types";
import React from "react";

import { keyboardTracker } from "../../utils/accessibility";

import Input from "../Input";

const INITIAL_SEARCH_PAGE_SIZE = 5;
const INPUT_DEBOUNCE_PERIOD = 120;

@keyboardTracker
class SearchPicker extends React.Component {
    static propTypes = {
        /**
         * A render function that is invoked with the following info:
         *   availableEntities {Array<Entity>} - The list of entities available
         *                     based on the current input. If no query term is
         *                     entered, the default search collection is used.
         *
         *   isLoading         {Boolean} - Whether or not a search is in progress.
         *   renderInput       {Function<props>} - A function to render the <Input>
         *   searchQuery       {String} - The current search term
         *   currentFauxFocusIndex {Number} - Index of the item currently selected.
         *   resultsText       {Object} - An object containing search result
         *                     summaries based on the the types provided
         *                     See this.getResultsText
         *
         */
        children: PropTypes.func.isRequired,

        /**
         * Which index in the list is currently selected.
         * via @keyboardTracker
         */
        currentFauxFocusIndex: PropTypes.number.isRequired,

        /**
         * This function can be set when custom search results filtering is required
         * It will override the internal search results filtering
         *
         * @param {Array} searchResults
         * @param {Array} selectedEntityIds
         * @param {Array} filteredSearchResults
         */
        filterAvailableEntities: PropTypes.func,

        /**
         * A handler for the input element event onKeyDown
         * via @keyboardTracker
         */
        handleKeyDown: PropTypes.func.isRequired,

        /**
         * The element ref to use for capturing keyboard input.
         * via @keyboardTracker
         */
        keyboardRef: PropTypes.shape({ current: PropTypes.elementType }),

        /**
         * Handler to call when an item is selected via the keyboard.
         */
        onItemSelected: PropTypes.func.isRequired,

        /**
         * The function to invoke to execute an authenticated public API search.
         * Different consumers will have different methods of authenticating.
         *
         * @param {Object} requestConfig
         * @return {Promise}
         */
        searchFunction: PropTypes.func.isRequired,

        /**
         * Additional options to pass to the search function when a search is
         * executed. Type and query will be overridden by this component.
         */
        searchOptions: PropTypes.object,

        /**
         * An optional list of ids which should be considered "selected".
         * If provided, these ids will be filtered out of the list.
         */
        selectedEntityIds: PropTypes.array,

        /**
         * Sets the number of items for the list index tracker.
         * via @keyboardTracker
         */
        setItemCount: PropTypes.func,

        /**
         * Sets the item select callback fn for the list index tracker.
         * via @keyboardTracker
         */
        setOnItemSelect: PropTypes.func,

        /* The entity types we should query search for. */
        supportedTypes: PropTypes.array.isRequired,
    };

    static defaultProps = {
        searchOptions: {},
        selectedEntityIds: [],
        setItemCount: noop,
        setOnItemSelect: noop,
    };

    /**
     * Notify keyboardTracker what to do when ENTER is pressed on an item
     * and kickoff the initial search to populate the default result set.
     */
    componentDidMount = () => {
        const { searchFunction, searchOptions, setOnItemSelect, supportedTypes } = this.props;
        setOnItemSelect(() => this.handleItemSelected);

        searchFunction({
            ...searchOptions,
            types: supportedTypes,
            query: "",
            per_page: INITIAL_SEARCH_PAGE_SIZE,
        })
            .then(results => {
                // Make this Promise catch if the results are not an array
                this.verifyResultOrThrow(results);
                this.setState({
                    defaultResults: results,
                });
            })
            .finally(() => {
                const { searchQuery } = this.state;
                // Always set loading false as long as searchQuery is empty
                if (!searchQuery) {
                    this.setState({
                        isLoading: false,
                    });
                }
            });
    };

    /**
     * Whenever the component updates, notify the keyboardTracker
     * of how many items are currently available. (this also resets
     * the faux focus index to 0)
     */
    componentDidUpdate = () => {
        const items = this.getAvailableEntities();
        this.props.setItemCount(items.length);
    };

    state = {
        currentSearch: null,
        defaultResults: [],
        isLoading: true,
        results: null,
        searchQuery: "",
    };

    handleItemSelected = index => {
        const { onItemSelected } = this.props;
        const { searchQuery } = this.state;
        onItemSelected(index, this.getAvailableEntities(), searchQuery);
    };

    /**
     * Handles changes to the search term by calling the debounced change handler.
     * If the term is an empty string, short circuit and reset the results.
     * @param {React.SyntheticEvent} event - The onInput event
     */
    handleOnInput = event => {
        const { value } = event.target;
        this.setState({
            searchQuery: value,
        });

        if (value) {
            this.handleOnInputDebounced(value);
        } else {
            const { currentSearch } = this.state;
            if (currentSearch) {
                currentSearch.reject("Search interrupted by empty search query.");
            }
            this.setState({
                isLoading: false,
                results: null,
                currentSearch: null,
            });
        }
    };

    /**
     * Execute the search function in a way that is cancellable by storing
     * the Promise resolution methods in state.
     * This enables subsequent invocations to cancel an in progress
     * search in favor of a new one.
     * @param {string} query - The term to search for.
     * @returns {Promise<Array>}
     */
    executeNewSearch = query =>
        new Promise((resolve, reject) => {
            const { searchFunction, searchOptions, supportedTypes } = this.props;
            const { currentSearch } = this.state;
            if (currentSearch) {
                currentSearch.reject(`Search interrupted by new search query: ${query}`);
            }
            this.setState({
                currentSearch: {
                    resolve,
                    reject,
                },
                isLoading: true,
            });
            searchFunction({
                ...searchOptions,
                types: supportedTypes,
                query: query.toLowerCase(),
            }).then(resolve, reject);
        });

    /**
     * A debounced method to invoke the search and store the result in state.
     * @param {string} query - The term to search for.
     */
    handleOnInputDebounced = debounce(
        query => {
            this.executeNewSearch(query).then(
                results => {
                    const { searchQuery } = this.state;
                    this.setState({
                        isLoading: false,
                        currentSearch: null,
                    });
                    // Ensure the query wasn't killed while waiting for the result.
                    if (searchQuery) {
                        this.setState({
                            results,
                        });
                    }
                },
                e => {
                    /** Swallow rejections */
                }
            );
        },
        !!process.env.JEST_WORKER_ID ? 0 : INPUT_DEBOUNCE_PERIOD
    );

    /**
     * Filter the current result set to exclude selected entities.
     * @returns {Array<Object>}
     */
    getAvailableEntities = () => {
        const { selectedEntityIds, filterAvailableEntities } = this.props;
        if (typeof filterAvailableEntities === "function") {
            return filterAvailableEntities(this.getResultSet(), selectedEntityIds);
        }
        return this.getResultSet().filter(result => !selectedEntityIds.includes(result.id));
    };

    /**
     * Helper method to return the current result set,
     * falling back to the default.
     * @returns {Array<Object>}
     */
    getResultSet = () => {
        const { results, defaultResults } = this.state;
        return results || defaultResults;
    };

    /**
     * Compute a human readable result text.
     * @returns {Object}
     */
    getResultsText = () => {
        const { supportedTypes } = this.props;
        const { isLoading, searchQuery } = this.state;
        const resultCount = this.getAvailableEntities().length;
        const summaryNoun = supportedTypes.length > 1 ? "entities" : `${supportedTypes[0]}s`;
        let summary = `Recently created ${summaryNoun}`;

        if (isLoading) {
            summary = `Searching for "${summaryNoun}" matching "${searchQuery}"`;
        } else if (searchQuery) {
            if (resultCount) {
                summary = `Found ${resultCount} ${summaryNoun} matching "${searchQuery}"`;
            } else {
                summary = `No ${summaryNoun} found matching "${searchQuery}"`;
            }
        }

        return {
            summary,
        };
    };

    /**
     * @description Provided an API response, an error will be thrown if the
     *  provided value is not an Array. This is used within a Promise and
     *  will only cause the Promise to catch instead of this component.
     * @param {*} results - The response of the provided searchFunction, which
     *  is expected to be an Array.
     */
    verifyResultOrThrow = results => {
        if (!Array.isArray(results)) {
            throw new Error("SearchPicker props.searchFunction resolved with a value that is not an Array.");
        }
    };

    /**
     * A render method passed to children so that they can render the search
     * input component wherever they like.
     * @param {Object} props - Any additional props to pass to the <Input>.
     * @returns {React.Component}
     */
    renderInput = (props = {}) => {
        const { handleKeyDown, supportedTypes } = this.props;
        const { searchQuery } = this.state;
        const { handleOnInput } = this;
        const placeholderNoun = supportedTypes.map(i => `${i}s`).join(",");
        return (
            <Input
                isFilter={true}
                onChange={handleOnInput}
                placeholder={`Search for ${placeholderNoun}`}
                testSection={`filter-${placeholderNoun}-input`}
                type="search"
                value={searchQuery}
                onKeyDown={handleKeyDown}
                {...props}
            />
        );
    };

    /**
     * A simple render method that passes relevant state and controls to the
     * children.
     * @returns {React.Component}
     */
    render() {
        const { children, currentFauxFocusIndex } = this.props;
        const { searchQuery, isLoading } = this.state;
        const { renderInput, handleOnInputDebounced } = this;
        const availableEntities = this.getAvailableEntities();
        return children({
            availableEntities,
            isLoading,
            renderInput,
            searchQuery,
            makeSearchRequest: handleOnInputDebounced,
            currentFauxFocusIndex,
            resultsText: this.getResultsText(),
        });
    }
}

export default SearchPicker;
