import log from './log';

import Asset, {AssetData, AssetId} from './Asset';
import Helper from './Helper';
import ProxyTool from './ProxyTool';
import {ScratchGetRequest, ScratchSendRequest, Tool} from './Tool';
import {AssetType} from './AssetType';
import {DataFormat} from './DataFormat';

/**
 * The request configuration
 */
type RequestConfig = ScratchGetRequest | ScratchSendRequest;

/**
 * The result of a UrlFunction, which can be a string URL or a full request configuration
 * object, or a promise for either of those.
 *
 * If set to null or undefined, the WebHelper will skip that store and move on to the
 * next one. This allows stores to be registered that only provide a subset of their
 * declared asset types at a given time.
 */
type RequestFnResult = null | undefined | string | ScratchGetRequest | ScratchSendRequest;

/**
 * Ensure that the provided request configuration is in object form, converting from
 * string if necessary.
 */
const ensureRequestConfig = async (
    reqConfig: RequestFnResult | Promise<RequestFnResult>
): Promise<RequestConfig | null | undefined> => {
    reqConfig = await reqConfig;

    if (typeof reqConfig === 'string') {
        return {
            url: reqConfig
        };
    }

    return reqConfig;
};

/**
 * A function which computes a URL from asset information.
 */
export type UrlFunction = (asset: Asset) => RequestFnResult | Promise<RequestFnResult>;

interface StoreRecord {
    types: string[],
    get: UrlFunction,
    create?: UrlFunction,
    update?: UrlFunction
}

export default class WebHelper extends Helper {
    public stores: StoreRecord[];
    public assetTool: Tool;
    public projectTool: Tool;

    constructor (parent) {
        super(parent);

        /**
         * @type {Array.<StoreRecord>}
         * @typedef {object} StoreRecord
         * @property {Array.<string>} types - The types of asset provided by this store, from AssetType's name field.
         * @property {UrlFunction} getFunction - A function which computes a URL from an Asset.
         * @property {UrlFunction} createFunction - A function which computes a URL from an Asset.
         * @property {UrlFunction} updateFunction - A function which computes a URL from an Asset.
         */
        this.stores = [];

        /**
         * Set of tools to best load many assets in parallel. If one tool
         * cannot be used, it will use the next.
         * @type {ProxyTool}
         */
        this.assetTool = new ProxyTool();

        /**
         * Set of tools to best load project data in parallel with assets. This
         * tool set prefers tools that are immediately ready. Some tools have
         * to initialize before they can load files.
         * @type {ProxyTool}
         */
        this.projectTool = new ProxyTool(ProxyTool.TOOL_FILTER.READY);
    }

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

    /**
     * Register a web-based store for assets. Sources will be checked in order of registration.
     * @param {Array.<AssetType>} types - The types of asset provided by this store.
     * @param {UrlFunction} getFunction - A function which computes a GET URL for an Asset
     * @param {UrlFunction} createFunction - A function which computes a POST URL for an Asset
     * @param {UrlFunction} updateFunction - A function which computes a PUT URL for an Asset
     */
    addStore (
        types: AssetType[],
        getFunction: UrlFunction,
        createFunction?: UrlFunction,
        updateFunction?: UrlFunction
    ): void {
        this.stores.push({
            types: types.map(assetType => assetType.name),
            get: getFunction,
            create: createFunction,
            update: updateFunction
        });
    }

    /**
     * Fetch an asset but don't process dependencies.
     * @param {AssetType} assetType - The type of asset to fetch.
     * @param {string} assetId - The ID of the asset to fetch: a project ID, MD5, etc.
     * @param {DataFormat} dataFormat - The file format / file extension of the asset to fetch: PNG, JPG, etc.
     * @returns {Promise.<Asset>} A promise for the contents of the asset.
     */
    async load (assetType: AssetType, assetId: AssetId, dataFormat: DataFormat): Promise<Asset | null> {

        /** @type {unknown[]} List of errors encountered while attempting to load the asset. */
        const errors: unknown[] = [];
        const stores = this.stores.slice()
            .filter(store => store.types.indexOf(assetType.name) >= 0);

        // New empty asset but it doesn't have data yet
        const asset = new Asset(assetType, assetId, dataFormat);

        let tool = this.assetTool;
        if (assetType.name === 'Project') {
            tool = this.projectTool;
        }

        for (const store of stores) {
            const reqConfigFunction = store && store.get;

            if (reqConfigFunction) {
                try {
                    const reqConfig = await ensureRequestConfig(reqConfigFunction(asset));
                    if (!reqConfig) {
                        continue;
                    }

                    const body = await tool.get(reqConfig);
                    if (body) {
                        asset.setData(body, dataFormat);
                        return asset;
                    }
                } catch (err) {
                    errors.push(err);
                }
            }
        }

        if (errors.length > 0) {
            return Promise.reject(errors);
        }

        // no stores matching asset
        return Promise.resolve(null);
    }

    /**
     * Create or update an asset with provided data. The create function is called if no asset id is provided
     * @param {AssetType} assetType - The type of asset to create or update.
     * @param {?DataFormat} dataFormat - DataFormat of the data for the stored asset.
     * @param {Buffer} data - The data for the cached asset.
     * @param {?string} assetId - The ID of the asset to fetch: a project ID, MD5, etc.
     * @returns {Promise.<object>} A promise for the response from the create or update request
     */
    async store (
        assetType: AssetType,
        dataFormat: DataFormat | undefined,
        data: AssetData,
        assetId?: AssetId
    ): Promise<string | {id: string}> {
        const asset = new Asset(assetType, assetId, dataFormat);
        // If we have an asset id, we should update, otherwise create to get an id
        const create = assetId === '' || assetId === null || typeof assetId === 'undefined';

        const candidateStores = this.stores.filter(s =>
            // Only use stores for the incoming asset type
            s.types.indexOf(assetType.name) !== -1 && (
                // Only use stores that have a create function if this is a create request
                // or an update function if this is an update request
                (create && s.create) || s.update
            )
        );

        const method = create ? 'post' : 'put';

        if (candidateStores.length === 0) {
            return Promise.reject(new Error('No appropriate stores'));
        }

        let tool = this.assetTool;
        if (assetType.name === 'Project') {
            tool = this.projectTool;
        }

        for (const store of candidateStores) {
            const reqConfig = await ensureRequestConfig(
                // The non-nullability of this gets checked above while looking up the store.
                // Making TS understand that is going to require code refactoring which we currently don't
                // feel safe to do.
                create ? store.create!(asset) : store.update!(asset)
            );

            if (!reqConfig) {
                continue;
            }

            const reqBodyConfig = Object.assign({body: data, method}, reqConfig);

            let body = await tool.send(reqBodyConfig);

            // xhr makes it difficult to both send FormData and
            // automatically parse a JSON response. So try to parse
            // everything as JSON.
            if (typeof body === 'string') {
                try {
                    body = JSON.parse(body);
                } catch (parseError) { // eslint-disable-line @typescript-eslint/no-unused-vars
                    // If it's not parseable, then we can't add the id even
                    // if we want to, so stop here
                    return body;
                }
            }

            return Object.assign({
                id: body['content-name'] || assetId
            }, body);
        }

        return Promise.reject(new Error('No store could handle the request'));
    }
}
