/**
 * External dependencies
 */
import { FormTokenField, __experimentalVStack as VStack } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';

/**
 * Internal dependencies
 */
import { useTaxonomies } from '@spectra-pro-helpers/loop-builder';

/**
 * An empty array constant used as a default value.
 *
 * @type {Array}
 */
const EMPTY_ARRAY = [];

/**
 * Base query parameters for fetching taxonomy terms.
 *
 * Defines default settings for ordering and fields to retrieve.
 *
 * @type {Object}
 */
const BASE_QUERY = {
	order: 'asc',
	_fields: 'id,name',
	context: 'view',
};

/**
 * Retrieves the term ID based on user input in the terms `FormTokenField`.
 *
 * This helper function attempts to match a term value (either an object or string)
 * to an existing term ID from the provided terms array. It first checks for an exact
 * match by ID or name, then falls back to a case-insensitive name match to align
 * with `FormTokenField` suggestion behavior.
 *
 * @param {Array}         terms     - An array of term objects.
 * @param {Object|string} termValue - The term value to match (either an object or string).
 * @return {number|undefined} The matched term ID, or undefined if no match is found.
 */
const getTermIdByTermValue = ( terms, termValue ) => {
	// First we check for exact match by `term.id` or case sensitive `term.name` match.
	const termId = termValue?.id || terms?.find( ( term ) => term.name === termValue )?.id;
	if ( termId ) {
		return termId;
	}

	/**
	 * Here we make an extra check for entered terms in a non case sensitive way,
	 * to match user expectations, due to `FormTokenField` behaviour that shows
	 * suggestions which are case insensitive.
	 *
	 * Although WP tries to discourage users to add terms with the same name (case insensitive),
	 * it's still possible if you manually change the name, as long as the terms have different slugs.
	 * In this edge case we always apply the first match from the terms list.
	 */
	const termValueLower = termValue.toLocaleLowerCase();
	return terms?.find( ( term ) => term.name.toLocaleLowerCase() === termValueLower )?.id;
};

/**
 * Renders a set of taxonomy controls for a query.
 *
 * This component displays a vertical stack of `FormTokenField` controls, one for each
 * taxonomy associated with the specified post type. It allows users to select terms
 * to filter the query results and updates the `taxQuery` object via the `onChange` callback.
 *
 * @param {Object}   props          The component props.
 * @param {Function} props.onChange Callback function to update the query with new taxonomy terms.
 * @param {Object}   props.query    The current query object containing `postType` and `taxQuery`.
 * @return {Element|null} The rendered VStack of taxonomy controls, or null if no taxonomies exist.
 */
export const TaxonomyControls = ( { onChange, query } ) => {
	const { postType, taxQuery } = query;

	// Fetch taxonomies available for the current post type.
	const taxonomies = useTaxonomies( postType );

	// If no taxonomies are available, render nothing.
	if ( ! taxonomies || taxonomies.length === 0 ) {
		return null;
	}

	return (
		<VStack spacing={ 4 }>
			{ taxonomies.map( ( taxonomy ) => {
				// Get the current term IDs for this taxonomy from the query.
				const termIds = taxQuery?.[ taxonomy.slug ] || [];

				// Handler to update the taxQuery object with new term IDs for this taxonomy.
				const handleChange = ( newTermIds ) =>
					onChange( {
						taxQuery: {
							...taxQuery,
							[ taxonomy.slug ]: newTermIds,
						},
					} );

				return (
					<TaxonomyItem
						key={ taxonomy.slug }
						taxonomy={ taxonomy }
						termIds={ termIds }
						onChange={ handleChange }
					/>
				);
			} ) }
		</VStack>
	);
};

/**
 * Renders a `FormTokenField` for selecting terms of a given taxonomy.
 *
 * This component provides a token field where users can search for and select terms
 * to filter query results for a specific taxonomy. It handles term suggestions,
 * debounced search, and ensures only valid term IDs are passed to the `onChange` callback.
 *
 * @since x.x.x
 *
 * @param {Object}   props          The component props.
 * @param {Object}   props.taxonomy The taxonomy object with `slug` and `name` properties.
 * @param {number[]} props.termIds  Array of selected term IDs for this taxonomy.
 * @param {Function} props.onChange Callback function to update the term IDs in the query.
 * @return {Element} The rendered FormTokenField component wrapped in a div.
 */
const TaxonomyItem = ( { taxonomy, termIds, onChange } ) => {
	// State for the current search term entered by the user.
	const [ search, setSearch ] = useState( '' );
	// State for the current value displayed in the token field (array of { id, value } objects).
	const [ value, setValue ] = useState( EMPTY_ARRAY );
	// State for the list of term name suggestions.
	const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY );
	// Debounced search function to reduce API calls during typing.
	const debouncedSearch = useDebounce( setSearch, 250 );
	// Fetch search results and resolution status for term suggestions.
	const { searchResults, searchHasResolved } = useSelect(
		( select ) => {
			const { getEntityRecords, hasFinishedResolution } = select( coreStore );
			
			// Prepare query args based on whether we have a search term.
			const queryArgs = {
				...BASE_QUERY,
				orderby: 'name',
				exclude: termIds,
				per_page: 20,
			};
			
			// Add search parameter only if there's a search term.
			if ( search ) {
				queryArgs.search = search;
			}
			
			const selectorArgs = [ 'taxonomy', taxonomy.slug, queryArgs ];
			
			return {
				searchResults: getEntityRecords( ...selectorArgs ) || EMPTY_ARRAY,
				searchHasResolved: hasFinishedResolution( 'getEntityRecords', selectorArgs ),
			};
		},
		[ search, termIds ]
	);
	// `existingTerms` are the ones fetched from the API and their type is `{ id: number; name: string }`.
	// They are used to extract the terms' names to populate the `FormTokenField` properly
	// and to sanitize the provided `termIds`, by setting only the ones that exist.
	const existingTerms = useSelect(
		( select ) => {
			if ( ! termIds?.length ) {
				return EMPTY_ARRAY;
			}
			const { getEntityRecords } = select( coreStore );
			return getEntityRecords( 'taxonomy', taxonomy.slug, {
				...BASE_QUERY,
				include: termIds,
				per_page: termIds.length,
			} );
		},
		[ termIds ]
	);
	// Update the `value` state only after the selectors are resolved
	// to avoid emptying the input when we're changing terms.
	useEffect( () => {
		// Reset value if no term IDs are selected.
		if ( ! termIds?.length ) {
			setValue( EMPTY_ARRAY );
		}

		// Wait for existing terms to load before updating.
		if ( ! existingTerms?.length ) {
			return;
		}
		// Returns only the existing entity ids. This prevents the component
		// from crashing in the editor, when non existing ids are provided.
		const sanitizedValue = termIds.reduce( ( accumulator, id ) => {
			const entity = existingTerms.find( ( term ) => term.id === id );
			if ( entity ) {
				accumulator.push( {
					id,
					value: entity.name,
				} );
			}
			return accumulator;
		}, [] );
		setValue( sanitizedValue );
	}, [ termIds, existingTerms ] );
	// Update suggestions only when the query has resolved.
	useEffect( () => {
		// Wait until the search has resolved before updating suggestions.
		if ( ! searchHasResolved ) {
			return;
		}
		// Map search results to an array of term names for suggestions.
		setSuggestions( searchResults.map( ( result ) => result.name ) );
	}, [ searchResults, searchHasResolved ] );

	/**
	 * Handles changes to the token field values.
	 *
	 * Converts the new term values into an array of valid term IDs and updates
	 * the query via the `onChange` callback.
	 *
	 * @param {Array} newTermValues Array of term values (strings or objects) from the token field.
	 */
	const onTermsChange = ( newTermValues ) => {
		const newTermIds = new Set(); // Use a Set to avoid duplicate IDs.
		for ( const termValue of newTermValues ) {
			// Get the term ID from the search results or existing terms based on the input value.
			const termId = getTermIdByTermValue( searchResults, termValue ) || 
			               getTermIdByTermValue( existingTerms, termValue );
			if ( termId ) {
				newTermIds.add( termId ); // Add valid term IDs to the Set.
			}
		}
		onChange( Array.from( newTermIds ) ); // Convert Set to array and update query.
	};
	return (
		<div className="block-library-query-inspector__taxonomy-control">
			<FormTokenField
				label={ taxonomy.name }
				value={ value }
				onInputChange={ debouncedSearch }
				suggestions={ suggestions }
				displayTransform={ decodeEntities }
				onChange={ onTermsChange }
				__experimentalShowHowTo={ false }
				__experimentalExpandOnFocus
				__nextHasNoMarginBottom
				__next40pxDefaultSize
			/>
		</div>
	);
};
