/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim:set ts=2 sw=2 sts=2 et: */
/* 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/. */

// We expect these to be defined in the global scope by runtest.py.
/* global __LOCATION__, _PROFILE_PATH, _SERVER_PORT, _SERVER_ADDR, _DISPLAY_RESULTS,
          _TEST_PREFIX, _HTTPD_PATH */
// Defined by xpcshell
/* global quit */

/* eslint-disable mozilla/use-chromeutils-generateqi */

// Set up a protocol substituion so that we can load the httpd.js file.
let protocolHandler = Services.io
  .getProtocolHandler("resource")
  .QueryInterface(Ci.nsIResProtocolHandler);
let httpdJSPath = PathUtils.toFileURI(_HTTPD_PATH);

protocolHandler.setSubstitution(
  "httpd-server",
  Services.io.newURI(httpdJSPath)
);
const { HttpServer, dumpn, setDebuggingStatus } = ChromeUtils.importESModule(
  "resource://httpd-server/httpd.sys.mjs"
);

protocolHandler.setSubstitution(
  "mochitest-server",
  Services.io.newFileURI(__LOCATION__.parent)
);
/* import-globals-from mochitestListingsUtils.js */
Services.scriptloader.loadSubScript(
  "resource://mochitest-server/mochitestListingsUtils.js",
  this
);

const CC = Components.Constructor;

const FileInputStream = CC(
  "@mozilla.org/network/file-input-stream;1",
  "nsIFileInputStream",
  "init"
);
const ConverterInputStream = CC(
  "@mozilla.org/intl/converter-input-stream;1",
  "nsIConverterInputStream",
  "init"
);

// Disable automatic network detection, so tests work correctly when
// not connected to a network.
// eslint-disable-next-line mozilla/use-services
var ios = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
ios.manageOfflineStatus = false;
ios.offline = false;

var server; // for use in the shutdown handler, if necessary

var _quitting = false;

/** Quit when all activity has completed. */
function serverStopped() {
  _quitting = true;
}

//
// SCRIPT CODE
//
runServer();

// We can only have gotten here if the /server/shutdown path was requested.
if (_quitting) {
  dumpn("HTTP server stopped, all pending requests complete");
  quit(0);
}

// Impossible as the stop callback should have been called, but to be safe...
dumpn("TEST-UNEXPECTED-FAIL | failure to correctly shut down HTTP server");
quit(1);

var serverBasePath;
var displayResults = true;

var gServerAddress;
var SERVER_PORT;

//
// SERVER SETUP
//
function runServer() {
  serverBasePath = __LOCATION__.parent;
  server = createMochitestServer(serverBasePath);

  // verify server address
  // if a.b.c.d or 'localhost'
  if (typeof _SERVER_ADDR != "undefined") {
    if (_SERVER_ADDR == "localhost") {
      gServerAddress = _SERVER_ADDR;
    } else {
      var quads = _SERVER_ADDR.split(".");
      if (quads.length == 4) {
        var invalid = false;
        for (var i = 0; i < 4; i++) {
          if (quads[i] < 0 || quads[i] > 255) {
            invalid = true;
          }
        }
        if (!invalid) {
          gServerAddress = _SERVER_ADDR;
        } else {
          throw new Error(
            "invalid _SERVER_ADDR, please specify a valid IP Address"
          );
        }
      }
    }
  } else {
    throw new Error(
      "please define _SERVER_ADDR (as an ip address) before running server.js"
    );
  }

  if (typeof _SERVER_PORT != "undefined") {
    if (parseInt(_SERVER_PORT) > 0 && parseInt(_SERVER_PORT) < 65536) {
      SERVER_PORT = _SERVER_PORT;
    }
  } else {
    throw new Error(
      "please define _SERVER_PORT (as a port number) before running server.js"
    );
  }

  // If DISPLAY_RESULTS is not specified, it defaults to true
  if (typeof _DISPLAY_RESULTS != "undefined") {
    displayResults = _DISPLAY_RESULTS;
  }

  server._start(SERVER_PORT, gServerAddress);

  // touch a file in the profile directory to indicate we're alive
  var foStream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
    Ci.nsIFileOutputStream
  );
  var serverAlive = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);

  if (typeof _PROFILE_PATH == "undefined") {
    serverAlive.initWithFile(serverBasePath);
    serverAlive.append("mochitesttestingprofile");
  } else {
    serverAlive.initWithPath(_PROFILE_PATH);
  }

  // Create a file to inform the harness that the server is ready
  if (serverAlive.exists()) {
    serverAlive.append("server_alive.txt");
    foStream.init(serverAlive, 0x02 | 0x08 | 0x20, 436, 0); // write, create, truncate
    var data = "It's alive!";
    foStream.write(data, data.length);
    foStream.close();
  } else {
    throw new Error(
      "Failed to create server_alive.txt because " +
        serverAlive.path +
        " could not be found."
    );
  }

  makeTags();

  //
  // The following is threading magic to spin an event loop -- this has to
  // happen manually in xpcshell for the server to actually work.
  //
  var thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
  while (!server.isStopped()) {
    thread.processNextEvent(true);
  }

  // Server stopped by /server/shutdown handler -- go through pending events
  // and return.

  // get rid of any pending requests
  while (thread.hasPendingEvents()) {
    thread.processNextEvent(true);
  }
}

/** Creates and returns an HTTP server configured to serve Mochitests. */
function createMochitestServer(serverBasePath) {
  var server = new HttpServer();

  server.registerDirectory("/", serverBasePath);
  server.registerPathHandler("/server/shutdown", serverShutdown);
  server.registerPathHandler("/server/debug", serverDebug);
  server.registerContentType("sjs", "sjs"); // .sjs == CGI-like functionality
  server.registerContentType("jar", "application/x-jar");
  server.registerContentType("ogg", "application/ogg");
  server.registerContentType("pdf", "application/pdf");
  server.registerContentType("ogv", "video/ogg");
  server.registerContentType("oga", "audio/ogg");
  server.registerContentType("opus", "audio/ogg; codecs=opus");
  server.registerContentType("dat", "text/plain; charset=utf-8");
  server.registerContentType("frag", "text/plain"); // .frag == WebGL fragment shader
  server.registerContentType("vert", "text/plain"); // .vert == WebGL vertex shader
  server.registerContentType("wasm", "application/wasm");
  server.setIndexHandler(defaultDirHandler);

  var serverRoot = {
    getFile: function getFile(path) {
      var file = serverBasePath.clone().QueryInterface(Ci.nsIFile);
      path.split("/").forEach(function (p) {
        file.appendRelativePath(p);
      });
      return file;
    },
    QueryInterface() {
      return this;
    },
  };

  server.setObjectState("SERVER_ROOT", serverRoot);

  processLocations(server);

  return server;
}

/**
 * Notifies the HTTP server about all the locations at which it might receive
 * requests, so that it can properly respond to requests on any of the hosts it
 * serves.
 */
function processLocations(server) {
  var serverLocations = serverBasePath.clone();
  serverLocations.append("server-locations.txt");

  const PR_RDONLY = 0x01;
  var fis = new FileInputStream(
    serverLocations,
    PR_RDONLY,
    292 /* 0444 */,
    Ci.nsIFileInputStream.CLOSE_ON_EOF
  );

  var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0);
  lis.QueryInterface(Ci.nsIUnicharLineInputStream);

  const LINE_REGEXP = new RegExp(
    "^([a-z][-a-z0-9+.]*)" +
      "://" +
      "(" +
      "\\d+\\.\\d+\\.\\d+\\.\\d+" +
      "|" +
      "(?:[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\\.)*" +
      "[a-z](?:[-a-z0-9]*[a-z0-9])?" +
      ")" +
      ":" +
      "(\\d+)" +
      "(?:" +
      "\\s+" +
      "(\\S+(?:,\\S+)*)" +
      ")?$"
  );

  var line = {};
  var lineno = 0;
  var seenPrimary = false;
  do {
    var more = lis.readLine(line);
    lineno++;

    var lineValue = line.value;
    if (lineValue.charAt(0) == "#" || lineValue == "") {
      continue;
    }

    var match = LINE_REGEXP.exec(lineValue);
    if (!match) {
      throw new Error("Syntax error in server-locations.txt, line " + lineno);
    }

    var [, scheme, host, port, options] = match;
    if (options) {
      if (options.split(",").includes("primary")) {
        if (seenPrimary) {
          throw new Error(
            "Multiple primary locations in server-locations.txt, " +
              "line " +
              lineno
          );
        }

        server.identity.setPrimary(scheme, host, port);
        seenPrimary = true;
        continue;
      }
    }

    server.identity.add(scheme, host, port);
  } while (more);
}

// PATH HANDLERS

// /server/shutdown
function serverShutdown(metadata, response) {
  response.setStatusLine("1.1", 200, "OK");
  response.setHeader("Content-type", "text/plain", false);

  var body = "Server shut down.";
  response.bodyOutputStream.write(body, body.length);

  dumpn("Server shutting down now...");
  server.stop(serverStopped);
}

// /server/debug?[012]
function serverDebug(metadata, response) {
  response.setStatusLine(metadata.httpVersion, 400, "Bad debugging level");
  if (metadata.queryString.length !== 1) {
    return;
  }

  var mode;
  if (metadata.queryString === "0") {
    // do this now so it gets logged with the old mode
    dumpn("Server debug logs disabled.");
    setDebuggingStatus(false, false);
    mode = "disabled";
  } else if (metadata.queryString === "1") {
    setDebuggingStatus(true, false);
    mode = "enabled";
  } else if (metadata.queryString === "2") {
    setDebuggingStatus(true, true);
    mode = "enabled, with timestamps";
  } else {
    return;
  }

  response.setStatusLine(metadata.httpVersion, 200, "OK");
  response.setHeader("Content-type", "text/plain", false);
  var body = "Server debug logs " + mode + ".";
  response.bodyOutputStream.write(body, body.length);
  dumpn(body);
}

/**
 * Produce a normal directory listing.
 */
function regularListing(metadata, response) {
  var [links] = list(metadata.path, metadata.getProperty("directory"), false);
  response.write(
    "<!DOCTYPE html>\n" +
      HTML(
        HEAD(TITLE("mochitest index ", metadata.path)),
        BODY(BR(), A({ href: ".." }, "Up a level"), UL(linksToListItems(links)))
      )
  );
}

/**
 * Read a manifestFile located at the root of the server's directory and turn
 * it into an object for creating a table of clickable links for each test.
 */
function convertManifestToTestLinks(root, manifest) {
  const { NetUtil } = ChromeUtils.importESModule(
    "resource://gre/modules/NetUtil.sys.mjs"
  );

  var manifestFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
  manifestFile.initWithFile(serverBasePath);
  manifestFile.append(manifest);

  var manifestStream = Cc[
    "@mozilla.org/network/file-input-stream;1"
  ].createInstance(Ci.nsIFileInputStream);
  manifestStream.init(manifestFile, -1, 0, 0);

  var manifestObj = JSON.parse(
    NetUtil.readInputStreamToString(manifestStream, manifestStream.available())
  );
  var paths = manifestObj.tests;
  var pathPrefix = "/" + root + "/";
  return [
    paths.reduce(function (t, p) {
      t[pathPrefix + p.path] = true;
      return t;
    }, {}),
    paths.length,
  ];
}

/**
 * Produce a test harness page containing all the test cases
 * below it, recursively.
 */
function testListing(metadata, response) {
  var links = {};
  var count = 0;
  if (!metadata.queryString.includes("manifestFile")) {
    [links, count] = list(
      metadata.path,
      metadata.getProperty("directory"),
      true
    );
  } else if (typeof Components != "undefined") {
    var manifest = metadata.queryString.match(/manifestFile=([^&]+)/)[1];

    [links, count] = convertManifestToTestLinks(
      metadata.path.split("/")[1],
      manifest
    );
  }

  var table_class =
    metadata.queryString.indexOf("hideResultsTable=1") > -1 ? "invisible" : "";

  let testname =
    metadata.queryString.indexOf("testname=") > -1
      ? metadata.queryString.match(/testname=([^&]+)/)[1]
      : "";

  dumpn("count: " + count);
  var tests = testname ? "['/" + testname + "']" : jsonArrayOfTestFiles(links);
  response.write(
    HTML(
      HEAD(
        TITLE("MochiTest | ", metadata.path),
        LINK({
          rel: "stylesheet",
          type: "text/css",
          href: "/static/harness.css",
        }),
        SCRIPT({
          type: "text/javascript",
          src: "/tests/SimpleTest/LogController.js",
        }),
        SCRIPT({
          type: "text/javascript",
          src: "/tests/SimpleTest/MemoryStats.js",
        }),
        SCRIPT({
          type: "text/javascript",
          src: "/tests/SimpleTest/TestRunner.js",
        }),
        SCRIPT({
          type: "text/javascript",
          src: "/tests/SimpleTest/MozillaLogger.js",
        }),
        SCRIPT({ type: "text/javascript", src: "/chunkifyTests.js" }),
        SCRIPT({ type: "text/javascript", src: "/manifestLibrary.js" }),
        SCRIPT({ type: "text/javascript", src: "/tests/SimpleTest/setup.js" }),
        SCRIPT(
          { type: "text/javascript" },
          "window.onload =  hookup; gTestList=" + tests + ";"
        )
      ),
      BODY(
        DIV(
          { class: "container" },
          H2("--> ", A({ href: "#", id: "runtests" }, "Run Tests"), " <--"),
          P(
            { style: "float: right;" },
            SMALL(
              "Based on the ",
              A({ href: "http://www.mochikit.com/" }, "MochiKit"),
              " unit tests."
            )
          ),
          DIV(
            { class: "status" },
            H1({ id: "indicator" }, "Status"),
            H2({ id: "pass" }, "Passed: ", SPAN({ id: "pass-count" }, "0")),
            H2({ id: "fail" }, "Failed: ", SPAN({ id: "fail-count" }, "0")),
            H2({ id: "fail" }, "Todo: ", SPAN({ id: "todo-count" }, "0"))
          ),
          DIV({ class: "clear" }),
          DIV(
            { id: "current-test" },
            B("Currently Executing: ", SPAN({ id: "current-test-path" }, "_"))
          ),
          DIV({ class: "clear" }),
          DIV(
            { class: "frameholder" },
            IFRAME({
              scrolling: "no",
              id: "testframe",
              allow: "geolocation 'src'",
              allowfullscreen: true,
            })
          ),
          DIV({ class: "clear" }),
          DIV(
            { class: "toggle" },
            A({ href: "#", id: "toggleNonTests" }, "Show Non-Tests"),
            BR()
          ),

          displayResults
            ? TABLE(
                {
                  cellpadding: 0,
                  cellspacing: 0,
                  class: table_class,
                  id: "test-table",
                },
                TR(TD("Passed"), TD("Failed"), TD("Todo"), TD("Test Files")),
                linksToTableRows(links, 0)
              )
            : "",
          BR(),
          TABLE({
            cellpadding: 0,
            cellspacing: 0,
            border: 1,
            bordercolor: "red",
            id: "fail-table",
          }),

          DIV({ class: "clear" })
        )
      )
    )
  );
}

/**
 * Respond to requests that match a file system directory.
 * Under the tests/ directory, return a test harness page.
 */
function defaultDirHandler(metadata, response) {
  response.setStatusLine("1.1", 200, "OK");
  response.setHeader("Content-type", "text/html;charset=utf-8", false);
  try {
    if (metadata.path.indexOf("/tests") != 0) {
      regularListing(metadata, response);
    } else {
      testListing(metadata, response);
    }
  } catch (ex) {
    response.write(ex);
  }
}
