import log from './log';

import BuiltinHelper from './BuiltinHelper';
import WebHelper, {UrlFunction} from './WebHelper';

import _Asset, {AssetData, AssetId} from './Asset';
import {AssetType as _AssetType, AssetType} from './AssetType';
import {DataFormat as _DataFormat, DataFormat} from './DataFormat';
import * as _scratchFetch from './scratchFetch';
import Helper from './Helper';

interface HelperWithPriority {
    helper: Helper,
    priority: number
}

export class ScratchStorage {
    public defaultAssetId: Record<AssetType['name'], AssetId>;
    public builtinHelper: BuiltinHelper;
    public webHelper: WebHelper;

    private _helpers: HelperWithPriority[];

    constructor () {
        this.defaultAssetId = {};

        this.builtinHelper = new BuiltinHelper(this);
        this.webHelper = new WebHelper(this);
        this.builtinHelper.registerDefaultAssets();

        this._helpers = [
            {
                helper: this.builtinHelper,
                priority: 100
            },
            {
                helper: this.webHelper,
                priority: -100
            }
        ];
    }

    /**
     * @returns {Asset} - the `Asset` class constructor.
     * @class
     */
    get Asset () {
        return _Asset;
    }

    /**
     * @returns {AssetType} - the list of supported asset types.
     * @class
     */
    get AssetType () {
        return _AssetType;
    }

    /**
     * @returns {DataFormat} - the list of supported data formats.
     * @class
     */
    get DataFormat () {
        return _DataFormat;
    }

    /**
     * Access the `scratchFetch` module within this library.
     * @returns {module} the scratchFetch module, with properties for `scratchFetch`, `setMetadata`, etc.
     */
    get scratchFetch () {
        return _scratchFetch;
    }

    /**
     * @deprecated Please use the `Asset` member of a storage instance instead.
     * @returns {Asset} - the `Asset` class constructor.
     * @class
     */
    static get Asset () {
        return _Asset;
    }

    /**
     * @deprecated Please use the `AssetType` member of a storage instance instead.
     * @returns {AssetType} - the list of supported asset types.
     * @class
     */
    static get AssetType () {
        return _AssetType;
    }

    /**
     * Add a storage helper to this manager. Helpers with a higher priority number will be checked first when loading
     * or storing assets. For comparison, the helper for built-in assets has `priority=100` and the default web helper
     * has `priority=-100`. The relative order of helpers with equal priorities is undefined.
     * @param {Helper} helper - the helper to be added.
     * @param {number} [priority] - the priority for this new helper (default: 0).
     */
    addHelper (helper: Helper, priority = 0) {
        this._helpers.push({helper, priority});
        this._helpers.sort((a, b) => b.priority - a.priority);
    }

    /**
     * Synchronously fetch a cached asset from built-in storage. Assets are cached when they are loaded.
     * @param {string} assetId - The id of the asset to fetch.
     * @returns {?Asset} The asset, if it exists.
     */
    get (assetId: string): _Asset | null {
        return this.builtinHelper.get(assetId);
    }

    /**
     * Deprecated API for caching built-in assets. Use createAsset.
     * @param {AssetType} assetType - The type of the asset to cache.
     * @param {DataFormat} dataFormat - The dataFormat of the data for the cached asset.
     * @param {Buffer} data - The data for the cached asset.
     * @param {string} id - The id for the cached asset.
     * @returns {string} The calculated id of the cached asset, or the supplied id if the asset is mutable.
     */
    cache (assetType: AssetType, dataFormat: DataFormat, data: AssetData, id: AssetId): AssetId {
        log.warn('Deprecation: Storage.cache is deprecated. Use Storage.createAsset, and store assets externally.');
        return this.builtinHelper._store(assetType, dataFormat, data, id);
    }

    /**
     * Construct an Asset, and optionally generate an md5 hash of its data to create an id
     * @param {AssetType} assetType - The type of the asset to cache.
     * @param {DataFormat} dataFormat - The dataFormat of the data for the cached asset.
     * @param {Buffer} data - The data for the cached asset.
     * @param {string} [id] - The id for the cached asset.
     * @param {bool} [generateId] - flag to set id to an md5 hash of data if `id` isn't supplied
     * @returns {Asset} generated Asset with `id` attribute set if not supplied
     */
    createAsset (
        assetType: AssetType,
        dataFormat: DataFormat,
        data: AssetData,
        id: AssetId,
        generateId: boolean
    ): _Asset {
        if (!dataFormat) throw new Error('Tried to create asset without a dataFormat');
        return new _Asset(assetType, id, dataFormat, data, generateId);
    }

    /**
     * Register a web-based source for assets. Sources will be checked in order of registration.
     * @param {Array.<AssetType>} types - The types of asset provided by this source.
     * @param {UrlFunction} getFunction - A function which computes a GET URL from an Asset.
     * @param {UrlFunction} createFunction - A function which computes a POST URL for asset data.
     * @param {UrlFunction} updateFunction - A function which computes a PUT URL for asset data.
     */
    addWebStore (
        types: AssetType[],
        getFunction: UrlFunction,
        createFunction?: UrlFunction,
        updateFunction?: UrlFunction
    ): void {
        this.webHelper.addStore(types, getFunction, createFunction, updateFunction);
    }

    /**
     * Register a web-based source for assets. Sources will be checked in order of registration.
     * @deprecated Please use addWebStore
     * @param {Array.<AssetType>} types - The types of asset provided by this source.
     * @param {UrlFunction} urlFunction - A function which computes a GET URL from an Asset.
     */
    addWebSource (types: AssetType[], urlFunction: UrlFunction): void {
        log.warn('Deprecation: Storage.addWebSource has been replaced by addWebStore.');
        this.addWebStore(types, urlFunction);
    }

    /**
     * TODO: Should this be removed in favor of requesting an asset with `null` as the ID?
     * @param {AssetType} type - Get the default ID for assets of this type.
     * @returns {?string} The ID of the default asset of the given type, if any.
     */
    getDefaultAssetId (type: AssetType): AssetId | undefined {
        if (Object.prototype.hasOwnProperty.call(this.defaultAssetId, type.name)) {
            return this.defaultAssetId[type.name];
        }
    }

    /**
     * Set the default ID for a particular type of asset. This default asset will be used if a requested asset cannot
     * be found and automatic fallback is enabled. Ideally this should be an asset that is available locally or even
     * one built into this module.
     * TODO: Should this be removed in favor of requesting an asset with `null` as the ID?
     * @param {AssetType} type - The type of asset for which the default will be set.
     * @param {string} id - The default ID to use for this type of asset.
     */
    setDefaultAssetId (type: AssetType, id: AssetId): void {
        this.defaultAssetId[type.name] = id;
    }

    /**
     * Fetch an asset by type & ID.
     * @param {AssetType} assetType - The type of asset to fetch. This also determines which asset store to use.
     * @param {string} assetId - The ID of the asset to fetch: a project ID, MD5, etc.
     * @param {DataFormat} [dataFormat] - Optional: load this format instead of the AssetType's default.
     * @returns {Promise.<Asset>} A promise for the requested Asset.
     *   If the promise is resolved with non-null, the value is the requested asset.
     *   If the promise is resolved with null, the desired asset could not be found with the current asset sources.
     *   If the promise is rejected, there was an error on at least one asset source. HTTP 404 does not count as an
     *   error here, but (for example) HTTP 403 does.
     */
    load (assetType: AssetType, assetId: AssetId, dataFormat: DataFormat): Promise<_Asset | null> {
        const helpers = this._helpers.map(x => x.helper);
        const errors: unknown[] = [];
        dataFormat = dataFormat || assetType.runtimeFormat;

        let helperIndex = 0;
        let helper: Helper;
        const tryNextHelper = (err?: unknown): Promise<_Asset | null> => {
            if (err) { // Track the error, but continue looking
                errors.push(err);
            }

            helper = helpers[helperIndex++];

            if (helper) {
                const loading = helper.load(assetType, assetId, dataFormat);
                if (loading === null) {
                    return tryNextHelper();
                }
                // Note that other attempts may have logged errors; if this succeeds they will be suppressed.
                return loading
                    // TODO: maybe some types of error should prevent trying the next helper?
                    .catch(tryNextHelper);
            } else if (errors.length > 0) {
                // We looked through all the helpers and couldn't find the asset, AND
                // at least one thing went wrong while we were looking.
                return Promise.reject(errors);
            }

            // Nothing went wrong but we couldn't find the asset.
            return Promise.resolve(null);
        };

        return tryNextHelper();
    }

    /**
     * Store an asset by type & ID.
     * @param {AssetType} assetType - The type of asset to fetch. This also determines which asset store to use.
     * @param {?DataFormat} [dataFormat] - Optional: load this format instead of the AssetType's default.
     * @param {Buffer} data - Data to store for the asset
     * @param {?string} [assetId] - The ID of the asset to fetch: a project ID, MD5, etc.
     * @returns {Promise.<object>} A promise for asset metadata
     */
    store (assetType: AssetType, dataFormat: DataFormat | null | undefined, data: AssetData, assetId?: AssetId) {
        dataFormat = dataFormat || assetType.runtimeFormat;

        return this.webHelper.store(assetType, dataFormat, data, assetId)
            .then(body => {
                // The previous logic here ignored that the body can be a string (if it's not a JSON),
                // so just ignore that case.
                // Also, having undefined was the previous behavior
                // eslint-disable-next-line no-undefined
                const id = typeof body === 'string' ? undefined : body.id;

                this.builtinHelper._store(assetType, dataFormat, data, id);
                return body;
            });
    }
}
