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

Services.prefs.setBoolPref("network.early-hints.enabled", true);

registerCleanupFunction(function () {
  Services.prefs.clearUserPref("network.early-hints.enabled");
});

// This function tests Early Hint responses before and in between HTTP redirects.
//
// Arguments:
// - name: String identifying the test case for easier parsing in the log
// - chain and destination: defines the redirect chain, see example below
//                          note: ALL preloaded urls must be image urls
// - expected: number of normal, cancelled and completed hinted responses.
//
// # Example
// The parameter values of
// ```
// chain = [
//  {link:"https://link1", host:"https://host1.com"},
//  {link:"https://link2", host:"https://host2.com"},
// ]
// ```
// and `destination = "https://host3.com/page.html" would result in the
// following HTTP exchange (simplified):
//
// ```
// > GET https://host1.com/redirect?something1
//
// < 103 Early Hints
// < Link: <https://link1>;rel=preload;as=image
// <
// < 307 Temporary Redirect
// < Location: https://host2.com/redirect?something2
// <
//
// > GET https://host2.com/redirect?something2
//
// < 103 Early Hints
// < Link: <https://link2>;rel=preload;as=image
// <
// < 307 Temporary Redirect
// < Location: https://host3.com/page.html
// <
//
// > GET https://host3.com/page.html
//
// < [...] Result depends on the final page
// ```
//
// Legend:
// * `>` indicates a request going from client to server
// * `<` indicates a response going from server to client
// * all lines are terminated with a `\r\n`
//
async function test_hint_redirect(
  name,
  chain,
  destination,
  hint_destination,
  expected
) {
  // pass the full redirect chain as a url parameter. Each redirect is handled
  // by `early_hint_redirect_html.sjs` which url-decodes the query string and
  // redirects to the result
  let links = [];
  let url = destination;
  for (let i = chain.length - 1; i >= 0; i--) {
    let qp = new URLSearchParams();
    if (chain[i].link != "") {
      qp.append("link", "<" + chain[i].link + ">;rel=preload;as=image");
      links.push(chain[i].link);
    }
    qp.append("location", url);

    url = `${
      chain[i].host
    }/browser/netwerk/test/browser/early_hint_redirect_html.sjs?${qp.toString()}`;
  }
  if (hint_destination != "") {
    links.push(hint_destination);
  }

  // reset the count
  let headers = new Headers();
  headers.append("X-Early-Hint-Count-Start", "");
  await fetch(
    "https://example.com/browser/netwerk/test/browser/early_hint_pixel_count.sjs",
    { headers }
  );

  // main request and all other must get their respective OnStopRequest
  let numRequestRemaining =
    expected.normal + expected.hinted + expected.cancelled;
  let observed = {
    hinted: 0,
    normal: 0,
    cancelled: 0,
  };
  // store channelIds
  let observedChannelIds = [];
  let callback;
  let promise = new Promise(resolve => {
    callback = resolve;
  });
  if (numRequestRemaining > 0) {
    let observer = {
      QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
      observe(aSubject, aTopic) {
        aSubject.QueryInterface(Ci.nsIIdentChannel);
        let id = aSubject.channelId;
        if (observedChannelIds.includes(id)) {
          return;
        }
        aSubject.QueryInterface(Ci.nsIRequest);
        dump("Observer aSubject.name " + aSubject.name + "\n");
        if (aTopic == "http-on-stop-request" && links.includes(aSubject.name)) {
          if (aSubject.status == Cr.NS_ERROR_ABORT) {
            observed.cancelled += 1;
          } else {
            aSubject.QueryInterface(Ci.nsIHttpChannel);
            let initiator = "";
            try {
              initiator = aSubject.getRequestHeader("X-Moz");
            } catch {}
            if (initiator == "early hint") {
              observed.hinted += 1;
            } else {
              observed.normal += 1;
            }
          }
          observedChannelIds.push(id);
          numRequestRemaining -= 1;
          dump("Observer numRequestRemaining " + numRequestRemaining + "\n");
        }
        if (numRequestRemaining == 0) {
          Services.obs.removeObserver(observer, "http-on-stop-request");
          callback();
        }
      },
    };
    Services.obs.addObserver(observer, "http-on-stop-request");
  } else {
    callback();
  }

  await BrowserTestUtils.withNewTab(
    {
      gBrowser,
      url,
      waitForLoad: true,
    },
    async function () {}
  );

  // wait until all requests are stopped, especially the cancelled ones
  await promise;

  let got = await fetch(
    "https://example.com/browser/netwerk/test/browser/early_hint_pixel_count.sjs"
  ).then(response => response.json());

  // stringify to pretty print assert output
  let g = JSON.stringify(observed);
  let e = JSON.stringify(expected);
  Assert.equal(
    expected.normal,
    observed.normal,
    `${name} normal observed from client expected ${expected.normal} (${e}) got ${observed.normal} (${g})`
  );
  Assert.equal(
    expected.hinted,
    observed.hinted,
    `${name} hinted observed from client expected ${expected.hinted} (${e})  got ${observed.hinted} (${g})`
  );
  Assert.equal(
    expected.cancelled,
    observed.cancelled,
    `${name} cancelled observed from client expected ${expected.cancelled} (${e})  got ${observed.cancelled} (${g})`
  );

  // each cancelled request might be cancelled after the request was already
  // made. Allow cancelled responses to count towards the hinted to avoid
  // intermittent test failures.
  Assert.ok(
    expected.hinted <= got.hinted &&
      got.hinted <= expected.hinted + expected.cancelled,
    `${name}: unexpected amount of hinted request made got ${
      got.hinted
    }, expected between ${expected.hinted} and ${
      expected.hinted + expected.cancelled
    }`
  );
  Assert.equal(
    got.normal,
    expected.normal,
    `${name}: unexpected amount of normal request made expected ${expected.normal}, got ${got.normal}`
  );
  Assert.equal(numRequestRemaining, 0, "Requests remaining");
}

add_task(async function double_redirect_cross_origin() {
  await test_hint_redirect(
    "double_redirect_cross_origin_both_hints",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.com/",
      },
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.net",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=1",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 2 }
  );
  await test_hint_redirect(
    "double_redirect_second_hint",
    [
      {
        link: "",
        host: "https://example.com/",
      },
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.net",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=1",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 1 }
  );
  await test_hint_redirect(
    "double_redirect_first_hint",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.com/",
      },
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.net",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=0",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 0, normal: 1, cancelled: 2 }
  );
});

add_task(async function redirect_cross_origin() {
  await test_hint_redirect(
    "redirect_cross_origin_start_second_preload",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.net",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=1",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 1 }
  );
  await test_hint_redirect(
    "redirect_cross_origin_dont_use_first_preload",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image&a",
        host: "https://example.net",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=0",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 0, normal: 1, cancelled: 1 }
  );
});

add_task(async function redirect_same_origin() {
  await test_hint_redirect(
    "hint_before_redirect_same_origin",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.org",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=1",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 0 }
  );
  await test_hint_redirect(
    "hint_after_redirect_same_origin",
    [
      {
        link: "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
        host: "https://example.org",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=0",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 0 }
  );
  await test_hint_redirect(
    "hint_after_redirect_same_origin",
    [
      {
        link: "",
        host: "https://example.org",
      },
    ],
    "https://example.org/browser/netwerk/test/browser/early_hint_asset_html.sjs?as=image&hinted=1",
    "https://example.org/browser/netwerk/test/browser/early_hint_asset.sjs?as=image",
    { hinted: 1, normal: 0, cancelled: 0 }
  );
});
