import { Builder, Capabilities, WebDriver } from 'selenium-webdriver';

interface Timeouts {
  /**
   * The timeout value for script execution.  Default 30000
   */
  script?: number;
  /**
   * The timeout value for page loading (and to be ready).  Default 30000
   */
  pageLoad?: number;
  /**
   * The timeout value for finding an element on the page.  Default 0
   */
  implicit?: number;
}
interface CapabilitiesConfiguration {
  /**
   * The WebDriver Capabilties for the configuration
   */
  capabilities?: Capabilities | Map<string, any> | {};
  /**
   * The WebDriver timeout values for the configuration
   */
  timeouts?: Timeouts;
}

/**
 * @private
 */
const defaultConfigName = '';
/**
 * @private
 */
const defaultCapabilities = new Capabilities({
  autoWebview: true,
  browserName: 'chrome'
});
/**
 * @private
 */
const defaultTimeouts: Timeouts = {
  implicit: 0,
  pageLoad: 30000,
  script: 30000
};
/**
 * @private
 */
const configs: { [key: string]: CapabilitiesConfiguration } = {};
/**
 * @private
 */
let currentInstance: { configName: string | undefined; driver: WebDriver | undefined } = {
  configName: undefined,
  driver: undefined
};

/**
 * The delay, in milliseconds, that the manager will wait after the driver is
 * release to see if there are pending requests for a driver instance.  If no
 * requests exist, then the driver is closed, and subsequent requests will
 * receive a new instance.
 * @private
 */
const quitDelay = 500;
/**
 * @private
 */
let builder: Builder;
/**
 * The NodeJS.Timer used to cleanup the driver instance if no subsequent test
 * asks for the driver. The type (NodeJS.Timer) cannot be defined here because
 * typedoc cannot resolve the NodeJS namespace, so we infer it by calling
 * setTimeout instead.
 * @private
 */
let releaseTimer = setTimeout(() => true, 0);
/**
 * @private
 */
let quitPromise = Promise.resolve();

/**
 * Manage instances of [WebDriver](index._internal_.WebDriver.html)
 * to be used by the tests. DriverManager
 * allows a single place where the WebDriver instance can be configured and
 * reused. Traditionally, tests instantiate the WebDriver instance on their own,
 * passing configurations such as the browser to use:
 * ```javascript
 * let driver = new Builder().withCapabilities({
 *   browserName: "chrome"
 * }).build()
 * ```
 * This boilerplate code then has to be copied to every test. Additionally, if
 * the capabilities need to change (say, running against a different browser),
 * that change must be applied to all test files.
 *
 * DriverManager addresses this problem by centralizing the place whereby
 * the configuration is done, and persists that configuration for all
 * subsequent requests for the WebDriver instance. The configuration only needs
 * to be set once in a "setup" file, and tests retrieve the configured instance
 * without needing to define anything else.
 *
 * A sample <code>mocha-setup.ts</code> file which configures DriverManager
 * ```javascript
 * import { DriverManager } from "@oracle/oraclejet-webdriver";
 *
 * DriverManager.registerConfig({
 *   capabilities: new Capabilities({
 *     browserName: "chrome"
 *   })
 * })
 * ```
 * This setup test script should be run before any other tests.
 * ```bash
 * $ node node_modules/mocha/bin/mocha --require=ts-node/register mocha-setup.ts other-test.spec.ts ...
 * ```
 * Test files are agnostic of the driver configuration, and simply get the instance
 * by calling <code>getDriver()</code>
 * ```javascript
 * import { DriverManager } from "@oracle/oraclejet-webdriver";
 *
 * describe("My test suite", function() {
 *   let driver: WebDriver;
 *
 *   before(async function() {
 *     driver = await DriverManager.getDriver();
 *   })
 *   it("open a page to test", async function() {
 *     await driver.get("...")
 *   })
 *   after(async function() {
 *     await DriverManager.releaseDriver(driver);
 *   })
 * })
 * ```
 *
 * ## Set default timeouts for WebDriver from Mocha.
 * When Mocha is used to start the WebDriver tests, there are two sets of timeout
 * values--one from Mocha and one from WebDriver. This causes some confusion as
 * the command-line argument <code>--timeout=nnn</code> is typically the only one
 * set and assumed to be the only timeout value in play. However, WebDriver has
 * its own set of timeout values whose defaults are sometimes longer than what's
 * set for Mocha. When this happens, WebDriver may be waiting on a condition to
 * timeout, but Mocha has errored the test because its own timeout was exceeded.
 * To ensure that WebDriver timeout conditions are properly reported to the test
 * runner (Mocha), its timeout values must be set to a value shorter than the
 * runner's. This is typically done in the setup test, and set to some factor of
 * the Mocha timeout.
 *
 * ### Set WebDriver timeout to 1/4 of Mocha
 * ```javascript
 * const mochaTimeout = this.timeouts();
 * const wdTimeout = mochaTimeout / 4;
 * DriverManager.registerConfig({
 *   timeouts: {
 *     pageLoad: wdTimeout,
 *     script: wdTimeout,
 *     implicit: wdTimeout
 *   }
 * });
 * ```
 *
 */
export class DriverManager {
  /**
   * Optionally set the [Builder]
   * (https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_Builder.html)
   * instance used by DriverManager to create WebDriver.
   * The Builder allows different settings for thigns such as the remote server,
   * proxies, and other runtime options often needed when running in distributed
   * environments.
   * The instance can be preconfigured with capabilities, and any additional
   * capabilities from [[registerConfig]] will also be applied during the
   * creation process.  If no Builder is explicitly passed, a default one will
   * be used.
   * If setting a custom Builder, this function must be called before the first
   * test calls [[getDriver]], and must only be called once per test run. If called
   * multiple times or after [[getDriver]], an error will be thrown.
   * @param b A builder instance
   */
  public static setBuilder(b: Builder) {
    if (builder) {
      throw Error('DriverManager Builder instance has already been set, and cannot be set again');
    }
    builder = b;
  }
  /**
   * Register a configuration for WebDriver instances. Configurations consist of
   * [Capabilities]
   * (https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_Capabilities.html)
   * and/or [Timeouts]
   * (https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/lib/capabilities_exports_Timeouts.html).
   * This configuration is used by [[getDriver]] to retrieve a configured WebDriver
   * instance.
   * @param config The driver configuration
   * @param name An optional name to assocaite with the config. If no name is given,
   * the config will be the default.
   *
   * ### Register a default config
   * ```javascript
   * DriverManager.registerConfig(
   *   {
   *     capabilities: new Capabilities({
   *       browserName: "chrome"
   *     }),
   *     timeouts: {
   *       implicit: 5000
   *     }
   *   }
   * );
   * ```
   * ### Register a Firefox config
   * ```javascript
   * DriverManager.registerConfig(
   *   {
   *     capabilities: new Capabilities({
   *       browserName: "firefox",
   *       hideAlerts: true
   *     })
   *   },
   *   "firefox-no-alerts"
   * );
   * ```
   */
  public static registerConfig(config: CapabilitiesConfiguration, name?: string): void {
    configs[name || defaultConfigName] = config;
  }

  /**
   * Get a
   * [WebDriver](https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html)
   * instance for a given configuration. If no configName is given, the returned
   * driver will use the default configuration.
   * @see [[registerConfig]]
   *
   * ### Get driver with default capabilities
   * ```javascript
   * let driver = await DriverManager.getDriver();
   * ```
   * ### Configure and get Firefox driver
   * ```javascript
   * // mocha-setup.ts
   * DriverManager.registerConfig(
   *   {
   *     browserName: "firefox"
   *   },
   *   "firefox-config");
   * // test.spec.ts
   * let driver = await DriverManager.getDriver("firefox-config");
   * ```
   * @param configName An optional configuration name, registered through
   * [[registerConfig]], whose set will be applied to the driver instance. If no
   * name is given, the default configuration with the "chrome" browser
   * will be used. If the given configName doesn't exist, an error will be thrown.
   * @return A Promise that resolves to a WebDriver instance, configured with
   * custom capabilities for the given configName, or the default capabilities
   * if none is specified.
   */
  public static async getDriver(configName?: string): Promise<WebDriver> {
    clearTimeout(releaseTimer);
    configName = configName || defaultConfigName;

    if (configName === currentInstance.configName) {
      // Test if current driver is still valid
      try {
        if (currentInstance.driver) {
          await currentInstance.driver.getCurrentUrl();
          return currentInstance.driver;
        }
      } catch (ex) {}
    }

    quitCurrentDriver();
    return createDriver(configName);
  }

  /**
   * Gets the current driver instance, if one exists, otherwise create and
   * return the default one. This method is useful for test setups which evaluate
   * the outcome of the previous test to capture screenshots on failures. After-
   * scripts call <code>getCurrentDriver</code> to get the instance of the driver
   * that experienced the test failure, then capture the screenshot from it.
   */
  public static async getCurrentDriver(): Promise<WebDriver> {
    return DriverManager.getDriver(currentInstance.configName);
  }

  /**
   * Release the WebDriver instance from use.  Called when each test is done
   * with its driver usage, typicall, in the Mocha <code>after</code> function.
   * @param driver The WebDriver instance
   */
  public static releaseDriver(driver: WebDriver): void {
    clearTimeout(releaseTimer);
    // Wait for the delay to quit the driver. If [getDriver] was called before
    // the timeout expired, then it will have cleared the timeout so that the
    // instance isn't closed.
    releaseTimer = setTimeout(() => quitDriver(driver), quitDelay);
  }
}
DriverManager.registerConfig({ capabilities: defaultCapabilities, timeouts: defaultTimeouts });

/**
 * Create a WebDriver instance from the given configuration name. The config should
 * already be registered via [registerConfig].
 * @param configName The configuration name used to retrieve the configuration
 * object for the driver instance
 * @return A Promise which resolves to the configured WebDriver instance
 * @private
 */
function createDriver(configName: string) {
  const config = configs[configName];
  if (!config) {
    throw Error(`No driver configuration exists for "${configName}"`);
  }
  // Merge default capabilities with custom ones
  const caps = new Capabilities(configs[defaultConfigName].capabilities);
  if (config.capabilities) {
    caps.merge(config.capabilities);
  }
  // Merge default timeouts with custom ones
  const timeouts = Object.assign({}, configs[defaultConfigName].timeouts, config.timeouts);
  if (!builder) {
    builder = new Builder();
  }

  return quitPromise.then(async () => {
    const driver = builder.withCapabilities(caps).build();
    currentInstance = { configName, driver };
    await driver.manage().setTimeouts(timeouts);
    return driver;
  });
}

/**
 * @private
 */
function quitCurrentDriver() {
  quitDriver(currentInstance.driver);
}

/**
 * @private
 */
function quitDriver(driver?: WebDriver) {
  if (driver) {
    currentInstance = {
      configName: undefined,
      driver: undefined
    };
    quitPromise = driver.quit().catch((ex) => console.warn(ex));
  }
}
