/**
 * External dependencies
 */
import { createStore, applyMiddleware } from 'redux';
import type { Store as ReduxStore, StoreEnhancer } from 'redux';
import EquivalentKeyMap from 'equivalent-key-map';

/**
 * WordPress dependencies
 */
import createReduxRoutineMiddleware from '@wordpress/redux-routine';
import { compose } from '@wordpress/compose';

/**
 * Internal dependencies
 */
import { combineReducers } from './combine-reducers';
import { builtinControls } from '../controls';
import { lock } from '../lock-unlock';
import promise from '../promise-middleware';
import createResolversCacheMiddleware from '../resolvers-cache-middleware';
import createThunkMiddleware from './thunk-middleware';
import metadataReducer from './metadata/reducer';
import * as metadataSelectors from './metadata/selectors';
import * as metadataActions from './metadata/actions';
import type {
	DataRegistry,
	ListenerFunction,
	StoreDescriptor,
	ReduxStoreConfig,
	ActionCreator,
	NormalizedResolver,
} from '../types';

export { combineReducers };

/**
 * Augment the Redux store with internal properties used by the data module.
 */
interface AugmentedReduxStore extends ReduxStore {
	__unstableOriginalGetState: ReduxStore[ 'getState' ];
}

interface ResolversCache {
	isRunning: ( selectorName: string, args: unknown[] ) => boolean;
	clear: ( selectorName: string, args: unknown[] ) => void;
	markAsRunning: ( selectorName: string, args: unknown[] ) => void;
}

interface BindingCache {
	get: ( itemName: string ) => ( ( ...args: unknown[] ) => unknown ) | null;
}

interface SelectorLike {
	( ...args: any[] ): any;
	hasResolver?: boolean;
	isRegistrySelector?: boolean;
	registry?: DataRegistry;
	__unstableNormalizeArgs?: ( args: unknown[] ) => unknown[];
}

const trimUndefinedValues = ( array: unknown[] ): unknown[] => {
	const result = [ ...array ];
	for ( let i = result.length - 1; i >= 0; i-- ) {
		if ( result[ i ] === undefined ) {
			result.splice( i, 1 );
		}
	}
	return result;
};

/**
 * Creates a new object with the same keys, but with `callback()` called as
 * a transformer function on each of the values.
 *
 * @param obj      The object to transform.
 * @param callback The function to transform each object value.
 * @return Transformed object.
 */
const mapValues = < T, U >(
	obj: Record< string, T > | undefined,
	callback: ( value: T, key: string ) => U
): Record< string, U > =>
	Object.fromEntries(
		Object.entries( obj ?? {} ).map( ( [ key, value ] ) => [
			key,
			callback( value, key ),
		] )
	);

// Convert  non serializable types to plain objects
const devToolsReplacer = ( _key: string, state: unknown ): unknown => {
	if ( state instanceof Map ) {
		return Object.fromEntries( state );
	}

	if (
		typeof window !== 'undefined' &&
		state instanceof window.HTMLElement
	) {
		return null;
	}

	return state;
};

/**
 * Create a cache to track whether resolvers started running or not.
 *
 * @return Resolvers Cache.
 */
function createResolversCache(): ResolversCache {
	const cache: Record< string, EquivalentKeyMap< unknown[], boolean > > = {};
	return {
		isRunning( selectorName: string, args: unknown[] ): boolean {
			return !! (
				cache[ selectorName ] &&
				cache[ selectorName ].get( trimUndefinedValues( args ) )
			);
		},

		clear( selectorName: string, args: unknown[] ): void {
			if ( cache[ selectorName ] ) {
				cache[ selectorName ].delete( trimUndefinedValues( args ) );
			}
		},

		markAsRunning( selectorName: string, args: unknown[] ): void {
			if ( ! cache[ selectorName ] ) {
				cache[ selectorName ] = new EquivalentKeyMap();
			}

			cache[ selectorName ].set( trimUndefinedValues( args ), true );
		},
	};
}

function createBindingCache(
	getItem: ( name: string ) => ( ( ...args: any[] ) => any ) | undefined,
	bindItem: (
		item: ( ...args: any[] ) => any,
		name: string
	) => ( ...args: unknown[] ) => unknown
): BindingCache {
	const cache = new WeakMap<
		( ...args: any[] ) => any,
		( ...args: unknown[] ) => unknown
	>();

	return {
		get( itemName: string ) {
			const item = getItem( itemName );
			if ( ! item ) {
				return null;
			}
			let boundItem = cache.get( item );
			if ( ! boundItem ) {
				boundItem = bindItem( item, itemName );
				cache.set( item, boundItem );
			}
			return boundItem;
		},
	};
}

function createPrivateProxy< T extends Record< string, any > >(
	publicItems: T,
	privateItems: BindingCache
): T {
	return new Proxy( publicItems, {
		get: ( target, itemName: string ) =>
			privateItems.get( itemName ) || Reflect.get( target, itemName ),
	} );
}

/**
 * Creates a data store descriptor for the provided Redux store configuration containing
 * properties describing reducer, actions, selectors, controls and resolvers.
 *
 * @example
 * ```js
 * import { createReduxStore } from '@wordpress/data';
 *
 * const store = createReduxStore( 'demo', {
 *     reducer: ( state = 'OK' ) => state,
 *     selectors: {
 *         getValue: ( state ) => state,
 *     },
 * } );
 * ```
 *
 * @param key     Unique namespace identifier.
 * @param options Registered store options, with properties
 *                describing reducer, actions, selectors,
 *                and resolvers.
 *
 * @return Store Object.
 */
export default function createReduxStore< State, Actions, Selectors >(
	key: string,
	options: ReduxStoreConfig< State, Actions, Selectors >
): StoreDescriptor< ReduxStoreConfig< State, Actions, Selectors > > {
	const privateActions: Record< string, ActionCreator > = {};
	const privateSelectors: Record< string, SelectorLike > = {};
	const privateRegistrationFunctions = {
		privateActions,
		registerPrivateActions: (
			actions: Record< string, ActionCreator >
		) => {
			Object.assign( privateActions, actions );
		},
		privateSelectors,
		registerPrivateSelectors: (
			selectors: Record< string, SelectorLike >
		) => {
			Object.assign( privateSelectors, selectors );
		},
	};
	const storeDescriptor = {
		name: key,
		instantiate: ( registry: DataRegistry ) => {
			/**
			 * Stores listener functions registered with `subscribe()`.
			 *
			 * When functions register to listen to store changes with
			 * `subscribe()` they get added here. Although Redux offers
			 * its own `subscribe()` function directly, by wrapping the
			 * subscription in this store instance it's possible to
			 * optimize checking if the state has changed before calling
			 * each listener.
			 */
			const listeners = new Set< ListenerFunction >();
			const reducer = options.reducer;

			// Object that every thunk function receives as the first argument. It contains the
			// `registry`, `dispatch`, `select` and `resolveSelect` fields. Some of them are
			// constructed as getters to avoid circular dependencies.
			const thunkArgs = {
				registry,
				get dispatch() {
					return thunkDispatch;
				},
				get select() {
					return thunkSelect;
				},
				get resolveSelect() {
					return resolveSelectors;
				},
			};

			const store = instantiateReduxStore(
				key,
				options,
				registry,
				thunkArgs
			) as AugmentedReduxStore;

			// Expose the private registration functions on the store
			// so they can be copied to a sub registry in registry.js.
			lock( store, privateRegistrationFunctions );
			const resolversCache = createResolversCache();

			// Binds an action creator (`action`) to the `store`, making it a callable function.
			// These are the functions that are returned by `useDispatch`, for example.
			// It always returns a `Promise`, although actions are not always async. That's an
			// unfortunate backward compatibility measure.
			function bindAction( action: ( ...args: any[] ) => any ) {
				return ( ...args: unknown[] ) =>
					Promise.resolve( store.dispatch( action( ...args ) ) );
			}

			/*
			 * Object with all public actions, both metadata and store actions.
			 */
			const actions = {
				...mapValues(
					metadataActions as Record< string, ActionCreator >,
					bindAction
				),
				...mapValues(
					options.actions as
						| Record< string, ActionCreator >
						| undefined,
					bindAction
				),
			};

			// Object with both public and private actions. Private actions are accessed through a proxy,
			// which looks them up in real time on the `privateActions` object. That's because private
			// actions can be registered at any time with `registerPrivateActions`. Also once a private
			// action creator is bound to the store, it is cached to give it a stable identity.
			const allActions = createPrivateProxy(
				actions,
				createBindingCache(
					( name ) => privateActions[ name ],
					bindAction
				)
			);

			// An object that implements the `dispatch` object that is passed to thunk functions.
			// It is callable (`dispatch( action )`) and also has methods (`dispatch.foo()`) that
			// correspond to bound registered actions, both public and private. Implemented with the proxy
			// `get` method, delegating to `allActions`.
			const thunkDispatch = new Proxy(
				( action: any ) => store.dispatch( action ),
				{
					get: ( _target, name: string ) => allActions[ name ],
				}
			);

			// To the public `actions` object, add the "locked" `allActions` object. When used,
			// `unlock( actions )` will return `allActions`, implementing a way how to get at the private actions.
			lock( actions, allActions );

			// If we have selector resolvers, convert them to a normalized form.
			const resolvers: Record< string, NormalizedResolver > =
				options.resolvers
					? mapValues(
							options.resolvers as Record< string, any >,
							mapResolver
					  )
					: {};

			// Bind a selector to the store. Call the selector with the current state, correct registry,
			// and if there is a resolver, attach the resolver logic to the selector.
			function bindSelector(
				selector: SelectorLike,
				selectorName: string
			): SelectorLike {
				if ( selector.isRegistrySelector ) {
					selector.registry = registry;
				}
				const boundSelector: SelectorLike = ( ...args: any[] ) => {
					args = normalize( selector, args );
					const state = store.__unstableOriginalGetState();
					// Before calling the selector, switch to the correct registry.
					if ( selector.isRegistrySelector ) {
						selector.registry = registry;
					}
					return selector( state.root, ...args );
				};

				// Expose normalization method on the bound selector
				// in order that it can be called when fulfilling
				// the resolver.
				boundSelector.__unstableNormalizeArgs =
					selector.__unstableNormalizeArgs;

				const resolver = resolvers[ selectorName ];

				if ( ! resolver ) {
					boundSelector.hasResolver = false;
					return boundSelector;
				}

				return mapSelectorWithResolver(
					boundSelector,
					selectorName,
					resolver,
					store,
					resolversCache,
					boundMetadataSelectors
				);
			}

			// Metadata selectors are bound differently: different state (`state.metadata`), no resolvers,
			// normalization depending on the target selector.
			function bindMetadataSelector(
				metaDataSelector: ( ...args: any[] ) => any
			): SelectorLike {
				const boundSelector: SelectorLike = (
					selectorName: string,
					selectorArgs: unknown[],
					...args: unknown[]
				) => {
					// Normalize the arguments passed to the target selector.
					if ( selectorName ) {
						const targetSelector = ( options.selectors as any )?.[
							selectorName
						];
						if ( targetSelector ) {
							selectorArgs = normalize(
								targetSelector,
								selectorArgs
							);
						}
					}

					const state = store.__unstableOriginalGetState();

					return metaDataSelector(
						state.metadata,
						selectorName,
						selectorArgs,
						...args
					);
				};
				boundSelector.hasResolver = false;
				return boundSelector;
			}

			// Perform binding of both metadata and store selectors and combine them in one
			// `selectors` object. These are all public selectors of the store.
			const boundMetadataSelectors = mapValues(
				metadataSelectors as Record<
					string,
					( ...args: any[] ) => any
				>,
				bindMetadataSelector
			);

			const boundSelectors = mapValues(
				options.selectors as Record< string, SelectorLike > | undefined,
				bindSelector
			);

			const selectors = {
				...boundMetadataSelectors,
				...boundSelectors,
			};

			// Cache of bound private selectors. They are bound only when first accessed, because
			// new private selectors can be registered at any time (with `registerPrivateSelectors`).
			// Once bound, they are cached to give them a stable identity.
			const boundPrivateSelectors = createBindingCache(
				( name ) => privateSelectors[ name ],
				bindSelector
			);

			const allSelectors = createPrivateProxy(
				selectors,
				boundPrivateSelectors
			);

			// Pre-bind the private selectors that have been registered by the time of
			// instantiation, so that registry selectors are bound to the registry.
			for ( const selectorName of Object.keys( privateSelectors ) ) {
				boundPrivateSelectors.get( selectorName );
			}

			// An object that implements the `select` object that is passed to thunk functions.
			// It is callable (`select( selector )`) and also has methods (`select.foo()`) that
			// correspond to bound registered selectors, both public and private. Implemented with the proxy
			// `get` method, delegating to `allSelectors`.
			const thunkSelect = new Proxy(
				( selector: ( state: any ) => any ) =>
					selector( store.__unstableOriginalGetState() ),
				{
					get: ( _target, name: string ) => allSelectors[ name ],
				}
			);

			// To the public `selectors` object, add the "locked" `allSelectors` object. When used,
			// `unlock( selectors )` will return `allSelectors`, implementing a way how to get at the private selectors.
			lock( selectors, allSelectors );

			// For each selector, create a function that calls the selector, waits for resolution and returns
			// a promise that resolves when the resolution is finished.
			const bindResolveSelector = mapResolveSelector(
				store,
				resolvers,
				boundMetadataSelectors
			);

			// Now apply this function to all bound selectors, public and private. We are excluding
			// metadata selectors because they don't have resolvers.
			const resolveSelectors = mapValues(
				boundSelectors,
				bindResolveSelector
			);

			const allResolveSelectors = createPrivateProxy(
				resolveSelectors,
				createBindingCache(
					( name ) =>
						boundPrivateSelectors.get( name ) as (
							...args: any[]
						) => any | undefined,
					bindResolveSelector
				)
			);

			// Lock the selectors so that `unlock( resolveSelectors )` returns `allResolveSelectors`.
			lock( resolveSelectors, allResolveSelectors );

			// Now, in a way very similar to `bindResolveSelector`, we create a function that maps
			// selectors to functions that throw a suspense promise if not yet resolved.
			const bindSuspendSelector = mapSuspendSelector(
				store,
				boundMetadataSelectors
			);

			const suspendSelectors = {
				...boundMetadataSelectors, // no special suspense behavior
				...mapValues( boundSelectors, bindSuspendSelector ),
			};

			const allSuspendSelectors = createPrivateProxy(
				suspendSelectors,
				createBindingCache(
					( name ) =>
						boundPrivateSelectors.get( name ) as (
							...args: any[]
						) => any | undefined,
					bindSuspendSelector
				)
			);

			// Lock the selectors so that `unlock( suspendSelectors )` returns 'allSuspendSelectors`.
			lock( suspendSelectors, allSuspendSelectors );

			const getSelectors = () => selectors;
			const getActions = () => actions;
			const getResolveSelectors = () => resolveSelectors;
			const getSuspendSelectors = () => suspendSelectors;

			// We have some modules monkey-patching the store object
			// It's wrong to do so but until we refactor all of our effects to controls
			// We need to keep the same "store" instance here.
			store.__unstableOriginalGetState = store.getState;
			store.getState = () => store.__unstableOriginalGetState().root;

			// Customize subscribe behavior to call listeners only on effective change,
			// not on every dispatch.
			const subscribe = ( listener: ListenerFunction ) => {
				listeners.add( listener );

				return () => listeners.delete( listener );
			};

			let lastState = store.__unstableOriginalGetState();
			store.subscribe( () => {
				const state = store.__unstableOriginalGetState();
				const hasChanged = state !== lastState;
				lastState = state;

				if ( hasChanged ) {
					for ( const listener of listeners ) {
						listener();
					}
				}
			} );

			// This can be simplified to just { subscribe, getSelectors, getActions }
			// Once we remove the use function.
			return {
				reducer,
				store,
				actions,
				selectors,
				resolvers,
				getSelectors,
				getResolveSelectors,
				getSuspendSelectors,
				getActions,
				subscribe,
			};
		},
	};

	// Expose the private registration functions on the store
	// descriptor. That's a natural choice since that's where the
	// public actions and selectors are stored.
	lock( storeDescriptor, privateRegistrationFunctions );

	return storeDescriptor as unknown as StoreDescriptor<
		ReduxStoreConfig< State, Actions, Selectors >
	>;
}

/**
 * Creates a redux store for a namespace.
 *
 * @param key       Unique namespace identifier.
 * @param options   Registered store options, with properties
 *                  describing reducer, actions, selectors,
 *                  and resolvers.
 * @param registry  Registry reference.
 * @param thunkArgs Argument object for the thunk middleware.
 * @return Newly created redux store.
 */
function instantiateReduxStore(
	key: string,
	options: ReduxStoreConfig< any, any, any >,
	registry: DataRegistry,
	thunkArgs: unknown
): ReduxStore {
	const controls = {
		...options.controls,
		...builtinControls,
	};

	const normalizedControls = mapValues( controls, ( control: any ) =>
		control.isRegistryControl ? control( registry ) : control
	);

	const middlewares = [
		createResolversCacheMiddleware( registry, key ),
		promise,
		createReduxRoutineMiddleware( normalizedControls ),
		createThunkMiddleware( thunkArgs ),
	];

	const enhancers: StoreEnhancer[] = [ applyMiddleware( ...middlewares ) ];
	if (
		typeof window !== 'undefined' &&
		( window as any ).__REDUX_DEVTOOLS_EXTENSION__
	) {
		enhancers.push(
			( window as any ).__REDUX_DEVTOOLS_EXTENSION__( {
				name: key,
				instanceId: key,
				serialize: {
					replacer: devToolsReplacer,
				},
			} )
		);
	}

	const { reducer, initialState } = options;
	const enhancedReducer = combineReducers( {
		metadata: metadataReducer,
		root: reducer,
	} );

	return createStore(
		enhancedReducer,
		{ root: initialState } as any,
		compose( ...enhancers ) as unknown as StoreEnhancer
	);
}

/**
 * Maps selectors to functions that return a resolution promise for them.
 *
 * @param store                  The redux store the selectors are bound to.
 * @param resolvers              The normalized resolvers for the store.
 * @param boundMetadataSelectors The bound metadata selectors.
 *
 * @return Function that maps selectors to resolvers.
 */
function mapResolveSelector(
	store: ReduxStore,
	resolvers: Record< string, NormalizedResolver >,
	boundMetadataSelectors: Record< string, SelectorLike >
) {
	return ( selector: SelectorLike, selectorName: string ) => {
		// If the selector doesn't have a resolver, just convert the return value
		// (including exceptions) to a Promise, no additional extra behavior is needed.
		if ( ! selector.hasResolver ) {
			return async ( ...args: unknown[] ) => selector( ...args );
		}

		return ( ...args: unknown[] ) =>
			new Promise( ( resolve, reject ) => {
				const resolver = resolvers[ selectorName ];
				const hasFinished = () => {
					return (
						boundMetadataSelectors.hasFinishedResolution(
							selectorName,
							args
						) ||
						( typeof resolver.isFulfilled === 'function' &&
							resolver.isFulfilled( store.getState(), ...args ) )
					);
				};
				const finalize = ( result: unknown ) => {
					const hasFailed =
						boundMetadataSelectors.hasResolutionFailed(
							selectorName,
							args
						);
					if ( hasFailed ) {
						const error = boundMetadataSelectors.getResolutionError(
							selectorName,
							args
						);
						reject( error );
					} else {
						resolve( result );
					}
				};
				const getResult = () => selector( ...args );

				// Trigger the selector (to trigger the resolver)
				const result = getResult();
				if ( hasFinished() ) {
					return finalize( result );
				}

				const unsubscribe = store.subscribe( () => {
					if ( hasFinished() ) {
						unsubscribe();
						finalize( getResult() );
					}
				} );
			} );
	};
}

/**
 * Maps selectors to functions that throw a suspense promise if not yet resolved.
 *
 * @param store                  The redux store the selectors select from.
 * @param boundMetadataSelectors The bound metadata selectors.
 *
 * @return Function that maps selectors to their suspending versions.
 */
function mapSuspendSelector(
	store: ReduxStore,
	boundMetadataSelectors: Record< string, SelectorLike >
) {
	return ( selector: SelectorLike, selectorName: string ) => {
		// Selector without a resolver doesn't have any extra suspense behavior.
		if ( ! selector.hasResolver ) {
			return selector;
		}

		return ( ...args: unknown[] ) => {
			const result = selector( ...args );

			if (
				boundMetadataSelectors.hasFinishedResolution(
					selectorName,
					args
				)
			) {
				if (
					boundMetadataSelectors.hasResolutionFailed(
						selectorName,
						args
					)
				) {
					throw boundMetadataSelectors.getResolutionError(
						selectorName,
						args
					);
				}

				return result;
			}

			throw new Promise< void >( ( resolve ) => {
				const unsubscribe = store.subscribe( () => {
					if (
						boundMetadataSelectors.hasFinishedResolution(
							selectorName,
							args
						)
					) {
						resolve();
						unsubscribe();
					}
				} );
			} );
		};
	};
}

/**
 * Convert a resolver to a normalized form, an object with `fulfill` method and
 * optional methods like `isFulfilled`.
 *
 * @param resolver Resolver to convert
 */
function mapResolver( resolver: any ): NormalizedResolver {
	if ( resolver.fulfill ) {
		return resolver;
	}

	return {
		...resolver, // Copy the enumerable properties of the resolver function.
		fulfill: resolver, // Add the fulfill method.
	};
}

/**
 * Returns a selector with a matched resolver.
 * Resolvers are side effects invoked once per argument set of a given selector call,
 * used in ensuring that the data needs for the selector are satisfied.
 *
 * @param selector               The selector function to be bound.
 * @param selectorName           The selector name.
 * @param resolver               Resolver to call.
 * @param store                  The redux store to which the resolvers should be mapped.
 * @param resolversCache         Resolvers Cache.
 * @param boundMetadataSelectors The bound metadata selectors.
 */
function mapSelectorWithResolver(
	selector: SelectorLike,
	selectorName: string,
	resolver: NormalizedResolver,
	store: AugmentedReduxStore,
	resolversCache: ResolversCache,
	boundMetadataSelectors: Record< string, SelectorLike >
): SelectorLike {
	function fulfillSelector( args: unknown[] ): void {
		if (
			resolversCache.isRunning( selectorName, args ) ||
			boundMetadataSelectors.hasStartedResolution( selectorName, args ) ||
			( typeof resolver.isFulfilled === 'function' &&
				resolver.isFulfilled( store.getState(), ...args ) )
		) {
			return;
		}

		resolversCache.markAsRunning( selectorName, args );

		setTimeout( async () => {
			resolversCache.clear( selectorName, args );
			store.dispatch(
				metadataActions.startResolution( selectorName, args )
			);
			try {
				const action = resolver.fulfill( ...args );
				if ( action ) {
					await store.dispatch( action );
				}
				store.dispatch(
					metadataActions.finishResolution( selectorName, args )
				);
			} catch ( error ) {
				store.dispatch(
					metadataActions.failResolution( selectorName, args, error )
				);
			}
		}, 0 );
	}

	const selectorResolver: SelectorLike = ( ...args: unknown[] ) => {
		args = normalize( selector, args );
		fulfillSelector( args );
		return selector( ...args );
	};
	selectorResolver.hasResolver = true;
	return selectorResolver;
}

/**
 * Applies selector's normalization function to the given arguments
 * if it exists.
 *
 * @param selector The selector potentially with a normalization method property.
 * @param args     selector arguments to normalize.
 * @return Potentially normalized arguments.
 */
function normalize( selector: SelectorLike, args: unknown[] ): unknown[] {
	if (
		selector.__unstableNormalizeArgs &&
		typeof selector.__unstableNormalizeArgs === 'function' &&
		args?.length
	) {
		return selector.__unstableNormalizeArgs( args );
	}
	return args;
}
