/* 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 { Cc, CC } = require("chrome");
const Promise = require("promise");
const { Task } = require("devtools/shared/task");
const { executeSoon } = require("devtools/shared/DevToolsUtils");
const { delimitedRead } = require("devtools/shared/transport/stream-utils");
const CryptoHash = CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString");
const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();

// Limit the header size to put an upper bound on allocated memory
const HEADER_MAX_LEN = 8000;

/**
 * Read a line from async input stream and return promise that resolves to the line once
 * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error.
 */
function readLine(input) {
  return new Promise((resolve, reject) => {
    let line = "";
    let wait = () => {
      input.asyncWait(stream => {
        try {
          let amountToRead = HEADER_MAX_LEN - line.length;
          line += delimitedRead(input, "\n", amountToRead);

          if (line.endsWith("\n")) {
            resolve(line.trimRight());
            return;
          }

          if (line.length >= HEADER_MAX_LEN) {
            throw new Error(
              `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes`);
          }

          wait();
        } catch (ex) {
          reject(ex);
        }
      }, 0, 0, threadManager.currentThread);
    };

    wait();
  });
}

/**
 * Write a string of bytes to async output stream and return promise that resolves once
 * all data has been written. Doesn't do any utf-16/utf-8 conversion - the string is
 * treated as an array of bytes.
 */
function writeString(output, data) {
  return new Promise((resolve, reject) => {
    let wait = () => {
      if (data.length === 0) {
        resolve();
        return;
      }

      output.asyncWait(stream => {
        try {
          let written = output.write(data, data.length);
          data = data.slice(written);
          wait();
        } catch (ex) {
          reject(ex);
        }
      }, 0, 0, threadManager.currentThread);
    };

    wait();
  });
}

/**
 * Read HTTP request from async input stream.
 * @return Request line (string) and Map of header names and values.
 */
const readHttpRequest = Task.async(function* (input) {
  let requestLine = "";
  let headers = new Map();

  while (true) {
    let line = yield readLine(input);
    if (line.length == 0) {
      break;
    }

    if (!requestLine) {
      requestLine = line;
    } else {
      let colon = line.indexOf(":");
      if (colon == -1) {
        throw new Error(`Malformed HTTP header: ${line}`);
      }

      let name = line.slice(0, colon).toLowerCase();
      let value = line.slice(colon + 1).trim();
      headers.set(name, value);
    }
  }

  return { requestLine, headers };
});

/**
 * Write HTTP response (array of strings) to async output stream.
 */
function writeHttpResponse(output, response) {
  let responseString = response.join("\r\n") + "\r\n\r\n";
  return writeString(output, responseString);
}

/**
 * Process the WebSocket handshake headers and return the key to be sent in
 * Sec-WebSocket-Accept response header.
 */
function processRequest({ requestLine, headers }) {
  let [ method, path ] = requestLine.split(" ");
  if (method !== "GET") {
    throw new Error("The handshake request must use GET method");
  }

  if (path !== "/") {
    throw new Error("The handshake request has unknown path");
  }

  let upgrade = headers.get("upgrade");
  if (!upgrade || upgrade !== "websocket") {
    throw new Error("The handshake request has incorrect Upgrade header");
  }

  let connection = headers.get("connection");
  if (!connection || !connection.split(",").map(t => t.trim()).includes("Upgrade")) {
    throw new Error("The handshake request has incorrect Connection header");
  }

  let version = headers.get("sec-websocket-version");
  if (!version || version !== "13") {
    throw new Error("The handshake request must have Sec-WebSocket-Version: 13");
  }

  // Compute the accept key
  let key = headers.get("sec-websocket-key");
  if (!key) {
    throw new Error("The handshake request must have a Sec-WebSocket-Key header");
  }

  return { acceptKey: computeKey(key) };
}

function computeKey(key) {
  let str = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

  let data = Array.from(str, ch => ch.charCodeAt(0));
  let hash = new CryptoHash("sha1");
  hash.update(data, data.length);
  return hash.finish(true);
}

/**
 * Perform the server part of a WebSocket opening handshake on an incoming connection.
 */
const serverHandshake = Task.async(function* (input, output) {
  // Read the request
  let request = yield readHttpRequest(input);

  try {
    // Check and extract info from the request
    let { acceptKey } = processRequest(request);

    // Send response headers
    yield writeHttpResponse(output, [
      "HTTP/1.1 101 Switching Protocols",
      "Upgrade: websocket",
      "Connection: Upgrade",
      `Sec-WebSocket-Accept: ${acceptKey}`,
    ]);
  } catch (error) {
    // Send error response in case of error
    yield writeHttpResponse(output, [ "HTTP/1.1 400 Bad Request" ]);
    throw error;
  }
});

/**
 * Accept an incoming WebSocket server connection.
 * Takes an established nsISocketTransport in the parameters.
 * Performs the WebSocket handshake and waits for the WebSocket to open.
 * Returns Promise with a WebSocket ready to send and receive messages.
 */
const accept = Task.async(function* (transport, input, output) {
  yield serverHandshake(input, output);

  let transportProvider = {
    setListener(upgradeListener) {
      // The onTransportAvailable callback shouldn't be called synchronously.
      executeSoon(() => {
        upgradeListener.onTransportAvailable(transport, input, output);
      });
    }
  };

  return new Promise((resolve, reject) => {
    let socket = WebSocket.createServerWebSocket(null, [], transportProvider, "");
    socket.addEventListener("close", () => {
      input.close();
      output.close();
    });

    socket.onopen = () => resolve(socket);
    socket.onerror = err => reject(err);
  });
});

exports.accept = accept;
