/* 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/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
  Command: "chrome://remote/content/marionette/message.sys.mjs",
  DebuggerTransport: "chrome://remote/content/marionette/transport.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  GeckoDriver: "chrome://remote/content/marionette/driver.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs",
  Message: "chrome://remote/content/marionette/message.sys.mjs",
  PollPromise: "chrome://remote/content/shared/Sync.sys.mjs",
  Response: "chrome://remote/content/marionette/message.sys.mjs",
});

XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
  lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
);
XPCOMUtils.defineLazyGetter(lazy, "ServerSocket", () => {
  return Components.Constructor(
    "@mozilla.org/network/server-socket;1",
    "nsIServerSocket",
    "initSpecialConnection"
  );
});

const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket;

const PROTOCOL_VERSION = 3;

/**
 * Bootstraps Marionette and handles incoming client connections.
 *
 * Starting the Marionette server will open a TCP socket sporting the
 * debugger transport interface on the provided `port`.  For every
 * new connection, a {@link TCPConnection} is created.
 */
export class TCPListener {
  /**
   * @param {number} port
   *     Port for server to listen to.
   */
  constructor(port) {
    this.port = port;
    this.socket = null;
    this.conns = new Set();
    this.nextConnID = 0;
    this.alive = false;
  }

  /**
   * Function produces a {@link GeckoDriver}.
   *
   * Determines the application to initialise the driver with.
   *
   * @returns {GeckoDriver}
   *     A driver instance.
   */
  driverFactory() {
    return new lazy.GeckoDriver(this);
  }

  async setAcceptConnections(value) {
    if (value) {
      if (!this.socket) {
        await lazy.PollPromise(
          (resolve, reject) => {
            try {
              const flags = KeepWhenOffline | LoopbackOnly;
              const backlog = 1;
              this.socket = new lazy.ServerSocket(this.port, flags, backlog);
              resolve();
            } catch (e) {
              lazy.logger.debug(
                `Could not bind to port ${this.port} (${e.name})`
              );
              reject();
            }
          },
          { interval: 250, timeout: 5000 }
        );

        // Since PollPromise doesn't throw when timeout expires,
        // we can end up in the situation when the socket is undefined.
        if (!this.socket) {
          throw new Error(`Could not bind to port ${this.port}`);
        }

        this.port = this.socket.port;

        this.socket.asyncListen(this);
        lazy.logger.info(`Listening on port ${this.port}`);
      }
    } else if (this.socket) {
      // Note that closing the server socket will not close currently active
      // connections.
      this.socket.close();
      this.socket = null;
      lazy.logger.info(`Stopped listening on port ${this.port}`);
    }
  }

  /**
   * Bind this listener to {@link #port} and start accepting incoming
   * socket connections on {@link #onSocketAccepted}.
   *
   * The marionette.port preference will be populated with the value
   * of {@link #port}.
   */
  async start() {
    if (this.alive) {
      return;
    }

    // Start socket server and listening for connection attempts
    await this.setAcceptConnections(true);
    lazy.MarionettePrefs.port = this.port;
    this.alive = true;
  }

  async stop() {
    if (!this.alive) {
      return;
    }

    // Shutdown server socket, and no longer listen for new connections
    await this.setAcceptConnections(false);
    this.alive = false;
  }

  onSocketAccepted(serverSocket, clientSocket) {
    let input = clientSocket.openInputStream(0, 0, 0);
    let output = clientSocket.openOutputStream(0, 0, 0);
    let transport = new lazy.DebuggerTransport(input, output);

    // Only allow a single active WebDriver session at a time
    const hasActiveSession = [...this.conns].find(
      conn => !!conn.driver.currentSession
    );
    if (hasActiveSession) {
      lazy.logger.warn(
        "Connection attempt denied because an active session has been found"
      );

      // Ideally we should stop the server to listen for new connection
      // attempts, but the current architecture doesn't allow us to do that.
      // As such just close the transport if no further connections are allowed.
      transport.close();
      return;
    }

    let conn = new TCPConnection(
      this.nextConnID++,
      transport,
      this.driverFactory.bind(this)
    );
    conn.onclose = this.onConnectionClosed.bind(this);
    this.conns.add(conn);

    lazy.logger.debug(
      `Accepted connection ${conn.id} ` +
        `from ${clientSocket.host}:${clientSocket.port}`
    );
    conn.sayHello();
    transport.ready();
  }

  onConnectionClosed(conn) {
    lazy.logger.debug(`Closed connection ${conn.id}`);
    this.conns.delete(conn);
  }
}

/**
 * Marionette client connection.
 *
 * Dispatches packets received to their correct service destinations
 * and sends back the service endpoint's return values.
 *
 * @param {number} connID
 *     Unique identifier of the connection this dispatcher should handle.
 * @param {DebuggerTransport} transport
 *     Debugger transport connection to the client.
 * @param {function(): GeckoDriver} driverFactory
 *     Factory function that produces a {@link GeckoDriver}.
 */
export class TCPConnection {
  constructor(connID, transport, driverFactory) {
    this.id = connID;
    this.conn = transport;

    // transport hooks are TCPConnection#onPacket
    // and TCPConnection#onClosed
    this.conn.hooks = this;

    // callback for when connection is closed
    this.onclose = null;

    // last received/sent message ID
    this.lastID = 0;

    this.driver = driverFactory();
  }

  #log(msg) {
    let dir = msg.origin == lazy.Message.Origin.Client ? "->" : "<-";
    lazy.logger.debug(`${this.id} ${dir} ${msg.toString()}`);
  }

  /**
   * Debugger transport callback that cleans up
   * after a connection is closed.
   */
  onClosed() {
    this.driver.deleteSession();
    if (this.onclose) {
      this.onclose(this);
    }
  }

  /**
   * Callback that receives data packets from the client.
   *
   * If the message is a Response, we look up the command previously
   * issued to the client and run its callback, if any.  In case of
   * a Command, the corresponding is executed.
   *
   * @param {Array.<number, number, ?, ?>} data
   *     A four element array where the elements, in sequence, signifies
   *     message type, message ID, method name or error, and parameters
   *     or result.
   */
  onPacket(data) {
    // unable to determine how to respond
    if (!Array.isArray(data)) {
      let e = new TypeError(
        "Unable to unmarshal packet data: " + JSON.stringify(data)
      );
      lazy.error.report(e);
      return;
    }

    // return immediately with any error trying to unmarshal message
    let msg;
    try {
      msg = lazy.Message.fromPacket(data);
      msg.origin = lazy.Message.Origin.Client;
      this.#log(msg);
    } catch (e) {
      let resp = this.createResponse(data[1]);
      resp.sendError(e);
      return;
    }

    // execute new command
    if (msg instanceof lazy.Command) {
      (async () => {
        await this.execute(msg);
      })();
    } else {
      lazy.logger.fatal("Cannot process messages other than Command");
    }
  }

  /**
   * Executes a Marionette command and sends back a response when it
   * has finished executing.
   *
   * If the command implementation sends the response itself by calling
   * <code>resp.send()</code>, the response is guaranteed to not be
   * sent twice.
   *
   * Errors thrown in commands are marshaled and sent back, and if they
   * are not {@link WebDriverError} instances, they are additionally
   * propagated and reported to {@link Components.utils.reportError}.
   *
   * @param {Command} cmd
   *     Command to execute.
   */
  async execute(cmd) {
    let resp = this.createResponse(cmd.id);
    let sendResponse = () => resp.sendConditionally(resp => !resp.sent);
    let sendError = resp.sendError.bind(resp);

    await this.despatch(cmd, resp)
      .then(sendResponse, sendError)
      .catch(lazy.error.report);
  }

  /**
   * Despatches command to appropriate Marionette service.
   *
   * @param {Command} cmd
   *     Command to run.
   * @param {Response} resp
   *     Mutable response where the command's return value will be
   *     assigned.
   *
   * @throws {Error}
   *     A command's implementation may throw at any time.
   */
  async despatch(cmd, resp) {
    const startTime = Cu.now();

    let fn = this.driver.commands[cmd.name];
    if (typeof fn == "undefined") {
      throw new lazy.error.UnknownCommandError(cmd.name);
    }

    if (cmd.name != "WebDriver:NewSession") {
      lazy.assert.session(this.driver.currentSession);
    }

    let rv = await fn.bind(this.driver)(cmd);

    // Bug 1819029: Some older commands cannot return a response wrapped within
    // a value field because it would break compatibility with geckodriver and
    // Marionette client. It's unlikely that we are going to fix that.
    //
    // Warning: No more commands should be added to this list!
    const commandsNoValueResponse = [
      "Marionette:Quit",
      "WebDriver:FindElements",
      "WebDriver:FindElementsFromShadowRoot",
      "WebDriver:CloseChromeWindow",
      "WebDriver:CloseWindow",
      "WebDriver:FullscreenWindow",
      "WebDriver:GetCookies",
      "WebDriver:GetElementRect",
      "WebDriver:GetTimeouts",
      "WebDriver:GetWindowHandles",
      "WebDriver:GetWindowRect",
      "WebDriver:MaximizeWindow",
      "WebDriver:MinimizeWindow",
      "WebDriver:NewSession",
      "WebDriver:NewWindow",
      "WebDriver:SetWindowRect",
    ];

    if (rv != null) {
      // By default the Response' constructor sets the body to `{ value: null }`.
      // As such we only want to override the value if it's neither `null` nor
      // `undefined`.
      if (commandsNoValueResponse.includes(cmd.name)) {
        resp.body = rv;
      } else {
        resp.body.value = rv;
      }
    }

    if (Services.profiler?.IsActive()) {
      ChromeUtils.addProfilerMarker(
        "Marionette: Command",
        { startTime, category: "Remote-Protocol" },
        `${cmd.name} (${cmd.id})`
      );
    }
  }

  /**
   * Fail-safe creation of a new instance of {@link Response}.
   *
   * @param {number} msgID
   *     Message ID to respond to.  If it is not a number, -1 is used.
   *
   * @returns {Response}
   *     Response to the message with `msgID`.
   */
  createResponse(msgID) {
    if (typeof msgID != "number") {
      msgID = -1;
    }
    return new lazy.Response(msgID, this.send.bind(this));
  }

  sendError(err, cmdID) {
    let resp = new lazy.Response(cmdID, this.send.bind(this));
    resp.sendError(err);
  }

  /**
   * When a client connects we send across a JSON Object defining the
   * protocol level.
   *
   * This is the only message sent by Marionette that does not follow
   * the regular message format.
   */
  sayHello() {
    let whatHo = {
      applicationType: "gecko",
      marionetteProtocol: PROTOCOL_VERSION,
    };
    this.sendRaw(whatHo);
  }

  /**
   * Delegates message to client based on the provided  {@code cmdID}.
   * The message is sent over the debugger transport socket.
   *
   * The command ID is a unique identifier assigned to the client's request
   * that is used to distinguish the asynchronous responses.
   *
   * Whilst responses to commands are synchronous and must be sent in the
   * correct order.
   *
   * @param {Message} msg
   *     The command or response to send.
   */
  send(msg) {
    msg.origin = lazy.Message.Origin.Server;
    if (msg instanceof lazy.Response) {
      this.sendToClient(msg);
    } else {
      lazy.logger.fatal("Cannot send messages other than Response");
    }
  }

  // Low-level methods:

  /**
   * Send given response to the client over the debugger transport socket.
   *
   * @param {Response} resp
   *     The response to send back to the client.
   */
  sendToClient(resp) {
    this.sendMessage(resp);
  }

  /**
   * Marshal message to the Marionette message format and send it.
   *
   * @param {Message} msg
   *     The message to send.
   */
  sendMessage(msg) {
    this.#log(msg);
    let payload = msg.toPacket();
    this.sendRaw(payload);
  }

  /**
   * Send the given payload over the debugger transport socket to the
   * connected client.
   *
   * @param {Object<string, ?>} payload
   *     The payload to ship.
   */
  sendRaw(payload) {
    this.conn.send(payload);
  }

  toString() {
    return `[object TCPConnection ${this.id}]`;
  }
}
