/*************************************************************
 *
 *  Copyright (c) 2018 The MathJax Consortium
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

/**
 * @fileoverview  Implements component Package object for handling
 *                dynamic loading of components.
 *
 * @author dpvc@mathjax.org (Davide Cervone)
 */

import {CONFIG} from './loader.js';

/*
 * The browser document (for creating scripts to load components)
 */
declare var document: Document;

/**
 * A map of package names to Package instances
 */
export type PackageMap = Map<string, Package>;

/**
 * An error class that includes the package name
 */
export class PackageError extends Error {
    public package: string;
    constructor(message: string, name: string) {
        super(message);
        this.package = name;
    }
};

/**
 * Types for ready() and failed() functions and for promises
 */
export type PackageReady = (name: string) => string | void;
export type PackageFailed = (message: PackageError) => void;
export type PackagePromise = (resolve: PackageReady, reject: PackageFailed) => void;

/**
 * The configuration data for a package
 */
export interface PackageConfig {
    ready?: PackageReady,                // Function to call when package is loaded successfully
    failed?: PackageFailed,              // Function to call when package fails to load
    checkReady?: () => Promise<void>     // Function called to see if package is fully loaded
                                         //   (may cause additional packages to load, for example)
};


/**
 * The Package class for handling individual components
 */
export class Package {
    /**
     * The set of packages being used
     */
    public static packages: PackageMap = new Map();

    /**
     * Compute the path for a package.
     *
     * First, look for a configured source for the package.
     * Then, if the path doesn't look like the start of a URI, or
     *   an absolute file path, and doesn't have a [path] prefix,
     *     prepend [mathjax]
     * Add .js if there isn't already a file type
     * While the path begins with a [path] prefix
     *   Look for the prefix in the path configuration and
     *     replace the [path] with the actual path
     *     (this is done recursively, so paths can refer to other paths)
     * return the result
     *
     * @param {string} name            The name of the package to resolve
     * @param {boolean} addExtension   True if .js shoudl be added automatically
     * @return {string}                The path (file or URL) for this package
     */
    public static resolvePath(name: string, addExtension: boolean = true) {
        let file = CONFIG.source[name] || name;
        if (!file.match(/^(?:[a-z]+:\/)?\/|\[/)) {
            file = '[mathjax]/' + file.replace(/^\.\//, '');
        }
        if (addExtension && !file.match(/\.[^\/]+$/)) {
            file += '.js';
        }
        let match;
        while ((match = file.match(/^\[([^\]]*)\]/))) {
            if (!CONFIG.paths.hasOwnProperty(match[1])) break;
            file = CONFIG.paths[match[1]] + file.substr(match[0].length);
        }
        return file;
    }

    /**
     * The pacakge name
     */
    public name: string;

    /**
     * True when the package has been loaded successfully
     */
    public isLoaded: boolean = false;

    /**
     * A promise that resolves when the package is loaded successfully and rejects when it fails to load
     */
    public promise: Promise<string>;

    /**
     * True when the package is being loaded but hasn't yet finished loading
     */
    protected isLoading: boolean = false;

    /**
     * True if the package has failed to load
     */
    protected hasFailed: boolean = false;

    /**
     * True if this package should be loaded automatically (e.g., it was created in reference
     *   to a MathJax.loader.ready() call when the package hasn't been requested to load)
     */
    protected noLoad: boolean;

    /**
     * The function that resolves the package's promise
     */
    protected resolve: PackageReady;

    /**
     * The function that rejects the package's promise
     */
    protected reject: PackageFailed;

    /**
     * The packages that require this one
     */
    protected dependents: Package[] = [];

    /**
     * The packages that this one depends on
     */
    protected dependencies: Package[] = [];

    /**
     * The number of dependencies that haven't yet been loaded
     */
    protected dependencyCount: number = 0;

    /**
     * The sub-packages that this one provides
     */
    protected provided: Package[] = [];

    /**
     * @return {boolean}  True when the package can be loaded (i.e., its depedencies are all loaded,
     *                    it is allowed to be loaded, isn't already loading, and hasn't failed to load
     *                    in the past)
     */
    get canLoad() {
        return this.dependencyCount === 0 && !this.noLoad && !this.isLoading && !this.hasFailed;
    }

    /**
     * @param {string} name        The name of the package
     * @param {boolean} noLoad     True when the package is just for reference, not loading
     */
    constructor(name: string, noLoad: boolean = false) {
        this.name = name;
        this.noLoad = noLoad;
        Package.packages.set(name, this);
        this.promise = this.makePromise(this.makeDependencies());
    }

    /**
     * @return {Promise<string>[]}   The array of promises that must be resolved before this package
     *                                 can be loaded
     */
    protected makeDependencies() {
        const promises = [] as Promise<string>[];
        const map = Package.packages;
        const noLoad = this.noLoad;
        const name = this.name;
        //
        //  Get the dependencies for this package
        //
        const dependencies = [] as string[];
        if (CONFIG.dependencies.hasOwnProperty(name)) {
            dependencies.push(...CONFIG.dependencies[name]);
        } else if (name !== 'core') {
            dependencies.push('core');  //  Add 'core' dependency by default
        }
        //
        //  Add all the dependencies (creating them, if needed)
        //    and record the promises of unloaded ones
        //
        for (const dependent of dependencies) {
            const extension = map.get(dependent) || new Package(dependent, noLoad);
            if (this.dependencies.indexOf(extension) < 0) {
                extension.addDependent(this, noLoad);
                this.dependencies.push(extension);
                if (!extension.isLoaded) {
                    this.dependencyCount++;
                    promises.push(extension.promise);
                }
            }
        }
        //
        //  Return the collected promises
        //
        return promises;
    }

    /**
     * @param {Promise<string>[]} promises  The array or promises that must be resolved before
     *                                        this package can load
     */
    protected makePromise(promises: Promise<string>[]) {
        //
        //  Make a promise and save its resolve/reject functions
        //
        let promise = new Promise<string>(((resolve, reject) => {
            this.resolve = resolve;
            this.reject = reject;
        }) as PackagePromise);
        //
        //  If there is a ready() function in the configuration for this package,
        //    Add running that to the promise
        //
        const config = (CONFIG[this.name] || {}) as PackageConfig;
        if (config.ready) {
            promise = promise.then((name: string) => config.ready(this.name)) as Promise<string>;
        }
        //
        //  If there are promises for dependencies,
        //    Add the one for loading this package and create a promise for all of them
        //      (That way, if any of them fail to load, our promise will reject automatically)
        //
        if (promises.length) {
            promises.push(promise);
            promise = Promise.all(promises).then((names: string[]) => names.join(', '));
        }
        //
        //  If there is a failed() function in the configuration for this package,
        //    Add a catch to handle the error
        //
        if (config.failed) {
            promise.catch((message: string) => config.failed(new PackageError(message, this.name)));
        }
        //
        //  Return the promise that represents when this file is loaded
        //
        return promise;
    }

    /**
     * Attempt to load this package
     */
    public load() {
        if (!this.isLoaded && !this.isLoading && !this.noLoad) {
            this.isLoading = true;
            const url = Package.resolvePath(this.name);
            if (CONFIG.require) {
                this.loadCustom(url);
            } else {
                this.loadScript(url);
            }
        }
    }

    /**
     * Load using a custom require method (usually the one from node.js)
     */
    protected loadCustom(url: string) {
        try {
            const result = CONFIG.require(url);
            if (result instanceof Promise) {
                result.then(() => this.checkLoad())
                      .catch(() => this.failed('Can\'t load "' + url + '"'));
            } else {
                this.checkLoad();
            }
        } catch (err) {
            this.failed(err.message);
        }
    }

    /**
     * Load in a browser by inserting a script to load the proper URL
     */
    protected loadScript(url: string) {
        const script = document.createElement('script');
        script.src = url;
        script.charset = 'UTF-8';
        script.onload = (event) => this.checkLoad();
        script.onerror = (event) => this.failed('Can\'t load "' + url + '"');
        // FIXME: Should there be a timeout failure as well?
        document.head.appendChild(script);
    }

    /**
     * Called when the package is loaded.
     *
     * Mark it as loaded, and tell its dependents that this package
     *   has been loaded (may cause dependents to load themselves).
     *   Mark any provided packages as loaded.
     * Resolve the promise that says this package is loaded.
     */
    public loaded() {
        this.isLoaded = true;
        this.isLoading = false;
        for (const dependent of this.dependents) {
            dependent.requirementSatisfied();
        }
        for (const provided of this.provided) {
            provided.loaded();
        }
        this.resolve(this.name);
    }

    /**
     * Called when the package fails to load for some reason
     *
     * Mark it as failed to load
     * Reject the promise for this package with an error
     *
     * @param {string} message   The error message for a load failure
     */
    protected failed(message: string) {
        this.hasFailed = true;
        this.isLoading = false;
        this.reject(new PackageError(message, this.name));
    }

    /**
     * Check if a package is really ready to be marked as loaded
     * (When it is loaded, it may set its own checkReady() function
     *  as a means of loading additional packages.  E.g., an output
     *  jax may load a font package, dependent on its configuration.)
     *
     *  The configuration's checkReady() function returns a promise
     *  that allows the loader to wait for addition actions to finish
     *  before marking the file as loaded (or failing to load).
     */
    protected checkLoad() {
        const config = (CONFIG[this.name] || {}) as PackageConfig;
        const checkReady = config.checkReady || (() => Promise.resolve());
        checkReady().then(() => this.loaded())
                    .catch((message) => this.failed(message));
    }

    /**
     * This is called when a dependency loads.
     *
     * Decrease the dependency count, and try to load this package
     * when the dependencies are all loaded.
     */
    public requirementSatisfied() {
        if (this.dependencyCount) {
            this.dependencyCount--;
            if (this.canLoad) {
                this.load();
            }
        }
    }

    /**
     * @param {string[]} names    The names of the packages that this package provides
     */
    public provides(names: string[] = []) {
        for (const name of names) {
            let provided = Package.packages.get(name);
            if (!provided) {
                if (!CONFIG.dependencies[name]) {
                    CONFIG.dependencies[name] = [];
                }
                CONFIG.dependencies[name].push(name);
                provided = new Package(name, true);
                provided.isLoading = true;
            }
            this.provided.push(provided);
        }
    }

    /**
     * Add a package as a dependent, and if it is not just for reference,
     *   check if we need to change our noLoad status.
     *
     * @param {Package} extension   The package to add as a dependent
     * @param {boolean} noLoad      The noLoad status of the dependent
     */
    public addDependent(extension: Package, noLoad: boolean) {
        this.dependents.push(extension);
        if (!noLoad) {
            this.checkNoLoad();
        }
    }

    /**
     * If this package is marked as noLoad, change that and check all
     *   our dependencies to see if they need to change their noLoad
     *   status as well.
     *
     *  I.e., if there are dependencies that were set up for reference
     *  and a leaf node needs to be loaded, make sure all parent nodes
     *  are marked as needing to be loaded as well.
     */
    public checkNoLoad() {
        if (this.noLoad) {
            this.noLoad = false;
            for (const dependency of this.dependencies) {
                dependency.checkNoLoad();
            }
        }
    }

    /**
     * Attempt to load all packages that are ready to be loaded
     * (i.e., that have no unloaded dependencies, and that haven't
     *  already been loaded, and that aren't in process of being
     *  loaded, and that aren't marked as noLoad).
     */
    public static loadAll() {
        for (const extension of this.packages.values()) {
            if (extension.canLoad) {
                extension.load();
            }
        }
    }

}
