/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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/. */

/* global tabTracker */
"use strict";

ChromeUtils.defineESModuleGetters(this, {
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

var { ExtensionError } = ExtensionUtils;

// A mapping of top-level ExtFind actors to arrays of results in each subframe.
let findResults = new WeakMap();

function getActorForBrowsingContext(browsingContext) {
  let windowGlobal = browsingContext.currentWindowGlobal;
  return windowGlobal ? windowGlobal.getActor("ExtFind") : null;
}

function getTopLevelActor(browser) {
  return getActorForBrowsingContext(browser.browsingContext);
}

function gatherActors(browsingContext) {
  let list = [];

  let actor = getActorForBrowsingContext(browsingContext);
  if (actor) {
    list.push({ actor, result: null });
  }

  let children = browsingContext.children;
  for (let child of children) {
    list.push(...gatherActors(child));
  }

  return list;
}

function mergeFindResults(params, list) {
  let finalResult = {
    count: 0,
  };

  if (params.includeRangeData) {
    finalResult.rangeData = [];
  }
  if (params.includeRectData) {
    finalResult.rectData = [];
  }

  let currentFramePos = -1;
  for (let item of list) {
    if (item.result.count == 0) {
      continue;
    }

    // The framePos is incremented for each different document that has matches.
    currentFramePos++;

    finalResult.count += item.result.count;
    if (params.includeRangeData && item.result.rangeData) {
      for (let range of item.result.rangeData) {
        range.framePos = currentFramePos;
      }

      finalResult.rangeData.push(...item.result.rangeData);
    }

    if (params.includeRectData && item.result.rectData) {
      finalResult.rectData.push(...item.result.rectData);
    }
  }

  return finalResult;
}

function sendMessageToAllActors(browser, message, params) {
  for (let { actor } of gatherActors(browser.browsingContext)) {
    actor.sendAsyncMessage("ext-Finder:" + message, params);
  }
}

async function getFindResultsForActor(findContext, message, params) {
  findContext.result = await findContext.actor.sendQuery(
    "ext-Finder:" + message,
    params
  );
  return findContext;
}

function queryAllActors(browser, message, params) {
  let promises = [];
  for (let findContext of gatherActors(browser.browsingContext)) {
    promises.push(getFindResultsForActor(findContext, message, params));
  }
  return Promise.all(promises);
}

async function collectFindResults(browser, findResults, params) {
  let results = await queryAllActors(browser, "CollectResults", params);
  findResults.set(getTopLevelActor(browser), results);
  return mergeFindResults(params, results);
}

async function runHighlight(browser, params) {
  let hasResults = false;
  let foundResults = false;
  let list = findResults.get(getTopLevelActor(browser));
  if (!list) {
    return Promise.reject({ message: "no search results to highlight" });
  }

  let highlightPromises = [];

  let index = params.rangeIndex;
  const highlightAll = typeof index != "number";

  for (let c = 0; c < list.length; c++) {
    if (list[c].result.count) {
      hasResults = true;
    }

    let actor = list[c].actor;
    if (highlightAll) {
      // Highlight all ranges.
      highlightPromises.push(
        actor.sendQuery("ext-Finder:HighlightResults", params)
      );
    } else if (!foundResults && index < list[c].result.count) {
      foundResults = true;
      params.rangeIndex = index;
      highlightPromises.push(
        actor.sendQuery("ext-Finder:HighlightResults", params)
      );
    } else {
      highlightPromises.push(
        actor.sendQuery("ext-Finder:ClearHighlighting", params)
      );
    }

    index -= list[c].result.count;
  }

  let responses = await Promise.all(highlightPromises);
  if (hasResults) {
    if (responses.includes("OutOfRange") || index >= 0) {
      return Promise.reject({ message: "index supplied was out of range" });
    } else if (responses.includes("Success")) {
      return;
    }
  }

  return Promise.reject({ message: "no search results to highlight" });
}

/**
 * runFindOperation
 * Utility for `find` and `highlightResults`.
 *
 * @param {BaseContext} context - context the find operation runs in.
 * @param {object} params - params to pass to message sender.
 * @param {string} message - identifying component of message name.
 *
 * @returns {Promise} a promise that will be resolved or rejected based on the
 *          data received by the message listener.
 */
function runFindOperation(context, params, message) {
  let { tabId } = params;
  let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
  let browser = tab.linkedBrowser;
  tabId = tabId || tabTracker.getId(tab);
  if (
    !context.privateBrowsingAllowed &&
    PrivateBrowsingUtils.isBrowserPrivate(browser)
  ) {
    return Promise.reject({ message: `Unable to search: ${tabId}` });
  }
  // We disallow find in about: urls.
  if (
    tab.linkedBrowser.contentPrincipal.isSystemPrincipal ||
    (["about", "chrome", "resource"].includes(
      tab.linkedBrowser.currentURI.scheme
    ) &&
      tab.linkedBrowser.currentURI.spec != "about:blank")
  ) {
    return Promise.reject({ message: `Unable to search: ${tabId}` });
  }

  if (message == "HighlightResults") {
    return runHighlight(browser, params);
  } else if (message == "CollectResults") {
    // Remove prior highlights before starting a new find operation.
    findResults.delete(getTopLevelActor(browser));
    return collectFindResults(browser, findResults, params);
  }
}

this.find = class extends ExtensionAPI {
  getAPI(context) {
    return {
      find: {
        /**
         * browser.find.find
         * Searches document and its frames for a given queryphrase and stores all found
         * Range objects in an array accessible by other browser.find methods.
         *
         * @param {string} queryphrase - The string to search for.
         * @param {object} params optional - may contain any of the following properties,
         *   all of which are optional:
         *   {number} tabId - Tab to query.  Defaults to the active tab.
         *   {boolean} caseSensitive - Highlight only ranges with case sensitive match.
         *   {boolean} entireWord - Highlight only ranges that match entire word.
         *   {boolean} includeRangeData - Whether to return range data.
         *   {boolean} includeRectData - Whether to return rectangle data.
         *
         * @returns {object} data received by the message listener that includes:
         *   {number} count - number of results found.
         *   {array} rangeData (if opted) - serialized representation of ranges found.
         *   {array} rectData (if opted) - rect data of ranges found.
         */
        find(queryphrase, params) {
          params = params || {};
          params.queryphrase = queryphrase;
          return runFindOperation(context, params, "CollectResults");
        },

        /**
         * browser.find.highlightResults
         * Highlights range(s) found in previous browser.find.find.
         *
         * @param {object} params optional - may contain any of the following properties,
         *   all of which are optional:
         *   {number} rangeIndex - Found range to be highlighted. Default highlights all ranges.
         *   {number} tabId - Tab to highlight.  Defaults to the active tab.
         *   {boolean} noScroll - Don't scroll to highlighted item.
         *
         * @returns {string} - data received by the message listener that may be:
         *   "Success" - Highlighting succeeded.
         *   "OutOfRange" - The index supplied was out of range.
         *   "NoResults" - There were no search results to highlight.
         */
        highlightResults(params) {
          params = params || {};
          return runFindOperation(context, params, "HighlightResults");
        },

        /**
         * browser.find.removeHighlighting
         * Removes all highlighting from previous search.
         *
         * @param {number} tabId optional
         *                 Tab to clear highlighting in.  Defaults to the active tab.
         */
        removeHighlighting(tabId) {
          let tab = tabId ? tabTracker.getTab(tabId) : tabTracker.activeTab;
          if (
            !context.privateBrowsingAllowed &&
            PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser)
          ) {
            throw new ExtensionError(`Invalid tab ID: ${tabId}`);
          }
          sendMessageToAllActors(tab.linkedBrowser, "ClearHighlighting", {});
        },
      },
    };
  }
};
