/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const {interfaces: Ci, utils: Cu} = Components;

Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm");

Cu.import("chrome://marionette/content/assert.js");
Cu.import("chrome://marionette/content/error.js");

this.EXPORTED_SYMBOLS = ["session"];

const logger = Log.repository.getLogger("Marionette");
const {pprint} = error;

// Enable testing this module, as Services.appinfo.* is not available
// in xpcshell tests.
const appinfo = {name: "<missing>", version: "<missing>"};
try { appinfo.name = Services.appinfo.name.toLowerCase(); } catch (e) {}
try { appinfo.version = Services.appinfo.version; } catch (e) {}

/** State associated with a WebDriver session. */
this.session = {};

/** Representation of WebDriver session timeouts. */
session.Timeouts = class {
  constructor () {
    // disabled
    this.implicit = 0;
    // five mintues
    this.pageLoad = 300000;
    // 30 seconds
    this.script = 30000;
  }

  toString () { return "[object session.Timeouts]"; }

  toJSON () {
    return {
      implicit: this.implicit,
      pageLoad: this.pageLoad,
      script: this.script,
    };
  }

  static fromJSON (json) {
    assert.object(json);
    let t = new session.Timeouts();

    for (let [typ, ms] of Object.entries(json)) {
      switch (typ) {
        case "implicit":
          t.implicit = assert.positiveInteger(ms);
          break;

        case "script":
          t.script = assert.positiveInteger(ms);
          break;

        case "pageLoad":
          t.pageLoad = assert.positiveInteger(ms);
          break;

        default:
          throw new InvalidArgumentError("Unrecognised timeout: " + typ);
      }
    }

    return t;
  }
};

/** Enum of page loading strategies. */
session.PageLoadStrategy = {
  None: "none",
  Eager: "eager",
  Normal: "normal",
};

/** Proxy configuration object representation. */
session.Proxy = class {
  constructor() {
    this.proxyType = null;
    this.httpProxy = null;
    this.httpProxyPort = null;
    this.sslProxy = null;
    this.sslProxyPort = null;
    this.ftpProxy = null;
    this.ftpProxyPort = null;
    this.socksProxy = null;
    this.socksProxyPort = null;
    this.socksVersion = null;
    this.proxyAutoconfigUrl = null;
  }

  /**
   * Sets Firefox proxy settings.
   *
   * @return {boolean}
   *     True if proxy settings were updated as a result of calling this
   *     function, or false indicating that this function acted as
   *     a no-op.
   */
  init() {
    switch (this.proxyType) {
      case "manual":
        Preferences.set("network.proxy.type", 1);
        if (this.httpProxy && this.httpProxyPort) {
          Preferences.set("network.proxy.http", this.httpProxy);
          Preferences.set("network.proxy.http_port", this.httpProxyPort);
        }
        if (this.sslProxy && this.sslProxyPort) {
          Preferences.set("network.proxy.ssl", this.sslProxy);
          Preferences.set("network.proxy.ssl_port", this.sslProxyPort);
        }
        if (this.ftpProxy && this.ftpProxyPort) {
          Preferences.set("network.proxy.ftp", this.ftpProxy);
          Preferences.set("network.proxy.ftp_port", this.ftpProxyPort);
        }
        if (this.socksProxy) {
          Preferences.set("network.proxy.socks", this.socksProxy);
          Preferences.set("network.proxy.socks_port", this.socksProxyPort);
          if (this.socksVersion) {
            Preferences.set("network.proxy.socks_version", this.socksVersion);
          }
        }
        return true;

      case "pac":
        Preferences.set("network.proxy.type", 2);
        Preferences.set("network.proxy.autoconfig_url", this.proxyAutoconfigUrl);
        return true;

      case "autodetect":
        Preferences.set("network.proxy.type", 4);
        return true;

      case "system":
        Preferences.set("network.proxy.type", 5);
        return true;

      case "noproxy":
        Preferences.set("network.proxy.type", 0);
        return true;

      default:
        return false;
    }
  }

  toString () { return "[object session.Proxy]"; }

  toJSON () {
    return marshal({
      proxyType: this.proxyType,
      httpProxy: this.httpProxy,
      httpProxyPort: this.httpProxyPort ,
      sslProxy: this.sslProxy,
      sslProxyPort: this.sslProxyPort,
      ftpProxy: this.ftpProxy,
      ftpProxyPort: this.ftpProxyPort,
      socksProxy: this.socksProxy,
      socksProxyPort: this.socksProxyPort,
      socksProxyVersion: this.socksProxyVersion,
      proxyAutoconfigUrl: this.proxyAutoconfigUrl,
    });
  }

  static fromJSON (json) {
    let p = new session.Proxy();
    if (typeof json == "undefined" || json === null) {
      return p;
    }

    assert.object(json);

    assert.in("proxyType", json);
    p.proxyType = json.proxyType;

    if (json.proxyType == "manual") {
      if (typeof json.httpProxy != "undefined") {
        p.httpProxy = assert.string(json.httpProxy);
        p.httpProxyPort = assert.positiveInteger(json.httpProxyPort);
      }

      if (typeof json.sslProxy != "undefined") {
        p.sslProxy = assert.string(json.sslProxy);
        p.sslProxyPort = assert.positiveInteger(json.sslProxyPort);
      }

      if (typeof json.ftpProxy != "undefined") {
        p.ftpProxy = assert.string(json.ftpProxy);
        p.ftpProxyPort = assert.positiveInteger(json.ftpProxyPort);
      }

      if (typeof json.socksProxy != "undefined") {
        p.socksProxy = assert.string(json.socksProxy);
        p.socksProxyPort = assert.positiveInteger(json.socksProxyPort);
        p.socksProxyVersion = assert.positiveInteger(json.socksProxyVersion);
      }
    }

    if (typeof json.proxyAutoconfigUrl != "undefined") {
      p.proxyAutoconfigUrl = assert.string(json.proxyAutoconfigUrl);
    }

    return p;
  }
};

/** WebDriver session capabilities representation. */
session.Capabilities = class extends Map {
  constructor () {
    super([
      // webdriver
      ["browserName", appinfo.name],
      ["browserVersion", appinfo.version],
      ["platformName", Services.sysinfo.getProperty("name").toLowerCase()],
      ["platformVersion", Services.sysinfo.getProperty("version")],
      ["pageLoadStrategy", session.PageLoadStrategy.Normal],
      ["acceptInsecureCerts", false],
      ["timeouts", new session.Timeouts()],
      ["proxy", new session.Proxy()],

      // features
      ["rotatable", appinfo.name == "B2G"],

      // proprietary
      ["specificationLevel", 0],
      ["moz:processID", Services.appinfo.processID],
      ["moz:profile", maybeProfile()],
      ["moz:accessibilityChecks", false],
    ]);
  }

  set (key, value) {
    if (key === "timeouts" && !(value instanceof session.Timeouts)) {
      throw new TypeError();
    } else if (key === "proxy" && !(value instanceof session.Proxy)) {
      throw new TypeError();
    }

    return super.set(key, value);
  }

  toString() { return "[object session.Capabilities]"; }

  toJSON() {
    return marshal(this);
  }

  /**
   * Unmarshal a JSON object representation of WebDriver capabilities.
   *
   * @param {Object.<string, ?>=} json
   *     WebDriver capabilities.
   * @param {boolean=} merge
   *     If providing |json| with |desiredCapabilities| or
   *     |requiredCapabilities| fields, or both, it should be set to
   *     true to merge these before parsing.  This indicates
   *     that the input provided is from a client and not from
   *     |session.Capabilities#toJSON|.
   *
   * @return {session.Capabilities}
   *     Internal representation of WebDriver capabilities.
   */
  static fromJSON (json, {merge = false} = {}) {
    if (typeof json == "undefined" || json === null) {
      json = {};
    }
    assert.object(json);

    if (merge) {
      json = session.Capabilities.merge_(json);
    }
    return session.Capabilities.match_(json);
  }

  // Processes capabilities as described by WebDriver.
  static merge_ (json) {
    for (let entry of [json.desiredCapabilities, json.requiredCapabilities]) {
      if (typeof entry == "undefined" || entry === null) {
        continue;
      }
      assert.object(entry, error.pprint`Expected ${entry} to be a capabilities object`);
    }

    let desired = json.desiredCapabilities || {};
    let required = json.requiredCapabilities || {};

    // One level deep union merge of desired- and required capabilities
    // with preference on required
    return Object.assign({}, desired, required);
  }

  // Matches capabilities as described by WebDriver.
  static match_ (caps = {}) {
    let matched = new session.Capabilities();

    const defined = v => typeof v != "undefined" && v !== null;
    const wildcard = v => v === "*";

    // Iff |actual| provides some value, or is a wildcard or an exact
    // match of |expected|.  This means it can be null or undefined,
    // or "*", or "firefox".
    function stringMatch (actual, expected) {
      return !defined(actual) || (wildcard(actual) || actual === expected);
    }

    for (let [k,v] of Object.entries(caps)) {
      switch (k) {
        case "browserName":
          let bname = matched.get("browserName");
          if (!stringMatch(v, bname)) {
            throw new TypeError(
                pprint`Given browserName ${v}, but my name is ${bname}`);
          }
          break;

        // TODO(ato): bug 1326397
        case "browserVersion":
          let bversion = matched.get("browserVersion");
          if (!stringMatch(v, bversion)) {
            throw new TypeError(
                pprint`Given browserVersion ${v}, ` +
                pprint`but current version is ${bversion}`);
          }
          break;

        case "platformName":
          let pname = matched.get("platformName");
          if (!stringMatch(v, pname)) {
            throw new TypeError(
                pprint`Given platformName ${v}, ` +
                pprint`but current platform is ${pname}`);
          }
          break;

        // TODO(ato): bug 1326397
        case "platformVersion":
          let pversion = matched.get("platformVersion");
          if (!stringMatch(v, pversion)) {
            throw new TypeError(
                pprint`Given platformVersion ${v}, ` +
                pprint`but current platform version is ${pversion}`);
          }
          break;

        case "acceptInsecureCerts":
          assert.boolean(v);
          matched.set("acceptInsecureCerts", v);
          break;

        case "pageLoadStrategy":
          if (v === null) {
            matched.set("pageLoadStrategy", session.PageLoadStrategy.Normal);
          } else {
            assert.string(v);

            if (Object.values(session.PageLoadStrategy).includes(v)) {
              matched.set("pageLoadStrategy", v);
            } else {
              throw new InvalidArgumentError("Unknown page load strategy: " + v);
            }
          }

          break;

        case "proxy":
          let proxy = session.Proxy.fromJSON(v);
          matched.set("proxy", proxy);
          break;

        case "timeouts":
          let timeouts = session.Timeouts.fromJSON(v);
          matched.set("timeouts", timeouts);
          break;

        case "specificationLevel":
          assert.positiveInteger(v);
          matched.set("specificationLevel", v);
          break;

        case "moz:accessibilityChecks":
          assert.boolean(v);
          matched.set("moz:accessibilityChecks", v);
          break;
      }
    }

    return matched;
  }
};

// Specialisation of |JSON.stringify| that produces JSON-safe object
// literals, dropping empty objects and entries which values are undefined
// or null.  Objects are allowed to produce their own JSON representations
// by implementing a |toJSON| function.
function marshal(obj) {
  let rv = Object.create(null);

  function* iter(mapOrObject) {
    if (mapOrObject instanceof Map) {
      for (const [k,v] of mapOrObject) {
        yield [k,v];
      }
    } else {
      for (const k of Object.keys(mapOrObject)) {
        yield [k, mapOrObject[k]];
      }
    }
  }

  for (let [k,v] of iter(obj)) {
    // Skip empty values when serialising to JSON.
    if (typeof v == "undefined" || v === null) {
      continue;
    }

    // Recursively marshal objects that are able to produce their own
    // JSON representation.
    if (typeof v.toJSON == "function") {
      v = marshal(v.toJSON());
    }

    // Or do the same for object literals.
    else if (isObject(v)) {
      v = marshal(v);
    }

    // And finally drop (possibly marshaled) objects which have no
    // entries.
    if (!isObjectEmpty(v)) {
      rv[k] = v;
    }
  }

  return rv;
}

function isObject(obj) {
  return Object.prototype.toString.call(obj) == "[object Object]";
}

function isObjectEmpty(obj) {
  return isObject(obj) && Object.keys(obj).length === 0;
}

// Services.dirsvc is not accessible from content frame scripts,
// but we should not panic about that.
function maybeProfile() {
  try {
    return Services.dirsvc.get("ProfD", Ci.nsIFile).path;
  } catch (e) {
    return "<protected>";
  }
}
