import {
    recreateNonProtoObject
} from '#core/types/object';
import {
    parseJson
} from '#core/types/object/json';

import {
    Services
} from '#service';

import {
    dev,
    devAssert
} from '#utils/log';

import {
    registerServiceBuilderForDoc
} from '../service-helpers';
import {
    getSourceOrigin
} from '../url';

/** @const */
const TAG = 'Storage';

/** @const */
const MAX_VALUES_PER_ORIGIN = 8;

/**
 * The storage API. This is an API equivalent to the Web LocalStorage API but
 * extended to all AMP embedding scenarios.
 *
 * The storage is done per source origin. See `get`, `set` and `remove` methods
 * for more info.
 *
 * @see https://html.spec.whatwg.org/multipage/webstorage.html
 * @private Visible for testing only.
 */
export class Storage {
    /**
     * @param {!./ampdoc-impl.AmpDoc} ampdoc
     * @param {!../service/viewer-interface.ViewerInterface} viewer
     * @param {!StorageBindingDef} binding
     */
    constructor(ampdoc, viewer, binding) {
        /** @const {!./ampdoc-impl.AmpDoc} */
        this.ampdoc = ampdoc;

        /** @private @const {!../service/viewer-interface.ViewerInterface} */
        this.viewer_ = viewer;

        /** @private @const {!StorageBindingDef} */
        this.binding_ = binding;

        /** @private @const {boolean} */
        this.isViewerStorage_ = binding instanceof ViewerStorageBinding;

        /** @const @private {string} */
        this.origin_ = getSourceOrigin(this.ampdoc.win.location);

        /** @private {?Promise<!Store>} */
        this.storePromise_ = null;
    }

    /**
     * @return {!Storage}
     * @protected
     */
    start_() {
        this.listenToBroadcasts_();
        return this;
    }

    /**
     * Returns the promise that yields the value of the property for the specified
     * key.
     * @param {string} name
     * @param {number=} opt_duration
     * @return {!Promise<*>}
     */
    get(name, opt_duration) {
        return this.getStore_().then((store) => store.get(name, opt_duration));
    }

    /**
     * Saves the value (restricted to boolean value) of the specified property.
     * Returns the promise that's resolved when the operation completes.
     * @param {string} name
     * @param {*} value
     * @param {boolean=} opt_isUpdate
     * @return {!Promise}
     */
    set(name, value, opt_isUpdate) {
        devAssert(typeof value == 'boolean', 'Only boolean values accepted');
        return this.setNonBoolean(name, value, opt_isUpdate);
    }

    /**
     * Saves the value of the specified property. Returns the promise that's
     * resolved when the operation completes.
     * Note: More restrict privacy review is required to store non boolean value.
     * @param {string} name
     * @param {*} value
     * @param {boolean=} opt_isUpdate
     * @return {!Promise}
     */
    setNonBoolean(name, value, opt_isUpdate) {
        return this.saveStore_((store) => store.set(name, value, opt_isUpdate));
    }

    /**
     * Removes the specified property. Returns the promise that's resolved when
     * the operation completes.
     * @param {string} name
     * @return {!Promise}
     */
    remove(name) {
        return this.saveStore_((store) => store.remove(name));
    }

    /**
     * Returns if this.binding is an instance of ViewerStorageBinding
     * @return {boolean}
     */
    isViewerStorage() {
        return this.isViewerStorage_;
    }

    /**
     * @return {!Promise<!Store>}
     * @private
     */
    getStore_() {
        if (!this.storePromise_) {
            this.storePromise_ = this.binding_
                .loadBlob(this.origin_)
                .then((blob) => (blob ? parseJson(atob(blob)) : {}))
                .catch((reason) => {
                    dev().expectedError(TAG, 'Failed to load store: ', reason);
                    return {};
                })
                .then((obj) => new Store(obj));
        }
        return this.storePromise_;
    }

    /**
     * @param {function(!Store)} mutator
     * @return {!Promise}
     * @private
     */
    saveStore_(mutator) {
        return this.getStore_()
            .then((store) => {
                mutator(store);
                // Need to encode stored object to avoid plain text,
                // but doesn't need to be base64encode. Can convert to some other
                // encoding method for further improvement.
                const blob = btoa(JSON.stringify(store.obj));
                return this.binding_.saveBlob(this.origin_, blob);
            })
            .then(this.broadcastReset_.bind(this));
    }

    /** @private */
    listenToBroadcasts_() {
        this.viewer_.onBroadcast((message) => {
            if (
                message['type'] == 'amp-storage-reset' &&
                message['origin'] == this.origin_
            ) {
                dev().fine(TAG, 'Received reset message');
                this.storePromise_ = null;
            }
        });
    }

    /** @private */
    broadcastReset_() {
        dev().fine(TAG, 'Broadcasted reset message');
        this.viewer_.broadcast(
            /** @type {!JsonObject} */
            ({
                'type': 'amp-storage-reset',
                'origin': this.origin_,
            })
        );
    }
}

/**
 * The implementation of store logic for get, set and remove.
 *
 * The structure of the store is equivalent to the following typedef:
 * ```
 * {
 *   vv: !Object<key(string), !{
 *     v: *,
 *     t: time
 *   }>
 * }
 * ```
 *
 * @private Visible for testing only.
 */
export class Store {
    /**
     * @param {!JsonObject} obj
     * @param {number=} opt_maxValues
     */
    constructor(obj, opt_maxValues) {
        /** @const {!JsonObject} */
        this.obj = /** @type {!JsonObject} */ (recreateNonProtoObject(obj));

        /** @private @const {number} */
        this.maxValues_ = opt_maxValues || MAX_VALUES_PER_ORIGIN;

        /** @private @const {!Object<string, !JsonObject>} */
        this.values_ = this.obj['vv'] || Object.create(null);
        if (!this.obj['vv']) {
            this.obj['vv'] = this.values_;
        }
    }

    /**
     * @param {string} name
     * @param {number|undefined} opt_duration
     * @return {*|undefined}
     */
    get(name, opt_duration) {
        // The structure is {key: {v: *, t: time}}
        const item = this.values_[name];
        const timestamp = item ? item['t'] : undefined;
        const isNotExpired =
            opt_duration && timestamp != undefined ?
            timestamp + opt_duration > Date.now() :
            true;
        const value = item && isNotExpired ? item['v'] : undefined;
        return value;
    }

    /**
     * Set the storage value along with the current timestamp.
     * When opt_isUpdated is true, timestamp will be the creation timestamp,
     * the stored value will be updated w/o updating timestamp.
     * @param {string} name
     * @param {*} value
     * @param {boolean=} opt_isUpdate
     */
    set(name, value, opt_isUpdate) {
        devAssert(
            name != '__proto__' && name != 'prototype',
            'Name is not allowed: %s',
            name
        );
        // The structure is {key: {v: *, t: time}}
        if (this.values_[name] !== undefined) {
            const item = this.values_[name];
            let timestamp = Date.now();
            if (opt_isUpdate) {
                // Update value w/o timestamp
                timestamp = item['t'];
            }
            item['v'] = value;
            item['t'] = timestamp;
        } else {
            this.values_[name] = {
                'v': value,
                't': Date.now(),
            };
        }

        // Purge old values.
        const keys = Object.keys(this.values_);
        if (keys.length > this.maxValues_) {
            let minTime = Infinity;
            let minKey = null;
            for (let i = 0; i < keys.length; i++) {
                const item = this.values_[keys[i]];
                if (item['t'] < minTime) {
                    minKey = keys[i];
                    minTime = item['t'];
                }
            }
            if (minKey) {
                delete this.values_[minKey];
            }
        }
    }

    /**
     * @param {string} name
     */
    remove(name) {
        // The structure is {key: {v: *, t: time}}
        delete this.values_[name];
    }
}

/**
 * A binding provides the specific implementation of storage technology.
 * @interface
 */
class StorageBindingDef {
    /**
     * Returns the promise that yields the store blob for the specified origin.
     * @param {string} unusedOrigin
     * @return {!Promise<?string>}
     */
    loadBlob(unusedOrigin) {}

    /**
     * Saves the store blob for the specified origin and returns the promise
     * that's resolved when the operation completes.
     * @param {string} unusedOrigin
     * @param {string} unusedBlob
     * @return {!Promise}
     */
    saveBlob(unusedOrigin, unusedBlob) {}
}

/**
 * Storage implementation using Web LocalStorage API.
 * @implements {StorageBindingDef}
 * @private Visible for testing only.
 */
export class LocalStorageBinding {
    /**
     * @param {!Window} win
     */
    constructor(win) {
        /** @const {!Window} */
        this.win = win;

        /** @private @const {boolean} */
        this.isLocalStorageSupported_ = this.checkIsLocalStorageSupported_();

        if (!this.isLocalStorageSupported_) {
            const error = new Error('localStorage not supported.');
            dev().expectedError(TAG, error);
        }
    }

    /**
     * Determines whether localStorage API is supported by ensuring it is declared
     * and does not throw an exception when used.
     * @return {boolean}
     * @private
     */
    checkIsLocalStorageSupported_() {
        try {
            if (!('localStorage' in this.win)) {
                return false;
            }

            // We do not care about the value fetched from local storage; we only care
            // whether the call throws an exception or not.  As such, we can look up
            // any arbitrary key.
            this.win.localStorage.getItem('test');
            return true;
        } catch (e) {
            return false;
        }
    }

    /**
     * @param {string} origin
     * @return {string}
     * @private
     */
    getKey_(origin) {
        return `amp-store:${origin}`;
    }

    /** @override */
    loadBlob(origin) {
        return new Promise((resolve) => {
            if (!this.isLocalStorageSupported_) {
                resolve(null);
                return;
            }
            resolve(this.win.localStorage.getItem(this.getKey_(origin)));
        });
    }

    /** @override */
    saveBlob(origin, blob) {
        return new Promise((resolve) => {
            if (!this.isLocalStorageSupported_) {
                resolve();
                return;
            }
            this.win.localStorage.setItem(this.getKey_(origin), blob);
            resolve();
        });
    }
}

/**
 * Storage implementation delegated to the Viewer.
 * @implements {StorageBindingDef}
 * @private Visible for testing only.
 */
export class ViewerStorageBinding {
    /**
     * @param {!../service/viewer-interface.ViewerInterface} viewer
     */
    constructor(viewer) {
        /** @private @const {!../service/viewer-interface.ViewerInterface} */
        this.viewer_ = viewer;
    }

    /** @override */
    loadBlob(origin) {
        return this.viewer_
            .sendMessageAwaitResponse('loadStore', {
                'origin': origin
            })
            .then((response) => response['blob']);
    }

    /** @override */
    saveBlob(origin, blob) {
        return /** @type {!Promise} */ this.viewer_
            .sendMessageAwaitResponse('saveStore', {
                'origin': origin,
                'blob': blob
            })
            .catch((reason) => {
                throw dev().createExpectedError(TAG, 'Failed to save store: ', reason);
            });
    }
}

/**
 * @param {!./ampdoc-impl.AmpDoc} ampdoc
 */
export function installStorageServiceForDoc(ampdoc) {
    registerServiceBuilderForDoc(
        ampdoc,
        'storage',
        function() {
            const viewer = Services.viewerForDoc(ampdoc);
            const overrideStorage = parseInt(viewer.getParam('storage'), 10);
            const binding = overrideStorage ?
                new ViewerStorageBinding(viewer) :
                new LocalStorageBinding(ampdoc.win);
            return new Storage(ampdoc, viewer, binding).start_();
        },
        /* opt_instantiate */
        true
    );
}