// vim: set ts=2 sw=2 sts=2 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/.

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

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

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

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

const lazy = {};

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

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "ClipboardHelper",
  "@mozilla.org/widget/clipboardhelper;1",
  "nsIClipboardHelper"
);

const kSelectionMaxLen = 150;
const kMatchesCountLimitPref = "accessibility.typeaheadfind.matchesCountLimit";

const activeFinderRoots = new WeakSet();

export function Finder(docShell) {
  this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance(
    Ci.nsITypeAheadFind
  );
  this._fastFind.init(docShell);

  this._currentFoundRange = null;
  this._docShell = docShell;
  this._listeners = [];
  this._previousLink = null;
  this._searchString = null;
  this._highlighter = null;

  docShell
    .QueryInterface(Ci.nsIInterfaceRequestor)
    .getInterface(Ci.nsIWebProgress)
    .addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
  docShell.domWindow.addEventListener(
    "unload",
    this.onLocationChange.bind(this, { isTopLevel: true })
  );
}

Finder.isFindbarVisible = function (docShell) {
  return activeFinderRoots.has(docShell.browsingContext.top);
};

Finder.prototype = {
  get iterator() {
    if (!this._iterator) {
      this._iterator = new lazy.FinderIterator();
    }
    return this._iterator;
  },

  destroy() {
    if (this._iterator) {
      this._iterator.reset();
    }
    let window = this._getWindow();
    if (this._highlighter && window) {
      // if we clear all the references before we hide the highlights (in both
      // highlighting modes), we simply can't use them to find the ranges we
      // need to clear from the selection.
      this._highlighter.hide(window);
      this._highlighter.clear(window);
      this.highlighter.removeScrollMarks();
    }
    this.listeners = [];
    this._docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebProgress)
      .removeProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
    this._listeners = [];
    this._currentFoundRange =
      this._fastFind =
      this._docShell =
      this._previousLink =
      this._highlighter =
        null;
  },

  addResultListener(aListener) {
    if (!this._listeners.includes(aListener)) {
      this._listeners.push(aListener);
    }
  },

  removeResultListener(aListener) {
    this._listeners = this._listeners.filter(l => l != aListener);
  },

  _setResults(options) {
    if (typeof options.storeResult != "boolean") {
      options.storeResult = true;
    }

    if (options.storeResult) {
      this._searchString = options.searchString;
      this.clipboardSearchString = options.searchString;
    }

    let foundLink = this._fastFind.foundLink;
    let linkURL = null;
    if (foundLink) {
      linkURL = Services.textToSubURI.unEscapeURIForUI(foundLink.href);
    }

    options.linkURL = linkURL;
    options.rect = this._getResultRect();
    options.searchString = this._searchString;

    this._outlineLink(options.drawOutline);

    for (let l of this._listeners) {
      try {
        l.onFindResult(options);
      } catch (ex) {}
    }
  },

  get searchString() {
    if (!this._searchString && this._fastFind.searchString) {
      this._searchString = this._fastFind.searchString;
    }
    return this._searchString;
  },

  get clipboardSearchString() {
    return GetClipboardSearchString(
      this._getWindow().docShell.QueryInterface(Ci.nsILoadContext)
    );
  },

  set clipboardSearchString(aSearchString) {
    if (!lazy.PrivateBrowsingUtils.isContentWindowPrivate(this._getWindow())) {
      SetClipboardSearchString(aSearchString);
    }
  },

  set caseSensitive(aSensitive) {
    if (this._fastFind.caseSensitive === aSensitive) {
      return;
    }
    this._fastFind.caseSensitive = aSensitive;
    this.iterator.reset();
  },

  set matchDiacritics(aMatchDiacritics) {
    if (this._fastFind.matchDiacritics === aMatchDiacritics) {
      return;
    }
    this._fastFind.matchDiacritics = aMatchDiacritics;
    this.iterator.reset();
  },

  set entireWord(aEntireWord) {
    if (this._fastFind.entireWord === aEntireWord) {
      return;
    }
    this._fastFind.entireWord = aEntireWord;
    this.iterator.reset();
  },

  get highlighter() {
    if (this._highlighter) {
      return this._highlighter;
    }

    const { FinderHighlighter } = ChromeUtils.importESModule(
      "resource://gre/modules/FinderHighlighter.sys.mjs"
    );
    return (this._highlighter = new FinderHighlighter(this));
  },

  get matchesCountLimit() {
    if (typeof this._matchesCountLimit == "number") {
      return this._matchesCountLimit;
    }

    this._matchesCountLimit =
      Services.prefs.getIntPref(kMatchesCountLimitPref) || 0;
    return this._matchesCountLimit;
  },

  _lastFindResult: null,

  /**
   * Used for normal search operations, highlights the first match.
   * This method is used only for compatibility with non-remote browsers.
   *
   * @param aSearchString String to search for.
   * @param aLinksOnly Only consider nodes that are links for the search.
   * @param aDrawOutline Puts an outline around matched links.
   */
  fastFind(aSearchString, aLinksOnly, aDrawOutline) {
    let searchLengthened =
      aSearchString.length > this._fastFind.searchString.length;

    this._lastFindResult = this._fastFind.find(
      aSearchString,
      aLinksOnly,
      Ci.nsITypeAheadFind.FIND_INITIAL,
      false
    );
    let searchString = this._fastFind.searchString;

    let results = {
      searchString,
      result: this._lastFindResult,
      findBackwards: false,
      findAgain: false,
      drawOutline: aDrawOutline,
      linksOnly: aLinksOnly,
      useSubFrames: true,
    };

    this._setResults(results);
    this.updateHighlightAndMatchCount(results);

    if (
      searchLengthened &&
      this._lastFindResult.result == Ci.nsITypeAheadFind.FIND_NOTFOUND &&
      !this._fastFind.entireWord
    ) {
      playSound("not-found");
    }

    return this._lastFindResult;
  },

  /**
   * Repeat the previous search. Should only be called after a previous
   * call to Finder.fastFind.
   * This method is used only for compatibility with non-remote browsers.
   *
   * @param aSearchString String to search for.
   * @param aFindBackwards Controls the search direction:
   *    true: before current match, false: after current match.
   * @param aLinksOnly Only consider nodes that are links for the search.
   * @param aDrawOutline Puts an outline around matched links.
   */
  findAgain(aSearchString, aFindBackwards, aLinksOnly, aDrawOutline) {
    let mode = aFindBackwards
      ? Ci.nsITypeAheadFind.FIND_PREVIOUS
      : Ci.nsITypeAheadFind.FIND_NEXT;
    this._lastFindResult = this._fastFind.find(
      aFindBackwards,
      aLinksOnly,
      mode,
      false
    );
    let searchString = this._fastFind.searchString;

    let results = {
      searchString,
      result: this._lastFindResult,
      findBackwards: aFindBackwards,
      findAgain: true,
      drawOutline: aDrawOutline,
      linksOnly: aLinksOnly,
      useSubFrames: true,
    };
    this._setResults(results);
    this.updateHighlightAndMatchCount(results);

    if (this._lastFindResult.result == Ci.nsITypeAheadFind.FIND_WRAPPED) {
      playSound("wrapped");
    }

    return this._lastFindResult;
  },

  /**
   * Used for normal search operations, highlights the first or
   * subsequent match depending on the mode.
   *
   * Options are:
   *  searchString String to search for.
   *  findAgain True if this a find again operation.
   *  mode Search mode from nsITypeAheadFind.
   *  linksOnly Only consider nodes that are links for the search.
   *  drawOutline Puts an outline around matched links.
   *  useSubFrames True to iterate over subframes.
   *  caseSensitive True for case sensitive searching.
   *  entireWord True to match entire words.
   *  matchDiacritics True to match diacritics.
   */
  find(options) {
    this.caseSensitive = options.caseSensitive;
    this.entireWord = options.entireWord;
    this.matchDiacritics = options.matchDiacritics;

    this._lastFindResult = this._fastFind.find(
      options.searchString,
      options.linksOnly,
      options.mode,
      !options.useSubFrames
    );
    let searchString = this._fastFind.searchString;
    let results = {
      searchString,
      result: this._lastFindResult,
      findBackwards:
        options.mode == Ci.nsITypeAheadFind.FIND_PREVIOUS ||
        options.mode == Ci.nsITypeAheadFind.FIND_LAST,
      findAgain: options.findAgain,
      drawOutline: options.drawOutline,
      linksOnly: options.linksOnly,
      entireWord: this._fastFind.entireWord,
      useSubFrames: options.useSubFrames,
    };
    this._setResults(results, options.mode);
    return new Promise(resolve => resolve(results));
  },

  /**
   * Forcibly set the search string of the find clipboard to the currently
   * selected text in the window, on supported platforms (i.e. OSX).
   */
  setSearchStringToSelection() {
    let searchInfo = this.getActiveSelectionText();

    // If an empty string is returned or a subframe is focused, don't
    // assign the search string.
    if (searchInfo.selectedText) {
      this.clipboardSearchString = searchInfo.selectedText;
    }

    return searchInfo;
  },

  async highlight(aHighlight, aWord, aLinksOnly, aUseSubFrames = true) {
    return this.highlighter.highlight(
      aHighlight,
      aWord,
      aLinksOnly,
      false,
      aUseSubFrames
    );
  },

  async updateHighlightAndMatchCount(aArgs) {
    this._lastFindResult = aArgs;

    if (
      !this.iterator.continueRunning({
        caseSensitive: this._fastFind.caseSensitive,
        entireWord: this._fastFind.entireWord,
        linksOnly: aArgs.linksOnly,
        matchDiacritics: this._fastFind.matchDiacritics,
        word: aArgs.searchString,
        useSubFrames: aArgs.useSubFrames,
      })
    ) {
      this.iterator.stop();
    }

    let highlightPromise = this.highlighter.update(
      aArgs,
      aArgs.useSubFrames ? false : aArgs.foundInThisFrame
    );
    let matchCountPromise = this.requestMatchesCount(
      aArgs.searchString,
      aArgs.linksOnly,
      aArgs.useSubFrames
    );

    let results = await Promise.all([highlightPromise, matchCountPromise]);

    this.highlighter.updateScrollMarks();

    if (results[1]) {
      return Object.assign(results[1], results[0]);
    } else if (results[0]) {
      return results[0];
    }

    return null;
  },

  getInitialSelection() {
    let initialSelection = this.getActiveSelectionText().selectedText;
    this._getWindow().setTimeout(() => {
      for (let l of this._listeners) {
        try {
          l.onCurrentSelection(initialSelection, true);
        } catch (ex) {}
      }
    }, 0);
  },

  getActiveSelectionText() {
    let focusedWindow = {};
    let focusedElement = Services.focus.getFocusedElementForWindow(
      this._getWindow(),
      true,
      focusedWindow
    );
    focusedWindow = focusedWindow.value;

    let selText;

    // If this is a remote subframe, return an empty string but
    // indiciate which browsing context was focused.
    if (
      focusedElement &&
      "frameLoader" in focusedElement &&
      BrowsingContext.isInstance(focusedElement.browsingContext)
    ) {
      return {
        focusedChildBrowserContextId: focusedElement.browsingContext.id,
        selectedText: "",
      };
    }

    if (focusedElement && focusedElement.editor) {
      // The user may have a selection in an input or textarea.
      selText = focusedElement.editor.selectionController
        .getSelection(Ci.nsISelectionController.SELECTION_NORMAL)
        .toString();
    } else {
      // Look for any selected text on the actual page.
      selText = focusedWindow.getSelection().toString();
    }

    if (!selText) {
      return { selectedText: "" };
    }

    // Process our text to get rid of unwanted characters.
    selText = selText.trim().replace(/\s+/g, " ");
    let truncLength = kSelectionMaxLen;
    if (selText.length > truncLength) {
      let truncChar = selText.charAt(truncLength).charCodeAt(0);
      if (truncChar >= 0xdc00 && truncChar <= 0xdfff) {
        truncLength++;
      }
      selText = selText.substr(0, truncLength);
    }

    return { selectedText: selText };
  },

  enableSelection() {
    this._fastFind.setSelectionModeAndRepaint(
      Ci.nsISelectionController.SELECTION_ON
    );
    this._restoreOriginalOutline();
  },

  removeSelection(keepHighlight) {
    this._fastFind.collapseSelection();
    this.enableSelection();
    let window = this._getWindow();
    if (keepHighlight) {
      this.highlighter.clearCurrentOutline(window);
    } else {
      this.highlighter.clear(window);
      this.highlighter.removeScrollMarks();
    }
  },

  focusContent() {
    // Allow Finder listeners to cancel focusing the content.
    for (let l of this._listeners) {
      try {
        if ("shouldFocusContent" in l && !l.shouldFocusContent()) {
          return;
        }
      } catch (ex) {
        console.error(ex);
      }
    }

    let fastFind = this._fastFind;
    try {
      // Try to find the best possible match that should receive focus and
      // block scrolling on focus since find already scrolls. Further
      // scrolling is due to user action, so don't override this.
      if (fastFind.foundLink) {
        Services.focus.setFocus(
          fastFind.foundLink,
          Services.focus.FLAG_NOSCROLL
        );
      } else if (fastFind.foundEditable) {
        Services.focus.setFocus(
          fastFind.foundEditable,
          Services.focus.FLAG_NOSCROLL
        );
        fastFind.collapseSelection();
      } else {
        this._getWindow().focus();
      }
    } catch (e) {}
  },

  onFindbarClose() {
    this.enableSelection();
    this.highlighter.highlight(false);
    this.highlighter.removeScrollMarks();
    this.iterator.reset();
    activeFinderRoots.delete(this._docShell.browsingContext.top);
  },

  onFindbarOpen() {
    activeFinderRoots.add(this._docShell.browsingContext.top);
  },

  onModalHighlightChange(useModalHighlight) {
    if (this._highlighter) {
      this._highlighter.onModalHighlightChange(useModalHighlight);
    }
  },

  onHighlightAllChange(highlightAll) {
    if (this._highlighter) {
      this._highlighter.onHighlightAllChange(highlightAll);
    }
    if (this._iterator) {
      this._iterator.reset();
    }
  },

  keyPress(aEvent) {
    let controller = this._getSelectionController(this._getWindow());
    let accelKeyPressed =
      AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey;

    switch (aEvent.keyCode) {
      case aEvent.DOM_VK_RETURN:
        if (this._fastFind.foundLink) {
          let view = this._fastFind.foundLink.ownerGlobal;
          this._fastFind.foundLink.dispatchEvent(
            new view.PointerEvent("click", {
              view,
              cancelable: true,
              bubbles: true,
              ctrlKey: aEvent.ctrlKey,
              altKey: aEvent.altKey,
              shiftKey: aEvent.shiftKey,
              metaKey: aEvent.metaKey,
            })
          );
        }
        break;
      case aEvent.DOM_VK_TAB:
        let direction = Services.focus.MOVEFOCUS_FORWARD;
        if (aEvent.shiftKey) {
          direction = Services.focus.MOVEFOCUS_BACKWARD;
        }
        Services.focus.moveFocus(this._getWindow(), null, direction, 0);
        break;
      case aEvent.DOM_VK_PAGE_UP:
        controller.scrollPage(false);
        break;
      case aEvent.DOM_VK_PAGE_DOWN:
        controller.scrollPage(true);
        break;
      case aEvent.DOM_VK_UP:
        if (accelKeyPressed) {
          controller.completeScroll(false);
        } else {
          controller.scrollLine(false);
        }
        break;
      case aEvent.DOM_VK_DOWN:
        if (accelKeyPressed) {
          controller.completeScroll(true);
        } else {
          controller.scrollLine(true);
        }
        break;
    }
  },

  _notifyMatchesCount(aWord, result = this._currentMatchesCountResult) {
    // The `_currentFound` property is only used for internal bookkeeping.
    delete result._currentFound;
    result.searchString = aWord;
    result.limit = this.matchesCountLimit;
    if (result.total == result.limit) {
      result.total = -1;
    }

    for (let l of this._listeners) {
      try {
        l.onMatchesCountResult(result);
      } catch (ex) {}
    }

    this._currentMatchesCountResult = null;
    return result;
  },

  async requestMatchesCount(aWord, aLinksOnly, aUseSubFrames = true) {
    if (
      this._lastFindResult == Ci.nsITypeAheadFind.FIND_NOTFOUND ||
      this.searchString == "" ||
      !aWord ||
      !this.matchesCountLimit
    ) {
      return this._notifyMatchesCount(aWord, {
        total: 0,
        current: 0,
      });
    }

    this._currentFoundRange = this._fastFind.getFoundRange();

    let params = {
      caseSensitive: this._fastFind.caseSensitive,
      entireWord: this._fastFind.entireWord,
      linksOnly: aLinksOnly,
      matchDiacritics: this._fastFind.matchDiacritics,
      word: aWord,
      useSubFrames: aUseSubFrames,
    };
    if (!this.iterator.continueRunning(params)) {
      this.iterator.stop();
    }

    await this.iterator.start(
      Object.assign(params, {
        finder: this,
        limit: this.matchesCountLimit,
        listener: this,
        useCache: true,
        useSubFrames: aUseSubFrames,
      })
    );

    // Without a valid result, there's nothing to notify about. This happens
    // when the iterator was started before and won the race.
    if (!this._currentMatchesCountResult) {
      return null;
    }

    return this._notifyMatchesCount(aWord);
  },

  // FinderIterator listener implementation

  onIteratorRangeFound(range) {
    let result = this._currentMatchesCountResult;
    if (!result) {
      return;
    }

    ++result.total;
    if (!result._currentFound) {
      ++result.current;
      result._currentFound =
        this._currentFoundRange &&
        range.startContainer == this._currentFoundRange.startContainer &&
        range.startOffset == this._currentFoundRange.startOffset &&
        range.endContainer == this._currentFoundRange.endContainer &&
        range.endOffset == this._currentFoundRange.endOffset;
    }
  },

  onIteratorReset() {},

  onIteratorRestart({ word, linksOnly, useSubFrames }) {
    this.requestMatchesCount(word, linksOnly, useSubFrames);
  },

  onIteratorStart() {
    this._currentMatchesCountResult = {
      total: 0,
      current: 0,
      _currentFound: false,
    };
  },

  _getWindow() {
    if (!this._docShell) {
      return null;
    }
    return this._docShell.domWindow;
  },

  /**
   * Get the bounding selection rect in CSS px relative to the origin of the
   * top-level content document.
   */
  _getResultRect() {
    let topWin = this._getWindow();
    let win = this._fastFind.currentWindow;
    if (!win) {
      return null;
    }

    let selection = win.getSelection();
    if (!selection.rangeCount || selection.isCollapsed) {
      // The selection can be into an input or a textarea element.
      let nodes = win.document.querySelectorAll("input, textarea");
      for (let node of nodes) {
        if (node.editor) {
          try {
            let sc = node.editor.selectionController;
            selection = sc.getSelection(
              Ci.nsISelectionController.SELECTION_NORMAL
            );
            if (selection.rangeCount && !selection.isCollapsed) {
              break;
            }
          } catch (e) {
            // If this textarea is hidden, then its selection controller might
            // not be intialized. Ignore the failure.
          }
        }
      }
    }

    if (!selection.rangeCount || selection.isCollapsed) {
      return null;
    }

    let utils = topWin.windowUtils;

    let scrollX = {},
      scrollY = {};
    utils.getScrollXY(false, scrollX, scrollY);

    for (let frame = win; frame != topWin; frame = frame.parent) {
      let rect = frame.frameElement.getBoundingClientRect();
      let left = frame.getComputedStyle(frame.frameElement).borderLeftWidth;
      let top = frame.getComputedStyle(frame.frameElement).borderTopWidth;
      scrollX.value += rect.left + parseInt(left, 10);
      scrollY.value += rect.top + parseInt(top, 10);
    }
    let rect = Rect.fromRect(selection.getRangeAt(0).getBoundingClientRect());
    return rect.translate(scrollX.value, scrollY.value);
  },

  _outlineLink(aDrawOutline) {
    let foundLink = this._fastFind.foundLink;

    // Optimization: We are drawing outlines and we matched
    // the same link before, so don't duplicate work.
    if (foundLink == this._previousLink && aDrawOutline) {
      return;
    }

    this._restoreOriginalOutline();

    if (foundLink && aDrawOutline) {
      // Backup original outline
      this._tmpOutline = foundLink.style.outline;
      this._tmpOutlineOffset = foundLink.style.outlineOffset;

      // Draw pseudo focus rect
      // XXX Should we change the following style for FAYT pseudo focus?
      // XXX Shouldn't we change default design if outline is visible
      //     already?
      // Don't set the outline-color, we should always use initial value.
      foundLink.style.outline = "1px dotted";
      foundLink.style.outlineOffset = "0";

      this._previousLink = foundLink;
    }
  },

  _restoreOriginalOutline() {
    // Removes the outline around the last found link.
    if (this._previousLink) {
      this._previousLink.style.outline = this._tmpOutline;
      this._previousLink.style.outlineOffset = this._tmpOutlineOffset;
      this._previousLink = null;
    }
  },

  _getSelectionController(aWindow) {
    // display: none iframes don't have a selection controller, see bug 493658
    try {
      if (!aWindow.innerWidth || !aWindow.innerHeight) {
        return null;
      }
    } catch (e) {
      // If getting innerWidth or innerHeight throws, we can't get a selection
      // controller.
      return null;
    }

    // Yuck. See bug 138068.
    let docShell = aWindow.docShell;

    let controller = docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsISelectionDisplay)
      .QueryInterface(Ci.nsISelectionController);
    return controller;
  },

  // Start of nsIWebProgressListener implementation.

  onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
    if (!aWebProgress.isTopLevel) {
      return;
    }
    // Ignore events that don't change the document.
    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
      return;
    }

    // Avoid leaking if we change the page.
    this._lastFindResult = this._previousLink = this._currentFoundRange = null;
    this.highlighter.onLocationChange();
    this.iterator.reset();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),
};

export function GetClipboardSearchString(aLoadContext) {
  let searchString = "";
  if (
    !Services.clipboard.isClipboardTypeSupported(
      Services.clipboard.kFindClipboard
    )
  ) {
    return searchString;
  }

  try {
    let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
      Ci.nsITransferable
    );
    trans.init(aLoadContext);
    trans.addDataFlavor("text/plain");

    Services.clipboard.getData(trans, Ci.nsIClipboard.kFindClipboard);

    let data = {};
    trans.getTransferData("text/plain", data);
    if (data.value) {
      data = data.value.QueryInterface(Ci.nsISupportsString);
      searchString = data.toString();
    }
  } catch (ex) {}

  return searchString;
}

export function SetClipboardSearchString(aSearchString) {
  if (
    !aSearchString ||
    !Services.clipboard.isClipboardTypeSupported(
      Services.clipboard.kFindClipboard
    )
  ) {
    return;
  }

  lazy.ClipboardHelper.copyStringToClipboard(
    aSearchString,
    Ci.nsIClipboard.kFindClipboard
  );
}
