/* 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 { NodeHTTP2Server } = ChromeUtils.importESModule(
  "resource://testing-common/NodeServer.sys.mjs"
);

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

let trrServer;
let h2Server;

add_setup(async function () {
  let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
    Ci.nsIX509CertDB
  );
  addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u");

  Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true);

  trrServer = new TRRServer();
  await trrServer.start();
  trr_test_setup();
  Services.prefs.setIntPref("network.trr.mode", 3);
  Services.prefs.setCharPref(
    "network.trr.uri",
    `https://foo.example.com:${trrServer.port()}/dns-query`
  );

  h2Server = new NodeHTTP2Server();
  await h2Server.start();
  await h2Server.registerPathHandler("/", (_req, resp) => {
    resp.writeHead(200, { "Content-Type": "text/plain" });
    resp.end("ok");
  });

  registerCleanupFunction(async () => {
    Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled");
    Services.prefs.clearUserPref("network.http.speculative-parallel-limit");
    trr_clear_prefs();
    if (trrServer) {
      await trrServer.stop();
    }
    if (h2Server) {
      await h2Server.stop();
    }
  });
});

async function resetConnections() {
  Services.obs.notifyObservers(null, "net:cancel-all-connections");
  let nssComponent = Cc["@mozilla.org/network/ssl-tokens-cache;1"].getService(
    Ci.nsISSLTokensCache
  );
  await nssComponent.asyncClearSSLExternalAndInternalSessionCache();
  Services.dns.clearCache(true);
  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
  await new Promise(resolve => setTimeout(resolve, 1000));
}

async function openChannelAndGetTimings(uri) {
  let chan = NetUtil.newChannel({
    uri,
    loadUsingSystemPrincipal: true,
    contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
  }).QueryInterface(Ci.nsIHttpChannel);
  chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI;

  let timedChannel = chan.QueryInterface(Ci.nsITimedChannel);
  let internalChannel = chan.QueryInterface(Ci.nsIHttpChannelInternal);

  let transportStatuses = [];
  let activityDistributor = Cc[
    "@mozilla.org/network/http-activity-distributor;1"
  ].getService(Ci.nsIHttpActivityDistributor);
  let observer = {
    observeActivity(aHttpChannel, aActivityType, aActivitySubtype) {
      if (
        aActivityType ===
        Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_SOCKET_TRANSPORT
      ) {
        try {
          let otherChan = aHttpChannel.QueryInterface(Ci.nsIChannel);
          if (otherChan.URI.spec === uri) {
            transportStatuses.push(aActivitySubtype);
          }
        } catch (e) {}
      }
    },
  };
  activityDistributor.addObserver(observer);

  await new Promise(resolve => {
    chan.asyncOpen(
      new ChannelListener((_req, _buf) => resolve(), null, CL_ALLOW_UNKNOWN_CL)
    );
  });

  activityDistributor.removeObserver(observer);

  return { timedChannel, internalChannel, transportStatuses };
}

function logTimings(timedChannel) {
  info(`domainLookupStartTime=${timedChannel.domainLookupStartTime}`);
  info(`domainLookupEndTime=${timedChannel.domainLookupEndTime}`);
  info(`connectStartTime=${timedChannel.connectStartTime}`);
  info(`tcpConnectEndTime=${timedChannel.tcpConnectEndTime}`);
  info(`secureConnectionStartTime=${timedChannel.secureConnectionStartTime}`);
  info(`connectEndTime=${timedChannel.connectEndTime}`);
}

function assertTimingsSet(timedChannel) {
  Assert.greater(
    timedChannel.domainLookupStartTime,
    0,
    "domainLookupStartTime should be set"
  );
  Assert.greater(
    timedChannel.domainLookupEndTime,
    0,
    "domainLookupEndTime should be set"
  );
  Assert.greater(
    timedChannel.connectStartTime,
    0,
    "connectStartTime should be set"
  );
  Assert.greater(
    timedChannel.connectEndTime,
    0,
    "connectEndTime should be set"
  );
  Assert.greater(
    timedChannel.secureConnectionStartTime,
    0,
    "secureConnectionStartTime should be set"
  );
}

function assertTransportStatusPresent(transportStatuses, expected) {
  for (let status of expected) {
    Assert.ok(
      transportStatuses.includes(status),
      `transport status 0x${status.toString(16)} should be present`
    );
  }
}

function assertTransportStatusUnique(transportStatuses, expected) {
  for (let status of expected) {
    Assert.equal(
      transportStatuses.filter(s => s === status).length,
      1,
      `transport status 0x${status.toString(16)} should appear exactly once`
    );
  }
}

function assertTimingsOrder(timedChannel) {
  Assert.lessOrEqual(
    timedChannel.domainLookupStartTime,
    timedChannel.domainLookupEndTime,
    "domainLookupStart <= domainLookupEnd"
  );
  // domainLookupEnd must never fall after the connection started: HE reports it
  // as min(connectStart, dnsEnd), matching Chrome and the web spec (bug
  // 2047648) rather than always reporting connectStart.
  Assert.lessOrEqual(
    timedChannel.domainLookupEndTime,
    timedChannel.connectStartTime,
    "domainLookupEnd <= connectStart"
  );
  Assert.lessOrEqual(
    timedChannel.connectStartTime,
    timedChannel.secureConnectionStartTime,
    "connectStart <= secureConnectionStart"
  );
  Assert.lessOrEqual(
    timedChannel.secureConnectionStartTime,
    timedChannel.connectEndTime,
    "secureConnectionStart <= connectEnd"
  );
}

add_task(async function test_tcp_timings_speculative_enabled() {
  await resetConnections();
  Services.prefs.setIntPref("network.http.speculative-parallel-limit", 6);

  let host = "foo.example.com";
  await trrServer.registerDoHAnswers(host, "A", {
    answers: [
      { name: host, ttl: 55, type: "A", flush: false, data: "127.0.0.1" },
    ],
  });
  await trrServer.registerDoHAnswers(host, "AAAA", {
    answers: [{ name: host, ttl: 55, type: "AAAA", flush: false, data: "::1" }],
  });

  let { timedChannel, internalChannel, transportStatuses } =
    await openChannelAndGetTimings(`https://${host}:${h2Server.port()}/`);

  logTimings(timedChannel);
  assertTimingsSet(timedChannel);
  assertTimingsOrder(timedChannel);
  Assert.ok(
    internalChannel.remoteAddress === "127.0.0.1" ||
      internalChannel.remoteAddress === "::1",
    `remoteAddress should be 127.0.0.1 or ::1, got ${internalChannel.remoteAddress}`
  );
  let expectedStatuses = [
    Ci.nsISocketTransport.STATUS_RESOLVING,
    Ci.nsISocketTransport.STATUS_RESOLVED,
    Ci.nsISocketTransport.STATUS_CONNECTING_TO,
    Ci.nsISocketTransport.STATUS_CONNECTED_TO,
  ];
  assertTransportStatusPresent(transportStatuses, expectedStatuses);
  assertTransportStatusUnique(transportStatuses, expectedStatuses);
});

add_task(async function test_tcp_timings_speculative_disabled() {
  await resetConnections();
  Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);

  let host = "alt1.example.com";
  await trrServer.registerDoHAnswers(host, "A", {
    answers: [
      { name: host, ttl: 55, type: "A", flush: false, data: "127.0.0.1" },
    ],
  });
  await trrServer.registerDoHAnswers(host, "AAAA", {
    answers: [{ name: host, ttl: 55, type: "AAAA", flush: false, data: "::1" }],
  });

  let { timedChannel, internalChannel, transportStatuses } =
    await openChannelAndGetTimings(`https://${host}:${h2Server.port()}/`);

  logTimings(timedChannel);
  assertTimingsSet(timedChannel);
  assertTimingsOrder(timedChannel);
  Assert.ok(
    internalChannel.remoteAddress === "127.0.0.1" ||
      internalChannel.remoteAddress === "::1",
    `remoteAddress should be 127.0.0.1 or ::1, got ${internalChannel.remoteAddress}`
  );
  let expectedStatuses = [
    Ci.nsISocketTransport.STATUS_RESOLVING,
    Ci.nsISocketTransport.STATUS_RESOLVED,
    Ci.nsISocketTransport.STATUS_CONNECTING_TO,
    Ci.nsISocketTransport.STATUS_CONNECTED_TO,
  ];
  assertTransportStatusPresent(transportStatuses, expectedStatuses);
  assertTransportStatusUnique(transportStatuses, expectedStatuses);
});

function assertH3Timings(timedChannel) {
  Assert.greater(
    timedChannel.domainLookupStartTime,
    0,
    "domainLookupStartTime should be set"
  );
  Assert.greater(
    timedChannel.domainLookupEndTime,
    0,
    "domainLookupEndTime should be set"
  );
  Assert.greater(
    timedChannel.connectStartTime,
    0,
    "connectStartTime should be set"
  );
  Assert.greater(
    timedChannel.connectEndTime,
    0,
    "connectEndTime should be set"
  );
  Assert.lessOrEqual(
    timedChannel.domainLookupStartTime,
    timedChannel.domainLookupEndTime,
    "domainLookupStart <= domainLookupEnd"
  );
  Assert.lessOrEqual(
    timedChannel.domainLookupEndTime,
    timedChannel.connectStartTime,
    "domainLookupEnd <= connectStart"
  );
  // For HTTP/3, connectStart == secureConnectionStart.
  Assert.equal(
    timedChannel.connectStartTime,
    timedChannel.secureConnectionStartTime,
    "connectStart == secureConnectionStart for HTTP/3"
  );
}

async function do_test_h3_timings(host) {
  await resetConnections();

  let { timedChannel, internalChannel, transportStatuses } =
    await openChannelAndGetTimings(`https://${host}/`);

  logTimings(timedChannel);
  assertH3Timings(timedChannel);
  Assert.equal(
    internalChannel.remoteAddress,
    "127.0.0.1",
    "remoteAddress should be 127.0.0.1 for HTTP/3"
  );
  let expectedStatuses = [
    Ci.nsISocketTransport.STATUS_RESOLVING,
    Ci.nsISocketTransport.STATUS_RESOLVED,
  ];
  assertTransportStatusPresent(transportStatuses, expectedStatuses);
  assertTransportStatusUnique(transportStatuses, expectedStatuses);
}

add_task(async function test_h3_timings() {
  let h3Port = Services.env.get("MOZHTTP3_PORT");
  Assert.notEqual(h3Port, null);
  Assert.notEqual(h3Port, "");

  await resetConnections();
  Services.prefs.setBoolPref("network.http.http3.enable", true);
  Services.prefs.setBoolPref("network.dns.disableIPv6", true);

  let host = "alt2.example.com";
  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    `${host};h3=:${h3Port}`
  );
  await trrServer.registerDoHAnswers(host, "A", {
    answers: [
      { name: host, ttl: 55, type: "A", flush: false, data: "127.0.0.1" },
    ],
  });

  Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
  await do_test_h3_timings(host);

  Services.prefs.setIntPref("network.http.speculative-parallel-limit", 6);
  await do_test_h3_timings(host);

  Services.prefs.clearUserPref("network.http.http3.enable");
  Services.prefs.clearUserPref("network.dns.disableIPv6");
  Services.prefs.clearUserPref(
    "network.http.http3.alt-svc-mapping-for-testing"
  );
});
