import {fs} from '@appium/support';
import {ArgumentParser} from 'argparse';
import _ from 'lodash';
import path from 'path';
import {
  DRIVER_TYPE,
  EXT_SUBCOMMAND_DOCTOR,
  EXT_SUBCOMMAND_INSTALL,
  EXT_SUBCOMMAND_LIST,
  EXT_SUBCOMMAND_RUN,
  EXT_SUBCOMMAND_UNINSTALL,
  EXT_SUBCOMMAND_UPDATE,
  PLUGIN_TYPE,
  SERVER_SUBCOMMAND,
  SETUP_SUBCOMMAND
} from '../constants';
import {finalizeSchema, getArgSpec, hasArgSpec} from '../schema';
import {rootDir} from '../config';
import {getExtensionArgs, getServerArgs} from './args';
import {
  DEFAULT_PLUGINS,
  SUBCOMMAND_MOBILE,
  SUBCOMMAND_DESKTOP,
  SUBCOMMAND_BROWSER,
  SUBCOMMAND_RESET,
  getPresetDrivers,
  determinePlatformName
} from './setup-command';

export const EXTRA_ARGS = 'extraArgs';

/**
 * If the parsed args do not contain any of these values, then we
 * will automatially inject the `server` subcommand.
 */
const NON_SERVER_ARGS = Object.freeze(
  new Set([SETUP_SUBCOMMAND, DRIVER_TYPE, PLUGIN_TYPE, SERVER_SUBCOMMAND, '-h', '--help', '-v', '--version'])
);

const version = fs.readPackageJsonFrom(rootDir).version;

/**
 * A wrapper around `argparse`
 *
 * - Handles instantiation, configuration, and monkeypatching of an
 *    `ArgumentParser` instance for Appium server and its extensions
 * - Handles error conditions, messages, and exit behavior
 */
class ArgParser {
  /**
   * @param {boolean} [debug] - If true, throw instead of exit on error.
   */
  constructor(debug = false) {
    const prog = process.argv[1] ? path.basename(process.argv[1]) : 'appium';
    const parser = new ArgumentParser({
      add_help: true,
      description:
        'A webdriver-compatible server that facilitates automation of web, mobile, and other ' +
        'types of apps across various platforms.',
      prog,
    });

    ArgParser._patchExit(parser);

    /**
     * Program name (typically `appium`)
     * @type {string}
     */
    this.prog = prog;

    /**
     * If `true`, throw an error on parse failure instead of printing help
     * @type {boolean}
     */
    this.debug = debug;

    /**
     * Wrapped `ArgumentParser` instance
     * @type {ArgumentParser}
     */
    this.parser = parser;

    parser.add_argument('-v', '--version', {
      action: 'version',
      version,
    });

    const subParsers = parser.add_subparsers({dest: 'subcommand'});

    // add the 'server' subcommand, and store the raw arguments on the parser
    // object as a way for other parts of the code to work with the arguments
    // conceptually rather than just through argparse
    const serverArgs = ArgParser._addServerToParser(subParsers);

    this.rawArgs = serverArgs;

    // add the 'driver' and 'plugin' subcommands
    ArgParser._addExtensionCommandsToParser(subParsers);

    // add the 'setup' command
    ArgParser._addSetupToParser(subParsers);

    // backwards compatibility / drop-in wrapper
    /**
     * @type {ArgParser['parseArgs']}
     */
    this.parse_args = this.parseArgs;
  }

  /**
   * Parse arguments from the command line.
   *
   * If no subcommand is passed in, this method will inject the `server` subcommand.
   *
   * `ArgParser.prototype.parse_args` is an alias of this method.
   * @template {import('appium/types').CliCommand} [Cmd=import('appium/types').CliCommandServer]
   * @param {string[]} [args] - Array of arguments, ostensibly from `process.argv`. Gathers args from `process.argv` if not provided.
   * @returns {import('appium/types').Args<Cmd>} - The parsed arguments
   */
  parseArgs(args = process.argv.slice(2)) {
    if (!NON_SERVER_ARGS.has(args[0])) {
      args.unshift(SERVER_SUBCOMMAND);
    }

    try {
      const parsed = this.parser.parse_known_args(args);
      const [knownArgs, unknownArgs] = parsed;
      // XXX: you'd think that argparse, when given an alias for a subcommand,
      // would set this value to the original subcommand name, but it doesn't.
      if (knownArgs?.driverCommand === 'ls') {
        knownArgs.driverCommand = 'list';
      } else if (knownArgs?.pluginCommand === 'ls') {
        knownArgs.pluginCommand = 'list';
      }
      if (
        unknownArgs?.length &&
        (knownArgs.driverCommand === 'run' || knownArgs.pluginCommand === 'run')
      ) {
        return ArgParser._transformParsedArgs(knownArgs, unknownArgs);
      } else if (unknownArgs?.length) {
        throw new Error(`[ERROR] Unrecognized arguments: ${unknownArgs.join(' ')}`);
      }
      return ArgParser._transformParsedArgs(knownArgs);
    } catch (err) {
      if (this.debug) {
        throw err;
      }
      // this isn't tested via unit tests (we use `debug: true`) so may escape coverage.

      /* istanbul ignore next */
      {
        // eslint-disable-next-line no-console
        console.error(); // need an extra space since argparse prints usage.
        // eslint-disable-next-line no-console
        console.error(err.message);
        process.exit(1);
      }
    }
  }

  /**
   * Given an object full of arguments as returned by `argparser.parse_args`,
   * expand the ones for extensions into a nested object structure and rename
   * keys to match the intended destination.
   *
   * E.g., `{'driver-foo-bar': baz}` becomes `{driver: {foo: {bar: 'baz'}}}`
   * @param {object} args
   * @param {string[]} [unknownArgs]
   * @returns {object}
   */
  static _transformParsedArgs(args, unknownArgs = []) {
    const result = _.reduce(
      args,
      (unpacked, value, key) => {
        if (!_.isUndefined(value) && hasArgSpec(key)) {
          const {dest} = /** @type {import('../schema/arg-spec').ArgSpec} */ (getArgSpec(key));
          _.set(unpacked, dest, value);
        } else {
          // this could be anything that _isn't_ a server arg
          unpacked[key] = value;
        }
        return unpacked;
      },
      {}
    );
    result[EXTRA_ARGS] = unknownArgs;
    return result;
  }

  /**
   * Patches the `exit()` method of the parser to throw an error, so we can handle it manually.
   * @param {ArgumentParser} parser
   */
  static _patchExit(parser) {
    parser.exit = (code, msg) => {
      if (code) {
        throw new Error(msg);
      }
      process.exit();
    };
  }

  /**
   *
   * @param {import('argparse').SubParser} subParser
   * @returns {import('./args').ArgumentDefinitions}
   */
  static _addServerToParser(subParser) {
    const serverParser = subParser.add_parser('server', {
      add_help: true,
      help: 'Start an Appium server',
      description: 'Start an Appium server (the "server" subcommand is optional)',
    });

    ArgParser._patchExit(serverParser);

    const serverArgs = getServerArgs();
    for (const [flagsOrNames, opts] of serverArgs) {
      // @ts-ignore TS doesn't like the spread operator here.
      serverParser.add_argument(...flagsOrNames, {...opts});
    }

    return serverArgs;
  }

  /**
   * Adds extension sub-sub-commands to `driver`/`plugin` subcommands
   * @param {import('argparse').SubParser} subParsers
   */
  static _addExtensionCommandsToParser(subParsers) {
    for (const type of /** @type {[DriverType, PluginType]} */ ([DRIVER_TYPE, PLUGIN_TYPE])) {
      const extParser = subParsers.add_parser(type, {
        add_help: true,
        help: `Manage Appium ${type}s`,
        description: `Manage Appium ${type}s using various subcommands`,
      });

      ArgParser._patchExit(extParser);

      const extSubParsers = extParser.add_subparsers({
        dest: `${type}Command`,
      });
      const extensionArgs = getExtensionArgs();
      /**
       * @type { {command: import('appium/types').CliExtensionSubcommand, args: import('./args').ArgumentDefinitions, help: string, aliases?: import('argparse').SubArgumentParserOptions['aliases']}[] }
       */
      const parserSpecs = [
        {
          command: EXT_SUBCOMMAND_LIST,
          args: extensionArgs[type].list,
          help: `List available and installed ${type}s`,
          aliases: ['ls'],
        },
        {
          command: EXT_SUBCOMMAND_INSTALL,
          args: extensionArgs[type].install,
          help: `Install a ${type}`,
        },
        {
          command: EXT_SUBCOMMAND_UNINSTALL,
          args: extensionArgs[type].uninstall,
          help: `Uninstall a ${type}`,
        },
        {
          command: EXT_SUBCOMMAND_UPDATE,
          args: extensionArgs[type].update,
          help: `Update one or more installed ${type}s to the latest version`,
        },
        {
          command: EXT_SUBCOMMAND_RUN,
          args: extensionArgs[type].run,
          help: `Run a script (if available) from the given ${type}`,
        },
        {
          command: EXT_SUBCOMMAND_DOCTOR,
          args: extensionArgs[type].doctor,
          help: `Run doctor checks (if available) for the given ${type}`,
        },
      ];

      for (const {command, args, help, aliases} of parserSpecs) {
        const parser = extSubParsers.add_parser(command, {help, aliases: aliases ?? []});

        ArgParser._patchExit(parser);

        for (const [flagsOrNames, opts] of args) {
          // add_argument mutates params so make sure to send in copies instead
          if (flagsOrNames.length === 2) {
            parser.add_argument(flagsOrNames[0], flagsOrNames[1], {...opts});
          } else {
            parser.add_argument(flagsOrNames[0], {...opts});
          }
        }
      }
    }
  }

  /**
   * Add subcommand and sub-sub commands for 'setup' subcommand.
   * @param {import('argparse').SubParser} subParser
   */
  static _addSetupToParser(subParser) {
    const setupParser = subParser.add_parser('setup', {
      add_help: true,
      help: 'Batch install or uninstall Appium drivers and plugins',
      description:
        `Install a preset of official drivers/plugins compatible with the current host platform ` +
        `(${determinePlatformName()}). Existing drivers/plugins will remain. The default preset ` +
        `is "mobile". Providing the special "reset" subcommand will instead uninstall all ` +
        `drivers and plugins, and remove their related manifest files.`,
    });


    ArgParser._patchExit(setupParser);
    const extSubParsers = setupParser.add_subparsers({
      dest: `setupCommand`,
    });

    const parserSpecs = [
      {
        command: SUBCOMMAND_MOBILE,
        help:
          `The preset for mobile devices ` +
          `(drivers: ${_.join(getPresetDrivers(SUBCOMMAND_MOBILE), ',')}; plugins: ${DEFAULT_PLUGINS})`
      },
      {
        command: SUBCOMMAND_BROWSER,
        help:
          `The preset for desktop browsers ` +
          `(drivers: ${_.join(getPresetDrivers(SUBCOMMAND_BROWSER), ',')}; plugins: ${DEFAULT_PLUGINS})`
      },
      {
        command: SUBCOMMAND_DESKTOP,
        help:
          `The preset for desktop applications ` +
          `(drivers: ${_.join(getPresetDrivers(SUBCOMMAND_DESKTOP), ',')}; plugins: ${DEFAULT_PLUGINS})`
      },
      {
        command: SUBCOMMAND_RESET,
        help: 'Remove all installed drivers and plugins'
      },
    ];

    for (const {command, help} of parserSpecs) {
      const parser = extSubParsers.add_parser(command, {help});
      ArgParser._patchExit(parser);
    }
  }
}

/**
 * Creates a {@link ArgParser} instance; finalizes the config schema.
 *
 * @constructs ArgParser
 * @param {boolean} [debug] - If `true`, throw instead of exit upon parsing error
 * @returns {ArgParser}
 */
function getParser(debug) {
  finalizeSchema();

  return new ArgParser(debug);
}

export {getParser, ArgParser};

/**
 * @typedef {import('@appium/types').DriverType} DriverType
 * @typedef {import('@appium/types').PluginType} PluginType
 */
