"use strict";

let commonEvents = {
  onBeforeRequest: [{ urls: ["<all_urls>"] }, ["blocking"]],
  onBeforeSendHeaders: [
    { urls: ["<all_urls>"] },
    ["blocking", "requestHeaders"],
  ],
  onSendHeaders: [{ urls: ["<all_urls>"] }, ["requestHeaders"]],
  onBeforeRedirect: [{ urls: ["<all_urls>"] }],
  onHeadersReceived: [
    { urls: ["<all_urls>"] },
    ["blocking", "responseHeaders"],
  ],
  // Auth tests will need to set their own events object
  // "onAuthRequired":      [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]],
  onResponseStarted: [{ urls: ["<all_urls>"] }],
  onCompleted: [{ urls: ["<all_urls>"] }, ["responseHeaders"]],
  onErrorOccurred: [{ urls: ["<all_urls>"] }],
};

function background(events) {
  const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;

  let expect;
  let ignore;
  let defaultOrigin;
  let watchAuth = Object.keys(events).includes("onAuthRequired");
  let expectedIp = null;

  browser.test.onMessage.addListener((msg, expected) => {
    if (msg !== "set-expected") {
      return;
    }
    expect = expected.expect;
    defaultOrigin = expected.origin;
    ignore = expected.ignore;
    let promises = [];
    // Initialize some stuff we'll need in the tests.
    for (let entry of Object.values(expect)) {
      // a place for the test infrastructure to store some state.
      entry.test = {};
      // Each entry in expected gets a Promise that will be resolved in the
      // last event for that entry.  This will either be onCompleted, or the
      // last entry if an events list was provided.
      promises.push(
        new Promise(resolve => {
          entry.test.resolve = resolve;
        })
      );
      // If events was left undefined, we're expecting all normal events we're
      // listening for, exclude onBeforeRedirect and onErrorOccurred
      if (entry.events === undefined) {
        entry.events = Object.keys(events).filter(
          name => name != "onErrorOccurred" && name != "onBeforeRedirect"
        );
      }
      if (entry.optional_events === undefined) {
        entry.optional_events = [];
      }
    }
    // When every expected entry has finished our test is done.
    Promise.all(promises).then(() => {
      browser.test.sendMessage("done");
    });
    browser.test.sendMessage("continue");
  });

  // Retrieve the per-file/test expected values.
  function getExpected(details) {
    let url = new URL(details.url);
    let filename = url.pathname.split("/").pop();
    if (ignore && ignore.includes(filename)) {
      return;
    }
    let expected = expect[filename];
    if (!expected) {
      browser.test.fail(`unexpected request ${filename}`);
      return;
    }
    // Save filename for redirect verification.
    expected.test.filename = filename;
    return expected;
  }

  // Process any test header modifications that can happen in request or response phases.
  // If a test includes headers, it needs a complete header object, no undefined
  // objects even if empty:
  // request: {
  //   add: {"HeaderName": "value",},
  //   modify: {"HeaderName": "value",},
  //   remove: ["HeaderName",],
  // },
  // response: {
  //   add: {"HeaderName": "value",},
  //   modify: {"HeaderName": "value",},
  //   remove: ["HeaderName",],
  // },
  function processHeaders(phase, expected, details) {
    // This should only happen once per phase [request|response].
    browser.test.assertFalse(
      !!expected.test[phase],
      `First processing of headers for ${phase}`
    );
    expected.test[phase] = true;

    let headers = details[`${phase}Headers`];
    browser.test.assertTrue(
      Array.isArray(headers),
      `${phase}Headers array present`
    );

    let { add, modify, remove } = expected.headers[phase];

    for (let name in add) {
      browser.test.assertTrue(
        !headers.find(h => h.name === name),
        `header ${name} to be added not present yet in ${phase}Headers`
      );
      let header = { name: name };
      if (name.endsWith("-binary")) {
        header.binaryValue = Array.from(add[name], c => c.charCodeAt(0));
      } else {
        header.value = add[name];
      }
      headers.push(header);
    }

    let modifiedAny = false;
    for (let header of headers) {
      if (header.name.toLowerCase() in modify) {
        header.value = modify[header.name.toLowerCase()];
        modifiedAny = true;
      }
    }
    browser.test.assertTrue(
      modifiedAny,
      `at least one ${phase}Headers element to modify`
    );

    let deletedAny = false;
    for (let j = headers.length; j-- > 0; ) {
      if (remove.includes(headers[j].name.toLowerCase())) {
        headers.splice(j, 1);
        deletedAny = true;
      }
    }
    browser.test.assertTrue(
      deletedAny,
      `at least one ${phase}Headers element to delete`
    );

    return headers;
  }

  // phase is request or response.
  function checkHeaders(phase, expected, details) {
    if (!/^https?:/.test(details.url)) {
      return;
    }

    let headers = details[`${phase}Headers`];
    browser.test.assertTrue(
      Array.isArray(headers),
      `valid ${phase}Headers array`
    );

    let { add, modify, remove } = expected.headers[phase];
    for (let name in add) {
      let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase())
        .value;
      browser.test.assertEq(
        value,
        add[name],
        `header ${name} correctly injected in ${phase}Headers`
      );
    }

    for (let name in modify) {
      let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase())
        .value;
      browser.test.assertEq(
        value,
        modify[name],
        `header ${name} matches modified value`
      );
    }

    for (let name of remove) {
      let found = headers.find(
        h => h.name.toLowerCase() === name.toLowerCase()
      );
      browser.test.assertFalse(
        !!found,
        `deleted header ${name} still found in ${phase}Headers`
      );
    }
  }

  let listeners = {
    onBeforeRequest(expected, details, result) {
      // Save some values to test request consistency in later events.
      browser.test.assertTrue(
        details.tabId !== undefined,
        `tabId ${details.tabId}`
      );
      browser.test.assertTrue(
        details.requestId !== undefined,
        `requestId ${details.requestId}`
      );
      // Validate requestId if it's already set, this happens with redirects.
      if (expected.test.requestId !== undefined) {
        browser.test.assertEq(
          "string",
          typeof expected.test.requestId,
          `requestid ${expected.test.requestId} is string`
        );
        browser.test.assertEq(
          "string",
          typeof details.requestId,
          `requestid ${details.requestId} is string`
        );
        browser.test.assertEq(
          "number",
          typeof parseInt(details.requestId, 10),
          "parsed requestid is number"
        );
        browser.test.assertEq(
          expected.test.requestId,
          details.requestId,
          "redirects will keep the same requestId"
        );
      } else {
        // Save any values we want to validate in later events.
        expected.test.requestId = details.requestId;
        expected.test.tabId = details.tabId;
      }
      // Tests we don't need to do every event.
      browser.test.assertTrue(
        details.type.toUpperCase() in browser.webRequest.ResourceType,
        `valid resource type ${details.type}`
      );
      if (details.type == "main_frame") {
        browser.test.assertEq(
          0,
          details.frameId,
          "frameId is zero when type is main_frame, see bug 1329299"
        );
      }
    },
    onBeforeSendHeaders(expected, details, result) {
      if (expected.headers && expected.headers.request) {
        result.requestHeaders = processHeaders("request", expected, details);
      }
      if (expected.redirect) {
        browser.test.log(`${name} redirect request`);
        result.redirectUrl = details.url.replace(
          expected.test.filename,
          expected.redirect
        );
      }
    },
    onBeforeRedirect() {},
    onSendHeaders(expected, details, result) {
      if (expected.headers && expected.headers.request) {
        checkHeaders("request", expected, details);
      }
    },
    onResponseStarted() {},
    onHeadersReceived(expected, details, result) {
      let expectedStatus = expected.status || 200;
      // If authentication is being requested we don't fail on the status code.
      if (watchAuth && [401, 407].includes(details.statusCode)) {
        expectedStatus = details.statusCode;
      }
      browser.test.assertEq(
        expectedStatus,
        details.statusCode,
        `expected HTTP status received for ${details.url} ${details.statusLine}`
      );
      if (expected.headers && expected.headers.response) {
        result.responseHeaders = processHeaders("response", expected, details);
      }
    },
    onAuthRequired(expected, details, result) {
      result.authCredentials = expected.authInfo;
    },
    onCompleted(expected, details, result) {
      // If we have already completed a GET request for this url,
      // and it was found, we expect for the response to come fromCache.
      // expected.cached may be undefined, force boolean.
      if (typeof expected.cached === "boolean") {
        let expectCached =
          expected.cached &&
          details.method === "GET" &&
          details.statusCode != 404;
        browser.test.assertEq(
          expectCached,
          details.fromCache,
          "fromCache is correct"
        );
      }
      // We can only tell IPs for non-cached HTTP requests.
      if (!details.fromCache && /^https?:/.test(details.url)) {
        browser.test.assertTrue(
          IP_PATTERN.test(details.ip),
          `IP for ${details.url} looks IP-ish: ${details.ip}`
        );

        // We can't easily predict the IP ahead of time, so just make
        // sure they're all consistent.
        expectedIp = expectedIp || details.ip;
        browser.test.assertEq(
          expectedIp,
          details.ip,
          `correct ip for ${details.url}`
        );
      }
      if (expected.headers && expected.headers.response) {
        checkHeaders("response", expected, details);
      }
    },
    onErrorOccurred(expected, details, result) {
      if (expected.error) {
        if (Array.isArray(expected.error)) {
          browser.test.assertTrue(
            expected.error.includes(details.error),
            "expected error message received in onErrorOccurred"
          );
        } else {
          browser.test.assertEq(
            expected.error,
            details.error,
            "expected error message received in onErrorOccurred"
          );
        }
      }
    },
  };

  function getListener(name) {
    return details => {
      let result = {};
      browser.test.log(`${name} ${details.requestId} ${details.url}`);
      let expected = getExpected(details);
      if (!expected) {
        return result;
      }
      let expectedEvent = expected.events[0] == name;
      if (expectedEvent) {
        expected.events.shift();
      } else {
        // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred
        expectedEvent = expected.optional_events.includes(name);
      }
      browser.test.assertTrue(expectedEvent, `received ${name}`);
      browser.test.assertEq(
        expected.type,
        details.type,
        "resource type is correct"
      );
      browser.test.assertEq(
        expected.origin || defaultOrigin,
        details.originUrl,
        "origin is correct"
      );

      if (name != "onBeforeRequest") {
        // On events after onBeforeRequest, check the previous values.
        browser.test.assertEq(
          expected.test.requestId,
          details.requestId,
          "correct requestId"
        );
        browser.test.assertEq(
          expected.test.tabId,
          details.tabId,
          "correct tabId"
        );
      }
      try {
        listeners[name](expected, details, result);
      } catch (e) {
        browser.test.fail(`unexpected webrequest failure ${name} ${e}`);
      }

      if (expected.cancel && expected.cancel == name) {
        browser.test.log(`${name} cancel request`);
        browser.test.sendMessage("cancelled");
        result.cancel = true;
      }
      // If we've used up all the events for this test, resolve the promise.
      // If something wrong happens and more events come through, there will be
      // failures.
      if (expected.events.length <= 0) {
        expected.test.resolve();
      }
      return result;
    };
  }

  for (let [name, args] of Object.entries(events)) {
    browser.test.log(`adding listener for ${name}`);
    try {
      browser.webRequest[name].addListener(getListener(name), ...args);
    } catch (e) {
      browser.test.assertTrue(
        /\brequestBody\b/.test(e.message),
        "Request body is unsupported"
      );

      // RequestBody is disabled in release builds.
      if (!/\brequestBody\b/.test(e.message)) {
        throw e;
      }

      args.splice(args.indexOf("requestBody"), 1);
      browser.webRequest[name].addListener(getListener(name), ...args);
    }
  }
}

/* exported makeExtension */

function makeExtension(events = commonEvents) {
  return ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
    },
    background: `(${background})(${JSON.stringify(events)})`,
  });
}

/* exported addStylesheet */

function addStylesheet(file) {
  let link = document.createElement("link");
  link.setAttribute("rel", "stylesheet");
  link.setAttribute("href", file);
  document.body.appendChild(link);
}

/* exported addLink */

function addLink(file) {
  let a = document.createElement("a");
  a.setAttribute("href", file);
  a.setAttribute("target", "_blank");
  a.setAttribute("rel", "opener");
  document.body.appendChild(a);
  return a;
}

/* exported addImage */

function addImage(file) {
  let img = document.createElement("img");
  img.setAttribute("src", file);
  document.body.appendChild(img);
}

/* exported addScript */

function addScript(file) {
  let script = document.createElement("script");
  script.setAttribute("type", "text/javascript");
  script.setAttribute("src", file);
  document
    .getElementsByTagName("head")
    .item(0)
    .appendChild(script);
}

/* exported addFrame */

function addFrame(file) {
  let frame = document.createElement("iframe");
  frame.setAttribute("width", "200");
  frame.setAttribute("height", "200");
  frame.setAttribute("src", file);
  document.body.appendChild(frame);
}
