import * as lwc from '@lwc/engine';
import { assert, autoBind } from 'talon/utils';
import { getBasePath, getMode, getLocale } from 'talon/configProvider';
import { getResourceUrl, getViewModuleFullyQualifiedName } from '@webruntime/common';
import scopedModuleResolver from 'talon/scopedModuleResolver';

const NAMESPACE_ALIASES = {
    lightning: 'interop',
};

/**
 * Module registry class.
 *
 * A single instance of it will be used throughout the app
 * and selected methods will be exported.
 *
 * We still export the class itself for testing purpose so that we can
 * create as many instances as needed.
 */
export class ModuleRegistry {
    caseInsensitiveRegistry = {
        get: (target, name) => {
            return target.hasModule(target, name);
        },

        set: (target, property, value) => {
            const existing = target.hasModule(target, property);
            if (!existing) {
                target[property] = value;
            }
            return true;
        },
    };

    caseInsensitiveLookup = (target, name) => {
        const foundKey = Object.keys(target).find(key => {
            return key.toLowerCase() === name.toLowerCase();
        });
        return target[foundKey];
    };

    registry = new Proxy(
        {
            hasModule: this.caseInsensitiveLookup,
        },
        this.caseInsensitiveRegistry
    );

    errorRegistry = new Proxy(
        {
            hasModule: this.caseInsensitiveLookup,
        },
        this.caseInsensitiveRegistry
    );
    resourceUids;
    requestQueue = {};

    /**
     * Assert that the dependency exists in the registry.
     * @param {String} dependency - the name of the dependency
     */
    assertHasModule(dependency) {
        assert(this.hasModule(dependency), `Cannot resolve dependency '${dependency}'`);
    }

    /**
     * Add multiple modules to the registry.
     *
     * This function is different from addModule() in the sense that it takes
     * already exported modules rather than an exporter function for the modules.
     *
     * This function can be used with labels, LWC component, ES modules.
     *
     * This function is meant to be called by code generated by the framework.
     *
     * @param {Object} modulesByName - A map of modules to add to the registry,
     *                  keyed by module name and in which values are exported modules.
     */
    addModules(modulesByName) {
        Object.entries(modulesByName).forEach(([name, module]) => {
            this.registry[name] = module;
        });
    }

    /**
     * Add a single module to the registry.
     *
     * This function is meant to be called by code generated by the framework.
     *
     * Modules should only exported/evaluated once, the call is ignored if the module
     * is already registered.
     *
     * @param {string} name - The name of the module to add
     * @param {string[]} dependencies - The list of dependencies to pass to the exporter function
     * @param {Function} exporter - A function that will export the module to add.
     */
    define(name, dependencies, exporter) {
        this.addModule(null, name, dependencies, exporter);
    }

    /**
     * Add a single module to the registry.
     *
     * This function is meant to be called by code generated by the framework.
     *
     * Modules should only exported/evaluated once, the call is ignored if the module
     * is already registered.
     *
     * @param {string} descriptor - Not used, kept here for Aura compatibility reasons
     * @param {string} name - The name of the module to add
     * @param {string[]} dependencies - The list of dependencies to pass to the exporter function
     * @param {Function} exporter - A function that will export the module to add.
     */
    addModule(descriptor, name, dependencies, exporter) {
        if (exporter === undefined && typeof dependencies === 'function') {
            // amd define does not include dependencies param if no dependencies.
            this.addModule(descriptor, name, [], dependencies);
            return;
        }

        // ignore if module is already registered
        if (this.registry[name]) {
            return;
        }

        try {
            const moduleExports = {};
            this.registry[name] =
                this.resolveExports(exporter, name, dependencies, moduleExports) || moduleExports;
        } catch (err) {
            this.errorRegistry[name] = err;
            throw err;
        }
    }

    /**
     * Executes the defined exporter function with dependencies, and returns the module exports
     * @param {Function} exporter - A function that will export the module to add.
     * @param {string} name - The module name of the exporter function
     * @param {string[]} dependencies - The list of dependencies to pass to the exporter function
     */
    resolveExports(exporter, name, dependencies, moduleExports) {
        return exporter.apply(
            undefined,
            dependencies.map(dependency => {
                return this.evaluateModuleDependency(dependency, moduleExports);
            })
        );
    }
    /**
     * Handle LWC component circular dependencies.
     *
     * This does not happen when compiling the components with Rollup
     * but it does in Aura.
     *
     * In this case, the LWC engine expects a function that returns the actual component
     * and with a symbol attached to it, see https://github.com/salesforce/lwc/blob/2c71effd1336e62a343fb818e987a927293786be/packages/@lwc/engine/src/framework/utils.ts.
     *
     * @param {string} name The name of the module
     */
    evaluateCircularDependency(name) {
        return Object.assign(
            () => {
                const module = this.evaluateUnscopedModuleDependency(name);
                assert(module, `Cannot resolve dependency '${name}'`);
                return module;
            },
            { __circular__: true }
        );
    }

    /**
     * Evaluate module dependency from its full import name.
     *
     * eg 'lwc' or 'lightning/button' or '@salesforce/cssvars/customProperties'
     *
     * @param {string} dependency - A dependency name
     * @param {string} moduleExports - The dependency's exports
     */
    evaluateModuleDependency(dependency, moduleExports) {
        // Module is already in the registry
        if (this.registry[dependency]) {
            return this.registry[dependency];
        }

        // Handle special cases
        if (dependency === 'lwc') {
            return lwc;
        } else if (dependency === 'exports') {
            return moduleExports;
        }

        // Handle scoped modules
        if (dependency.startsWith('@')) {
            this.registry[dependency] = scopedModuleResolver.resolve(dependency);
            return this.registry[dependency];
        }

        // Handle unscoped case
        this.registry[dependency] = this.evaluateUnscopedModuleDependency(dependency);

        if (!this.registry[dependency]) {
            return this.evaluateCircularDependency(dependency);
        }

        return this.registry[dependency];
    }

    /**
     * Evaluate unscoped dependency from its module name and component.
     *
     * eg 'lightning/button' or 'interop/menuItem'
     *
     * @param {string} dependency - The dependency name of the form `namespace/name`
     */
    evaluateUnscopedModuleDependency(dependency) {
        // Module is in the registry
        if (this.registry[dependency]) {
            return this.registry[dependency];
        }

        // See if module is in the registry under an namespace alias
        const [namespace, name] = dependency.split('/');
        if (NAMESPACE_ALIASES[namespace]) {
            const aliasedName = [NAMESPACE_ALIASES[namespace], name].join('/');
            return this.registry[aliasedName];
        }

        // Cannot find the module, we're likely dealing with circular dependency
        // This will be handled by the caller
        return undefined;
    }

    /**
     * Gets a generated view template from the registry, loading it from the server if needed
     *
     * @param {string} name - The template name
     * @returns a promise which resolves the exported module
     */
    async getTemplate(name) {
        return this.getModule(
            getViewModuleFullyQualifiedName(name),
            this.getResourceUrl({ view: name })
        );
    }

    /**
     * Gets a generated component from the registry, loading it from the server if needed
     *
     * @param {string} name - The component name
     * @returns a promise which resolves the exported module
     */
    async getComponent(name) {
        return this.getModule(name, this.getResourceUrl({ component: name }));
    }

    /**
     * Gets a module synchronously from the registry if it is present.
     *
     * @param {string} name - The module name
     * @returns the exported module if present in the registry, null if not
     */
    getModuleIfPresent(name) {
        return this.registry[name];
    }

    /**
     * Gets a module from the registry, loading it from the server if needed.
     *
     * @param {string} name - The module name
     * @returns a promise which resolves the exported module
     */
    async getModule(name, resourceUrl) {
        let moduleFromRegistry = this.registry[name];

        // return the module from the registry
        if (moduleFromRegistry) {
            return moduleFromRegistry;
        }

        const errorFromRegistry = this.errorRegistry[name];
        if (errorFromRegistry) {
            return Promise.reject(errorFromRegistry);
        }

        // Module gets are case insensitive
        const lowerCasedName = name.toLowerCase();
        if (this.requestQueue[lowerCasedName]) {
            return this.requestQueue[lowerCasedName];
        }

        // fetch the component from the server if it is not available yet
        this.requestQueue[lowerCasedName] = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.type = 'text/javascript';

            script.src = `${getBasePath()}${resourceUrl ||
                this.getResourceUrl({ component: name })}`;

            const cleanup = () => {
                script.remove();
                delete this.requestQueue[name];
            };

            script.onload = () => {
                cleanup();
                moduleFromRegistry = this.registry[name];
                if (moduleFromRegistry) {
                    return resolve(moduleFromRegistry);
                }
                if (this.errorRegistry[name]) {
                    return reject(this.errorRegistry[name]);
                }
                const error = new Error(`Failed to load module: ${name}`);
                return reject(error);
            };
            script.onerror = error => {
                cleanup();
                reject(error);
            };

            document.body.appendChild(script);
        });

        return this.requestQueue[lowerCasedName];
    }

    setResourceUids(resourceUids) {
        this.resourceUids = resourceUids;
    }

    getResourceUrl({ component, view }) {
        const prefix = component ? 'component' : 'view';
        const resource = component || view;
        const mode = getMode();
        const { langLocale } = getLocale();
        const resourceName = `${prefix}://${resource}@${langLocale}`;
        const uid = this.resourceUids && this.resourceUids[resourceName];
        return getResourceUrl(resourceName, mode, uid);
    }

    hasModule(name) {
        const module = this.registry[name];
        return typeof module !== 'undefined' && module !== null;
    }
}

// create an instance with bound methods so that they can be exported
const instance = autoBind(new ModuleRegistry());

export const {
    addModule,
    addModules,
    getModule,
    getComponent,
    getTemplate,
    getModuleIfPresent,
    hasModule,
    setResourceUids,
    define,
} = instance;

export default {
    addModule,
    addModules,
    getModule,
    getComponent,
    getTemplate,
    getModuleIfPresent,
    hasModule,
    setResourceUids,
    define,
};
