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

import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  NLP: "resource://gre/modules/NLP.sys.mjs",
  Rect: "resource://gre/modules/Geometry.sys.mjs",
});

const kDebug = false;
const kIterationSizeMax = 100;
const kTimeoutPref = "findbar.iteratorTimeout";

/**
 * FinderIterator. See the documentation for the `start()` method to
 * learn more.
 */
export class FinderIterator {
  constructor() {
    this._listeners = new Map();
    this._currentParams = null;
    this._catchingUp = new Set();
    this._previousParams = null;
    this._previousRanges = [];
    this._spawnId = 0;
    this._timer = null;
    this.ranges = [];
    this.running = false;
    this.useSubFrames = false;
  }

  _timeout = Services.prefs.getIntPref(kTimeoutPref);

  // Expose `kIterationSizeMax` to the outside world for unit tests to use.
  get kIterationSizeMax() {
    return kIterationSizeMax;
  }

  get params() {
    if (!this._currentParams && !this._previousParams) {
      return null;
    }
    return Object.assign({}, this._currentParams || this._previousParams);
  }

  /**
   * Start iterating the active Finder docShell, using the options below. When
   * it already started at the request of another consumer, we first yield the
   * results we already collected before continuing onward to yield fresh results.
   * We make sure to pause every `kIterationSizeMax` iterations to make sure we
   * don't block the host process too long. In the case of a break like this, we
   * yield `undefined`, instead of a range.
   * Upon re-entrance after a break, we check if `stop()` was called during the
   * break and if so, we stop iterating.
   * Results are also passed to the `listener.onIteratorRangeFound` callback
   * method, along with a flag that specifies if the result comes from the cache
   * or is fresh. The callback also adheres to the `limit` flag.
   * The returned promise is resolved when 1) the limit is reached, 2) when all
   * the ranges have been found or 3) when `stop()` is called whilst iterating.
   *
   * @param {Number}  [options.allowDistance] Allowed edit distance between the
   *                                          current word and `options.word`
   *                                          when the iterator is already running
   * @param {Boolean} options.caseSensitive   Whether to search in case sensitive
   *                                          mode
   * @param {Boolean} options.entireWord      Whether to search in entire-word mode
   * @param {Finder}  options.finder          Currently active Finder instance
   * @param {Number}  [options.limit]         Limit the amount of results to be
   *                                          passed back. Optional, defaults to no
   *                                          limit.
   * @param {Boolean} [options.linksOnly]     Only yield ranges that are inside a
   *                                          hyperlink (used by QuickFind).
   *                                          Optional, defaults to `false`.
   * @param {Object}  options.listener        Listener object that implements the
   *                                          following callback functions:
   *                                           - onIteratorRangeFound({Range} range);
   *                                           - onIteratorReset();
   *                                           - onIteratorRestart({Object} iterParams);
   *                                           - onIteratorStart({Object} iterParams);
   * @param {Boolean} options.matchDiacritics Whether to search in
   *                                          diacritic-matching mode
   * @param {Boolean} [options.useCache]        Whether to allow results already
   *                                            present in the cache or demand fresh.
   *                                            Optional, defaults to `false`.
   * @param {Boolean} [options.useSubFrames]    Whether to iterate over subframes.
   *                                            Optional, defaults to `false`.
   * @param {String}  options.word              Word to search for
   * @return {Promise}
   */
  start({
    allowDistance,
    caseSensitive,
    entireWord,
    finder,
    limit,
    linksOnly,
    listener,
    matchDiacritics,
    useCache,
    word,
    useSubFrames,
  }) {
    // Take care of default values for non-required options.
    if (typeof allowDistance != "number") {
      allowDistance = 0;
    }
    if (typeof limit != "number") {
      limit = -1;
    }
    if (typeof linksOnly != "boolean") {
      linksOnly = false;
    }
    if (typeof useCache != "boolean") {
      useCache = false;
    }
    if (typeof useSubFrames != "boolean") {
      useSubFrames = false;
    }

    // Validate the options.
    if (typeof caseSensitive != "boolean") {
      throw new Error("Missing required option 'caseSensitive'");
    }
    if (typeof entireWord != "boolean") {
      throw new Error("Missing required option 'entireWord'");
    }
    if (typeof matchDiacritics != "boolean") {
      throw new Error("Missing required option 'matchDiacritics'");
    }
    if (!finder) {
      throw new Error("Missing required option 'finder'");
    }
    if (!word) {
      throw new Error("Missing required option 'word'");
    }
    if (typeof listener != "object" || !listener.onIteratorRangeFound) {
      throw new TypeError("Missing valid, required option 'listener'");
    }

    // If the listener was added before, make sure the promise is resolved before
    // we replace it with another.
    if (this._listeners.has(listener)) {
      let { onEnd } = this._listeners.get(listener);
      if (onEnd) {
        onEnd();
      }
    }

    let window = finder._getWindow();
    let resolver;
    let promise = new Promise(resolve => (resolver = resolve));
    let iterParams = {
      caseSensitive,
      entireWord,
      linksOnly,
      matchDiacritics,
      useCache,
      window,
      word,
      useSubFrames,
    };

    this._listeners.set(listener, { limit, onEnd: resolver });

    // If we're not running anymore and we're requesting the previous result, use it.
    if (!this.running && this._previousResultAvailable(iterParams)) {
      this._yieldPreviousResult(listener, window);
      return promise;
    }

    if (this.running) {
      // Double-check if we're not running the iterator with a different set of
      // parameters, otherwise report an error with the most common reason.
      if (
        !this._areParamsEqual(this._currentParams, iterParams, allowDistance)
      ) {
        if (kDebug) {
          console.error(
            `We're currently iterating over '${this._currentParams.word}', not '${word}'\n` +
              new Error().stack
          );
        }
        this._listeners.delete(listener);
        resolver();
        return promise;
      }

      // if we're still running, yield the set we have built up this far.
      this._yieldIntermediateResult(listener, window);

      return promise;
    }

    // Start!
    this.running = true;
    this._currentParams = iterParams;
    this._findAllRanges(finder, ++this._spawnId);

    return promise;
  }

  /**
   * Stop the currently running iterator as soon as possible and optionally cache
   * the result for later.
   *
   * @param {Boolean} [cachePrevious] Whether to save the result for later.
   *                                  Optional.
   */
  stop(cachePrevious = false) {
    if (!this.running) {
      return;
    }

    if (this._timer) {
      clearTimeout(this._timer);
      this._timer = null;
    }
    if (this._runningFindResolver) {
      this._runningFindResolver();
      this._runningFindResolver = null;
    }

    if (cachePrevious) {
      this._previousRanges = [].concat(this.ranges);
      this._previousParams = Object.assign({}, this._currentParams);
    } else {
      this._previousRanges = [];
      this._previousParams = null;
    }

    this._catchingUp.clear();
    this._currentParams = null;
    this.ranges = [];
    this.running = false;

    for (let [, { onEnd }] of this._listeners) {
      onEnd();
    }
  }

  /**
   * Stops the iteration that currently running, if it is, and start a new one
   * with the exact same params as before.
   *
   * @param {Finder} finder Currently active Finder instance
   */
  restart(finder) {
    // Capture current iterator params before we stop the show.
    let iterParams = this.params;
    if (!iterParams) {
      return;
    }
    this.stop();

    // Restart manually.
    this.running = true;
    this._currentParams = iterParams;

    this._findAllRanges(finder, ++this._spawnId);
    this._notifyListeners("restart", iterParams);
  }

  /**
   * Reset the internal state of the iterator. Typically this would be called
   * when the docShell is not active anymore, which makes the current and cached
   * previous result invalid.
   * If the iterator is running, it will be stopped as soon as possible.
   */
  reset() {
    if (this._timer) {
      clearTimeout(this._timer);
      this._timer = null;
    }
    if (this._runningFindResolver) {
      this._runningFindResolver();
      this._runningFindResolver = null;
    }

    this._catchingUp.clear();
    this._currentParams = this._previousParams = null;
    this._previousRanges = [];
    this.ranges = [];
    this.running = false;

    this._notifyListeners("reset");
    for (let [, { onEnd }] of this._listeners) {
      onEnd();
    }
    this._listeners.clear();
  }

  /**
   * Check if the currently running iterator parameters are the same as the ones
   * passed through the arguments. When `true`, we can keep it running as-is and
   * the consumer should stop the iterator when `false`.
   *
   * @param {Boolean}  options.caseSensitive Whether to search in case sensitive
   *                                         mode
   * @param {Boolean}  options.entireWord    Whether to search in entire-word mode
   * @param  {Boolean} options.linksOnly     Whether to search for the word to be
   *                                         present in links only
   * @param {Boolean}  options.matchDiacritics Whether to search in
   *                                           diacritic-matching mode
   * @param  {String}  options.word          The word being searched for
   * @param  (Boolean) options.useSubFrames  Whether to search subframes
   * @return {Boolean}
   */
  continueRunning({
    caseSensitive,
    entireWord,
    linksOnly,
    matchDiacritics,
    word,
    useSubFrames,
  }) {
    return (
      this.running &&
      this._currentParams.caseSensitive === caseSensitive &&
      this._currentParams.entireWord === entireWord &&
      this._currentParams.linksOnly === linksOnly &&
      this._currentParams.matchDiacritics === matchDiacritics &&
      this._currentParams.word == word &&
      this._currentParams.useSubFrames == useSubFrames
    );
  }

  /**
   * The default mode of operation of the iterator is to not accept duplicate
   * listeners, resolve the promise of the older listeners and replace it with
   * the new listener.
   * Consumers may opt-out of this behavior by using this check and not call
   * start().
   *
   * @param  {Object} paramSet Property bag with the same signature as you would
   *                           pass into `start()`
   * @return {Boolean}
   */
  isAlreadyRunning(paramSet) {
    return (
      this.running &&
      this._areParamsEqual(this._currentParams, paramSet) &&
      this._listeners.has(paramSet.listener)
    );
  }

  /**
   * Safely notify all registered listeners that an event has occurred.
   *
   * @param {String}   callback    Name of the callback to invoke
   * @param {mixed}    [params]    Optional argument that will be passed to the
   *                               callback
   * @param {Iterable} [listeners] Set of listeners to notify. Optional, defaults
   *                               to `this._listeners.keys()`.
   */
  _notifyListeners(callback, params, listeners = this._listeners.keys()) {
    callback =
      "onIterator" + callback.charAt(0).toUpperCase() + callback.substr(1);
    for (let listener of listeners) {
      try {
        listener[callback](params);
      } catch (ex) {
        console.error("FinderIterator Error: ", ex);
      }
    }
  }

  /**
   * Internal; check if an iteration request is available in the previous result
   * that we cached.
   *
   * @param  {Boolean} options.caseSensitive Whether to search in case sensitive
   *                                         mode
   * @param  {Boolean} options.entireWord    Whether to search in entire-word mode
   * @param  {Boolean} options.linksOnly     Whether to search for the word to be
   *                                         present in links only
   * @param  {Boolean} options.matchDiacritics Whether to search in
   *                                           diacritic-matching mode
   * @param  {Boolean} options.useCache      Whether the consumer wants to use the
   *                                         cached previous result at all
   * @param  {String}  options.word          The word being searched for
   * @return {Boolean}
   */
  _previousResultAvailable({
    caseSensitive,
    entireWord,
    linksOnly,
    matchDiacritics,
    useCache,
    word,
  }) {
    return !!(
      useCache &&
      this._areParamsEqual(this._previousParams, {
        caseSensitive,
        entireWord,
        linksOnly,
        matchDiacritics,
        word,
      }) &&
      this._previousRanges.length
    );
  }

  /**
   * Internal; compare if two sets of iterator parameters are equivalent.
   *
   * @param  {Object} paramSet1       First set of params (left hand side)
   * @param  {Object} paramSet2       Second set of params (right hand side)
   * @param  {Number} [allowDistance] Allowed edit distance between the two words.
   *                                  Optional, defaults to '0', which means 'no
   *                                  distance'.
   * @return {Boolean}
   */
  _areParamsEqual(paramSet1, paramSet2, allowDistance = 0) {
    return (
      !!paramSet1 &&
      !!paramSet2 &&
      paramSet1.caseSensitive === paramSet2.caseSensitive &&
      paramSet1.entireWord === paramSet2.entireWord &&
      paramSet1.linksOnly === paramSet2.linksOnly &&
      paramSet1.matchDiacritics === paramSet2.matchDiacritics &&
      paramSet1.window === paramSet2.window &&
      paramSet1.useSubFrames === paramSet2.useSubFrames &&
      lazy.NLP.levenshtein(paramSet1.word, paramSet2.word) <= allowDistance
    );
  }

  /**
   * Internal; iterate over a predefined set of ranges that have been collected
   * before.
   * Also here, we make sure to pause every `kIterationSizeMax` iterations to
   * make sure we don't block the host process too long. In the case of a break
   * like this, we yield `undefined`, instead of a range.
   *
   * @param {Object}       listener    Listener object
   * @param {Array}        rangeSource Set of ranges to iterate over
   * @param {nsIDOMWindow} window      The window object is only really used
   *                                   for access to `setTimeout`
   * @param {Boolean}      [withPause] Whether to pause after each `kIterationSizeMax`
   *                                   number of ranges yielded. Optional, defaults
   *                                   to `true`.
   * @yield {Range}
   */
  async _yieldResult(listener, rangeSource, window, withPause = true) {
    // We keep track of the number of iterations to allow a short pause between
    // every `kIterationSizeMax` number of iterations.
    let iterCount = 0;
    let { limit, onEnd } = this._listeners.get(listener);
    let ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
    for (let range of ranges) {
      try {
        range.startContainer;
      } catch (ex) {
        // Don't yield dead objects, so use the escape hatch.
        if (ex.message.includes("dead object")) {
          return;
        }
      }

      // Pass a flag that is `true` when we're returning the result from a
      // cached previous iteration.
      listener.onIteratorRangeFound(range, !this.running);
      await range;

      if (withPause && ++iterCount >= kIterationSizeMax) {
        iterCount = 0;
        // Make sure to save the current limit for later.
        this._listeners.set(listener, { limit, onEnd });
        // Sleep for the rest of this cycle.
        await new Promise(resolve => window.setTimeout(resolve, 0));
        // After a sleep, the set of ranges may have updated.
        ranges = rangeSource.slice(0, limit > -1 ? limit : undefined);
      }

      if (limit !== -1 && --limit === 0) {
        // We've reached our limit; no need to do more work.
        this._listeners.delete(listener);
        onEnd();
        return;
      }
    }

    // Save the updated limit globally.
    this._listeners.set(listener, { limit, onEnd });
  }

  /**
   * Internal; iterate over the set of previously found ranges. Meanwhile it'll
   * mark the listener as 'catching up', meaning it will not receive fresh
   * results from a running iterator.
   *
   * @param {Object}       listener Listener object
   * @param {nsIDOMWindow} window   The window object is only really used
   *                                for access to `setTimeout`
   * @yield {Range}
   */
  async _yieldPreviousResult(listener, window) {
    this._notifyListeners("start", this.params, [listener]);
    this._catchingUp.add(listener);
    await this._yieldResult(listener, this._previousRanges, window);
    this._catchingUp.delete(listener);
    let { onEnd } = this._listeners.get(listener);
    if (onEnd) {
      onEnd();
    }
  }

  /**
   * Internal; iterate over the set of already found ranges. Meanwhile it'll
   * mark the listener as 'catching up', meaning it will not receive fresh
   * results from the running iterator.
   *
   * @param {Object}       listener Listener object
   * @param {nsIDOMWindow} window   The window object is only really used
   *                                for access to `setTimeout`
   * @yield {Range}
   */
  async _yieldIntermediateResult(listener, window) {
    this._notifyListeners("start", this.params, [listener]);
    this._catchingUp.add(listener);
    await this._yieldResult(listener, this.ranges, window, false);
    this._catchingUp.delete(listener);
  }

  /**
   * Internal; see the documentation of the start() method above.
   *
   * @param {Finder}       finder  Currently active Finder instance
   * @param {Number}       spawnId Since `stop()` is synchronous and this method
   *                               is not, this identifier is used to learn if
   *                               it's supposed to still continue after a pause.
   * @yield {Range}
   */
  async _findAllRanges(finder, spawnId) {
    if (this._timeout) {
      if (this._timer) {
        clearTimeout(this._timer);
      }
      if (this._runningFindResolver) {
        this._runningFindResolver();
      }

      let timeout = this._timeout;
      let searchTerm = this._currentParams.word;
      // Wait a little longer when the first or second character is typed into
      // the findbar.
      if (searchTerm.length == 1) {
        timeout *= 4;
      } else if (searchTerm.length == 2) {
        timeout *= 2;
      }
      await new Promise(resolve => {
        this._runningFindResolver = resolve;
        this._timer = setTimeout(resolve, timeout);
      });
      this._timer = this._runningFindResolver = null;
      // During the timeout, we could have gotten the signal to stop iterating.
      // Make sure we do here.
      if (!this.running || spawnId !== this._spawnId) {
        return;
      }
    }

    this._notifyListeners("start", this.params);

    let { linksOnly, useSubFrames, window } = this._currentParams;
    // First we collect all frames we need to search through, whilst making sure
    // that the parent window gets dibs.
    let frames = [window];
    if (useSubFrames) {
      frames.push(...this._collectFrames(window, finder));
    }
    let iterCount = 0;
    for (let frame of frames) {
      for (let range of this._iterateDocument(this._currentParams, frame)) {
        // Between iterations, for example after a sleep of one cycle, we could
        // have gotten the signal to stop iterating. Make sure we do here.
        if (!this.running || spawnId !== this._spawnId) {
          return;
        }

        // Deal with links-only mode here.
        if (linksOnly && !this._rangeStartsInLink(range)) {
          continue;
        }

        this.ranges.push(range);

        // Call each listener with the range we just found.
        for (let [listener, { limit, onEnd }] of this._listeners) {
          if (this._catchingUp.has(listener)) {
            continue;
          }

          listener.onIteratorRangeFound(range);

          if (limit !== -1 && --limit === 0) {
            // We've reached our limit; no need to do more work for this listener.
            this._listeners.delete(listener);
            onEnd();
            continue;
          }

          // Save the updated limit globally.
          this._listeners.set(listener, { limit, onEnd });
        }

        await range;

        if (++iterCount >= kIterationSizeMax) {
          iterCount = 0;
          // Sleep for the rest of this cycle.
          await new Promise(resolve => window.setTimeout(resolve, 0));
        }
      }
    }

    // When the iterating has finished, make sure we reset and save the state
    // properly.
    this.stop(true);
  }

  /**
   * Internal; basic wrapper around nsIFind that provides a generator yielding
   * a range each time an occurence of `word` string is found.
   *
   * @param {Boolean}      options.caseSensitive Whether to search in case
   *                                             sensitive mode
   * @param {Boolean}      options.entireWord    Whether to search in entire-word
   *                                             mode
   * @param {Boolean}      options.matchDiacritics Whether to search in
   *                                               diacritic-matching mode
   * @param {String}       options.word          The word to search for
   * @param {nsIDOMWindow} window                The window to search in
   * @yield {Range}
   */
  *_iterateDocument(
    { caseSensitive, entireWord, matchDiacritics, word },
    window
  ) {
    let doc = window.document;
    let body = doc.body || doc.documentElement;

    if (!body) {
      return;
    }

    let searchRange = doc.createRange();
    searchRange.selectNodeContents(body);

    let startPt = searchRange.cloneRange();
    startPt.collapse(true);

    let endPt = searchRange.cloneRange();
    endPt.collapse(false);

    let retRange = null;

    let nsIFind = Cc["@mozilla.org/embedcomp/rangefind;1"]
      .createInstance()
      .QueryInterface(Ci.nsIFind);
    nsIFind.caseSensitive = caseSensitive;
    nsIFind.entireWord = entireWord;
    nsIFind.matchDiacritics = matchDiacritics;

    while ((retRange = nsIFind.Find(word, searchRange, startPt, endPt))) {
      yield retRange;
      startPt = retRange.cloneRange();
      startPt.collapse(false);
    }
  }

  /**
   * Internal; helper method for the iterator that recursively collects all
   * visible (i)frames inside a window.
   *
   * @param  {nsIDOMWindow} window The window to extract the (i)frames from
   * @param  {Finder}       finder The Finder instance
   * @return {Array}        Stack of frames to iterate over
   */
  _collectFrames(window, finder) {
    let frames = [];
    if (!("frames" in window) || !window.frames.length) {
      return frames;
    }

    // Casting `window.frames` to an Iterator doesn't work, so we're stuck with
    // a plain, old for-loop.
    let dwu = window.windowUtils;
    for (let i = 0, l = window.frames.length; i < l; ++i) {
      let frame = window.frames[i];
      // Don't count matches in hidden frames; get the frame element rect and
      // check if it's empty. We shan't flush!
      let frameEl = frame && frame.frameElement;
      if (
        !frameEl ||
        lazy.Rect.fromRect(dwu.getBoundsWithoutFlushing(frameEl)).isEmpty()
      ) {
        continue;
      }
      // All conditions pass, so push the current frame and its children on the
      // stack.
      frames.push(frame, ...this._collectFrames(frame, finder));
    }

    return frames;
  }

  /**
   * Internal; helper method to extract the docShell reference from a Window or
   * Range object.
   *
   * @param  {Range} windowOrRange Window object to query. May also be a
   *                               Range, from which the owner window will
   *                               be queried.
   * @return {nsIDocShell}
   */
  _getDocShell(windowOrRange) {
    let window = windowOrRange;
    // Ranges may also be passed in, so fetch its window.
    if (ChromeUtils.getClassName(windowOrRange) === "Range") {
      window = windowOrRange.startContainer.ownerGlobal;
    }
    return window.docShell;
  }

  /**
   * Internal; determines whether a range is inside a link.
   *
   * @param  {Range} range the range to check
   * @return {Boolean}     True if the range starts in a link
   */
  _rangeStartsInLink(range) {
    let isInsideLink = false;
    let node = range.startContainer;

    if (node.nodeType == node.ELEMENT_NODE) {
      if (node.hasChildNodes) {
        let childNode = node.item(range.startOffset);
        if (childNode) {
          node = childNode;
        }
      }
    }

    const XLink_NS = "http://www.w3.org/1999/xlink";
    const HTMLAnchorElement = (node.ownerDocument || node).defaultView
      .HTMLAnchorElement;
    do {
      if (HTMLAnchorElement.isInstance(node)) {
        isInsideLink = node.hasAttribute("href");
        break;
      } else if (
        typeof node.hasAttributeNS == "function" &&
        node.hasAttributeNS(XLink_NS, "href")
      ) {
        isInsideLink = node.getAttributeNS(XLink_NS, "type") == "simple";
        break;
      }

      node = node.parentNode;
    } while (node);

    return isInsideLink;
  }
}
