/**
 * @copy 该文件代码大部分出自 umi，有需要请参考：
 * https://github.com/umijs/umi/tree/master/packages/core
 */
import { join } from 'path';
import { EventEmitter } from 'events';
import assert from 'assert';
import { AsyncSeriesWaterfallHook } from 'tapable';
import { existsSync } from 'fs';
import { lodash, chalk } from '@fesjs/utils';
import { Command, Option } from 'commander';
import { resolvePresets, pathToObj, resolvePlugins } from './utils/pluginUtils';
import loadDotEnv from './utils/loadDotEnv';
import isPromise from './utils/isPromise';
import BabelRegister from './babelRegister';
import PluginAPI from './pluginAPI';
import {
    ApplyPluginsType,
    ConfigChangeType,
    EnableBy,
    PluginType,
    ServiceStage
} from './enums';
import Config from '../config';
import { getUserConfigWithKey } from '../config/utils/configUtils';
import getPaths from './getPaths';

// TODO
// 1. duplicated key
// 2. Logger
export default class Service extends EventEmitter {
    cwd;

    pkg;

    skipPluginIds = new Set();

    // lifecycle stage
    stage = ServiceStage.uninitialized;

    // registered commands
    commands = {};

    // including plugins
    plugins = {};

    // plugin methods
    pluginMethods = {};

    // initial presets and plugins from arguments, config, process.env, and package.json
    initialPresets = [];

    // initial plugins from arguments, config, process.env, and package.json
    initialPlugins = [];

    _extraPresets = [];

    _extraPlugins = [];

    // user config
    userConfig;

    configInstance;

    config = null;

    // babel register
    babelRegister;

    // hooks
    hooksByPluginId = {};

    hooks = {};

    // paths
    paths = {};

    env;

    ApplyPluginsType = ApplyPluginsType;

    EnableBy = EnableBy;

    ConfigChangeType = ConfigChangeType;

    ServiceStage = ServiceStage;

    args;

    constructor(opts) {
        super();
        this.cwd = opts.cwd || process.cwd();
        // repoDir should be the root dir of repo
        this.pkg = opts.pkg || this.resolvePackage();
        this.env = opts.env || process.env.NODE_ENV;
        this.fesPkg = opts.fesPkg || {};


        assert(existsSync(this.cwd), `cwd ${this.cwd} does not exist.`);

        // register babel before config parsing
        this.babelRegister = new BabelRegister();

        // load .env or .local.env
        this.loadEnv();

        // get user config without validation
        this.configInstance = new Config({
            cwd: this.cwd,
            service: this,
            localConfig: this.env === 'development'
        });
        this.userConfig = this.configInstance.getUserConfig();

        // get paths
        this.paths = getPaths({
            cwd: this.cwd,
            config: this.userConfig,
            env: this.env
        });

        this.program = this.initCommand();


        // setup initial plugins
        const baseOpts = {
            pkg: this.pkg,
            cwd: this.cwd
        };
        this.initialPresets = resolvePresets({
            ...baseOpts,
            presets: opts.presets || [],
            userConfigPresets: this.userConfig.presets || []
        });
        this.initialPlugins = resolvePlugins({
            ...baseOpts,
            plugins: opts.plugins || [],
            userConfigPlugins: this.userConfig.plugins || []
        });
    }

    setStage(stage) {
        this.stage = stage;
    }

    resolvePackage() {
        try {
            // eslint-disable-next-line
            return require(join(this.cwd, "package.json"));
        } catch (e) {
            return {};
        }
    }

    loadEnv() {
        const basePath = join(this.cwd, '.env');
        const localPath = `${basePath}.local`;
        loadDotEnv(basePath);
        if (process.env.FES_ENV) {
            loadDotEnv(`${basePath}.${process.env.FES_ENV}`);
        }
        loadDotEnv(localPath);
    }

    async init() {
        this.setStage(ServiceStage.init);
        await this.initPresetsAndPlugins();

        // hooksByPluginId -> hooks
        // hooks is mapped with hook key, prepared for applyPlugins()
        this.setStage(ServiceStage.initHooks);
        Object.keys(this.hooksByPluginId).forEach((id) => {
            const hooks = this.hooksByPluginId[id];
            hooks.forEach((hook) => {
                const { key } = hook;
                hook.pluginId = id;
                this.hooks[key] = (this.hooks[key] || []).concat(hook);
            });
        });

        // plugin is totally ready
        this.setStage(ServiceStage.pluginReady);
        await this.applyPlugins({
            key: 'onPluginReady',
            type: ApplyPluginsType.event
        });

        // get config, including:
        // 1. merge default config
        // 2. validate
        this.setStage(ServiceStage.getConfig);
        await this.setConfig();

        // merge paths to keep the this.paths ref
        this.setStage(ServiceStage.getPaths);
        // config.outputPath may be modified by plugins
        if (this.config.outputPath) {
            this.paths.absOutputPath = join(this.cwd, this.config.outputPath);
        }
        const paths = await this.applyPlugins({
            key: 'modifyPaths',
            type: ApplyPluginsType.modify,
            initialValue: this.paths
        });
        Object.keys(paths).forEach((key) => {
            this.paths[key] = paths[key];
        });
    }

    async setConfig() {
        const defaultConfig = await this.applyPlugins({
            key: 'modifyDefaultConfig',
            type: this.ApplyPluginsType.modify,
            initialValue: await this.configInstance.getDefaultConfig()
        });
        this.config = await this.applyPlugins({
            key: 'modifyConfig',
            type: this.ApplyPluginsType.modify,
            initialValue: this.configInstance.getConfig({
                defaultConfig
            })
        });
    }

    async initPresetsAndPlugins() {
        this.setStage(ServiceStage.initPresets);
        this._extraPlugins = [];
        while (this.initialPresets.length) {
            // eslint-disable-next-line
            await this.initPreset(this.initialPresets.shift());
        }

        this.setStage(ServiceStage.initPlugins);
        this._extraPlugins.push(...this.initialPlugins);
        while (this._extraPlugins.length) {
            // eslint-disable-next-line
            await this.initPlugin(this._extraPlugins.shift());
        }
    }

    getPluginAPI(opts) {
        const pluginAPI = new PluginAPI(opts);

        // register built-in methods
        [
            'onPluginReady',
            'modifyPaths',
            'onStart',
            'modifyDefaultConfig',
            'modifyConfig'
        ].forEach((name) => {
            pluginAPI.registerMethod({
                name,
                exitsError: false
            });
        });

        return new Proxy(pluginAPI, {
            get: (target, prop) => {
                // 由于 pluginMethods 需要在 register 阶段可用
                // 必须通过 proxy 的方式动态获取最新，以实现边注册边使用的效果
                if (this.pluginMethods[prop]) return this.pluginMethods[prop];
                if (
                    [
                        'applyPlugins',
                        'ApplyPluginsType',
                        'EnableBy',
                        'ConfigChangeType',
                        'babelRegister',
                        'stage',
                        'ServiceStage',
                        'paths',
                        'cwd',
                        'pkg',
                        'configInstance',
                        'userConfig',
                        'config',
                        'env',
                        'args',
                        'hasPlugins',
                        'hasPresets',
                        'setConfig'
                    ].includes(prop)
                ) {
                    return typeof this[prop] === 'function'
                        ? this[prop].bind(this)
                        : this[prop];
                }
                return target[prop];
            }
        });
    }

    async applyAPI(opts) {
        let ret = opts.apply()(opts.api);
        if (isPromise(ret)) {
            ret = await ret;
        }
        return ret || {};
    }

    async initPreset(preset) {
        const { id, key, apply } = preset;
        preset.isPreset = true;

        const api = this.getPluginAPI({ id, key, service: this });

        // register before apply
        this.registerPlugin(preset);
        const { presets, plugins } = await this.applyAPI({
            api,
            apply
        });

        // register extra presets and plugins
        if (presets) {
            assert(
                Array.isArray(presets),
                `presets returned from preset ${id} must be Array.`
            );
            // 插到最前面，下个 while 循环优先执行
            this._extraPresets.splice(
                0,
                0,
                ...presets.map(path => pathToObj({
                    type: PluginType.preset,
                    path,
                    cwd: this.cwd
                }))
            );
        }

        // 深度优先
        const extraPresets = lodash.clone(this._extraPresets);
        this._extraPresets = [];
        while (extraPresets.length) {
            // eslint-disable-next-line
            await this.initPreset(extraPresets.shift());
        }

        if (plugins) {
            assert(
                Array.isArray(plugins),
                `plugins returned from preset ${id} must be Array.`
            );
            this._extraPlugins.push(
                ...plugins.map(path => pathToObj({
                    type: PluginType.plugin,
                    path,
                    cwd: this.cwd
                }))
            );
        }
    }


    async initPlugin(plugin) {
        const { id, key, apply } = plugin;

        const api = this.getPluginAPI({
            id,
            key,
            service: this
        });

        // register before apply
        this.registerPlugin(plugin);
        await this.applyAPI({
            api,
            apply
        });
    }

    getPluginOptsWithKey(key) {
        return getUserConfigWithKey({
            key,
            userConfig: this.userConfig
        });
    }

    registerPlugin(plugin) {
        this.plugins[plugin.id] = plugin;
    }

    isPluginEnable(pluginId) {
        // api.skipPlugins() 的插件
        if (this.skipPluginIds.has(pluginId)) return false;

        const { key, enableBy } = this.plugins[pluginId];

        // 手动设置为 false
        if (this.userConfig[key] === false) return false;

        // 配置开启
        if (enableBy === this.EnableBy.config && !(key in this.userConfig)) {
            return false;
        }

        // 函数自定义开启
        if (typeof enableBy === 'function') {
            return enableBy();
        }

        // 注册开启
        return true;
    }

    hasPresets(presetIds) {
        return presetIds.every((presetId) => {
            const preset = this.plugins[presetId];
            return preset && preset.isPreset && this.isPluginEnable(presetId);
        });
    }

    hasPlugins(pluginIds) {
        return pluginIds.every((pluginId) => {
            const plugin = this.plugins[pluginId];
            return plugin && !plugin.isPreset && this.isPluginEnable(pluginId);
        });
    }

    async applyPlugins(opts) {
        const hooks = this.hooks[opts.key] || [];
        switch (opts.type) {
            case ApplyPluginsType.add:
                if ('initialValue' in opts) {
                    assert(
                        Array.isArray(opts.initialValue),
                        'applyPlugins failed, opts.initialValue must be Array if opts.type is add.'
                    );
                }
                // eslint-disable-next-line
                const tAdd = new AsyncSeriesWaterfallHook(["memo"]);
                for (const hook of hooks) {
                    if (!this.isPluginEnable(hook.pluginId)) {
                        continue;
                    }
                    tAdd.tapPromise(
                        {
                            name: hook.pluginId,
                            stage: hook.stage || 0,
                            // @ts-ignore
                            before: hook.before
                        },
                        async (memo) => {
                            const items = await hook.fn(opts.args);
                            return memo.concat(items);
                        }
                    );
                }
                return tAdd.promise(opts.initialValue || []);
            case ApplyPluginsType.modify:
                // eslint-disable-next-line
                const tModify = new AsyncSeriesWaterfallHook(["memo"]);
                for (const hook of hooks) {
                    if (!this.isPluginEnable(hook.pluginId)) {
                        continue;
                    }
                    tModify.tapPromise(
                        {
                            name: hook.pluginId,
                            stage: hook.stage || 0,
                            // @ts-ignore
                            before: hook.before
                        },
                        async memo => hook.fn(memo, opts.args)
                    );
                }
                return tModify.promise(opts.initialValue);
            case ApplyPluginsType.event:
                // eslint-disable-next-line
                const tEvent = new AsyncSeriesWaterfallHook(["_"]);
                for (const hook of hooks) {
                    if (!this.isPluginEnable(hook.pluginId)) {
                        continue;
                    }
                    tEvent.tapPromise(
                        {
                            name: hook.pluginId,
                            stage: hook.stage || 0,
                            // @ts-ignore
                            before: hook.before
                        },
                        async () => {
                            await hook.fn(opts.args);
                        }
                    );
                }
                return tEvent.promise();
            default:
                throw new Error(
                    `applyPlugin failed, type is not defined or is not matched, got ${opts.type}.`
                );
        }
    }

    initCommand() {
        const command = new Command();
        command
            .usage('<command> [options]')
            .version(`@fesjs/fes ${this.fesPkg.version}`, '-v, --vers', 'output the current version')
            .description(chalk.cyan('一个好用的前端应用解决方案'));
        return command;
    }

    async run({ rawArgv = {}, args = {} }) {
        await this.init();

        this.setStage(ServiceStage.run);
        await this.applyPlugins({
            key: 'onStart',
            type: ApplyPluginsType.event,
            args: {
                args
            }
        });

        return this.runCommand({ rawArgv, args });
    }

    async runCommand({ rawArgv = {}, args = {} }) {
        assert(this.stage >= ServiceStage.init, 'service is not initialized.');
        Object.keys(this.commands).forEach((command) => {
            const commandOptionConfig = this.commands[command];
            const program = this.program;
            let c = program.command(command).description(commandOptionConfig.description);
            if (Array.isArray(commandOptionConfig.options)) {
                commandOptionConfig.options.forEach((config) => {
                    const option = new Option(config.name, config.description);
                    if (config.default) {
                        option.default(config.default);
                    }
                    if (config.choices) {
                        option.choices(config.choices);
                    }
                    c = c.addOption(option);
                });
            }
            if (commandOptionConfig.fn) {
                c.action(async () => {
                    await commandOptionConfig.fn({
                        rawArgv, args, options: c.opts(), program
                    });
                });
            }
        });

        return this.parseCommand();
    }

    async parseCommand() {
        this.program.on('--help', () => {
            console.log();
            console.log(
                `  Run ${chalk.cyan(
                    'fes <command> --help'
                )} for detailed usage of given command.`
            );
            console.log();
        });
        this.program.commands.forEach(c => c.on('--help', () => console.log()));
        return this.program.parseAsync(process.argv);
    }
}
