/**
 * @license
 * Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
 * This code may only be used under the BSD style license found at
 * http://polymer.github.io/LICENSE.txt
 * The complete set of authors may be found at
 * http://polymer.github.io/AUTHORS.txt
 * The complete set of contributors may be found at
 * http://polymer.github.io/CONTRIBUTORS.txt
 * Code distributed by Google as part of the polymer project is also
 * subject to an additional IP rights grant found at
 * http://polymer.github.io/PATENTS.txt
 */

import * as findup from 'findup-sync';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as nomnom from 'nomnom';
import * as path from 'path';
import * as resolve from 'resolve';
import {Capabilities} from 'wd';

import {BrowserDef} from './browserrunner';
import {Context} from './context';
import * as paths from './paths';
import {Plugin} from './plugin';


const HOME_DIR = path.resolve(
    process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE);
const JSON_MATCHER = 'wct.conf.json';
const CONFIG_MATCHER = 'wct.conf.*';

export type Browser = string|{browserName: string, platform: string};

export interface Config {
  suites?: string[];
  output?: NodeJS.WritableStream;
  ttyOutput?: boolean;
  verbose?: boolean;
  quiet?: boolean;
  expanded?: boolean;
  root?: string;
  testTimeout?: number;
  persistent?: boolean;
  extraScripts?: string[];
  wctPackageName?: string;
  clientOptions?:
      {root?: string; verbose?: boolean; environmentScripts?: string[]};
  activeBrowsers?: BrowserDef[];
  browserOptions?: {[name: string]: Capabilities};
  plugins?: (string|boolean)[]|{[key: string]: ({disabled: boolean} | boolean)};
  registerHooks?: (wct: Context) => void;
  enforceJsonConf?: boolean;
  webserver?: {
    // The port that the main webserver should run on. A port will be
    // determined at runtime if none is provided.
    port: number;

    // The hostname used when generating URLs for the webdriver client.
    hostname: string;

    _generatedIndexContent?: string;
    _servers?: {variant: string, url: string}[];
  };
  npm?: boolean;
  moduleResolution?: 'none'|'node';
  packageName?: string;
  skipPlugins?: string[];
  sauce?: {};
  remote?: {};
  origSuites?: string[];
  compile?: 'auto'|'always'|'never';
  skipCleanup?: boolean;
  simpleOutput?: boolean;
  skipUpdateCheck?: boolean;
  configFile?: string;
  proxy?: {
    // Top-level path that should be redirected to the proxy-target.  E.g.
    // `api/v1` when you want to redirect all requests of
    // `https://localhost/api/v1/`.
    path: string;
    // Host URL to proxy to, for example `https://myredirect:8080/foo`.
    target: string;
  };
  /** A deprecated option */
  browsers?: Browser[]|Browser;
}

/**
 * config helper: A basic function to synchronously read JSON,
 * log any errors, and return null if no file or invalid JSON
 * was found.
 */
function readJsonSync(filename: string, dir?: string): any|null {
  const configPath = path.resolve(dir || '', filename);
  let config: any;
  try {
    config = fs.readFileSync(configPath, 'utf-8');
  } catch (e) {
    return null;
  }
  try {
    return JSON.parse(config);
  } catch (e) {
    console.error(`Could not parse ${configPath} as JSON`);
    console.error(e);
  }
  return null;
}

/**
 * Determines the package name by reading from the following sources:
 *
 * 1. `options.packageName`
 * 2. bower.json or package.json, depending on options.npm
 */
export function getPackageName(options: Config): string|undefined {
  if (options.packageName) {
    return options.packageName;
  }
  const manifestName = (options.npm ? 'package.json' : 'bower.json');
  const manifest = readJsonSync(manifestName, options.root);
  if (manifest !== null) {
    return manifest.name;
  }
  const basename = path.basename(options.root);
  console.warn(
      `no ${manifestName} found, defaulting to packageName=${basename}`);
  return basename;
}

/**
 * Return the root package directory of the given NPM package.
 */
function resolvePackageDir(
    packageName: string, opts?: resolve.SyncOpts): string {
  // package.json files are always in the root directory of a package, so we can
  // resolve that and then drop the filename.
  return path.dirname(
      resolve.sync(path.join(packageName, 'package.json'), opts));
}

// The full set of options, as a reference.
export function defaults(): Config {
  return {
    // The test suites that should be run.
    suites: ['test/'],
    // Output stream to write log messages to.
    output: process.stdout,
    // Whether the output stream should be treated as a TTY (and be given more
    // complex output formatting). Defaults to `output.isTTY`.
    ttyOutput: undefined,
    // Spew all sorts of debugging messages.
    verbose: false,
    // Silence output
    quiet: false,
    // Display test results in expanded form. Verbose implies expanded.
    expanded: false,
    // The on-disk path where tests & static files should be served from. Paths
    // (such as `suites`) are evaluated relative to this.
    //
    // Defaults to the project directory.
    root: undefined,
    // Idle timeout for tests.
    testTimeout: 90 * 1000,
    // Whether the browser should be closed after the tests run.
    persistent: false,
    // Additional .js files to include in *generated* test indexes.
    extraScripts: [],
    // Configuration options passed to the browser client.
    clientOptions: {
      root: '/components/',
    },
    compile: 'auto',

    // Webdriver capabilities objects for each browser that should be run.
    //
    // Capabilities can also contain a `url` value which is either a string URL
    // for the webdriver endpoint, or {hostname:, port:, user:, pwd:}.
    //
    // Most of the time you will want to rely on the WCT browser plugins to fill
    // this in for you (e.g. via `--local`, `--sauce`, etc).
    activeBrowsers: [],
    // Default capabilities to use when constructing webdriver connections (for
    // each browser specified in `activeBrowsers`). A handy place to hang common
    // configuration.
    //
    // Selenium: https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities
    // Sauce:    https://docs.saucelabs.com/reference/test-configuration/
    browserOptions: {},
    // The plugins that should be loaded, and their configuration.
    //
    // When an array, the named plugins will be loaded with their default
    // configuration. When an object, each key maps to a plugin, and values are
    // configuration values to be merged.
    //
    //   plugins: {
    //     local: {browsers: ['firefox', 'chrome']},
    //   }
    //
    plugins: ['local', 'sauce'],
    // Callback that allows you to perform advanced configuration of the WCT
    // runner.
    //
    // The hook is given the WCT context, and can generally be written like a
    // plugin. For example, to serve custom content via the internal webserver:
    //
    //     registerHooks: function(wct) {
    //       wct.hook('prepare:webserver', function(app) {
    //         app.use(...);
    //         return Promise.resolve();
    //       });
    //     }
    //
    registerHooks: function(_wct) {},
    // Whether `wct.conf.*` is allowed, or only `wct.conf.json`.
    //
    // Handy for CI suites that want to be locked down.
    enforceJsonConf: false,
    // Configuration options for the webserver that serves up your test files
    // and dependencies.
    //
    // Typically, you will not need to modify these values.
    webserver: {
      // The port that the webserver should run on. A port will be determined at
      // runtime if none is provided.
      port: undefined,
      hostname: 'localhost',
    },
    // The name of the NPM package that is vending wct's browser.js will be
    // determined automatically if no name is specified.
    wctPackageName: undefined,

    moduleResolution: 'node'
  };
}

/**
 * nomnom configuration for command line arguments.
 *
 * This might feel like duplication with `defaults()`, and out of place (why not
 * in `cli.js`?). But, not every option matches a configurable value, and it is
 * best to keep the configuration for these together to help keep them in sync.
 */
const ARG_CONFIG = {
  persistent: {
    help: 'Keep browsers active (refresh to rerun tests).',
    abbr: 'p',
    flag: true,
  },
  root: {
    help: 'The root directory to serve tests from.',
    transform: path.resolve,
  },
  plugins: {
    help: 'Plugins that should be loaded.',
    metavar: 'NAME',
    full: 'plugin',
    list: true,
  },
  skipPlugins: {
    help: 'Configured plugins that should _not_ be loaded.',
    metavar: 'NAME',
    full: 'skip-plugin',
    list: true,
  },
  expanded: {
    help: 'Log a status line for each test run.',
    flag: true,
  },
  verbose: {
    help: 'Turn on debugging output.',
    flag: true,
  },
  quiet: {
    help: 'Silence output.',
    flag: true,
  },
  simpleOutput: {
    help: 'Avoid fancy terminal output.',
    full: 'simple-output',
    flag: true,
  },
  skipUpdateCheck: {
    help: 'Don\'t check for updates.',
    full: 'skip-update-check',
    flag: true,
  },
  configFile: {
    help: 'Config file that needs to be used by wct. ie: wct.config-sauce.js',
    full: 'config-file',
  },
  npm: {
    help: 'Use node_modules instead of bower_components for all browser ' +
        'components and packages.  Uses polyserve with `--npm` flag.',
    flag: true,
  },
  moduleResolution: {
    // kebab case to match the polyserve flag
    full: 'module-resolution',
    help: 'Algorithm to use for resolving module specifiers in import ' +
        'and export statements when rewriting them to be web-compatible. ' +
        'Valid values are "none" and "node". "none" disables module ' +
        'specifier rewriting. "node" uses Node.js resolution to find modules.',
    // type: 'string',
    choices: ['none', 'node'],
  },
  version: {
    help: 'Display the current version of web-component-tester.  Ends ' +
        'execution immediately (not useable with other options.)',
    abbr: 'V',
    flag: true,
  },
  'webserver.port': {
    help: 'A port to use for the test webserver.',
    full: 'webserver-port',
  },
  'webserver.hostname': {
    full: 'webserver-hostname',
    hidden: true,
  },
  // Managed by supports-color; let's not freak out if we see it.
  color: {flag: true},

  compile: {
    help: 'Whether to compile ES2015 down to ES5. ' +
        'Options: "always", "never", "auto". Auto means that we will ' +
        'selectively compile based on the requesting user agent.'
  },
  wctPackageName: {
    full: 'wct-package-name',
    help: 'NPM package name that contains web-component-tester\'s browser ' +
        'code.  By default, WCT will detect the installed package, but ' +
        'this flag allows explicitly naming the package. ' +
        'This is only to be used with --npm option.'
  },

  // Deprecated

  browsers: {
    abbr: 'b',
    hidden: true,
    list: true,
  },
  remote: {
    abbr: 'r',
    hidden: true,
    flag: true,
  },
};

// Values that should be extracted when pre-parsing args.
const PREPARSE_ARGS =
    ['plugins', 'skipPlugins', 'simpleOutput', 'skipUpdateCheck', 'configFile'];

export interface PreparsedArgs {
  plugins?: string[];
  skipPlugins?: string[];
  simpleOutput?: boolean;
  skipUpdateCheck?: boolean;
}

/**
 * Discovers appropriate config files (global, and for the project), merging
 * them, and returning them.
 *
 * @param {string} matcher
 * @param {string} root
 * @return {!Object} The merged configuration.
 */
export function fromDisk(matcher: string, root?: string): Config {
  const globalFile = path.join(HOME_DIR, matcher);
  const projectFile = findup(matcher, {nocase: true, cwd: root});
  // Load a shared config from the user's home dir, if they have one, and then
  // try the project-specific path (starting at the current working directory).
  const paths = _.union([globalFile, projectFile]);
  const configs =
      _.filter(paths, fs.existsSync).map((f) => loadProjectFile(f as string));
  const options: Config = merge.apply(null, configs);

  if (!options.root && projectFile && projectFile !== globalFile) {
    options.root = path.dirname(projectFile);
  }

  return options;
}

/**
 * @param {string} file
 * @return {Object?}
 */
function loadProjectFile(file: string) {
  // If there are _multiple_ configs at this path, prefer `json`
  if (path.extname(file) === '.js' && fs.existsSync(file + 'on')) {
    file = file + 'on';
  }

  try {
    if (path.extname(file) === '.json') {
      return JSON.parse(fs.readFileSync(file, 'utf-8'));
    } else {
      return require(file);
    }
  } catch (error) {
    throw new Error(`Failed to load WCT config "${file}": ${error.message}`);
  }
}

/**
 * Runs a simplified options parse over the command line arguments, extracting
 * any values that are necessary for a full parse.
 *
 * See const: PREPARSE_ARGS for the values that are extracted.
 *
 * @param {!Array<string>} args
 * @return {!Object}
 */
export function preparseArgs(args: string[]): PreparsedArgs {
  // Don't let it short circuit on help.
  args = _.difference(args, ['--help', '-h']);

  const parser = nomnom();
  parser.options(<any>ARG_CONFIG);
  parser.printer(function() {});  // No-op output & errors.
  const options = parser.parse(args);

  return _expandOptionPaths(_.pick(options, PREPARSE_ARGS));
}

/**
 * Runs a complete options parse over the args, respecting plugin options.
 *
 * @param {!Context} context The context, containing plugin state and any base
 *     options to merge into.
 * @param {!Array<string>} args The args to parse.
 */
export async function parseArgs(
    context: Context, args: string[]): Promise<void> {
  const parser = nomnom();
  parser.script('wct');
  parser.options(<any>ARG_CONFIG);

  const plugins = await context.plugins();
  plugins.forEach(_configurePluginOptions.bind(null, parser));
  const options = <any>_expandOptionPaths(normalize(parser.parse(args)));
  if (options._ && options._.length > 0) {
    options.suites = options._;
  }

  context.options = merge(context.options, options);
}

function _configurePluginOptions(
    parser: NomnomInternal.Parser, plugin: Plugin) {
  /** HACK(rictic): this looks wrong, cliConfig shouldn't have a length. */
  if (!plugin.cliConfig || (<any>plugin.cliConfig).length === 0) {
    return;
  }

  // Group options per plugin. It'd be nice to also have a header, but that ends
  // up shifting all the options over.
  parser.option('plugins.' + plugin.name + '.', {string: ' '});

  _.each(plugin.cliConfig, function(config, key) {
    // Make sure that we don't expose the name prefixes.
    if (!config['full']) {
      config['full'] = key;
    }
    parser.option(
        'plugins.' + plugin.name + '.' + key,
        config as NomnomInternal.Parser.Option);
  });
}

function _expandOptionPaths(options: {[key: string]: any}): any {
  const result = {};
  _.each(options, function(value, key) {
    let target = result;
    const parts = key.split('.');
    for (const part of parts.slice(0, -1)) {
      target = target[part] = target[part] || {};
    }
    target[_.last(parts)] = value;
  });
  return result;
}

/**
 * @param {!Object...} configs Configuration objects to merge.
 * @return {!Object} The merged configuration, where configuration objects
 *     specified later in the arguments list are given precedence.
 */
export function merge(...configs: Config[]): Config;
export function merge(): Config {
  let configs: Config[] = Array.prototype.slice.call(arguments);
  const result = <Config>{};
  configs = configs.map(normalize);
  _.merge.apply(_, [result, ...configs]);

  // false plugin configs are preserved.
  configs.forEach(function(config) {
    _.each(config.plugins, function(value, key) {
      if (typeof value === 'boolean' && value === false) {
        result.plugins[key] = false;
      }
    });
  });

  return result;
}

export function normalize(config: Config): Config {
  if (_.isArray(config.plugins)) {
    const pluginConfigs = <{[key: string]: {disabled: boolean}}>{};
    for (let i = 0, name: string; name = <string>config.plugins[i]; i++) {
      // A named plugin is explicitly enabled (e.g. --plugin foo).
      pluginConfigs[name] = {disabled: false};
    }
    config.plugins = pluginConfigs;
  }

  // Always wins.
  if (config.skipPlugins) {
    config.plugins = config.plugins || {};
    for (let i = 0, name: string; name = config.skipPlugins[i]; i++) {
      config.plugins[name] = false;
    }
  }

  return config;
}

/**
 * Expands values within the configuration based on the current environment.
 *
 * @param {!Context} context The context for the current run.
 */
export async function expand(context: Context): Promise<void> {
  const options = context.options;
  let root = context.options.root || process.cwd();
  context.options.root = root = path.resolve(root);

  options.origSuites = _.clone(options.suites);

  expandDeprecated(context);

  options.suites = await paths.expand(root, options.suites);
}

/**
 * Expands any options that have been deprecated, and warns about it.
 *
 * @param {!Context} context The context for the current run.
 */
function expandDeprecated(context: Context) {
  const options = context.options;
  // We collect configuration fragments to be merged into the options object.
  const fragments = [];

  let browsers: Browser[] = <any>(
      _.isArray(options.browsers) ? options.browsers : [options.browsers]);
  browsers = <any>_.compact(<any>browsers);
  if (browsers.length > 0) {
    context.emit(
        'log:warn',
        'The --browsers flag/option is deprecated. Please use ' +
            '--local and --sauce instead, or configure via plugins.' +
            '[local|sauce].browsers.');
    const fragment: {
      plugins: {[name: string]: {browsers?: Browser[]}}
    } = {plugins: {sauce: {}, local: {}}};
    fragments.push(fragment);

    for (const browser of browsers) {
      const name = (<any>browser).browserName || browser;
      const plugin = (<any>browser).platform || name.indexOf('/') !== -1 ?
          'sauce' :
          'local';
      fragment.plugins[plugin].browsers =
          fragment.plugins[plugin].browsers || [];
      fragment.plugins[plugin].browsers.push(browser);
    }

    delete options.browsers;
  }

  if (options.sauce) {
    context.emit(
        'log:warn',
        'The sauce configuration key is deprecated. Please use ' +
            'plugins.sauce instead.');
    fragments.push({
      plugins: {sauce: options.sauce},
    });
    delete options.sauce;
  }

  if (options.remote) {
    context.emit(
        'log:warn',
        'The --remote flag is deprecated. Please use ' +
            '--sauce default instead.');
    fragments.push({
      plugins: {sauce: {browsers: ['default']}},
    });
    delete options.remote;
  }

  if (fragments.length > 0) {
    // We are careful to modify context.options in place.
    _.merge(context.options, merge.apply(null, fragments as any));
  }
}

/**
 * @param {!Object} options The configuration to validate.
 */
export async function validate(options: Config): Promise<void> {
  if (options['webRunner']) {
    throw new Error(
        'webRunner is no longer a supported configuration option. ' +
        'Please list the files you wish to test as arguments, ' +
        'or as `suites` in a configuration object.');
  }
  if (options['component']) {
    throw new Error(
        'component is no longer a supported configuration option. ' +
        'Please list the files you wish to test as arguments, ' +
        'or as `suites` in a configuration object.');
  }

  if (options.activeBrowsers.length === 0) {
    throw new Error('No browsers configured to run');
  }
  if (options.suites.length === 0) {
    const root = options.root || process.cwd();
    const globs = options.origSuites.join(', ');
    throw new Error(
        'No test suites were found matching your configuration\n' +
        '\n' +
        '  WCT searched for .js and .html files matching: ' + globs + '\n' +
        '\n' +
        '  Relative paths were resolved against: ' + root);
  }
}
