/* 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/. */

// COMPLETE_LENGTH and PARTIAL_LENGTH copied from nsUrlClassifierDBService.h,
// they correspond to the length, in bytes, of a hash prefix and the total
// hash.
const COMPLETE_LENGTH = 32;
const PARTIAL_LENGTH = 4;

// Upper limit on the server response minimumWaitDuration
const MIN_WAIT_DURATION_MAX_VALUE = 24 * 60 * 60 * 1000;
const PREF_DEBUG_ENABLED = "browser.safebrowsing.debug";

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";

const lazy = {};

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gDbService",
  "@mozilla.org/url-classifier/dbservice;1",
  Ci.nsIUrlClassifierDBService
);

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gUrlUtil",
  "@mozilla.org/url-classifier/utils;1",
  Ci.nsIUrlClassifierUtils
);

let loggingEnabled = false;

// Log only if browser.safebrowsing.debug is true
function log(...stuff) {
  if (!loggingEnabled) {
    return;
  }

  var d = new Date();
  let msg = "hashcompleter: " + d.toTimeString() + ": " + stuff.join(" ");
  dump(Services.urlFormatter.trimSensitiveURLs(msg) + "\n");
}

// Map the HTTP response code to a Telemetry bucket
// https://developers.google.com/safe-browsing/developers_guide_v2?hl=en
// eslint-disable-next-line complexity
function httpStatusToBucket(httpStatus) {
  var statusBucket;
  switch (httpStatus) {
    case 100:
    case 101:
      // Unexpected 1xx return code
      statusBucket = 0;
      break;
    case 200:
      // OK - Data is available in the HTTP response body.
      statusBucket = 1;
      break;
    case 201:
    case 202:
    case 203:
    case 205:
    case 206:
      // Unexpected 2xx return code
      statusBucket = 2;
      break;
    case 204:
      // No Content - There are no full-length hashes with the requested prefix.
      statusBucket = 3;
      break;
    case 300:
    case 301:
    case 302:
    case 303:
    case 304:
    case 305:
    case 307:
    case 308:
      // Unexpected 3xx return code
      statusBucket = 4;
      break;
    case 400:
      // Bad Request - The HTTP request was not correctly formed.
      // The client did not provide all required CGI parameters.
      statusBucket = 5;
      break;
    case 401:
    case 402:
    case 405:
    case 406:
    case 407:
    case 409:
    case 410:
    case 411:
    case 412:
    case 414:
    case 415:
    case 416:
    case 417:
    case 421:
    case 426:
    case 428:
    case 429:
    case 431:
    case 451:
      // Unexpected 4xx return code
      statusBucket = 6;
      break;
    case 403:
      // Forbidden - The client id is invalid.
      statusBucket = 7;
      break;
    case 404:
      // Not Found
      statusBucket = 8;
      break;
    case 408:
      // Request Timeout
      statusBucket = 9;
      break;
    case 413:
      // Request Entity Too Large - Bug 1150334
      statusBucket = 10;
      break;
    case 500:
    case 501:
    case 510:
      // Unexpected 5xx return code
      statusBucket = 11;
      break;
    case 502:
    case 504:
    case 511:
      // Local network errors, we'll ignore these.
      statusBucket = 12;
      break;
    case 503:
      // Service Unavailable - The server cannot handle the request.
      // Clients MUST follow the backoff behavior specified in the
      // Request Frequency section.
      statusBucket = 13;
      break;
    case 505:
      // HTTP Version Not Supported - The server CANNOT handle the requested
      // protocol major version.
      statusBucket = 14;
      break;
    default:
      statusBucket = 15;
  }
  return statusBucket;
}

class FullHashMatch {
  constructor(table, hash, duration) {
    this.tableName = table;
    this.fullHash = hash;
    this.cacheDuration = duration;
  }

  QueryInterface = ChromeUtils.generateQI(["nsIFullHashMatch"]);
}

export class HashCompleter {
  // The current HashCompleterRequest in flight. Once it is started, it is set
  // to null. It may be used by multiple calls to |complete| in succession to
  // avoid creating multiple requests to the same gethash URL.
  #currentRequest = null;
  // An Array of ongoing gethash requests which is used to find requests for
  // the same hash prefix.
  #ongoingRequests = [];
  // A map of gethashUrls to HashCompleterRequests that haven't yet begun.
  #pendingRequests = new Map();
  // A map of gethash URLs to RequestBackoff objects.
  #backoffs = new Map();
  // Whether we have been informed of a shutdown by the shutdown event.
  #shuttingDown = false;
  // A map of gethash URLs to next gethash time in miliseconds
  #nextGethashTimeMs = new Map();

  constructor() {
    Services.obs.addObserver(this, "quit-application");
    Services.prefs.addObserver(PREF_DEBUG_ENABLED, this);

    loggingEnabled = Services.prefs.getBoolPref(PREF_DEBUG_ENABLED);
  }

  classID = Components.ID("{9111de73-9322-4bfc-8b65-2b727f3e6ec8}");
  QueryInterface = ChromeUtils.generateQI([
    "nsIUrlClassifierHashCompleter",
    "nsIRunnable",
    "nsIObserver",
    "nsISupportsWeakReference",
  ]);

  // This is mainly how the HashCompleter interacts with other components.
  // Even though it only takes one partial hash and callback, subsequent
  // calls are made into the same HTTP request by using a thread dispatch.
  complete(aPartialHash, aGethashUrl, aTableName, aCallback) {
    if (!aGethashUrl) {
      throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
    }

    // Check ongoing requests before creating a new HashCompleteRequest
    for (const r of this.#ongoingRequests) {
      if (r.find(aPartialHash, aGethashUrl, aTableName)) {
        log(
          "Merge gethash request in " +
            aTableName +
            " for prefix : " +
            btoa(aPartialHash)
        );
        r.add(aPartialHash, aCallback, aTableName);
        return;
      }
    }

    if (!this.#currentRequest) {
      this.#currentRequest = this.makeHashCompleterRequest(
        aTableName,
        aGethashUrl
      );
    }
    if (this.#currentRequest.gethashUrl == aGethashUrl) {
      this.#currentRequest.add(aPartialHash, aCallback, aTableName);
    } else {
      if (!this.#pendingRequests.has(aGethashUrl)) {
        this.#pendingRequests.set(
          aGethashUrl,
          this.makeHashCompleterRequest(aTableName, aGethashUrl)
        );
      }
      this.#pendingRequests
        .get(aGethashUrl)
        .add(aPartialHash, aCallback, aTableName);
    }

    if (!this.#backoffs.has(aGethashUrl)) {
      // Initialize request backoffs separately, since requests are deleted
      // after they are dispatched.
      var jslib =
        Cc["@mozilla.org/url-classifier/jslib;1"].getService().wrappedJSObject;

      // Using the V4 backoff algorithm for both V2 and V4. See bug 1273398.
      this.#backoffs.set(
        aGethashUrl,
        new jslib.RequestBackoffV4(
          10 /* keep track of max requests */,
          0 /* don't throttle on successful requests per time period */,
          lazy.gUrlUtil.getProvider(aTableName) /* used by testcase */
        )
      );
    }

    if (!this.#nextGethashTimeMs.has(aGethashUrl)) {
      this.#nextGethashTimeMs.set(aGethashUrl, 0);
    }

    // Start off this request. Without dispatching to a thread, every call to
    // complete makes an individual HTTP request.
    Services.tm.dispatchToMainThread(this);
  }

  // A helper function to create a HashCompleterRequest based on the table name.
  makeHashCompleterRequest(aTableName, aGethashUrl) {
    let provider = lazy.gUrlUtil.getProvider(aTableName);
    if (provider == "google4") {
      return new HashCompleterRequestV4(this, aGethashUrl);
    } else if (provider == "google5") {
      return new HashCompleterRequestV5(this, aGethashUrl);
    } else if (provider == "mozilla") {
      return new HashCompleterRequestV2(this, aGethashUrl);
    } else if (provider == "test") {
      // If the table name ends with "-proto", use the V4 request. Otherwise.
      // We use the v2 request.
      if (aTableName.endsWith("-proto")) {
        if (aTableName.includes("google5")) {
          return new HashCompleterRequestV5(this, aGethashUrl, true);
        }
        return new HashCompleterRequestV4(this, aGethashUrl, true);
      }
      return new HashCompleterRequestV2(this, aGethashUrl, true);
    }

    // We use the V2 request as the fallback.
    return new HashCompleterRequestV2(this, aGethashUrl);
  }

  // This is called after several calls to |complete|, or after the
  // currentRequest has finished.  It starts off the HTTP request by making a
  // |begin| call to the HashCompleterRequest.
  run() {
    // Clear everything on shutdown
    if (this.#shuttingDown) {
      this.#currentRequest = null;
      this.#pendingRequests.clear();
      this.#nextGethashTimeMs.clear();
      this.#backoffs.clear();
      throw Components.Exception("", Cr.NS_ERROR_NOT_INITIALIZED);
    }

    // If we don't have an in-flight request, make one
    let pendingUrls = Array.from(this.#pendingRequests.keys());
    if (!this.#currentRequest && pendingUrls.length) {
      let nextUrl = pendingUrls[0];
      this.#currentRequest = this.#pendingRequests.get(nextUrl);
      this.#pendingRequests.delete(nextUrl);
    }

    if (this.#currentRequest) {
      try {
        if (this.#currentRequest.begin()) {
          this.#ongoingRequests.push(this.#currentRequest);
        }
      } finally {
        // If |begin| fails, we should get rid of our request.
        this.#currentRequest = null;
      }
    }
  }

  // Pass the server response status to the RequestBackoff for the given
  // gethashUrl and fetch the next pending request, if there is one.
  finishRequest(aRequest, aStatus) {
    this.#ongoingRequests = this.#ongoingRequests.filter(v => v != aRequest);

    this.#backoffs.get(aRequest.gethashUrl).noteServerResponse(aStatus);
    Services.tm.dispatchToMainThread(this);
  }

  // Returns true if we can make a request from the given url, false otherwise.
  canMakeRequest(aGethashUrl) {
    return (
      this.#backoffs.get(aGethashUrl).canMakeRequest() &&
      Date.now() >= this.#nextGethashTimeMs.get(aGethashUrl)
    );
  }

  // Notifies the RequestBackoff of a new request so we can throttle based on
  // max requests/time period. This must be called before a channel is opened,
  // and finishRequest must be called once the response is received.
  noteRequest(aGethashUrl) {
    return this.#backoffs.get(aGethashUrl).noteRequest();
  }

  // Remove a request from ongoing requests
  removeFromOngoingRequests(request) {
    this.#ongoingRequests = this.#ongoingRequests.filter(v => v != request);
  }

  // Set the next gethash time for a URL
  setNextGethashTime(gethashUrl, time) {
    this.#nextGethashTimeMs.set(gethashUrl, time);
  }

  observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case "quit-application":
        this.#shuttingDown = true;
        Services.obs.removeObserver(this, "quit-application");
        break;
      case "nsPref:changed":
        if (aData == PREF_DEBUG_ENABLED) {
          loggingEnabled = Services.prefs.getBoolPref(PREF_DEBUG_ENABLED);
        }
        break;
    }
  }
}

class HashCompleterRequestBase {
  // HashCompleter object that created this HashCompleterRequestBase.
  completer;
  // nsIChannel that the hash completion query is transmitted over.
  channel = null;
  // The internal set of hashes and callbacks that this request corresponds to.
  requests = [];
  // Response body of hash completion. Created in onDataAvailable.
  response = "";
  // Whether we have been informed of a shutdown by the quit-application event.
  #shuttingDown = false;

  constructor(aCompleter, aGethashUrl) {
    this.completer = aCompleter;
    this.gethashUrl = aGethashUrl;

    // Multiple partial hashes can be associated with the same tables
    // so we use a map here.
    this.tableNames = new Map();

    this.telemetryProvider = "";
    this.telemetryClockStart = 0;
  }

  QueryInterface = ChromeUtils.generateQI([
    "nsIRequestObserver",
    "nsIStreamListener",
    "nsIObserver",
    "nsITimerCallback",
  ]);

  // This is called by the HashCompleter to add a hash and callback to the
  // HashCompleterRequest. It must be called before calling |begin|.
  add(aPartialHash, aCallback, aTableName) {
    this.requests.push({
      partialHash: aPartialHash,
      callback: aCallback,
      tableName: aTableName,
      response: { matches: [] },
    });

    if (!aTableName) {
      return;
    }

    let providerFromTableName = lazy.gUrlUtil.getProvider(aTableName);
    if (providerFromTableName != this.provider) {
      log(
        "ERROR: Cannot mix tables with different providers within " +
          "the same gethash URL."
      );
    }

    if (!this.tableNames.has(aTableName)) {
      this.tableNames.set(aTableName);
    }

    // Get the telemetry provider from the table name.
    if (this.telemetryProvider == "") {
      this.telemetryProvider = lazy.gUrlUtil.getTelemetryProvider(aTableName);
    }
  }

  // This is called by the HashCompleter to find if a partial hash is already
  // added to the request.
  find(aPartialHash, aGetHashUrl, aTableName) {
    if (this.gethashUrl != aGetHashUrl || !this.tableNames.has(aTableName)) {
      return false;
    }

    return this.requests.find(function (r) {
      return r.partialHash === aPartialHash;
    });
  }

  // This initiates the HTTP request. It can fail due to backoff timings and
  // will notify all callbacks as necessary. We notify the backoff object on
  // begin.
  begin() {
    if (!this.completer.canMakeRequest(this.gethashUrl)) {
      log("Can't make request to " + this.gethashUrl + "\n");
      this.notifyFailure(Cr.NS_ERROR_ABORT);
      return false;
    }

    Services.obs.addObserver(this, "quit-application");

    this.beginBuildChannel();

    return true;
  }

  // This should be implemented by the subclasses to build the channel for
  // completing the find full hash request.
  beginBuildChannel() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  }

  notify() {
    // If we haven't gotten onStopRequest, just cancel. This will call us
    // with onStopRequest since we implement nsIStreamListener on the
    // channel.
    if (this.channel && this.channel.isPending()) {
      log("cancelling request to " + this.gethashUrl + " (timeout)\n");
      Glean.urlclassifier.completeTimeout
        .get(this.telemetryProvider, true)
        .add(1);
      this.channel.cancel(Cr.NS_BINDING_ABORTED);
    }
  }

  // Creates an nsIChannel for the request and fills the body.
  // Enforce bypassing URL Classifier check because if the request is
  // blocked, it means SafeBrowsing is malfunction.
  openChannel() {
    let loadFlags =
      Ci.nsIChannel.INHIBIT_CACHING |
      Ci.nsIChannel.LOAD_BYPASS_CACHE |
      Ci.nsIChannel.LOAD_BYPASS_URL_CLASSIFIER;

    this.request = {
      url: this.makeChannelURL(),
      body: "",
    };

    log("actualGethashUrl: " + this.request.url);

    let channel = NetUtil.newChannel({
      uri: this.request.url,
      loadUsingSystemPrincipal: true,
    });
    channel.loadFlags = loadFlags;
    channel.loadInfo.originAttributes = {
      // The firstPartyDomain value should sync with NECKO_SAFEBROWSING_FIRST_PARTY_DOMAIN
      // defined in nsNetUtil.h.
      firstPartyDomain:
        "safebrowsing.86868755-6b82-4842-b301-72671a0db32e.mozilla",
    };

    // Disable keepalive.
    let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
    httpChannel.setRequestHeader("Connection", "close", false);

    this.channel = channel;

    this.setupChannel(channel);

    // Set a timer that cancels the channel after timeout_ms in case we
    // don't get a gethash response.
    this.timer_ = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    // Ask the timer to use nsITimerCallback (.notify()) when ready
    let timeout = Services.prefs.getIntPref("urlclassifier.gethash.timeout_ms");
    this.timer_.initWithCallback(this, timeout, this.timer_.TYPE_ONE_SHOT);
    channel.asyncOpen(this);
    this.telemetryClockStart = Date.now();
  }

  // This should be implemented by the subclasses to build the channel URL.
  makeChannelURL() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  }

  // This should be implemented by the subclasses to setup the channel.
  setupChannel(_channel) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  }

  // This should be implemented by the subclasses to handle the response.
  handleResponse() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  }

  // This adds a complete hash to any entry in |this._requests| that matches
  // the hash.
  handleItem(aData) {
    // Only perform provider check if the table name is provided.
    if (aData.tableName) {
      let provider = lazy.gUrlUtil.getProvider(aData.tableName);
      if (provider != this.provider) {
        log(
          "Ignoring table " +
            aData.tableName +
            " since it belongs to " +
            provider +
            " while the response came from " +
            this.provider +
            "."
        );
        return;
      }
    }

    for (const request of this.requests) {
      if (aData.completeHash.startsWith(request.partialHash)) {
        request.response.matches.push(aData);
      }
    }
  }

  // notifySuccess and notifyFailure are used to alert the callbacks with
  // results. notifySuccess makes |completion| and |completionFinished| calls
  // while notifyFailure only makes a |completionFinished| call with the error
  // code.

  // This should be implemented by the subclasses to notify the success.
  notifySuccess() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  }

  notifyFailure(aStatus) {
    log("notifying failure\n");
    for (const request of this.requests) {
      request.callback.completionFinished(aStatus);
    }
  }

  onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
    let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
      Ci.nsIScriptableInputStream
    );
    sis.init(aInputStream);
    this.response += sis.readBytes(aCount);
  }

  onStartRequest() {
    // At this point no data is available for us and we have no reason to
    // terminate the connection, so we do nothing until |onStopRequest|.
    this.completer.setNextGethashTime(this.gethashUrl, 0);

    if (this.telemetryClockStart > 0) {
      let msecs = Date.now() - this.telemetryClockStart;
      Glean.urlclassifier.completeServerResponseTime[
        this.telemetryProvider
      ].accumulateSingleSample(msecs);
    }
  }

  onStopRequest(aRequest, aStatusCode) {
    Services.obs.removeObserver(this, "quit-application");

    if (this.timer_) {
      this.timer_.cancel();
      this.timer_ = null;
    }

    this.telemetryClockStart = 0;

    if (this.#shuttingDown) {
      throw Components.Exception("", Cr.NS_ERROR_ABORT);
    }

    // Default HTTP status to service unavailable, in case we can't retrieve
    // the true status from the channel.
    let httpStatus = 503;
    if (Components.isSuccessCode(aStatusCode)) {
      let channel = aRequest.QueryInterface(Ci.nsIHttpChannel);
      let success = channel.requestSucceeded;
      httpStatus = channel.responseStatus;
      if (!success) {
        aStatusCode = Cr.NS_ERROR_ABORT;
      }
    }
    let success = Components.isSuccessCode(aStatusCode);
    log(
      "Received a " +
        httpStatus +
        " status code from the " +
        this.provider +
        " gethash server (success=" +
        success +
        "): " +
        btoa(this.response)
    );

    Glean.urlclassifier.completeRemoteStatus2[
      this.telemetryProvider
    ].accumulateSingleSample(httpStatusToBucket(httpStatus));
    if (httpStatus == 400) {
      dump(
        "Safe Browsing server returned a 400 during completion: request= " +
          this.request.url +
          ",payload= " +
          this.request.body +
          ",response= " +
          this.response +
          "\n"
      );
    }

    Glean.urlclassifier.completeTimeout
      .get(this.telemetryProvider, false)
      .add(1);

    // Notify the RequestBackoff once a response is received.
    this.completer.finishRequest(this, httpStatus);

    if (success) {
      try {
        this.handleResponse();
      } catch (err) {
        log(err.stack);
        aStatusCode = err.value;
        success = false;
      }
    }

    if (success) {
      this.notifySuccess();
    } else {
      this.notifyFailure(aStatusCode);
    }
  }

  observe(aSubject, aTopic) {
    if (aTopic == "quit-application") {
      this.#shuttingDown = true;
      if (this.channel) {
        this.channel.cancel(Cr.NS_ERROR_ABORT);
        this.telemetryClockStart = 0;
      }

      Services.obs.removeObserver(this, "quit-application");
    }
  }
}

class HashCompleterRequestV2 extends HashCompleterRequestBase {
  constructor(aCompleter, aGethashUrl, aIsTesting = false) {
    super(aCompleter, aGethashUrl);

    this.provider = aIsTesting ? "test" : "mozilla";
  }

  beginBuildChannel() {
    try {
      this.openChannel();
      // Notify the RequestBackoff if opening the channel succeeded. At this
      // point, finishRequest must be called.
      this.completer.noteRequest(this.gethashUrl);
    } catch (err) {
      this.completer.removeFromOngoingRequests(this);
      this.notifyFailure(err);
      throw err;
    }
  }

  makeChannelURL() {
    return this.gethashUrl;
  }

  // Returns a string for the request body based on the contents of
  // this._requests.
  buildRequest() {
    // Sometimes duplicate entries are sent to HashCompleter but we do not need
    // to propagate these to the server. (bug 633644)
    let prefixes = [];

    for (let i = 0; i < this.requests.length; i++) {
      let request = this.requests[i];
      if (!prefixes.includes(request.partialHash)) {
        prefixes.push(request.partialHash);
      }
    }

    // Sort to make sure the entries are arbitrary mixed in a deterministic way
    prefixes.sort();

    let body;
    body =
      PARTIAL_LENGTH +
      ":" +
      PARTIAL_LENGTH * prefixes.length +
      "\n" +
      prefixes.join("");

    log(
      "Requesting completions for " +
        prefixes.length +
        " " +
        PARTIAL_LENGTH +
        "-byte prefixes: " +
        body
    );
    return body;
  }

  setupChannel(channel) {
    let body = this.buildRequest();
    let inputStream = Cc[
      "@mozilla.org/io/string-input-stream;1"
    ].createInstance(Ci.nsIStringInputStream);

    inputStream.setByteStringData(body);

    let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel);
    uploadChannel.setUploadStream(inputStream, "text/plain", -1);

    let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
    httpChannel.requestMethod = "POST";
  }

  handleResponse() {
    if (this.response == "") {
      return;
    }

    let start = 0;

    let length = this.response.length;
    while (start != length) {
      start = this.handleTable(start);
    }
  }

  // This parses a table entry in the response body and calls |handleItem|
  // for complete hash in the table entry.
  handleTable(aStart) {
    let body = this.response.substring(aStart);

    // deal with new line indexes as there could be
    // new line characters in the data parts.
    let newlineIndex = body.indexOf("\n");
    if (newlineIndex == -1) {
      throw errorWithStack();
    }
    let header = body.substring(0, newlineIndex);
    let entries = header.split(":");
    if (entries.length != 3) {
      throw errorWithStack();
    }

    let list = entries[0];
    let addChunk = parseInt(entries[1]);
    let dataLength = parseInt(entries[2]);

    log("Response includes add chunks for " + list + ": " + addChunk);
    if (
      dataLength % COMPLETE_LENGTH != 0 ||
      dataLength == 0 ||
      dataLength > body.length - (newlineIndex + 1)
    ) {
      throw errorWithStack();
    }

    let data = body.substr(newlineIndex + 1, dataLength);
    for (let i = 0; i < dataLength / COMPLETE_LENGTH; i++) {
      this.handleItem({
        completeHash: data.substr(i * COMPLETE_LENGTH, COMPLETE_LENGTH),
        tableName: list,
        chunkId: addChunk,
      });
    }

    return aStart + newlineIndex + 1 + dataLength;
  }

  notifySuccess() {
    // V2 completion handler
    let completion = req => {
      req.response.matches.forEach(m => {
        req.callback.completionV2(m.completeHash, m.tableName, m.chunkId);
      });

      req.callback.completionFinished(Cr.NS_OK);
    };

    this.requests.forEach(req => {
      completion(req);
    });
  }
}

class HashCompleterRequestV4 extends HashCompleterRequestBase {
  constructor(aCompleter, aGethashUrl, aIsTesting = false) {
    super(aCompleter, aGethashUrl, aIsTesting);

    this.provider = aIsTesting ? "test" : "google4";
  }

  fillTableStatesBase64(aCallback) {
    lazy.gDbService.getTables(aTableData => {
      aTableData.split("\n").forEach(line => {
        let p = line.indexOf(";");
        if (-1 === p) {
          return;
        }
        // [tableName];[stateBase64]:[checksumBase64]
        let tableName = line.substring(0, p);
        if (this.tableNames.has(tableName)) {
          let metadata = line.substring(p + 1).split(":");
          let stateBase64 = metadata[0];
          this.tableNames.set(tableName, stateBase64);
        }
      });

      aCallback();
    });
  }

  beginBuildChannel() {
    // V4 requires table states to build the request so we need
    // a async call to retrieve the table states from disk.
    // Note that |HCR_begin| is fine to be sync because
    // it doesn't appear in a sync call chain.
    this.fillTableStatesBase64(() => {
      try {
        this.openChannel();
        // Notify the RequestBackoff if opening the channel succeeded. At this
        // point, finishRequest must be called.
        this.completer.noteRequest(this.gethashUrl);
      } catch (err) {
        this.completer.removeFromOngoingRequests(this);
        this.notifyFailure(err);
        throw err;
      }
    });
  }

  makeChannelURL() {
    return this.gethashUrl + "&$req=" + this.buildRequest();
  }

  buildRequest() {
    // Convert the "name to state" mapping to two equal-length arrays.
    let tableNameArray = [];
    let stateArray = [];
    this.tableNames.forEach((state, name) => {
      // We skip the table which is not associated with a state.
      if (state) {
        tableNameArray.push(name);
        stateArray.push(state);
      }
    });

    // Build the "distinct" prefix array.
    // The array is sorted to make sure the entries are arbitrary mixed in a
    // deterministic way
    let prefixSet = new Set();
    this.requests.forEach(r => prefixSet.add(btoa(r.partialHash)));
    let prefixArray = Array.from(prefixSet).sort();

    log(
      "Build v4 gethash request with " +
        JSON.stringify(tableNameArray) +
        ", " +
        JSON.stringify(stateArray) +
        ", " +
        JSON.stringify(prefixArray)
    );

    return lazy.gUrlUtil.makeFindFullHashRequestV4(
      tableNameArray,
      stateArray,
      prefixArray
    );
  }

  setupChannel(channel) {
    let httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
    httpChannel.setRequestHeader("X-HTTP-Method-Override", "POST", false);
  }

  handleResponse() {
    if (this.response == "") {
      return;
    }

    let callback = {
      // onCompleteHashFound will be called for each fullhash found in
      // FullHashResponse.
      onCompleteHashFound: (
        aCompleteHash,
        aTableNames,
        aPerHashCacheDuration
      ) => {
        log(
          "V4 fullhash response complete hash found callback: " +
            aTableNames +
            ", CacheDuration(" +
            aPerHashCacheDuration +
            ")"
        );

        // Filter table names which we didn't request.
        let filteredTables = aTableNames.split(",").filter(name => {
          return this.tableNames.has(name);
        });
        if (0 === filteredTables.length) {
          log("ERROR: Got complete hash which is from unknown table.");
          return;
        }
        if (filteredTables.length > 1) {
          log("WARNING: Got complete hash which has ambigious threat type.");
        }

        this.handleItem({
          completeHash: aCompleteHash,
          tableName: filteredTables[0],
          cacheDuration: aPerHashCacheDuration,
        });
      },

      // onResponseParsed will be called no matter if there is match in
      // FullHashResponse, the callback is mainly used to pass negative cache
      // duration and minimum wait duration.
      onResponseParsed: (aMinWaitDuration, aNegCacheDuration) => {
        log(
          "V4 fullhash response parsed callback: " +
            "MinWaitDuration(" +
            aMinWaitDuration +
            "), " +
            "NegativeCacheDuration(" +
            aNegCacheDuration +
            ")"
        );

        let minWaitDuration = aMinWaitDuration;

        if (aMinWaitDuration > MIN_WAIT_DURATION_MAX_VALUE) {
          log(
            "WARNING: Minimum wait duration too large, clamping it down " +
              "to a reasonable value."
          );
          minWaitDuration = MIN_WAIT_DURATION_MAX_VALUE;
        } else if (aMinWaitDuration < 0) {
          log("WARNING: Minimum wait duration is negative, reset it to 0");
          minWaitDuration = 0;
        }

        this.completer.setNextGethashTime(
          this.gethashUrl,
          Date.now() + minWaitDuration
        );

        // A fullhash request may contain more than one prefix, so the negative
        // cache duration should be set for all the prefixes in the request.
        this.requests.forEach(request => {
          request.response.negCacheDuration = aNegCacheDuration;
        });
      },
    };

    lazy.gUrlUtil.parseFindFullHashResponseV4(this.response, callback);
  }

  notifySuccess() {
    let completion = req => {
      let matches = Cc["@mozilla.org/array;1"].createInstance(
        Ci.nsIMutableArray
      );

      req.response.matches.forEach(m => {
        matches.appendElement(
          new FullHashMatch(m.tableName, m.completeHash, m.cacheDuration)
        );
      });

      req.callback.completionV4(
        req.partialHash,
        req.tableName,
        req.response.negCacheDuration,
        matches
      );

      req.callback.completionFinished(Cr.NS_OK);
    };

    this.requests.forEach(req => {
      completion(req);
    });
  }
}

class HashCompleterRequestV5 extends HashCompleterRequestBase {
  constructor(aCompleter, aGethashUrl, aIsTesting = false) {
    super(aCompleter, aGethashUrl);

    this.provider = aIsTesting ? "test" : "google5";
  }

  beginBuildChannel() {
    try {
      this.openChannel();
      // Notify the RequestBackoff if opening the channel succeeded. At this
      // point, finishRequest must be called.
      this.completer.noteRequest(this.gethashUrl);
    } catch (err) {
      this.completer.removeFromOngoingRequests(this);
      this.notifyFailure(err);
      throw err;
    }
  }

  makeChannelURL() {
    let prefixSet = new Set();
    // In V5, the prefix is sent using query parameters. We need to encode the
    // prefixes to avoid issues with special characters.
    this.requests.forEach(r =>
      prefixSet.add(encodeURIComponent(btoa(r.partialHash)))
    );
    let prefixArray = Array.from(prefixSet).sort();

    log("Build v5 gethash request URL with " + JSON.stringify(prefixArray));

    return (
      this.gethashUrl +
      "&" +
      lazy.gUrlUtil.makeFindFullHashRequestV5(prefixArray)
    );
  }

  // In V5, we don't need to do extra setup for the channel. The request uses
  // the default GET method.
  setupChannel(_channel) {}

  handleResponse() {
    if (this.response == "") {
      return;
    }

    let callback = {
      // onCompleteHashFound will be called for each fullhash found in
      // FullHashResponse.
      onCompleteHashFound: (
        aCompleteHash,
        aTableNames,
        aPerHashCacheDuration
      ) => {
        log(
          "V5 hashes::search response complete hash found callback: " +
            aTableNames +
            ", CacheDuration(" +
            aPerHashCacheDuration +
            ")"
        );

        // Filter table names which we didn't request.
        let filteredTables = aTableNames.split(",").filter(name => {
          return this.tableNames.has(name);
        });
        if (0 === filteredTables.length) {
          log("ERROR: Got complete hash which is from unknown table.");
          return;
        }
        if (filteredTables.length > 1) {
          log("WARNING: Got complete hash which has ambiguous threat type.");
        }

        this.handleItem({
          completeHash: aCompleteHash,
          tableName: filteredTables[0],
          cacheDuration: aPerHashCacheDuration,
        });
      },

      // onResponseParsed will be called no matter if there is match in
      // FullHashResponse, the callback is mainly used to pass negative cache
      // duration and minimum wait duration.
      onResponseParsed: (aMinWaitDuration, aNegCacheDuration) => {
        log(
          "V5 hashes::search response parsed callback: " +
            "MinWaitDuration(" +
            aMinWaitDuration +
            "), " +
            "NegativeCacheDuration(" +
            aNegCacheDuration +
            ")"
        );

        let minWaitDuration = aMinWaitDuration;

        if (aMinWaitDuration > MIN_WAIT_DURATION_MAX_VALUE) {
          log(
            "WARNING: Minimum wait duration too large, clamping it down " +
              "to a reasonable value."
          );
          minWaitDuration = MIN_WAIT_DURATION_MAX_VALUE;
        } else if (aMinWaitDuration < 0) {
          log("WARNING: Minimum wait duration is negative, reset it to 0");
          minWaitDuration = 0;
        }

        this.completer.setNextGethashTime(
          this.gethashUrl,
          Date.now() + minWaitDuration
        );

        // A fullhash request may contain more than one prefix, so the negative
        // cache duration should be set for all the prefixes in the request.
        this.requests.forEach(request => {
          request.response.negCacheDuration = aNegCacheDuration;
        });
      },
    };

    lazy.gUrlUtil.parseFindFullHashResponseV5(this.response, callback);
  }

  notifySuccess() {
    let completion = req => {
      let matches = Cc["@mozilla.org/array;1"].createInstance(
        Ci.nsIMutableArray
      );

      req.response.matches.forEach(m => {
        matches.appendElement(
          new FullHashMatch(m.tableName, m.completeHash, m.cacheDuration)
        );
      });

      // We still use the V4 completion method for V5 because V5 uses the same
      // caching mechanism as V4.
      req.callback.completionV4(
        req.partialHash,
        req.tableName,
        req.response.negCacheDuration,
        matches
      );

      req.callback.completionFinished(Cr.NS_OK);
    };

    this.requests.forEach(req => {
      completion(req);
    });
  }
}

function errorWithStack() {
  let err = new Error();
  err.value = Cr.NS_ERROR_FAILURE;
  return err;
}
