/**
 * External dependencies
 */
// @ts-expect-error -- is-plain-object types don't resolve with package.json "exports".
import { isPlainObject } from 'is-plain-object';
import deepmerge from 'deepmerge';

/**
 * Internal dependencies
 */
import defaultStorage from './storage/default';
import { combineReducers } from '../../';
import type {
	DataRegistry,
	ReduxStoreConfig,
	StorageInterface,
} from '../../types';

interface PersistencePluginOptions {
	/**
	 * Persistent storage implementation. This must
	 * at least implement `getItem` and `setItem` of
	 * the Web Storage API.
	 */
	storage?: StorageInterface;
	/**
	 * Key on which to set in persistent storage.
	 */
	storageKey?: string;
}

interface PersistenceInterface {
	get: () => Record< string, unknown >;
	set: ( key: string, value: unknown ) => void;
}

/**
 * Default plugin storage.
 */
const DEFAULT_STORAGE = defaultStorage;

/**
 * Default plugin storage key.
 */
const DEFAULT_STORAGE_KEY = 'WP_DATA';

/**
 * Higher-order reducer which invokes the original reducer only if state is
 * inequal from that of the action's `nextState` property, otherwise returning
 * the original state reference.
 *
 * @param reducer Original reducer.
 *
 * @return Enhanced reducer.
 */
export const withLazySameState =
	( reducer: ( state: any, action: any ) => any ) =>
	( state: unknown, action: { nextState: unknown } ) => {
		if ( action.nextState === state ) {
			return state;
		}

		return reducer( state, action );
	};

/**
 * Creates a persistence interface, exposing getter and setter methods (`get`
 * and `set` respectively).
 *
 * @param options Plugin options.
 *
 * @return Persistence interface.
 */
export function createPersistenceInterface(
	options: PersistencePluginOptions
): PersistenceInterface {
	const { storage = DEFAULT_STORAGE, storageKey = DEFAULT_STORAGE_KEY } =
		options;

	let data: Record< string, unknown > | undefined;

	/**
	 * Returns the persisted data as an object, defaulting to an empty object.
	 *
	 * @return Persisted data.
	 */
	function getData(): Record< string, unknown > {
		if ( data === undefined ) {
			// If unset, getItem is expected to return null. Fall back to
			// empty object.
			const persisted = storage.getItem( storageKey );
			if ( persisted === null ) {
				data = {};
			} else {
				try {
					data = JSON.parse( persisted );
				} catch {
					// Similarly, should any error be thrown during parse of
					// the string (malformed JSON), fall back to empty object.
					data = {};
				}
			}
		}

		return data!;
	}

	/**
	 * Merges an updated reducer state into the persisted data.
	 *
	 * @param key   Key to update.
	 * @param value Updated value.
	 */
	function setData( key: string, value: unknown ): void {
		data = { ...data, [ key ]: value };
		storage.setItem( storageKey, JSON.stringify( data ) );
	}

	return {
		get: getData,
		set: setData,
	};
}

/**
 * Data plugin to persist store state into a single storage key.
 *
 * @param registry      Data registry.
 * @param pluginOptions Plugin options.
 *
 * @return Data plugin.
 */
function persistencePlugin(
	registry: DataRegistry,
	pluginOptions: PersistencePluginOptions
): Partial< DataRegistry > {
	const persistence = createPersistenceInterface( pluginOptions );

	/**
	 * Creates an enhanced store dispatch function, triggering the state of the
	 * given store name to be persisted when changed.
	 *
	 * @param getState  Function which returns current state.
	 * @param storeName Store name.
	 * @param keys      Optional subset of keys to save.
	 *
	 * @return Enhanced dispatch function.
	 */
	function createPersistOnChange(
		getState: () => unknown,
		storeName: string,
		keys: boolean | string[]
	): () => void {
		let getPersistedState: ( state: any, action: any ) => any;
		if ( Array.isArray( keys ) ) {
			// Given keys, the persisted state should by produced as an object
			// of the subset of keys. This implementation uses combineReducers
			// to leverage its behavior of returning the same object when none
			// of the property values changes. This allows a strict reference
			// equality to bypass a persistence set on an unchanging state.
			const reducers = keys.reduce(
				(
					accumulator: Record<
						string,
						( state: any, action: any ) => any
					>,
					key: string
				) =>
					Object.assign( accumulator, {
						[ key ]: (
							state: unknown,
							action: { nextState: Record< string, unknown > }
						) => action.nextState[ key ],
					} ),
				{}
			);

			getPersistedState = withLazySameState(
				combineReducers( reducers )
			);
		} else {
			getPersistedState = (
				_state: unknown,
				action: { nextState: unknown }
			) => action.nextState;
		}

		let lastState = getPersistedState( undefined, {
			nextState: getState(),
		} );

		return () => {
			const state = getPersistedState( lastState, {
				nextState: getState(),
			} );
			if ( state !== lastState ) {
				persistence.set( storeName, state );
				lastState = state;
			}
		};
	}

	return {
		registerStore(
			storeName: string,
			options: ReduxStoreConfig< any, any, any > & {
				persist?: boolean | string[];
			}
		) {
			if ( ! options.persist ) {
				return registry.registerStore( storeName, options );
			}

			// Load from persistence to use as initial state.
			const persistedState = persistence.get()[ storeName ];
			if ( persistedState !== undefined ) {
				let initialState = options.reducer( options.initialState, {
					type: '@@WP/PERSISTENCE_RESTORE',
				} );

				if (
					isPlainObject( initialState ) &&
					isPlainObject( persistedState )
				) {
					// If state is an object, ensure that:
					// - Other keys are left intact when persisting only a
					//   subset of keys.
					// - New keys in what would otherwise be used as initial
					//   state are deeply merged as base for persisted value.
					initialState = deepmerge(
						initialState as Record< string, unknown >,
						persistedState as Record< string, unknown >,
						{
							isMergeableObject: isPlainObject,
						}
					);
				} else {
					// If there is a mismatch in object-likeness of default
					// initial or persisted state, defer to persisted value.
					initialState = persistedState;
				}

				options = {
					...options,
					initialState,
				};
			}

			const store = registry.registerStore( storeName, options );

			store.subscribe(
				createPersistOnChange(
					store.getState,
					storeName,
					options.persist!
				)
			);

			return store;
		},
	};
}

export default Object.assign( persistencePlugin, {
	__unstableMigrate: () => {},
} );
