/* 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 {
  Http3ProxyFilter,
  with_node_servers,
  NodeHTTPServer,
  NodeHTTPSServer,
  NodeHTTP2Server,
  NodeHTTP2ProxyServer,
  NodeWebSocketHttp2Server,
  WebSocketConnection,
  NodeTCPEchoServer,
  NodeTLSEchoServer,
} = ChromeUtils.importESModule("resource://testing-common/NodeServer.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;
}

function channelOpenPromise(chan, flags) {
  return new Promise(resolve => {
    function finish(req, buffer) {
      resolve([req, buffer]);
    }
    chan.asyncOpen(new ChannelListener(finish, null, flags));
  });
}

let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService();
let proxyHost;
let proxyPort;
let noResponsePort;
let proxyAuth;
let proxyFilter;

/**
 * Sets up proxy filter to MASQUE H3 proxy
 */
async function setup_http3_proxy() {
  Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true);
  Services.prefs.setBoolPref("network.dns.disableIPv6", true);
  Services.prefs.setIntPref("network.webtransport.datagram_size", 1500);
  Services.prefs.setCharPref("network.dns.localDomains", "foo.example.com");
  Services.prefs.setIntPref("network.http.http3.max_gso_segments", 1); // TODO: fix underflow
  let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
    Ci.nsIX509CertDB
  );
  addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u");
  addCertFromFile(certdb, "proxy-ca.pem", "CTu,u,u");

  proxyHost = "foo.example.com";
  ({ masqueProxyPort: proxyPort, noResponsePort } =
    await create_masque_proxy_server());
  proxyAuth = "";

  Assert.notEqual(proxyPort, null);
  Assert.notEqual(proxyPort, "");

  // A dummy request to make sure AltSvcCache::mStorage is ready.
  let chan = makeChan(`https://localhost`);
  await channelOpenPromise(chan, CL_EXPECT_FAILURE);

  proxyFilter = new Http3ProxyFilter(
    proxyHost,
    proxyPort,
    0,
    "/.well-known/masque/udp/{target_host}/{target_port}/",
    proxyAuth
  );
  pps.registerFilter(proxyFilter, 10);

  registerCleanupFunction(() => {
    Services.prefs.clearUserPref("network.proxy.allow_hijacking_localhost");
  });
}

/**
 * Tests HTTP connect through H3 proxy to HTTP, HTTPS and H2 servers
 * Makes multiple requests. Expects success.
 */
async function test_http_connect() {
  info("Running test_http_connect");
  await with_node_servers(
    [NodeHTTPServer, NodeHTTPSServer, NodeHTTP2Server],
    async server => {
      info(`Proxying to ${server.constructor.name} server`);
      await server.registerPathHandler("/first", (req, resp) => {
        resp.writeHead(200);
        resp.end("first");
      });
      await server.registerPathHandler("/second", (req, resp) => {
        resp.writeHead(200);
        resp.end("second");
      });
      await server.registerPathHandler("/third", (req, resp) => {
        resp.writeHead(200);
        resp.end("third");
      });
      let chan = makeChan(
        `${server.protocol()}://alt1.example.com:${server.port()}/first`
      );
      let [req, buf] = await channelOpenPromise(
        chan,
        CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
      );
      Assert.equal(req.status, Cr.NS_OK);
      Assert.equal(buf, "first");
      chan = makeChan(
        `${server.protocol()}://alt1.example.com:${server.port()}/second`
      );
      [req, buf] = await channelOpenPromise(
        chan,
        CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
      );
      Assert.equal(req.status, Cr.NS_OK);
      Assert.equal(buf, "second");

      chan = makeChan(
        `${server.protocol()}://alt1.example.com:${server.port()}/third`
      );
      [req, buf] = await channelOpenPromise(
        chan,
        CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
      );
      Assert.equal(req.status, Cr.NS_OK);
      Assert.equal(buf, "third");
    }
  );
}

/**
 * Test HTTP CONNECT authentication failure - tests behavior when proxy
 * authentication is required but not provided or incorrect
 */
async function test_http_connect_auth_failure() {
  info("Running test_http_connect_auth_failure");
  await with_node_servers(
    [NodeHTTPServer, NodeHTTPSServer, NodeHTTP2Server],
    async server => {
      info(`Testing auth failure with ${server.constructor.name} server`);
      // Register a handler that requires authentication
      await server.registerPathHandler("/auth-required", (req, resp) => {
        const auth = req.headers.authorization;
        if (!auth || auth !== "Basic dGVzdDp0ZXN0") {
          resp.writeHead(401, {
            "WWW-Authenticate": 'Basic realm="Test Realm"',
            "Content-Type": "text/plain",
          });
          resp.end("");
        } else {
          resp.writeHead(200);
          resp.end("Authenticated");
        }
      });

      let chan = makeChan(
        `${server.protocol()}://alt1.example.com:${server.port()}/auth-required`
      );
      let [req] = await channelOpenPromise(
        chan,
        CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
      );

      // Should receive 401 Unauthorized through the tunnel
      Assert.equal(req.status, Cr.NS_OK);
      Assert.equal(req.QueryInterface(Ci.nsIHttpChannel).responseStatus, 401);
    }
  );
}

/**
 * Test HTTP CONNECT with large request/response data - ensures the tunnel
 * can handle substantial data transfer without corruption or truncation
 */
async function test_http_connect_large_data() {
  info("Running test_http_connect_large_data");
  await with_node_servers(
    [NodeHTTPServer, NodeHTTPSServer, NodeHTTP2Server],
    async server => {
      info(
        `Testing large data transfer with ${server.constructor.name} server`
      );
      // Create a large response payload (1MB of data)
      const largeData = "x".repeat(1024 * 1024);
      await server.registerPathHandler("/large", (req, resp) => {
        const largeData = "x".repeat(1024 * 1024);
        resp.writeHead(200, { "Content-Type": "text/plain" });
        resp.end(largeData);
      });

      let chan = makeChan(
        `${server.protocol()}://alt1.example.com:${server.port()}/large`
      );
      let [req, buf] = await channelOpenPromise(
        chan,
        CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
      );

      Assert.equal(req.status, Cr.NS_OK);
      Assert.equal(buf.length, largeData.length);
      Assert.equal(buf, largeData);
    }
  );
}

/**
 * Test HTTP CONNECT tunnel connection refused - simulates target server
 * being unreachable or refusing connections
 */
async function test_http_connect_connection_refused() {
  info("Running test_http_connect_connection_refused");
  // Test connecting to a port that's definitely not in use
  let chan = makeChan(`http://alt1.example.com:667/refused`);
  let [req] = await channelOpenPromise(chan, CL_EXPECT_FAILURE);

  // Should fail to establish tunnel connection
  Assert.notEqual(req.status, Cr.NS_OK);
  info(`Connection refused status: ${req.status}`);
}

/**
 * Test HTTP CONNECT with invalid target host - verifies proper error handling
 * when trying to tunnel to a non-existent hostname
 */
async function test_http_connect_invalid_host() {
  info("Running test_http_connect_invalid_host");
  let chan = makeChan(`http://nonexistent.invalid.example/test`);
  let [req] = await channelOpenPromise(chan, CL_EXPECT_FAILURE);

  // Should fail DNS resolution for invalid hostname
  Assert.notEqual(req.status, Cr.NS_OK);
  info(`Invalid host status: ${req.status}`);
}

/**
 * Test concurrent HTTP CONNECT tunnels - ensures multiple simultaneous
 * requests can be established and used independently through the same H3 proxy
 */
async function test_concurrent_http_connect_tunnels() {
  info("Running test_concurrent_http_connect_tunnels");
  await with_node_servers(
    [NodeHTTPServer, NodeHTTPSServer, NodeHTTP2Server],
    async server => {
      info(`Testing concurrent tunnels with ${server.constructor.name} server`);

      // Register multiple endpoints
      await server.registerPathHandler("/concurrent1", (req, resp) => {
        resp.writeHead(200);
        resp.end("response1");
      });
      await server.registerPathHandler("/concurrent2", (req, resp) => {
        resp.writeHead(200);
        resp.end("response2");
      });
      await server.registerPathHandler("/concurrent3", (req, resp) => {
        resp.writeHead(200);
        resp.end("response3");
      });

      // Create multiple concurrent requests through the tunnel
      const promises = [];
      for (let i = 1; i <= 3; i++) {
        let chan = makeChan(
          `${server.protocol()}://alt1.example.com:${server.port()}/concurrent${i}`
        );
        promises.push(
          channelOpenPromise(chan, CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL)
        );
      }

      const results = await Promise.all(promises);

      // Verify all requests succeeded with correct responses
      for (let i = 0; i < 3; i++) {
        const [req, buf] = results[i];
        Assert.equal(req.status, Cr.NS_OK);
        Assert.equal(buf, `response${i + 1}`);
      }
      info("All concurrent tunnels completed successfully");
    }
  );
}

/**
 * Test HTTP CONNECT tunnel stream closure handling - verifies proper cleanup
 * when the tunnel connection is closed unexpectedly
 */
// eslint-disable-next-line no-unused-vars
async function test_http_connect_stream_closure() {
  info("Running test_http_connect_stream_closure");
  await with_node_servers([NodeHTTPServer], async server => {
    info(`Testing stream closure with ${server.constructor.name} server`);

    await server.registerPathHandler("/close", (req, resp) => {
      // Send partial response then close connection abruptly
      resp.writeHead(200, { "Content-Type": "text/plain" });
      resp.write("partial");
      // Simulate connection closure
      resp.destroy();
    });

    let chan = makeChan(
      `${server.protocol()}://alt1.example.com:${server.port()}/close`
    );
    let [req] = await channelOpenPromise(chan, CL_EXPECT_FAILURE);

    // Should handle connection closure gracefully
    Assert.notEqual(req.status, Cr.NS_OK);
    info(`Stream closure status: ${req.status}`);
  });
}

/**
 * Test connect-udp - SUCCESS case.
 * Will use h3 proxy to connect to h3 server.
 */
async function test_connect_udp() {
  info("Running test_connect_udp");
  let h3Port = Services.env.get("MOZHTTP3_PORT");
  info(`h3Port = ${h3Port}`);

  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    `alt1.example.com;h3=:${h3Port}`
  );

  {
    let chan = makeChan(`https://alt1.example.com:${h3Port}/no_body`);
    let [req] = await channelOpenPromise(
      chan,
      CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
    );
    Assert.equal(req.protocolVersion, "h3");
    Assert.equal(req.status, Cr.NS_OK);
    Assert.equal(req.responseStatus, 200);
  }
}

async function test_http_connect_fallback() {
  info("Running test_http_connect_fallback");
  pps.unregisterFilter(proxyFilter);

  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    ""
  );

  let proxyPort = noResponsePort;
  let proxy = new NodeHTTP2ProxyServer();
  await proxy.startWithoutProxyFilter(proxyPort);
  Assert.equal(proxyPort, proxy.port());
  dump(`proxy port=${proxy.port()}\n`);

  let server = new NodeHTTP2Server();
  await server.start();

  // Register multiple endpoints
  await server.registerPathHandler("/concurrent1", (req, resp) => {
    resp.writeHead(200);
    resp.end("response1");
  });
  await server.registerPathHandler("/concurrent2", (req, resp) => {
    resp.writeHead(200);
    resp.end("response2");
  });
  await server.registerPathHandler("/concurrent3", (req, resp) => {
    resp.writeHead(200);
    resp.end("response3");
  });

  let filter = new Http3ProxyFilter(
    proxyHost,
    proxy.port(),
    0,
    "/.well-known/masque/udp/{target_host}/{target_port}/",
    proxyAuth
  );
  pps.registerFilter(filter, 10);

  registerCleanupFunction(async () => {
    await proxy.stop();
    await server.stop();
  });

  // Create multiple concurrent requests through the tunnel
  const promises = [];
  for (let i = 1; i <= 3; i++) {
    let chan = makeChan(
      `${server.protocol()}://alt1.example.com:${server.port()}/concurrent${i}`
    );
    promises.push(channelOpenPromise(chan, CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL));
  }

  const results = await Promise.all(promises);

  // Verify all requests succeeded with correct responses
  for (let i = 0; i < 3; i++) {
    const [req, buf] = results[i];
    Assert.equal(req.status, Cr.NS_OK);
    Assert.equal(buf, `response${i + 1}`);
  }

  let h3Port = server.port();
  console.log(`h3Port = ${h3Port}`);

  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    `alt1.example.com;h3=:${h3Port}`
  );

  let chan = makeChan(`https://alt1.example.com:${h3Port}/concurrent1`);
  let [req] = await channelOpenPromise(
    chan,
    CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
  );
  Assert.equal(req.status, Cr.NS_OK);
  Assert.equal(req.responseStatus, 200);

  await proxy.stop();
  pps.unregisterFilter(filter);
  await server.stop();
}

/**
 * Helper function to open a WebSocket connection
 */
async function wsChannelOpenPromise(url, msg) {
  let conn = new WebSocketConnection();
  let statusObj = await Promise.race([conn.open(url), conn.finished()]);
  if (statusObj && statusObj.status != Cr.NS_OK) {
    return [statusObj.status, ""];
  }
  let finalStatusPromise = conn.finished();
  conn.send(msg);
  let res = await conn.receiveMessages();
  conn.close();
  let finalStatus = await finalStatusPromise;
  return [finalStatus.status, res];
}

/**
 * Test WebSocket through H3 proxy using H2 WebSocket server
 */
async function test_http_connect_websocket() {
  info("Running test_http_connect_websocket");
  Services.prefs.setBoolPref("network.http.http2.websockets", true);

  let wss = new NodeWebSocketHttp2Server();
  await wss.start();

  registerCleanupFunction(async () => {
    await wss.stop();
  });

  Assert.notEqual(wss.port(), null);
  await wss.registerMessageHandler((data, ws) => {
    ws.send(data);
  });

  let url = `wss://alt1.example.com:${wss.port()}`;
  const msg = "test h2 websocket through h3 proxy";
  let [status, res] = await wsChannelOpenPromise(url, msg);
  Assert.equal(status, Cr.NS_OK);
  Assert.deepEqual(res, [msg]);

  // Test multiple messages
  let conn = new WebSocketConnection();
  await conn.open(url);
  conn.send("message1");
  let mess1 = await conn.receiveMessages();
  Assert.deepEqual(mess1, ["message1"]);

  conn.send("message2");
  conn.send("message3");
  let mess2 = [];
  while (mess2.length < 2) {
    mess2 = mess2.concat(await conn.receiveMessages());
  }
  Assert.deepEqual(mess2, ["message2", "message3"]);

  conn.close();
  let { status: finalStatus } = await conn.finished();
  Assert.equal(finalStatus, Cr.NS_OK);

  await wss.stop();
}

async function test_inner_connection_fallback(ServerClass) {
  info("Running test_inner_connection_fallback");
  let h3Port = Services.env.get("MOZHTTP3_PORT_NO_RESPONSE");
  info(`h3Port = ${h3Port}`);

  // Register the connect-udp proxy.
  pps.registerFilter(proxyFilter, 10);

  let httpsProxy = new NodeHTTP2ProxyServer();
  await httpsProxy.startWithoutProxyFilter(proxyPort);

  let server = new ServerClass();
  await server.start(h3Port);

  // Register multiple endpoints
  await server.registerPathHandler("/concurrent1", (req, resp) => {
    resp.writeHead(200);
    resp.end("fallback1");
  });
  await server.registerPathHandler("/concurrent2", (req, resp) => {
    resp.writeHead(200);
    resp.end("fallback2");
  });
  await server.registerPathHandler("/concurrent3", (req, resp) => {
    resp.writeHead(200);
    resp.end("fallback3");
  });
  registerCleanupFunction(async () => {
    await server.stop();
    await httpsProxy.stop();
  });

  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    `alt1.example.com;h3=:${h3Port}`
  );

  // Create multiple concurrent requests through the tunnel
  const promises = [];
  for (let i = 1; i <= 3; i++) {
    let chan = makeChan(
      `${server.protocol()}://alt1.example.com:${h3Port}/concurrent${i}`
    );
    promises.push(channelOpenPromise(chan, CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL));
  }

  const results = await Promise.all(promises);

  // Verify all requests succeeded with correct responses
  for (let i = 0; i < 3; i++) {
    const [req, buf] = results[i];
    Assert.equal(req.status, Cr.NS_OK);
    Assert.equal(buf, `fallback${i + 1}`);
  }
  await server.stop();
  await httpsProxy.stop();

  pps.unregisterFilter(proxyFilter);
}

const CC = Components.Constructor;
const BinaryInputStream = CC(
  "@mozilla.org/binaryinputstream;1",
  "nsIBinaryInputStream",
  "setInputStream"
);
const BinaryOutputStream = CC(
  "@mozilla.org/binaryoutputstream;1",
  "nsIBinaryOutputStream",
  "setOutputStream"
);

function createUpgradeHandlers({ writePayload = "hello" } = {}) {
  const handler = {
    _resolve: null,

    waitForData() {
      return new Promise(resolve => {
        this._resolve = resolve;
      });
    },

    onInputStreamReady(input) {
      try {
        const bis = new BinaryInputStream(input);
        const n = input.available();
        if (n > 0) {
          const data = bis.readByteArray(n);
          if (this._resolve) {
            this._resolve(data);
            this._resolve = null;
          }
        }
      } catch (e) {
        Assert.ok(false, `onInputStreamReady threw: ${e}`);
      }
    },

    onOutputStreamReady(output) {
      try {
        const bos = new BinaryOutputStream(output);
        bos.writeByteArray(new TextEncoder().encode(writePayload));
      } catch (e) {
        Assert.ok(false, `onOutputStreamReady threw: ${e}`);
      }
    },

    QueryInterface: ChromeUtils.generateQI([
      "nsIInputStreamCallback",
      "nsIOutputStreamCallback",
    ]),
  };

  const listener = {
    onTransportAvailable(transport, socketIn, socketOut) {
      Assert.ok(
        transport && socketIn && socketOut,
        "transport and streams present"
      );
      socketIn.asyncWait(handler, 0, 0, Services.tm.mainThread);
      socketOut.asyncWait(handler, 0, 0, Services.tm.mainThread);
    },
    QueryInterface: ChromeUtils.generateQI(["nsIHttpUpgradeListener"]),
  };

  return { upgradeListener: listener, connectHandler: handler };
}

async function test_http_connect_only() {
  info("Running test_http_connect_only");
  Services.prefs.setCharPref(
    "network.http.http3.alt-svc-mapping-for-testing",
    ""
  );

  let echo = new NodeTCPEchoServer();
  await echo.start();
  registerCleanupFunction(async () => {
    await echo.stop();
  });

  pps.registerFilter(proxyFilter, 10);

  const TEST_DATA = "HelloWorld";
  const { upgradeListener, connectHandler } = createUpgradeHandlers({
    writePayload: TEST_DATA,
  });

  const dataPromise = connectHandler.waitForData();

  let chan = makeChan(`http://localhost:${echo.port()}/`);
  let internal = chan.QueryInterface(Ci.nsIHttpChannelInternal);
  internal.HTTPUpgrade("webrtc", upgradeListener);
  internal.setConnectOnly(true);

  let [req] = await channelOpenPromise(
    chan,
    CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
  );

  Assert.equal(req.status, Cr.NS_OK);
  Assert.equal(req.responseStatus, 200);

  const data = await dataPromise;
  Assert.equal(TEST_DATA, String.fromCharCode.apply(String, data));
}

async function test_https_connect_only() {
  info("Running test_http_connect_only");
  pps.unregisterFilter(proxyFilter);

  let echo = new NodeTLSEchoServer();
  await echo.start();
  registerCleanupFunction(async () => {
    await echo.stop();
  });

  pps.registerFilter(proxyFilter, 10);

  const TEST_DATA = "HelloWorld";
  const { upgradeListener, connectHandler } = createUpgradeHandlers({
    writePayload: TEST_DATA,
  });

  const dataPromise = connectHandler.waitForData();

  let chan = makeChan(`https://localhost:${echo.port()}/`);
  let internal = chan.QueryInterface(Ci.nsIHttpChannelInternal);
  internal.HTTPUpgrade("webrtc", upgradeListener);
  internal.setConnectOnly(true);

  let [req] = await channelOpenPromise(
    chan,
    CL_IGNORE_CL | CL_ALLOW_UNKNOWN_CL
  );

  Assert.equal(req.status, Cr.NS_OK);
  Assert.equal(req.responseStatus, 200);

  const data = await dataPromise;
  Assert.equal(TEST_DATA, String.fromCharCode.apply(String, data));
}
