/**
 * WordPress dependencies
 */
import { FormTokenField } from '@safe-wordpress/components';
import { useSelect } from '@safe-wordpress/data';
import {
	useCallback,
	useEffect,
	useRef,
	useState,
} from '@safe-wordpress/element';
import { sprintf, _x } from '@safe-wordpress/i18n';
import type { TokenItem } from '@wordpress/components/build-types/form-token-field/types';

/**
 * External dependencies
 */
import clsx from 'clsx';
import { truncate, noop, isString, debounce, difference, map } from 'lodash';
import { store as NAB_DATA } from '@nab/data';
import { EMPTY_ARRAY, isDefined } from '@nab/utils';
import type { RefObject } from 'react';
import type {
	EntityId,
	EntityInstance,
	EntityKindName,
	Maybe,
} from '@nab/types';

/**
 * Internal dependencies
 */
import './style.scss';
import { useOptionLoader } from '../utils';
import type {
	PostSearcherProps,
	OptionLoaderProps,
	OptionResults,
} from '../types';

export const MultiplePostSearcher = ( {
	className,
	disabled,
	type,
	value,
	onChange,
	...loaderProps
}: PostSearcherProps< ReadonlyArray< EntityId > > ): JSX.Element => {
	const [ autoExpand, setAutoExpand ] = useState( false ); // HACK. Workaround.

	const {
		setQuery,
		items: foundItems,
		loadMoreItems,
	} = useSearchResult( type, loaderProps );
	const ref = useRef< HTMLDivElement >( null );
	useEffectOnScrollEnd( ref, loadMoreItems );
	useEffectOnFocusAndBlur( ref, setAutoExpand );

	const onSelectionChange = (
		selection: ReadonlyArray< TokenItem | string >
	): void => {
		const str = selection.find( isString ) ?? '';
		const item = findByName( str, foundItems );
		onChange(
			[ ...selection, { itemId: item?.id } ]
				.filter( hasItemId )
				.map( ( s ) => s.itemId )
		);
	};

	const currentRecords = useEntityRecords( type, value );
	const selectedItems = [
		...currentRecords.items.map( simplify ),
		...( currentRecords.finished
			? currentRecords.missingItems.map( makeMissingItem )
			: currentRecords.pendingItems.map( makeLoadingItem ) ),
	];

	return (
		<div
			ref={ ref }
			className={ clsx( [ 'nab-multiple-post-searcher', className ] ) }
		>
			<FormTokenField
				value={ selectedItems.map( itemToFormValue ) }
				disabled={ disabled }
				suggestions={ foundItems
					.filter(
						( fi ) =>
							! selectedItems.some( ( si ) => si.id === fi.id )
					)
					.map( ( p ) => p.name ) }
				onInputChange={ setQuery }
				onChange={ onSelectionChange }
				{ ...{
					__experimentalExpandOnFocus: autoExpand,
					placeholder: _x( 'Search', 'command', 'nelio-popups' ),
				} }
			/>
		</div>
	);
};

// =====
// HOOKS
// =====

const useEntityRecords = (
	type: Maybe< EntityKindName >,
	ids: ReadonlyArray< EntityId >
):
	| {
			readonly finished: false;
			readonly items: ReadonlyArray< EntityInstance >;
			readonly pendingItems: ReadonlyArray< EntityId >;
	  }
	| {
			readonly finished: true;
			readonly items: ReadonlyArray< EntityInstance >;
			readonly missingItems: ReadonlyArray< EntityId >;
	  } => {
	const normalizedIds = normalizeIds( ids );
	const items = useSelect(
		( select ) =>
			restoreOriginalIds( normalizedIds )
				.map( ( id ) =>
					type
						? select( NAB_DATA ).getEntityRecord( type, id )
						: undefined
				)
				.filter( isDefined ),
		[ type, normalizedIds ]
	);

	const missingIds = difference( ids, map( items, 'id' ) );
	const normalizedMissingIds = normalizeIds( missingIds );

	const finished = useSelect(
		( select ) => {
			const { hasFinishedResolution, hasResolutionFailed } =
				select( NAB_DATA );
			const done = ( id: EntityId ) =>
				hasFinishedResolution( 'getEntityRecord', [ type, id ] ) ||
				hasResolutionFailed( 'getEntityRecord', [ type, id ] );
			return restoreOriginalIds( normalizedMissingIds ).every( done );
		},
		[ type, normalizedMissingIds ]
	);

	return finished
		? { finished, items, missingItems: missingIds }
		: { finished, items, pendingItems: missingIds };
};

type NormalizedIds = string & { _type: 'NormalizedIds' };
const normalizeIds = ( ids: ReadonlyArray< EntityId > = [] ): NormalizedIds =>
	JSON.stringify( ids ) as NormalizedIds;

function restoreOriginalIds(
	normalizedIds: NormalizedIds
): ReadonlyArray< EntityId > {
	try {
		return JSON.parse( normalizedIds ) as ReadonlyArray< EntityId >;
	} catch ( _ ) {
		return [];
	}
}

type QueryResults = {
	readonly options: OptionResults[ 'options' ];
	readonly page: number;
	readonly hasMore: boolean;
	readonly lastRequest: Maybe< Promise< unknown > >;
};

const DEFAULT_QUERY_RESULTS: QueryResults = {
	options: EMPTY_ARRAY,
	page: 1,
	hasMore: true,
	lastRequest: undefined,
};

const useSearchResult = (
	type: Maybe< EntityKindName >,
	loaderProps: Omit< OptionLoaderProps, 'type' >
): {
	setQuery: ( v: string ) => void;
	items: ReadonlyArray< Item >;
	loadMoreItems?: () => void;
} => {
	const [ query, doSetQuery ] = useState( '' );
	const [ queryResults, setQueryResults ] = useState<
		Record< string, QueryResults >
	>( {} );

	const loadOptions = useOptionLoader( {
		type: type ?? 'post',
		...loaderProps,
	} );

	const currentQueryResults: QueryResults =
		queryResults[ query ] ?? DEFAULT_QUERY_RESULTS;

	const loadMoreItems = useCallback(
		() => ( {
			...queryResults,
			[ query ]: {
				...currentQueryResults,
				lastRequest: loadOptions( query, null, {
					page: currentQueryResults.page,
				} ).then(
					( r ) =>
						void setQueryResults( {
							...queryResults,
							[ query ]: {
								options: [
									...currentQueryResults.options,
									...r.options,
								],
								page: r.additional.page,
								hasMore: r.hasMore,
								lastRequest: undefined,
							},
						} )
				),
			},
		} ),
		[ currentQueryResults, query, queryResults, loadOptions ]
	);

	// Run first load.
	useEffect( () => {
		if ( !! queryResults[ query ] ) {
			return;
		}
		loadMoreItems();
	}, [ query, queryResults, loadMoreItems ] );

	if ( ! type ) {
		return { items: [], setQuery: debounce( doSetQuery, 1000 ) };
	}

	return {
		items: currentQueryResults.options.map( ( { value, label } ) => ( {
			id: value,
			name: sprintf( '%1$s (#%2$d)', label, value ),
		} ) ),
		loadMoreItems: !! queryResults[ query ]?.hasMore
			? loadMoreItems
			: undefined,
		setQuery: debounce( doSetQuery, 1000 ),
	};
};

const useEffectOnScrollEnd = (
	ref: RefObject< HTMLDivElement | null >,
	callback = noop
) => {
	const current = ref.current;
	useEffect( () => {
		if ( ! current ) {
			return;
		}
		const onScroll = debounce(
			( ev: Event ) =>
				ev.target &&
				isBottomScroll( ev.target as HTMLElement ) &&
				callback(),
			200
		);
		const opts = { capture: true };
		current.addEventListener( 'scroll', onScroll, opts );
		return () => current.removeEventListener( 'scroll', onScroll, opts );
	}, [ callback, current ] );
};

const useEffectOnFocusAndBlur = (
	ref: RefObject< HTMLDivElement | null >,
	callback: ( focus: boolean ) => void = noop
) => {
	const current = ref.current;
	useEffect( () => {
		if ( ! current ) {
			return;
		}
		const onFocus = () => callback( true );
		const onBlur = () => callback( false );
		const opts = { capture: true };
		current.addEventListener( 'focus', onFocus, opts );
		current.addEventListener( 'blur', onBlur, opts );
		return () => {
			current.removeEventListener( 'focus', onFocus, opts );
			current.removeEventListener( 'blur', onBlur, opts );
		};
	}, [ callback, current ] );
};

// =======
// HELPERS
// =======

const makeMissingItem = ( itemId: EntityId ): Item => ( {
	id: itemId,
	/* translators: %d: Item id. */
	name: sprintf( _x( 'Missing item %d', 'text', 'nelio-popups' ), itemId ),
} );

const makeLoadingItem = ( itemId: EntityId ): Item => ( {
	id: itemId,
	name: sprintf(
		/* translators: %d: Id. */
		_x( 'Loading item %d…', 'text', 'nelio-popups' ),
		itemId
	),
} );

const findByName = (
	name: string,
	items: ReadonlyArray< Item >
): Item | undefined =>
	items.find( ( item ) => item.name.toLowerCase() === name.toLowerCase() );

const itemToFormValue = ( item: Item ): TokenItem =>
	( {
		itemId: item.id,
		value: item.name,
		title: item.name,
	} ) as TokenItem;

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const hasItemId = ( p: any ): p is { itemId: EntityId } => !! p.itemId;

const getName = ( item: EntityInstance ): string => {
	// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
	const hasName = ( i: any ): i is { name: string } => !! i.name;
	return hasName( item ) ? item.name : item.title;
};

const simplify = ( item: EntityInstance ): Item => ( {
	id: item.id,
	name: sprintf(
		'%1$s (#%2$d)',
		truncate( getName( item ), { length: 20, omission: '…' } ).replace(
			/,/g,
			''
		),
		item.id
	),
} );

const isBottomScroll = ( el: HTMLElement ): boolean =>
	el.scrollHeight - el.scrollTop === el.clientHeight;

// ============
// HELPER TYPES
// ============

type Item = {
	readonly id: EntityId;
	readonly name: string;
};
