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

const {
  dumpn,
  LineData,
  nsHttpHeaders,
  HttpServer,
  WriteThroughCopier,
  overrideBinaryStreamsForTests,
} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");

// if these tests fail, we'll want the debug output
var linDEBUG = true;

var { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);
var { NetUtil } = ChromeUtils.importESModule(
  "resource://gre/modules/NetUtil.sys.mjs"
);

const CC = Components.Constructor;
const FileInputStream = CC(
  "@mozilla.org/network/file-input-stream;1",
  "nsIFileInputStream",
  "init"
);
var BinaryInputStream = CC(
  "@mozilla.org/binaryinputstream;1",
  "nsIBinaryInputStream",
  "setInputStream"
);
var BinaryOutputStream = CC(
  "@mozilla.org/binaryoutputstream;1",
  "nsIBinaryOutputStream",
  "setOutputStream"
);
const ScriptableInputStream = CC(
  "@mozilla.org/scriptableinputstream;1",
  "nsIScriptableInputStream",
  "init"
);

/**
 * Constructs a new nsHttpServer instance.  This function is intended to
 * encapsulate construction of a server so that at some point in the future it
 * is possible to run these tests (with at most slight modifications) against
 * the server when used as an XPCOM component (not as an inline script).
 */
function createServer() {
  return new HttpServer();
}

/**
 * Creates a new HTTP channel.
 *
 * @param url
 *   the URL of the channel to create
 */
function makeChannel(url) {
  return NetUtil.newChannel({
    uri: url,
    loadUsingSystemPrincipal: true,
  }).QueryInterface(Ci.nsIHttpChannel);
}

/**
 * Make a binary input stream wrapper for the given stream.
 *
 * @param stream
 *   the nsIInputStream to wrap
 */
function makeBIS(stream) {
  return new BinaryInputStream(stream);
}

/**
 * Returns the contents of the file as a string.
 *
 * @param file : nsIFile
 *   the file whose contents are to be read
 * @returns string
 *   the contents of the file
 */
function fileContents(file) {
  const PR_RDONLY = 0x01;
  var fis = new FileInputStream(
    file,
    PR_RDONLY,
    0o444,
    Ci.nsIFileInputStream.CLOSE_ON_EOF
  );
  var sis = new ScriptableInputStream(fis);
  var contents = sis.read(file.fileSize);
  sis.close();
  return contents;
}

/**
 * Iterates over the lines, delimited by CRLF, in data, returning each line
 * without the trailing line separator.
 *
 * @param data : string
 *   a string consisting of lines of data separated by CRLFs
 * @returns Iterator
 *   an Iterator which returns each line from data in turn; note that this
 *   includes a final empty line if data ended with a CRLF
 */
function* LineIterator(data) {
  var index = 0;
  do {
    index = data.indexOf("\r\n");
    if (index >= 0) {
      yield data.substring(0, index);
    } else {
      yield data;
    }

    data = data.substring(index + 2);
  } while (index >= 0);
}

/**
 * Throws if iter does not contain exactly the CRLF-separated lines in the
 * array expectedLines.
 *
 * @param iter : Iterator
 *   an Iterator which returns lines of text
 * @param expectedLines : [string]
 *   an array of the expected lines of text
 * @throws an error message if iter doesn't agree with expectedLines
 */
function expectLines(iter, expectedLines) {
  var index = 0;
  for (var line of iter) {
    if (expectedLines.length == index) {
      throw new Error(
        `Error: got more than ${expectedLines.length} expected lines!`
      );
    }

    var expected = expectedLines[index++];
    if (expected !== line) {
      throw new Error(`Error on line ${index}!
  actual: '${line}',
  expect: '${expected}'`);
    }
  }

  if (expectedLines.length !== index) {
    throw new Error(
      `Expected more lines!  Got ${index}, expected ${expectedLines.length}`
    );
  }
}

/**
 * Spew a bunch of HTTP metadata from request into the body of response.
 *
 * @param request : nsIHttpRequest
 *   the request whose metadata should be output
 * @param response : nsIHttpResponse
 *   the response to which the metadata is written
 */
function writeDetails(request, response) {
  response.write("Method:  " + request.method + "\r\n");
  response.write("Path:    " + request.path + "\r\n");
  response.write("Query:   " + request.queryString + "\r\n");
  response.write("Version: " + request.httpVersion + "\r\n");
  response.write("Scheme:  " + request.scheme + "\r\n");
  response.write("Host:    " + request.host + "\r\n");
  response.write("Port:    " + request.port);
}

/**
 * Advances iter past all non-blank lines and a single blank line, after which
 * point the body of the response will be returned next from the iterator.
 *
 * @param iter : Iterator
 *   an iterator over the CRLF-delimited lines in an HTTP response, currently
 *   just after the Request-Line
 */
function skipHeaders(iter) {
  var line = iter.next().value;
  while (line !== "") {
    line = iter.next().value;
  }
}

/**
 * Checks that the exception e (which may be an XPConnect-created exception
 * object or a raw nsresult number) is the given nsresult.
 *
 * @param e : Exception or nsresult
 *   the actual exception
 * @param code : nsresult
 *   the expected exception
 */
function isException(e, code) {
  if (e !== code && e.result !== code) {
    do_throw("unexpected error: " + e);
  }
}

/**
 * Calls the given function at least the specified number of milliseconds later.
 * The callback will not undershoot the given time, but it might overshoot --
 * don't expect precision!
 *
 * @param milliseconds : uint
 *   the number of milliseconds to delay
 * @param callback : function() : void
 *   the function to call
 */
function callLater(msecs, callback) {
  do_timeout(msecs, callback);
}

/** *****************************************************
 * SIMPLE SUPPORT FOR LOADING/TESTING A SERIES OF URLS *
 *******************************************************/

/**
 * Create a completion callback which will stop the given server and end the
 * test, assuming nothing else remains to be done at that point.
 */
function testComplete(srv) {
  return function complete() {
    do_test_pending();
    srv.stop(function quit() {
      do_test_finished();
    });
  };
}

/**
 * Represents a path to load from the tested HTTP server, along with actions to
 * take before, during, and after loading the associated page.
 *
 * @param path
 *   the URL to load from the server
 * @param initChannel
 *   a function which takes as a single parameter a channel created for path and
 *   initializes its state, or null if no additional initialization is needed
 * @param onStartRequest
 *   called during onStartRequest for the load of the URL, with the same
 *   parameters; the request parameter has been QI'd to nsIHttpChannel and
 *   nsIHttpChannelInternal for convenience; may be null if nothing needs to be
 *   done
 * @param onStopRequest
 *   called during onStopRequest for the channel, with the same parameters plus
 *   a trailing parameter containing an array of the bytes of data downloaded in
 *   the body of the channel response; the request parameter has been QI'd to
 *   nsIHttpChannel and nsIHttpChannelInternal for convenience; may be null if
 *   nothing needs to be done
 */
function Test(path, initChannel, onStartRequest, onStopRequest) {
  function nil() {}

  this.path = path;
  this.initChannel = initChannel || nil;
  this.onStartRequest = onStartRequest || nil;
  this.onStopRequest = onStopRequest || nil;
}

/**
 * Runs all the tests in testArray.
 *
 * @param testArray
 *   a non-empty array of Tests to run, in order
 * @param done
 *   function to call when all tests have run (e.g. to shut down the server)
 */
function runHttpTests(testArray, done) {
  /** Kicks off running the next test in the array. */
  function performNextTest() {
    if (++testIndex == testArray.length) {
      try {
        done();
      } catch (e) {
        do_report_unexpected_exception(e, "running test-completion callback");
      }
      return;
    }

    do_test_pending();

    var test = testArray[testIndex];
    var ch = makeChannel(test.path);
    try {
      test.initChannel(ch);
    } catch (e) {
      try {
        do_report_unexpected_exception(
          e,
          "testArray[" + testIndex + "].initChannel(ch)"
        );
      } catch (x) {
        /* swallow and let tests continue */
      }
    }

    listener._channel = ch;
    ch.asyncOpen(listener);
  }

  /** Index of the test being run. */
  var testIndex = -1;

  /** Stream listener for the channels. */
  var listener = {
    /** Current channel being observed by this. */
    _channel: null,
    /** Array of bytes of data in body of response. */
    _data: [],

    onStartRequest(request) {
      Assert.strictEqual(request, this._channel);
      var ch = request
        .QueryInterface(Ci.nsIHttpChannel)
        .QueryInterface(Ci.nsIHttpChannelInternal);

      this._data.length = 0;
      try {
        try {
          testArray[testIndex].onStartRequest(ch);
        } catch (e) {
          do_report_unexpected_exception(
            e,
            "testArray[" + testIndex + "].onStartRequest"
          );
        }
      } catch (e) {
        do_note_exception(
          e,
          "!!! swallowing onStartRequest exception so onStopRequest is " +
            "called..."
        );
      }
    },
    onDataAvailable(request, inputStream, offset, count) {
      var quantum = 262144; // just above half the argument-count limit
      var bis = makeBIS(inputStream);
      for (var start = 0; start < count; start += quantum) {
        var newData = bis.readByteArray(Math.min(quantum, count - start));
        Array.prototype.push.apply(this._data, newData);
      }
    },
    onStopRequest(request, status) {
      this._channel = null;

      var ch = request
        .QueryInterface(Ci.nsIHttpChannel)
        .QueryInterface(Ci.nsIHttpChannelInternal);

      // NB: The onStopRequest callback must run before performNextTest here,
      //     because the latter runs the next test's initChannel callback, and
      //     we want one test to be sequentially processed before the next
      //     one.
      try {
        testArray[testIndex].onStopRequest(ch, status, this._data);
      } catch (e) {
        do_report_unexpected_exception(
          e,
          "testArray[" + testIndex + "].onStartRequest"
        );
      } finally {
        try {
          performNextTest();
        } finally {
          do_test_finished();
        }
      }
    },
    QueryInterface: ChromeUtils.generateQI([
      "nsIStreamListener",
      "nsIRequestObserver",
    ]),
  };

  performNextTest();
}

/** **************************************
 * RAW REQUEST FORMAT TESTING FUNCTIONS *
 ****************************************/

/**
 * Sends a raw string of bytes to the given host and port and checks that the
 * response is acceptable.
 *
 * @param host : string
 *   the host to which a connection should be made
 * @param port : PRUint16
 *   the port to use for the connection
 * @param data : string or [string...]
 *   either:
 *     - the raw data to send, as a string of characters with codes in the
 *       range 0-255, or
 *     - an array of such strings whose concatenation forms the raw data
 * @param responseCheck : function(string) : void
 *   a function which is provided with the data sent by the remote host which
 *   conducts whatever tests it wants on that data; useful for tweaking the test
 *   environment between tests
 */
function RawTest(host, port, data, responseCheck) {
  if (0 > port || 65535 < port || port % 1 !== 0) {
    throw new Error("bad port");
  }
  if (!(data instanceof Array)) {
    data = [data];
  }
  if (data.length <= 0) {
    throw new Error("bad data length");
  }

  if (
    !data.every(function (v) {
      // eslint-disable-next-line no-control-regex
      return /^[\x00-\xff]*$/.test(v);
    })
  ) {
    throw new Error("bad data contained non-byte-valued character");
  }

  this.host = host;
  this.port = port;
  this.data = data;
  this.responseCheck = responseCheck;
}

/**
 * Runs all the tests in testArray, an array of RawTests.
 *
 * @param testArray : [RawTest]
 *   an array of RawTests to run, in order
 * @param done
 *   function to call when all tests have run (e.g. to shut down the server)
 * @param beforeTestCallback
 *   function to call before each test is run. Gets passed testIndex when called
 */
function runRawTests(testArray, done, beforeTestCallback) {
  do_test_pending();

  var sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService(
    Ci.nsISocketTransportService
  );

  var currentThread =
    Cc["@mozilla.org/thread-manager;1"].getService().currentThread;

  /** Kicks off running the next test in the array. */
  function performNextTest() {
    if (++testIndex == testArray.length) {
      do_test_finished();
      try {
        done();
      } catch (e) {
        do_report_unexpected_exception(e, "running test-completion callback");
      }
      return;
    }

    if (beforeTestCallback) {
      try {
        beforeTestCallback(testIndex);
      } catch (e) {
        /* We don't care if this call fails */
      }
    }

    var rawTest = testArray[testIndex];

    var transport = sts.createTransport(
      [],
      rawTest.host,
      rawTest.port,
      null,
      null
    );

    var inStream = transport.openInputStream(0, 0, 0);
    var outStream = transport.openOutputStream(0, 0, 0);

    // reset
    dataIndex = 0;
    received = "";

    waitForMoreInput(inStream);
    waitToWriteOutput(outStream);
  }

  function waitForMoreInput(stream) {
    reader.stream = stream;
    stream = stream.QueryInterface(Ci.nsIAsyncInputStream);
    stream.asyncWait(reader, 0, 0, currentThread);
  }

  function waitToWriteOutput(stream) {
    // Do the QueryInterface here, not earlier, because there is no
    // guarantee that 'stream' passed in here been QIed to nsIAsyncOutputStream
    // since the last GC.
    stream = stream.QueryInterface(Ci.nsIAsyncOutputStream);
    stream.asyncWait(
      writer,
      0,
      testArray[testIndex].data[dataIndex].length,
      currentThread
    );
  }

  /** Index of the test being run. */
  var testIndex = -1;

  /**
   * Index of remaining data strings to be written to the socket in current
   * test.
   */
  var dataIndex = 0;

  /** Data received so far from the server. */
  var received = "";

  /** Reads data from the socket. */
  var reader = {
    onInputStreamReady(stream) {
      Assert.strictEqual(stream, this.stream);
      try {
        var bis = new BinaryInputStream(stream);

        var av = 0;
        try {
          av = bis.available();
        } catch (e) {
          /* default to 0 */
          do_note_exception(e);
        }

        if (av > 0) {
          var quantum = 262144;
          for (var start = 0; start < av; start += quantum) {
            var bytes = bis.readByteArray(Math.min(quantum, av - start));
            received += String.fromCharCode.apply(null, bytes);
          }
          waitForMoreInput(stream);
          return;
        }
      } catch (e) {
        do_report_unexpected_exception(e);
      }

      var rawTest = testArray[testIndex];
      try {
        rawTest.responseCheck(received);
      } catch (e) {
        do_report_unexpected_exception(e);
      } finally {
        try {
          stream.close();
          performNextTest();
        } catch (e) {
          do_report_unexpected_exception(e);
        }
      }
    },
  };

  /** Writes data to the socket. */
  var writer = {
    onOutputStreamReady(stream) {
      var str = testArray[testIndex].data[dataIndex];

      var written = 0;
      try {
        written = stream.write(str, str.length);
        if (written == str.length) {
          dataIndex++;
        } else {
          testArray[testIndex].data[dataIndex] = str.substring(written);
        }
      } catch (e) {
        do_note_exception(e);
        /* stream could have been closed, just ignore */
      }

      try {
        // Keep writing data while we can write and
        // until there's no more data to read
        if (written > 0 && dataIndex < testArray[testIndex].data.length) {
          waitToWriteOutput(stream);
        } else {
          stream.close();
        }
      } catch (e) {
        do_report_unexpected_exception(e);
      }
    },
  };

  performNextTest();
}
