/**
 * WordPress dependencies
 */
import deprecated from '@wordpress/deprecated';

/**
 * Internal dependencies
 */
import createReduxStore from './redux-store';
import coreDataStore from './store';
import { createEmitter } from './utils/emitter';
import { lock, unlock } from './lock-unlock';
import type {
	StoreDescriptor,
	StoreNameOrDescriptor,
	DataRegistry,
	DataPlugin,
	InternalStoreInstance,
	AnyConfig,
	ReduxStoreConfig,
} from './types';

function getStoreName( storeNameOrDescriptor: StoreNameOrDescriptor ): string {
	return typeof storeNameOrDescriptor === 'string'
		? storeNameOrDescriptor
		: storeNameOrDescriptor.name;
}

/**
 * Creates a new store registry, given an optional object of initial store
 * configurations.
 *
 * @param storeConfigs Initial store configurations.
 * @param parent       Parent registry.
 *
 * @return Data registry.
 */
export function createRegistry(
	storeConfigs: Record< string, ReduxStoreConfig< any, any, any > > = {},
	parent: DataRegistry | null = null
): DataRegistry {
	const stores: Record< string, InternalStoreInstance > = {};
	const emitter = createEmitter();
	let listeningStores: Set< string > | null = null;

	/**
	 * Global listener called for each store's update.
	 */
	function globalListener() {
		emitter.emit();
	}

	/**
	 * Subscribe to changes to any data, either in all stores in registry, or
	 * in one specific store.
	 *
	 * @param listener              Listener function.
	 * @param storeNameOrDescriptor Optional store name.
	 *
	 * @return Unsubscribe function.
	 */
	const subscribe = (
		listener: () => void,
		storeNameOrDescriptor?: StoreNameOrDescriptor
	): ( () => void ) => {
		// subscribe to all stores
		if ( ! storeNameOrDescriptor ) {
			return emitter.subscribe( listener );
		}

		// subscribe to one store
		const storeName = getStoreName( storeNameOrDescriptor );
		const store = stores[ storeName ];
		if ( store ) {
			return store.subscribe( listener );
		}

		// Trying to access a store that hasn't been registered,
		// this is a pattern rarely used but seen in some places.
		// We fallback to global `subscribe` here for backward-compatibility for now.
		// See https://github.com/WordPress/gutenberg/pull/27466 for more info.
		if ( ! parent ) {
			return emitter.subscribe( listener );
		}

		return parent.subscribe( listener, storeNameOrDescriptor );
	};

	/**
	 * Calls a selector given the current state and extra arguments.
	 *
	 * @param storeNameOrDescriptor Unique namespace identifier for the store
	 *                              or the store descriptor.
	 *
	 * @return The selector's returned value.
	 */
	function select( storeNameOrDescriptor: StoreNameOrDescriptor ) {
		const storeName = getStoreName( storeNameOrDescriptor );
		listeningStores?.add( storeName );
		const store = stores[ storeName ];
		if ( store ) {
			return store.getSelectors();
		}

		return parent?.select( storeName );
	}

	function __unstableMarkListeningStores< T >(
		this: DataRegistry,
		callback: () => T,
		ref: { current: string[] | null }
	): T {
		listeningStores = new Set();
		try {
			return callback.call( this );
		} finally {
			ref.current = Array.from( listeningStores );
			listeningStores = null;
		}
	}

	/**
	 * Given a store descriptor, returns an object containing the store's selectors pre-bound to
	 * state so that you only need to supply additional arguments, and modified so that they return
	 * promises that resolve to their eventual values, after any resolvers have ran.
	 *
	 * @param storeNameOrDescriptor The store descriptor. The legacy calling
	 *                              convention of passing the store name is
	 *                              also supported.
	 *
	 * @return Each key of the object matches the name of a selector.
	 */
	function resolveSelect( storeNameOrDescriptor: StoreNameOrDescriptor ) {
		const storeName = getStoreName( storeNameOrDescriptor );
		listeningStores?.add( storeName );
		const store = stores[ storeName ];
		if ( store ) {
			return store.getResolveSelectors!();
		}

		return parent && parent.resolveSelect( storeName );
	}

	/**
	 * Given a store descriptor, returns an object containing the store's selectors pre-bound to
	 * state so that you only need to supply additional arguments, and modified so that they throw
	 * promises in case the selector is not resolved yet.
	 *
	 * @param storeNameOrDescriptor The store descriptor. The legacy calling
	 *                              convention of passing the store name is
	 *                              also supported.
	 *
	 * @return Object containing the store's suspense-wrapped selectors.
	 */
	function suspendSelect( storeNameOrDescriptor: StoreNameOrDescriptor ) {
		const storeName = getStoreName( storeNameOrDescriptor );
		listeningStores?.add( storeName );
		const store = stores[ storeName ];
		if ( store ) {
			return store.getSuspendSelectors!();
		}

		return parent && parent.suspendSelect( storeName );
	}

	/**
	 * Returns the available actions for a part of the state.
	 *
	 * @param storeNameOrDescriptor Unique namespace identifier for the store
	 *                              or the store descriptor.
	 *
	 * @return The action's returned value.
	 */
	function dispatch( storeNameOrDescriptor: StoreNameOrDescriptor ) {
		const storeName = getStoreName( storeNameOrDescriptor );
		const store = stores[ storeName ];
		if ( store ) {
			return store.getActions();
		}

		return parent && parent.dispatch( storeName );
	}

	//
	// Deprecated
	// TODO: Remove this after `use()` is removed.
	function withPlugins(
		attributes: Record< string, unknown >
	): Record< string, unknown > {
		return Object.fromEntries(
			Object.entries( attributes ).map( ( [ key, attribute ] ) => {
				if ( typeof attribute !== 'function' ) {
					return [ key, attribute ];
				}
				return [
					key,
					( ...args: unknown[] ) => {
						return ( registry as any )[ key ]( ...args );
					},
				];
			} )
		);
	}

	/**
	 * Registers a store instance.
	 *
	 * @param name            Store registry name.
	 * @param createStoreFunc Function that creates a store object (getSelectors, getActions, subscribe).
	 */
	function registerStoreInstance(
		name: string,
		createStoreFunc: () => InternalStoreInstance
	): InternalStoreInstance {
		if ( stores[ name ] ) {
			// eslint-disable-next-line no-console
			console.error( 'Store "' + name + '" is already registered.' );
			return stores[ name ];
		}

		const store: any = createStoreFunc();

		if ( typeof store.getSelectors !== 'function' ) {
			throw new TypeError( 'store.getSelectors must be a function' );
		}
		if ( typeof store.getActions !== 'function' ) {
			throw new TypeError( 'store.getActions must be a function' );
		}
		if ( typeof store.subscribe !== 'function' ) {
			throw new TypeError( 'store.subscribe must be a function' );
		}
		// The emitter is used to keep track of active listeners when the registry
		// get paused, that way, when resumed we should be able to call all these
		// pending listeners.
		store.emitter = createEmitter();
		const currentSubscribe = store.subscribe;
		store.subscribe = ( listener: () => void ) => {
			const unsubscribeFromEmitter = store.emitter.subscribe( listener );
			const unsubscribeFromStore = currentSubscribe( () => {
				if ( store.emitter.isPaused ) {
					store.emitter.emit();
					return;
				}
				listener();
			} );

			return () => {
				unsubscribeFromStore?.();
				unsubscribeFromEmitter?.();
			};
		};
		stores[ name ] = store;
		store.subscribe( globalListener );

		// Copy private actions and selectors from the parent store.
		if ( parent ) {
			try {
				unlock( store.store ).registerPrivateActions(
					unlock( parent ).privateActionsOf( name )
				);
				unlock( store.store ).registerPrivateSelectors(
					unlock( parent ).privateSelectorsOf( name )
				);
			} catch {
				// unlock() throws if store.store was not locked.
				// The error indicates there's nothing to do here so let's
				// ignore it.
			}
		}

		return store;
	}

	/**
	 * Registers a new store given a store descriptor.
	 *
	 * @param store Store descriptor.
	 */
	function register( store: StoreDescriptor< AnyConfig > ) {
		registerStoreInstance(
			store.name,
			() => store.instantiate( registry ) as InternalStoreInstance
		);
	}

	function registerGenericStore(
		name: string,
		store: InternalStoreInstance
	) {
		deprecated( 'wp.data.registerGenericStore', {
			since: '5.9',
			alternative: 'wp.data.register( storeDescriptor )',
		} );
		registerStoreInstance( name, () => store );
	}

	/**
	 * Registers a standard `@wordpress/data` store.
	 *
	 * @param storeName Unique namespace identifier.
	 * @param options   Store description (reducer, actions, selectors, resolvers).
	 *
	 * @return Registered store object.
	 */
	function registerStore(
		storeName: string,
		options: ReduxStoreConfig< any, any, any >
	) {
		if ( ! options.reducer ) {
			throw new TypeError( 'Must specify store reducer' );
		}

		const store = registerStoreInstance(
			storeName,
			() =>
				createReduxStore( storeName, options ).instantiate(
					registry
				) as InternalStoreInstance
		);

		return store.store;
	}

	function batch( callback: () => void ) {
		// If we're already batching, just call the callback.
		if ( emitter.isPaused ) {
			callback();
			return;
		}

		emitter.pause();
		Object.values( stores ).forEach( ( store ) => store.emitter.pause() );
		try {
			callback();
		} finally {
			emitter.resume();
			Object.values( stores ).forEach( ( store ) =>
				store.emitter.resume()
			);
		}
	}

	let registry: DataRegistry = {
		batch,
		stores,
		namespaces: stores, // TODO: Deprecate/remove this.
		subscribe,
		select,
		resolveSelect,
		suspendSelect,
		dispatch,
		use,
		register,
		registerGenericStore,
		registerStore,
		__unstableMarkListeningStores,
	} as DataRegistry;

	//
	// TODO:
	// This function will be deprecated as soon as it is no longer internally referenced.
	function use( plugin: DataPlugin, options?: Record< string, unknown > ) {
		if ( ! plugin ) {
			return;
		}

		registry = {
			...registry,
			...plugin( registry, options ),
		};

		return registry;
	}

	registry.register( coreDataStore );

	for ( const [ name, config ] of Object.entries( storeConfigs ) ) {
		registry.register( createReduxStore( name, config ) );
	}

	if ( parent ) {
		parent.subscribe( globalListener );
	}

	const registryWithPlugins = withPlugins(
		registry as unknown as Record< string, unknown >
	);
	lock( registryWithPlugins, {
		privateActionsOf: ( name: string ) => {
			try {
				return unlock( stores[ name ].store ).privateActions;
			} catch {
				// unlock() throws an error the store was not locked – this means
				// there no private actions are available
				return {};
			}
		},
		privateSelectorsOf: ( name: string ) => {
			try {
				return unlock( stores[ name ].store ).privateSelectors;
			} catch {
				return {};
			}
		},
	} );
	return registryWithPlugins as unknown as DataRegistry;
}
