/* 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";

/* import-globals-from head_cache.js */
/* import-globals-from head_cookies.js */
/* import-globals-from head_channels.js */
/* import-globals-from head_http3.js */

const { HttpServer } = ChromeUtils.importESModule(
  "resource://testing-common/httpd.sys.mjs"
);

function makeChan(uri) {
  let chan = NetUtil.newChannel({
    uri,
    loadUsingSystemPrincipal: true,
  }).QueryInterface(Ci.nsIHttpChannel);
  chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI;
  return chan;
}

// Promise-backed Http3CheckListener
class Http3CheckListener {
  constructor(
    { expectedStatus = Cr.NS_OK, expectedRoute = "" } = {},
    resolve,
    reject
  ) {
    this.onDataAvailableFired = false;
    this.expectedStatus = expectedStatus;
    this.expectedRoute = expectedRoute;
    this._resolve = resolve;
    this._reject = reject;
  }

  onStartRequest(request) {
    Assert.ok(request instanceof Ci.nsIHttpChannel);
    Assert.equal(request.status, this.expectedStatus);
    if (Components.isSuccessCode(this.expectedStatus)) {
      Assert.equal(request.responseStatus, 200);
    }
  }

  onDataAvailable(request, stream, off, cnt) {
    this.onDataAvailableFired = true;
    read_stream(stream, cnt);
  }

  onStopRequest(request, status) {
    Assert.equal(status, this.expectedStatus);

    let routed = "NA";
    try {
      routed = request.getRequestHeader("Alt-Used");
    } catch (e) {}
    dump("routed is " + routed + "\n");

    Assert.equal(routed, this.expectedRoute);

    if (Components.isSuccessCode(this.expectedStatus)) {
      let httpVersion = "";
      try {
        httpVersion = request.protocolVersion;
      } catch (e) {}
      Assert.equal(httpVersion, "h3");
      Assert.equal(this.onDataAvailableFired, true);
      Assert.equal(request.getResponseHeader("X-Firefox-Http3"), "h3");
    }

    this._resolve?.(request);
  }
}

class WaitForHttp3Listener extends Http3CheckListener {
  constructor(
    {
      expectedStatus = Cr.NS_OK,
      expectedRoute = "",
      uri = "",
      h3AltSvc = "",
      retry,
      delayMs = 500,
    } = {},
    resolve,
    reject
  ) {
    super({ expectedStatus, expectedRoute }, resolve, reject);
    this.uri = uri;
    this.h3AltSvc = h3AltSvc;
    this._retry = retry; // function to re-open the request
    this._delayMs = delayMs; // poll interval
  }

  onStopRequest(request, status) {
    Assert.equal(status, this.expectedStatus);

    let routed = "NA";
    try {
      routed = request.getRequestHeader("Alt-Used");
    } catch (e) {}
    dump(`routed is ${routed}\n`);

    let httpVersion = "";
    try {
      httpVersion = request.protocolVersion;
    } catch (e) {}

    if (routed === this.expectedRoute) {
      // (This is where run_next_test() used to be.)
      Assert.equal(routed, this.expectedRoute); // useful log/assert
      Assert.equal(httpVersion, "h3");
      this._resolve?.(request);
      return;
    }

    // Not routed yet: mirror old behavior (log + supportsHTTP3 check + retry)
    dump("poll later for alt-svc mapping\n");
    if (httpVersion === "h2") {
      request.QueryInterface(Ci.nsIHttpChannelInternal);
      Assert.ok(request.supportsHTTP3);
    }

    if (typeof this._retry === "function") {
      // schedule another attempt (replaces do_test_pending/do_timeout recursion)
      do_timeout(this._delayMs, () =>
        this._retry(this.uri, this.expectedRoute, this.h3AltSvc)
      );
    }
    // Promise remains pending until a later attempt matches expectedRoute.
  }
}

// Factory to create { listener, promise }
function createHttp3CheckListener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new Http3CheckListener(options, resolve, reject);
  return { listener, promise };
}

function createWaitForHttp3Listener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new WaitForHttp3Listener(options, resolve, reject);
  return { listener, promise };
}

// --- Async wrapper that does the polling by re-issuing the request ---
async function waitForHttp3Route(
  uri,
  expectedRoute,
  altSvc,
  { delayMs = 500 } = {}
) {
  let listenerRef;

  let firstAttempt = true;
  // Function to (re)open the channel using the same listener instance.
  const retry = () => {
    if (!firstAttempt) {
      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      do_timeout(1000, () => {
        Services.obs.notifyObservers(null, "net:cancel-all-connections");
        const chan = makeChan(uri);
        if (altSvc) {
          chan.setRequestHeader("x-altsvc", altSvc, false);
        }
        chan.asyncOpen(listenerRef);
      });
      return;
    }
    firstAttempt = false;
    const chan = makeChan(uri);
    if (altSvc) {
      chan.setRequestHeader("x-altsvc", altSvc, false);
    }
    chan.asyncOpen(listenerRef);
  };

  const { listener, promise } = createWaitForHttp3Listener({
    expectedStatus: Cr.NS_OK,
    expectedRoute,
    uri,
    h3AltSvc: altSvc,
    retry,
    delayMs,
  });
  listenerRef = listener;

  // Kick off first attempt; subsequent attempts are scheduled by the listener.
  retry();

  // Resolves only when routed === expectedRoute
  return promise;
}

// Promise-backed MultipleListener
class MultipleListener {
  constructor(
    {
      number_of_parallel_requests = 0,
      expectedRoute = "",
      with_error = Cr.NS_OK, // NS_OK means we expect success for all
    } = {},
    resolve,
    reject
  ) {
    this.number_of_parallel_requests = number_of_parallel_requests;
    this.expectedRoute = expectedRoute;
    this.with_error = with_error;

    this.count_of_done_requests = 0;
    this.error_found_onstart = false;
    this.error_found_onstop = false;
    this.need_cancel_found = false;

    this._resolve = resolve;
    this._reject = reject;
  }

  onStartRequest(request) {
    Assert.ok(request instanceof Ci.nsIHttpChannel);

    // Optional cancel behavior via header "CancelMe"
    let need_cancel = "";
    try {
      need_cancel = request.getRequestHeader("CancelMe");
    } catch (_) {}
    if (need_cancel !== "") {
      this.need_cancel_found = true;
      request.cancel(Cr.NS_ERROR_ABORT);
      return;
    }

    // Original logic: either 200 OK for success, or exactly one failure
    if (Components.isSuccessCode(request.status)) {
      Assert.equal(request.responseStatus, 200);
    } else if (this.error_found_onstart) {
      // Fail fast: more than one failing request on start
      this._reject?.(
        new Error("We should have only one request failing (onStart).")
      );
    } else {
      Assert.equal(request.status, this.with_error);
      this.error_found_onstart = true;
    }
  }

  onDataAvailable(request, stream, off, cnt) {
    read_stream(stream, cnt);
  }

  onStopRequest(request) {
    // Check Alt-Used routing matches expectation
    let routed = "";
    try {
      routed = request.getRequestHeader("Alt-Used");
    } catch (_) {}
    Assert.equal(routed, this.expectedRoute);

    // If success, ensure HTTP/3
    if (Components.isSuccessCode(request.status)) {
      let httpVersion = "";
      try {
        httpVersion = request.protocolVersion;
      } catch (_) {}
      Assert.equal(httpVersion, "h3");
    }

    // Track/validate failures (at most one)
    if (!Components.isSuccessCode(request.status)) {
      if (this.error_found_onstop) {
        this._reject?.(
          new Error("We should have only one request failing (onStop).")
        );
        return;
      }
      Assert.equal(request.status, this.with_error);
      this.error_found_onstop = true;
    }

    // Count completion and maybe resolve
    this.count_of_done_requests++;
    if (this.count_of_done_requests === this.number_of_parallel_requests) {
      if (Components.isSuccessCode(this.with_error)) {
        // All were expected to succeed
        Assert.equal(this.error_found_onstart, false);
        Assert.equal(this.error_found_onstop, false);
      } else {
        // One failure was expected OR a cancel path was exercised
        Assert.ok(this.error_found_onstart || this.need_cancel_found);
        Assert.equal(this.error_found_onstop, true);
      }
      this._resolve?.();
    }
  }
}

// Factory to create { listener, promise }
function createMultipleListener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new MultipleListener(options, resolve, reject);
  return { listener, promise };
}

async function do_test_multiple_requests(
  number_of_parallel_requests,
  h3Route,
  httpsOrigin
) {
  dump("test_multiple_requests()\n");

  const { listener, promise } = createMultipleListener({
    number_of_parallel_requests,
    expectedRoute: h3Route,
    with_error: Cr.NS_OK,
  });

  for (let i = 0; i < number_of_parallel_requests; i++) {
    const chan = makeChan(httpsOrigin + "20000");
    chan.asyncOpen(listener);
  }

  await promise;
}

async function do_test_request_cancelled_by_server(h3Route, httpsOrigin) {
  dump("do_test_request_cancelled_by_server()\n");

  const { listener, promise } = createHttp3CheckListener({
    expectedStatus: Cr.NS_ERROR_NET_INTERRUPT,
    expectedRoute: h3Route,
  });

  const chan = makeChan(httpsOrigin + "RequestCancelled");
  chan.asyncOpen(listener);

  // Resolves at the point where run_next_test() used to be called
  await promise;
}

// Server resets request stream with an unrecognized application error code.
// The transaction must retry via H2/H1 and succeed.
async function do_test_unknown_reset(httpsOrigin) {
  dump("do_test_unknown_reset()\n");

  const chan = makeChan(httpsOrigin + "UnknownReset");
  const promise = new Promise(resolve => {
    const listener = {
      onStartRequest(request) {
        Assert.ok(request instanceof Ci.nsIHttpChannel);
        Assert.equal(request.status, Cr.NS_OK);
        Assert.equal(request.responseStatus, 200);
      },
      onDataAvailable(request, stream, off, cnt) {
        read_stream(stream, cnt);
      },
      onStopRequest(request, status) {
        Assert.equal(status, Cr.NS_OK);
        let httpVersion = "";
        try {
          httpVersion = request.protocolVersion;
        } catch (e) {}
        Assert.notEqual(httpVersion, "h3");
        resolve(request);
      },
    };
    chan.asyncOpen(listener);
  });

  await promise;
}

// Promise-backed Http3CheckListener must already exist:
//   createHttp3CheckListener({ expectedStatus, expectedRoute })

class CancelRequestListener extends Http3CheckListener {
  constructor({ expectedRoute = "" } = {}, resolve, reject) {
    super(
      { expectedStatus: Cr.NS_ERROR_ABORT, expectedRoute },
      resolve,
      reject
    );
  }

  onStartRequest(request) {
    Assert.ok(request instanceof Ci.nsIHttpChannel);
    Assert.equal(Components.isSuccessCode(request.status), true);
    // Cancel the request immediately (simulate Necko cancelling)
    request.cancel(Cr.NS_ERROR_ABORT);
  }
}

function createCancelRequestListener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new CancelRequestListener(options, resolve, reject);
  return { listener, promise };
}

// Cancel stream after OnStartRequest.
async function do_test_stream_cancelled_by_necko(h3Route, httpsOrigin) {
  dump("do_test_stream_cancelled_by_necko()\n");

  const { listener, promise } = createCancelRequestListener({
    expectedRoute: h3Route,
  });

  const chan = makeChan(httpsOrigin + "20000");
  chan.asyncOpen(listener);

  // Resolves at the end of onStopRequest (where run_next_test() used to be)
  await promise;
}

async function do_test_multiple_request_one_is_cancelled(
  number_of_parallel_requests,
  h3Route,
  httpsOrigin
) {
  dump("do_test_multiple_request_one_is_cancelled()\n");

  const { listener, promise } = createMultipleListener({
    number_of_parallel_requests,
    expectedRoute: h3Route,
    with_error: Cr.NS_ERROR_NET_INTERRUPT, // one request is expected to fail (server-cancelled)
  });

  for (let i = 0; i < number_of_parallel_requests; i++) {
    let uri = httpsOrigin + "20000";
    if (i === 4) {
      // Add a request that will be cancelled by the server.
      uri = httpsOrigin + "RequestCancelled";
    }
    const chan = makeChan(uri);
    chan.asyncOpen(listener);
  }

  // Resolves when all parallel requests complete and invariants are checked
  await promise;
}

async function do_test_multiple_request_one_is_cancelled_by_necko(
  number_of_parallel_requests,
  h3Route,
  httpsOrigin
) {
  dump("do_test_multiple_request_one_is_cancelled_by_necko()\n");

  const { listener, promise } = createMultipleListener({
    number_of_parallel_requests,
    expectedRoute: h3Route,
    with_error: Cr.NS_ERROR_ABORT,
  });

  for (let i = 0; i < number_of_parallel_requests; i++) {
    let chan = makeChan(httpsOrigin + "20000");
    if (i === 4) {
      // MultipleListener will cancel request with this header.
      chan.setRequestHeader("CancelMe", "true", false);
    }
    chan.asyncOpen(listener);
  }

  // Resolves when all parallel requests complete and invariants are checked
  await promise;
}

// Promise-backed Http3CheckListener assumed available:
// function createHttp3CheckListener({ expectedStatus, expectedRoute })

class PostListener extends Http3CheckListener {
  constructor(opts = {}, resolve, reject) {
    super(opts, resolve, reject);
  }
  onDataAvailable(request, stream, off, cnt) {
    this.onDataAvailableFired = true;
    read_stream(stream, cnt);
  }
}

// Factory for PostListener
function createPostListener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new PostListener(options, resolve, reject);
  return { listener, promise };
}

// Helper to perform a POST (or any method with a body)
function openWithBody(
  content,
  chan,
  method = "POST",
  contentType = "text/plain"
) {
  const stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
    Ci.nsIStringInputStream
  );
  stream.setByteStringData(content);

  const uchan = chan.QueryInterface(Ci.nsIUploadChannel);
  uchan.setUploadStream(stream, contentType, stream.available());
  chan.requestMethod = method;
  return chan;
}

// Generate a post with known pre-calculated md5 sum.
function generateContent(size) {
  let content = "";
  for (let i = 0; i < size; i++) {
    content += "0";
  }
  return content;
}

let post = generateContent(10);

// Test a simple POST (async)
async function do_test_post(httpsOrigin, h3Route) {
  dump("do_test_post()\n");

  const chan = makeChan(httpsOrigin + "post");
  openWithBody(post, chan, "POST");

  const { listener, promise } = createPostListener({
    expectedStatus: Cr.NS_OK,
    expectedRoute: h3Route,
  });

  chan.asyncOpen(listener);
  await promise; // resolves at end of onStopRequest in Http3CheckListener
}

// Test a simple PATCH
async function do_test_patch(httpsOrigin, h3Route) {
  dump("do_test_post()\n");

  const chan = makeChan(httpsOrigin + "patch");
  openWithBody(post, chan, "PATCH");

  const { listener, promise } = createPostListener({
    expectedStatus: Cr.NS_OK,
    expectedRoute: h3Route,
  });

  chan.asyncOpen(listener);
  await promise;
}

let h1Server = null;
let altsvcHost = "";
let httpOrigin = "";

function h1Response(metadata, response) {
  response.setStatusLine(metadata.httpVersion, 200, "OK");
  response.setHeader("Content-Type", "text/plain", false);
  response.setHeader("Connection", "close", false);
  response.setHeader("Cache-Control", "no-cache", false);
  response.setHeader("Access-Control-Allow-Origin", "*", false);
  response.setHeader("Access-Control-Allow-Method", "GET", false);
  response.setHeader("Access-Control-Allow-Headers", "x-altsvc", false);

  try {
    let hval = "h3=" + metadata.getHeader("x-altsvc");
    response.setHeader("Alt-Svc", hval, false);
  } catch (e) {}

  let body = "Q: What did 0 say to 8? A: Nice Belt!\n";
  response.bodyOutputStream.write(body, body.length);
}

function h1ServerWK(metadata, response) {
  response.setStatusLine(metadata.httpVersion, 200, "OK");
  response.setHeader("Content-Type", "application/json", false);
  response.setHeader("Connection", "close", false);
  response.setHeader("Cache-Control", "no-cache", false);
  response.setHeader("Access-Control-Allow-Origin", "*", false);
  response.setHeader("Access-Control-Allow-Method", "GET", false);
  response.setHeader("Access-Control-Allow-Headers", "x-altsvc", false);

  let body = `["http://${altsvcHost}:${h1Server.identity.primaryPort}"]`;
  response.bodyOutputStream.write(body, body.length);
}

function setup_h1_server(host) {
  altsvcHost = host;
  h1Server = new HttpServer();
  h1Server.registerPathHandler("/http3-test", h1Response);
  h1Server.registerPathHandler("/.well-known/http-opportunistic", h1ServerWK);
  h1Server.registerPathHandler("/VersionFallback", h1Response);
  h1Server.start(-1);
  h1Server.identity.setPrimary(
    "http",
    altsvcHost,
    h1Server.identity.primaryPort
  );
  httpOrigin = `http://${altsvcHost}:${h1Server.identity.primaryPort}/`;
  registerCleanupFunction(() => {
    h1Server.stop();
  });
}

// Promise-backed base assumed available:
//   class Http3CheckListener { ... }
//   function createHttp3CheckListener(opts) { return { listener, promise }; }

class SlowReceiverListener extends Http3CheckListener {
  constructor(
    {
      expectedStatus = Cr.NS_OK,
      expectedRoute = "",
      expectedBytes = 10_000_000,
    } = {},
    resolve,
    reject
  ) {
    super({ expectedStatus, expectedRoute }, resolve, reject);
    this.count = 0;
    this.expectedBytes = expectedBytes;
  }

  onDataAvailable(request, stream, off, cnt) {
    this.onDataAvailableFired = true;
    this.count += cnt;
    read_stream(stream, cnt);
  }

  onStopRequest(request, status) {
    Assert.equal(status, this.expectedStatus);
    Assert.equal(this.count, this.expectedBytes);

    let routed = "NA";
    try {
      routed = request.getRequestHeader("Alt-Used");
    } catch (e) {}
    dump(`routed is ${routed}\n`);
    Assert.equal(routed, this.expectedRoute);

    if (Components.isSuccessCode(this.expectedStatus)) {
      let httpVersion = "";
      try {
        httpVersion = request.protocolVersion;
      } catch (e) {}
      Assert.equal(httpVersion, "h3");
      Assert.equal(this.onDataAvailableFired, true);
    }

    // Resolve where run_next_test() used to be
    this._resolve?.(request);
  }
}

function createSlowReceiverListener(options = {}) {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new SlowReceiverListener(options, resolve, reject);
  return { listener, promise };
}

// Test: slow receiver (suspend, then resume)
async function do_test_slow_receiver(httpsOrigin, h3Route) {
  dump("do_test_slow_receiver()\n");

  const chan = makeChan(httpsOrigin + "10000000");

  const { listener, promise } = createSlowReceiverListener({
    expectedStatus: Cr.NS_OK,
    expectedRoute: h3Route,
    expectedBytes: 10_000_000,
  });

  chan.asyncOpen(listener);

  // Suspend immediately, then resume after 1s (replaces do_test_pending/do_timeout)
  chan.suspend();
  await new Promise(r => do_timeout(1000, r));
  chan.resume();

  // Wait for completion (used to be run_next_test/do_test_finished)
  await promise;
}

// Promise-backed listener for version fallback checks
class CheckFallbackListener {
  constructor(resolve, reject) {
    this._resolve = resolve;
    this._reject = reject;
  }

  onStartRequest(request) {
    Assert.ok(request instanceof Ci.nsIHttpChannel);
    Assert.equal(request.status, Cr.NS_OK);
    Assert.equal(request.responseStatus, 200);
  }

  onDataAvailable(request, stream, off, cnt) {
    read_stream(stream, cnt);
  }

  onStopRequest(request, status) {
    Assert.equal(status, Cr.NS_OK);

    let routed = "NA";
    try {
      routed = request.getRequestHeader("Alt-Used");
    } catch (e) {}
    dump(`routed is ${routed}\n`);
    Assert.equal(routed, "0");

    let httpVersion = "";
    try {
      httpVersion = request.protocolVersion;
    } catch (e) {}
    Assert.equal(httpVersion, "http/1.1");

    // Resolve where run_next_test() used to be called
    this._resolve?.(request);
  }
}

// Factory to create { listener, promise }
function createCheckFallbackListener() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  const listener = new CheckFallbackListener(resolve, reject);
  return { listener, promise };
}

// Server cancels request with VersionFallback.
async function do_test_version_fallback(httpsOrigin) {
  dump("do_test_version_fallback()\n");

  const chan = makeChan(httpsOrigin + "VersionFallback");
  const { listener, promise } = createCheckFallbackListener();

  chan.asyncOpen(listener);

  await promise;
}
