import AsyncStorage from '@react-native-async-storage/async-storage';

/**
 * A Web Sorage API implementation used for polyfilling
 * {@code window.localStorage} and/or {@code window.sessionStorage}.
 * <p>
 * The Web Storage API is synchronous whereas React Native's builtin generic
 * storage API {@code AsyncStorage} is asynchronous so the implementation with
 * persistence is optimistic: it will first store the value locally in memory so
 * that results can be served synchronously and then persist the value
 * asynchronously. If an asynchronous operation produces an error, it's ignored.
 */
export default class Storage {
    /**
     * Initializes a new {@code Storage} instance. Loads all previously
     * persisted data items from React Native's {@code AsyncStorage} if
     * necessary.
     *
     * @param {string|undefined} keyPrefix - The prefix of the
     * {@code AsyncStorage} keys to be persisted by this storage.
     */
    constructor(keyPrefix) {
        /**
         * The prefix of the {@code AsyncStorage} keys persisted by this
         * storage. If {@code undefined}, then the data items stored in this
         * storage will not be persisted.
         *
         * @private
         * @type {string}
         */
        this._keyPrefix = keyPrefix;

        // Perform optional asynchronous initialization.
        const initializing = this._initializeAsync();

        if (initializing) {
            // Indicate that asynchronous initialization is under way.
            this._initializing = initializing;

            // When the asynchronous initialization completes, indicate its
            // completion.
            initializing.finally(() => {
                if (this._initializing === initializing) {
                    this._initializing = undefined;
                }
            });
        }
    }

    /**
     * Removes all keys from this storage.
     *
     * @returns {void}
     */
    clear() {
        for (const key of Object.keys(this)) {
            this.removeItem(key);
        }
    }

    /**
     * Returns the value associated with a specific key in this storage.
     *
     * @param {string} key - The name of the key to retrieve the value of.
     * @returns {string|null} The value associated with {@code key} or
     * {@code null}.
     */
    getItem(key) {
        return this.hasOwnProperty(key) ? this[key] : null;
    }

    /**
     * Returns the value associated with a specific key in this {@code Storage}
     * in an async manner. The method is required for the cases where we need
     * the stored data but we're not sure yet whether this {@code Storage} is
     * already initialized (e.g. On app start).
     *
     * @param {string} key - The name of the key to retrieve the value of.
     * @returns {Promise}
     */
    _getItemAsync(key) {
        return (
            (this._initializing || Promise.resolve())
                .catch(() => { /* _getItemAsync should always resolve! */ })
                .then(() => this.getItem(key)));
    }

    /**
     * Performs asynchronous initialization of this {@code Storage} instance
     * such as loading all keys from {@link AsyncStorage}.
     *
     * @private
     * @returns {Promise}
     */
    _initializeAsync() {
        if (typeof this._keyPrefix !== 'undefined') {
            // Load all previously persisted data items from React Native's
            // AsyncStorage.

            return new Promise(resolve => {
                AsyncStorage.getAllKeys().then((...getAllKeysCallbackArgs) => {
                    // XXX The keys argument of getAllKeys' callback may or may
                    // not be preceded by an error argument.
                    const keys
                        = getAllKeysCallbackArgs[
                            getAllKeysCallbackArgs.length - 1
                        ].filter(key => key.startsWith(this._keyPrefix));

                    AsyncStorage.multiGet(keys)
                    .then((...multiGetCallbackArgs) => {
                        // XXX The result argument of multiGet may or may not be
                        // preceded by an errors argument.
                        const result
                            = multiGetCallbackArgs[
                                multiGetCallbackArgs.length - 1
                            ];
                        const keyPrefixLength
                            = this._keyPrefix && this._keyPrefix.length;

                        // eslint-disable-next-line prefer-const
                        for (let [ key, value ] of result) {
                            key = key.substring(keyPrefixLength);

                            // XXX The loading of the previously persisted data
                            // items from AsyncStorage is asynchronous which
                            // means that it is technically possible to invoke
                            // setItem with a key before the key is loaded from
                            // AsyncStorage.
                            if (!this.hasOwnProperty(key)) {
                                this[key] = value;
                            }
                        }

                        resolve();
                    });
                });
            });
        }

        return undefined;
    }

    /**
     * Returns the name of the nth key in this storage.
     *
     * @param {number} n - The zero-based integer index of the key to get the
     * name of.
     * @returns {string} The name of the nth key in this storage.
     */
    key(n) {
        const keys = Object.keys(this);

        return n < keys.length ? keys[n] : null;
    }

    /**
     * Returns an integer representing the number of data items stored in this
     * storage.
     *
     * @returns {number}
     */
    get length() {
        return Object.keys(this).length;
    }

    /**
     * Removes a specific key from this storage.
     *
     * @param {string} key - The name of the key to remove.
     * @returns {void}
     */
    removeItem(key) {
        delete this[key];
        typeof this._keyPrefix === 'undefined'
            || AsyncStorage.removeItem(`${String(this._keyPrefix)}${key}`);
    }

    /**
     * Adds a specific key to this storage and associates it with a specific
     * value. If the key exists already, updates its value.
     *
     * @param {string} key - The name of the key to add/update.
     * @param {string} value - The value to associate with {@code key}.
     * @returns {void}
     */
    setItem(key, value) {
        value = String(value); // eslint-disable-line no-param-reassign
        this[key] = value;
        typeof this._keyPrefix === 'undefined'
            || AsyncStorage.setItem(`${String(this._keyPrefix)}${key}`, value);
    }
}
