#!/usr/bin/env node

import http from 'node:http';
import https from 'node:https';
import nodeFS from 'node:fs/promises';
import child_process from 'node:child_process';
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
import { fetch } from './client/fetch.js';
import { completionOptions, parse } from './input/args.js';
import { MAX_ARGUMENT_LENGTH, MAX_ARGUMENT_COUNT } from './input/constants.js';
import { getEnv, isFalsy } from './input/env.js';
import { distance } from './input/distance.js';
import { getCommandCompletion, getArgumentsCompletion } from './input/completion.js';
import { prompt } from './input/prompt.js';
import { logger, LOG_LEVEL } from './output/logger.js';
import { success, failure, dimmed, entity, warning } from './output/style.js';
import { getGeneralHelp, getCommandHelp } from './output/help.js';
import { format } from './output/format.js';
import { load } from './model/configuration.js';

const scriptFilePath = realpathSync(process.argv[1]);
const moduleFilePath = fileURLToPath(import.meta.url);
const currentNodeVersion = process.versions.node.split('.');

const isRoot = () => new Promise(resolve => {
    if ('win32' === process.platform) {
        child_process.exec('net session', err => resolve(!err));
    } else {
        resolve(!process.getuid());
    }
});

/**
 * Creates new namespace, containing exports from original command
 * and wrapping command. In case named export is a function that
 * is defined in both commands, the resulting namespace will contain
 * a function that calls function from wrapping module and passes a
 * function that calls function from original module as additional
 * last positional argument.
 *
 * This functionality is required to enable plugins to extend
 * existing commands with additional functionality.
 *
 * @example
 * // oldCommand
 * export const getMetadata = meta0;
 * export const run = run0;
 *
 * // newCommand
 * export const getMetadata = meta1;
 * export const getCompletions = (ctx) => ['plugin'];
 *
 * // wrapCommand(oldCommand, newCommand)
 * {
 *   getMetadata: (ctx) => meta1(ctx, () => meta0(ctx)),
 *   run: (ctx) => run0(ctx),
 *   getCompletions: (ctx) => ['plugin']
 * }
 *
 * @param {object} oldCommand original command module exports
 * @param {object} newCommand wrapping command module exports
 * @returns {object} namespace with union of commands exports
 */
const wrapCommand = (oldCommand, newCommand) => {
    const wrappedCommand = {};
    const allExportedKeys = new Set(Object.keys(oldCommand))
        .union(new Set(Object.keys(newCommand)));
    allExportedKeys.forEach(key => {
        const oldExport = oldCommand[key];
        const newExport = newCommand[key];
        if (typeof oldExport === 'function' && typeof newExport === 'function') {
            // When newCommand exported function is called, add extra argument, which is
            // the function that executes oldCommand same name exported function with
            // the original arguments
            wrappedCommand[key] = (...args) => newExport(...args, () => oldExport(...args));
        } else {
            // inherit non-overriden exports
            wrappedCommand[key] ??= newExport ?? oldExport;
        }
    });
    return wrappedCommand;
};

/**
 * Run Application Frontend service CLI
 *
 * @param {Object} [opts] - options
 * @param {Object} [opts.env] - environment variables
 * @param {Array.<string>} [opts.args] - command line arguments
 * @param {stream.Readable} [opts.stdin] - standard input stream
 * @param {stream.Writable} [opts.stdout] - standard output stream
 * @param {stream.Writable} [opts.stderr] - standard error stream
 * @param {stream.Writable} [opts.trace] - trace stream
 * @param {fs} [opts.fs] - file system
 * @returns {Promise<number>} promise that is resolved with process exit code
 */
export const run = async ({
    env = process.env,
    args = process.argv.slice(2),
    stdin = process.stdin,
    stdout = process.stdout,
    stderr = process.stderr,
    trace = undefined,
    fs = nodeFS
} = {}) => {
    // Default exit
    let exit = async code => code;

    // Default logger
    let console = globalThis.console;

    // Limit file system
    fs = {
        access: fs.access,
        cp: fs.cp,
        mkdir: fs.mkdir,
        open: fs.open,
        readdir: fs.readdir,
        readFile: fs.readFile,
        rename: fs.rename,
        stat: fs.stat,
        writeFile: fs.writeFile
    };

    try {
        // package.json
        const pkg = JSON.parse(await fs.readFile(
            new URL('../package.json', import.meta.url),
            { encoding: 'utf8' }
        ));

        // CLI executable name
        const bin = Object.keys(pkg.bin)[0];

        // CLI metadata
        const cli = {
            name: pkg.name,
            version: pkg.version,
            description: pkg.description,
            bin
        };

        // Validate length of user input
        if (args.length > MAX_ARGUMENT_COUNT) {
            const err = new Error(
                'Number of arguments is too large. ' +
                'Expected number of arguments to be less or equal to:' +
                MAX_ARGUMENT_COUNT
            );
            throw err;
        }
        args.forEach(arg => {
            if (arg.length > MAX_ARGUMENT_LENGTH) {
                const err = new Error(
                    'Argument is too long. ' +
                    'Expected argument length to be less or equal to:' +
                    MAX_ARGUMENT_LENGTH
                );
                throw err;
            }
        });

        // Init context
        const ctx = {
            cli,
            env: getEnv(env, bin),
            args: parse(args, completionOptions),
            prompt: prompt({stdin, stdout}),
            fs
        };

        ctx.isAppStudio = !!env.WS_BASE_URL;

        // Init trace (if needed)
        if (typeof trace === 'undefined' && ctx.env.TRACE) {
            const fd = await fs.open(ctx.env.TRACE, 'a');
            trace = fd.createWriteStream({
                flush: true
            });
            // wait for traces are flushed to disk before exit
            const _exit = exit;
            exit = async code => new Promise((resolve) => {
                fd.once('close', () => resolve(_exit(code)));
                trace.close();
            });
        }

        // Init logger
        ctx.console = console = logger({
            stdout, stderr, trace,
            level: (ctx.args.verbose && LOG_LEVEL.DEBUG) ?? ctx.env.LOG_LEVEL ?? LOG_LEVEL.INFO,
            colors: !isFalsy(ctx.env.COLORS)
        });

        // Init fetch
        ctx.fetch = fetch({cli, fs, console, http, https});

        // Check Node.js version
        const supportedNodeVersion = pkg.engines.node.slice(1).split('.');
        for (let i = 0; i < supportedNodeVersion.length; i++) {
            const supported = parseInt(supportedNodeVersion[i], 10);
            const current = parseInt(currentNodeVersion[i], 10);
            if (supported > current) {
                const err = new Error(
                    `Node.js version ${currentNodeVersion.join('.')} is not supported by ${cli.name}@${cli.version}. `+
                    `Please use Node.js ${pkg.engines.node} instead.`
                );
                throw err;
            } else if (supported < current) {
                break;
            }
        }

        // Don't allow to run with root previliges by default (SEC-277)
        if (await isRoot()) {
            if (ctx.args.root) {
                console.debug('=== Running with root user privileges! ===');
            } else {
                ctx.console.warn(warning(
                    '==========================================================================\n' +
                    `WARNING: Running ${bin} as root is dangerous and not supported by default.\n` +
                    `The ${bin} does not require root previliges for any command.\n` +
                    'If you know what are you doing and accept the risk, use \'--root\'\n' +
                    `global flag with each ${bin} command that you run as root.\n` +
                    '==========================================================================\n'
                ));
                // === Exit ===
                return await exit(1);
            }
        }

        // === Start CLI ===
        console.debug(`${ctx.cli.description} (${ctx.cli.name}@${ctx.cli.version})`);

        // Load configuration
        ctx.config = await load(ctx.env.CONFIG ?? join(homedir(), `.${bin}/config.yaml`), { fs, console });

        // Import commands
        const commandsDirPath = join(moduleFilePath, '..', 'commands');
        const commandFiles = await fs.readdir(commandsDirPath, {withFileTypes: true});
        const commandsIndex = {};
        console.debug('Loading commands from \'%s\':', commandsDirPath);
        const commands = (await Promise.all(
            commandFiles.filter(dirent => dirent.isFile()).map(
                (commandFile, i) => {
                    console.debug(' - %s', commandFile.name);
                    return import(pathToFileURL(join(commandsDirPath, commandFile.name))).then((command) => {
                        const metadata = command.getMetadata();
                        commandsIndex[metadata.name] = i;
                        if (metadata.alias) {
                            commandsIndex[metadata.alias] = i;
                        }
                        return command;
                    });
                }
            )
        ));

        // Import plugins
        if (ctx.config.plugins) {
            const pluginEntries = Object.entries(ctx.config.plugins);
            if (pluginEntries.length) {
                console.debug('Loading plugins:');
                await Promise.all(
                    pluginEntries.map(async ([pluginName, {dir, main}]) => {
                        const pluginMainPath = join(dir, main);
                        try {
                            const plugin = await import(pluginMainPath);
                            console.debug(' - %s', pluginName);
                            if (Array.isArray(plugin.commands)) {
                                console.debug('   commands:');
                                plugin.commands.forEach(command => {
                                    const commandMetadata = command?.getMetadata?.();
                                    if (commandMetadata?.name) {
                                        const name = commandMetadata.name;
                                        if (commandsIndex.hasOwnProperty(name)) {
                                            console.debug('    - %s', name, '(wraps existing)');
                                            const i = commandsIndex[name];
                                            commands[i] = wrapCommand(commands[i], command);
                                            return;
                                        }
                                        console.debug('    - %s', name);
                                        commands.push(command);
                                    }
                                });
                            } else {
                                console.debug('   commands: plugin does not export any commands');
                            }
                        } catch (err) {
                            console.debug(`Filed to load plugin '${pluginName}' from ${pluginMainPath}.`, err);
                        }
                    })
                );
            } else {
                console.debug('No plugins to load');
            }
        }

        // Handle command completion
        let commandName;
        if (ctx.args.getCompletions) {
            if (ctx.args['_'].length === 2) {
                getCommandCompletion(ctx, commands)?.forEach?.(item =>
                    console.info(item)
                );
                // === Exit ===
                return await exit(0);
            }
            commandName = ctx.args['_'][1];
        }
        commandName ??= ctx.args['_'][0];

        // Find command to run (and similar commands)
        let activeCommand;
        const maybeCommands = [];
        if (commandName) {
            for (const command of commands) {
                const meta = command.getMetadata();
                if (meta.name === commandName || (meta.alias && meta.alias === commandName)) {
                    activeCommand = command;
                    break;
                } else if (distance(meta.name, commandName) <= 2) {
                    maybeCommands.push(meta.name);
                }
            }
        }

        // Handle command arguments completion
        if (ctx.args.getCompletions && activeCommand) {
            (await getArgumentsCompletion(ctx, activeCommand))?.forEach?.(item =>
                console.info(item)
            );
            // === Exit ===
            return await exit(0);
        }

        // General help
        if (!commandName) {
            console.info(getGeneralHelp(ctx, commands));
            // === Exit ===
            return await exit(0);
        }

        // Command help
        if (ctx.args.help && activeCommand) {
            console.info(getCommandHelp(activeCommand.getMetadata()));
            // === Exit ===
            return await exit(0);
        }

        // Command found (exact match)
        if (activeCommand) {
            const meta = activeCommand.getMetadata();
            const argsWithoutCommand = args.slice();
            argsWithoutCommand.splice(argsWithoutCommand.indexOf(commandName), 1);
            console.debug('Parsing command specific arguments');
            ctx.args = parse(argsWithoutCommand, meta.options);
            console.debug('Running command \'%s\' with context: %O', meta.name, ctx);
            // === RUN COMMAND ===
            let value = await activeCommand.run(ctx);
            // === OK ===
            if (value) {
                const formatType = ctx.args.format ?? 'raw';
                console.debug(`Command returned non empty value. Formatting value to '${formatType}'`);
                try {
                    value = format(value, formatType);
                } catch(err) {
                    throw new Error('Failed to format command output', { cause: err });
                }
            }
            console.log(success('OK\n'));
            value && console.info(value);
            // === Exit ===
            return await exit(0);
        }

        // Command not found
        throw new Error(
            `Unknown command ${entity(commandName)}.` +
            (maybeCommands.length > 0 ?
                ` Did you mean ${maybeCommands.map(name => entity(name)).join(' or ')}?\n` :
                '\n'
            ) +
            'To get help about all available commands or specific command, '+
            `run '${dimmed(cli.bin + ' --help')}' or '${dimmed(cli.bin + ' <command> --help')}'`
        );
    } catch(err) {
        console.error(failure('FAILED\n'));
        console.log(err?.userMessage ?? err?.message ?? 'The program exited unexpectedly');
        console.trace(err);
        // === Exit ===
        return await exit(1);
    }
};

/* node:coverage ignore next */
(scriptFilePath === moduleFilePath) && process.exit(await run());
