// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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/. */
"use strict";

XPCOMUtils.defineLazyModuleGetter(this, "Snackbars", "resource://gre/modules/Snackbars.jsm");

const PHONE_REGEX = /^\+?[0-9\s,-.\(\)*#pw]{1,30}$/; // Are we a phone #?


/**
 * ActionBarHandler Object and methods. Interface between Gecko Text Selection code
 * (AccessibleCaret, etc) and the Mobile ActionBar UI.
 */
var ActionBarHandler = {
  // Error codes returned from _init().
  START_TOUCH_ERROR: {
    NO_CONTENT_WINDOW: "No valid content Window found.",
    NONE: "",
  },

  _nextSelectionID: 1, // Next available.
  _selectionID: null, // Unique Selection ID, assigned each time we _init().

  _boundingClientRect: null, // Current selections boundingClientRect.
  _actionBarActions: null, // Most-recent set of actions sent to ActionBar.

  /**
   * Receive and act on AccessibleCarets caret state-change
   * (mozcaretstatechanged) events.
   */
  caretStateChangedHandler: function(e) {
    // Close an open ActionBar, if carets no longer logically visible.
    if (this._selectionID && !e.caretVisible) {
      this._uninit(false);
      return;
    }

    if (!this._selectionID && e.collapsed) {
      switch (e.reason) {
        case 'longpressonemptycontent':
        case 'taponcaret':
          // Show ActionBar when long pressing on an empty input or single
          // tapping on the caret.
          this._init(e.boundingClientRect);
          break;

        case 'updateposition':
          // Do not show ActionBar when single tapping on an non-empty editable
          // input.
          break;

        default:
          break;
      }
      return;
    }

    // Open a closed ActionBar if carets actually visible.
    if (!this._selectionID && e.caretVisuallyVisible) {
      this._init(e.boundingClientRect);
      return;
    }

    // Else, update an open ActionBar.
    if (this._selectionID) {
      if (!this._selectionHasChanged()) {
        // Still the same active selection.
        if (e.reason == 'presscaret' || e.reason == 'scroll') {
          // boundingClientRect doesn't matter since we are hiding the floating
          // toolbar.
          this._updateVisibility();
        } else {
          // Selection changes update boundingClientRect.
          this._boundingClientRect = e.boundingClientRect;
          let forceUpdate = e.reason == 'updateposition' || e.reason == 'releasecaret';
          this._sendActionBarActions(forceUpdate);
        }
      } else {
        // We've started a new selection entirely.
        this._uninit(false);
        this._init(e.boundingClientRect);
      }
    }
  },

  /**
   * ActionBarHandler notification observers.
   */
  observe: function(subject, topic, data) {
    switch (topic) {
      // User click an ActionBar button.
      case "TextSelection:Action": {
        if (!this._selectionID) {
          break;
        }
        for (let type in this.actions) {
          let action = this.actions[type];
          if (action.id == data) {
            action.action(this._targetElement, this._contentWindow);
            break;
          }
        }
        break;
      }

      // Provide selected text to FindInPageBar on request.
      case "TextSelection:Get": {
        Messaging.sendRequest({
          type: "TextSelection:Data",
          requestId: data,
          text: this._getSelectedText(),
        });

        this._uninit();
        break;
      }

      // User closed ActionBar by clicking "checkmark" button.
      case "TextSelection:End": {
        // End the requested selection only.
        if (this._selectionID == JSON.parse(data).selectionID) {
          this._uninit();
        }
        break;
      }
    }
  },

  /**
   * Called when Gecko AccessibleCaret becomes visible.
   */
  _init: function(boundingClientRect) {
    let [element, win] = this._getSelectionTargets();
    if (!win) {
      return this.START_TOUCH_ERROR.NO_CONTENT_WINDOW;
    }

    // Hold the ActionBar ID provided by Gecko.
    this._selectionID = this._nextSelectionID++;
    [this._targetElement, this._contentWindow] = [element, win];
    this._boundingClientRect = boundingClientRect;

    // Open the ActionBar, send it's actions list.
    Messaging.sendRequest({
      type: "TextSelection:ActionbarInit",
      selectionID: this._selectionID,
    });
    this._sendActionBarActions(true);

    return this.START_TOUCH_ERROR.NONE;
  },

  /**
   * Called when content is scrolled and handles are hidden.
   */
  _updateVisibility: function() {
    Messaging.sendRequest({
      type: "TextSelection:Visibility",
      selectionID: this._selectionID,
    });
  },

  /**
   * Determines the window containing the selection, and its
   * editable element if present.
   */
  _getSelectionTargets: function() {
    let [element, win] = [Services.focus.focusedElement, Services.focus.focusedWindow];
    if (!element) {
      // No focused editable.
      return [null, win];
    }

    // Return focused editable text element and its window.
    if (((element instanceof HTMLInputElement) && element.mozIsTextField(false)) ||
        (element instanceof HTMLTextAreaElement) ||
        element.isContentEditable) {
      return [element, win];
    }

    // Focused element can't contain text.
    return [null, win];
  },

  /**
   * The active Selection has changed, if the current focused element / win,
   * pair, or state of the win's designMode changes.
   */
  _selectionHasChanged: function() {
    let [element, win] = this._getSelectionTargets();
    return (this._targetElement !== element ||
            this._contentWindow !== win ||
            this._isInDesignMode(this._contentWindow) !== this._isInDesignMode(win));
  },

  /**
   * Called when Gecko AccessibleCaret becomes hidden,
   * ActionBar is closed by user "close" request, or as a result of object
   * methods such as SELECT_ALL, PASTE, etc.
   */
  _uninit: function(clearSelection = true) {
    // Bail if there's no active selection.
    if (!this._selectionID) {
      return;
    }

    // Close the ActionBar.
    Messaging.sendRequest({
      type: "TextSelection:ActionbarUninit",
    });

    // Clear the selection ID to complete the uninit(), but leave our reference
    // to selectionTargets (_targetElement, _contentWindow) in case we need
    // a final clearSelection().
    this._selectionID = null;
    this._boundingClientRect = null;

    // Clear selection required if triggered by self, or TextSelection icon
    // actions. If called by Gecko CaretStateChangedEvent,
    // visibility state is already correct.
    if (clearSelection) {
      this._clearSelection();
    }
  },

  /**
   * Final UI cleanup when Actionbar is closed by icon click, or where
   * we terminate selection state after before/after actionbar actions
   * (Cut, Copy, Paste, Search, Share, Call).
   */
  _clearSelection: function(element = this._targetElement, win = this._contentWindow) {
    // Commit edit compositions, and clear focus from editables.
    if (element) {
      let imeSupport = this._getEditor(element, win).QueryInterface(Ci.nsIEditorIMESupport);
      if (imeSupport.composing) {
        imeSupport.forceCompositionEnd();
      }
      element.blur();
    }

    // Remove Selection from non-editables and now-unfocused contentEditables.
    if (!element || element.isContentEditable) {
      this._getSelection().removeAllRanges();
    }
  },

  /**
   * Called to determine current ActionBar actions and send to TextSelection
   * handler. By default we only send if current action state differs from
   * the previous.
   * @param By default we only send an ActionBarStatus update message if
   *        there is a change from the previous state. sendAlways can be
   *        set by init() for example, where we want to always send the
   *        current state.
   */
  _sendActionBarActions: function(sendAlways) {
    let actions = this._getActionBarActions();

    let actionCountUnchanged = this._actionBarActions &&
      actions.length === this._actionBarActions.length;
    let actionsMatch = actionCountUnchanged &&
      this._actionBarActions.every((e,i) => {
        return e.id === actions[i].id;
      });

    if (sendAlways || !actionsMatch) {
      Messaging.sendRequest({
        type: "TextSelection:ActionbarStatus",
        selectionID: this._selectionID,
        actions: actions,
        x: this._boundingClientRect.x,
        y: this._boundingClientRect.y,
        width: this._boundingClientRect.width,
        height: this._boundingClientRect.height
      });
    }

    this._actionBarActions = actions;
  },

  /**
   * Determine and return current ActionBar state.
   */
  _getActionBarActions: function(element = this._targetElement, win = this._contentWindow) {
    let actions = [];

    for (let type in this.actions) {
      let action = this.actions[type];
      if (action.selector.matches(element, win)) {
        let a = {
          id: action.id,
          label: this._getActionValue(action, "label", "", element),
          icon: this._getActionValue(action, "icon", "drawable://ic_status_logo", element),
          order: this._getActionValue(action, "order", 0, element),
          floatingOrder: this._getActionValue(action, "floatingOrder", 9, element),
          showAsAction: this._getActionValue(action, "showAsAction", true, element),
        };
        actions.push(a);
      }
    }
    actions.sort((a, b) => b.order - a.order);

    return actions;
  },

  /**
   * Provides a value from an action. If the action defines the value as a function,
   * we return the result of calling the function. Otherwise, we return the value
   * itself. If the value isn't defined for this action, will return a default.
   */
  _getActionValue: function(obj, name, defaultValue, element) {
    if (!(name in obj))
      return defaultValue;

    if (typeof obj[name] == "function")
      return obj[name](element);

    return obj[name];
  },

  /**
   * Actionbar callback methods.
   */
  actions: {

    SELECT_ALL: {
      id: "selectall_action",
      label: Strings.browser.GetStringFromName("contextmenu.selectAll"),
      icon: "drawable://ab_select_all",
      order: 5,
      floatingOrder: 5,

      selector: {
        matches: function(element, win) {
          // For editable, check its length. For default contentWindow, assume
          // true, else there'd been nothing to long-press to open ActionBar.
          return (element) ? element.textLength != 0 : true;
        },
      },

      action: function(element, win) {
        // Some Mobile keyboards such as SwiftKeyboard, provide auto-suggest
        // style highlights via composition selections in editables.
        if (element) {
          // If we have an active composition string, commit it, and 
          // ensure proper element focus.
          let imeSupport = ActionBarHandler._getEditor(element, win).
            QueryInterface(Ci.nsIEditorIMESupport);
          if (imeSupport.composing) {
            element.blur();
            element.focus();
          }
        }

        // Close ActionBarHandler, then selectAll, and display handles.
        ActionBarHandler._getSelectAllController(element, win).selectAll();
        UITelemetry.addEvent("action.1", "actionbar", null, "select_all");
      },
    },

    CUT: {
      id: "cut_action",
      label: Strings.browser.GetStringFromName("contextmenu.cut"),
      icon: "drawable://ab_cut",
      order: 4,
      floatingOrder: 1,

      selector: {
        matches: function(element, win) {
          // Can cut from editable, or design-mode document.
          if (!element && !ActionBarHandler._isInDesignMode(win)) {
            return false;
          }
          // Don't allow "cut" from password fields.
          if (element instanceof Ci.nsIDOMHTMLInputElement &&
              !element.mozIsTextField(true)) {
            return false;
          }
          // Don't allow "cut" from disabled/readonly fields.
          if (element && (element.disabled || element.readOnly)) {
            return false;
          }
          // Allow if selected text exists.
          return (ActionBarHandler._getSelectedText().length > 0);
        },
      },

      action: function(element, win) {
        // First copy the selection text to the clipboard.
        let selectedText = ActionBarHandler._getSelectedText();
        let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].
          getService(Ci.nsIClipboardHelper);
        clipboard.copyString(selectedText);

        let msg = Strings.browser.GetStringFromName("selectionHelper.textCopied");
        Snackbars.show(msg, Snackbars.LENGTH_LONG);

        // Then cut the selection text.
        ActionBarHandler._getSelection(element, win).deleteFromDocument();

        ActionBarHandler._uninit();
        UITelemetry.addEvent("action.1", "actionbar", null, "cut");
      },
    },

    COPY: {
      id: "copy_action",
      label: Strings.browser.GetStringFromName("contextmenu.copy"),
      icon: "drawable://ab_copy",
      order: 3,
      floatingOrder: 2,

      selector: {
        matches: function(element, win) {
          // Don't allow "copy" from password fields.
          if (element instanceof Ci.nsIDOMHTMLInputElement &&
              !element.mozIsTextField(true)) {
            return false;
          }
          // Allow if selected text exists.
          return (ActionBarHandler._getSelectedText().length > 0);
        },
      },

      action: function(element, win) {
        let selectedText = ActionBarHandler._getSelectedText();
        let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].
          getService(Ci.nsIClipboardHelper);
        clipboard.copyString(selectedText);

        let msg = Strings.browser.GetStringFromName("selectionHelper.textCopied");
        Snackbars.show(msg, Snackbars.LENGTH_LONG);

        ActionBarHandler._uninit();
        UITelemetry.addEvent("action.1", "actionbar", null, "copy");
      },
    },

    PASTE: {
      id: "paste_action",
      label: Strings.browser.GetStringFromName("contextmenu.paste"),
      icon: "drawable://ab_paste",
      order: 2,
      floatingOrder: 3,

      selector: {
        matches: function(element, win) {
          // Can paste to editable, or design-mode document.
          if (!element && !ActionBarHandler._isInDesignMode(win)) {
            return false;
          }
          // Can't paste into disabled/readonly fields.
          if (element && (element.disabled || element.readOnly)) {
            return false;
          }
          // Can't paste if Clipboard empty.
          let flavors = ["text/unicode"];
          return Services.clipboard.hasDataMatchingFlavors(flavors, flavors.length,
            Ci.nsIClipboard.kGlobalClipboard);
        },
      },

      action: function(element, win) {
        // Paste the clipboard, then close the ActionBarHandler and ActionBar.
        ActionBarHandler._getEditor(element, win).
          paste(Ci.nsIClipboard.kGlobalClipboard);
        ActionBarHandler._uninit();
        UITelemetry.addEvent("action.1", "actionbar", null, "paste");
      },
    },

    CALL: {
      id: "call_action",
      label: Strings.browser.GetStringFromName("contextmenu.call"),
      icon: "drawable://phone",
      order: 1,
      floatingOrder: 0,

      selector: {
        matches: function(element, win) {
          return (ActionBarHandler._getSelectedPhoneNumber() != null);
        },
      },

      action: function(element, win) {
        BrowserApp.loadURI("tel:" +
          ActionBarHandler._getSelectedPhoneNumber());

        ActionBarHandler._uninit();
        UITelemetry.addEvent("action.1", "actionbar", null, "call");
      },
    },

    SEARCH: {
      id: "search_action",
      label: () => Strings.browser.formatStringFromName("contextmenu.search",
        [Services.search.defaultEngine.name], 1),
      icon: "drawable://ab_search",
      order: 1,
      floatingOrder: 6,

      selector: {
        matches: function(element, win) {
          // Allow if selected text exists.
          return (ActionBarHandler._getSelectedText().length > 0);
        },
      },

      action: function(element, win) {
        let selectedText = ActionBarHandler._getSelectedText();
        ActionBarHandler._uninit();

        // Set current tab as parent of new tab,
        // and set new tab as private if the parent is.
        let searchSubmission = Services.search.defaultEngine.getSubmission(selectedText);
        let parent = BrowserApp.selectedTab;
        let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(parent.browser);
        BrowserApp.addTab(searchSubmission.uri.spec,
          { parentId: parent.id,
            selected: true,
            isPrivate: isPrivate,
          }
        );

        UITelemetry.addEvent("action.1", "actionbar", null, "search");
      },
    },

    SEARCH_ADD: {
      id: "search_add_action",
      label: Strings.browser.GetStringFromName("contextmenu.addSearchEngine3"),
      icon: "drawable://ab_add_search_engine",
      order: 0,
      floatingOrder: 8,

      selector: {
        matches: function(element, win) {
          if(!(element instanceof HTMLInputElement)) {
            return false;
          }
          let form = element.form;
          if (!form || element.type == "password") {
            return false;
          }

          let method = form.method.toUpperCase();
          let canAddEngine = (method == "GET") ||
            (method == "POST" && (form.enctype != "text/plain" && form.enctype != "multipart/form-data"));
          if (!canAddEngine) {
            return false;
          }

          // If SearchEngine query finds it, then we don't want action to add displayed.
          if (SearchEngines.visibleEngineExists(element)) {
            return false;
          }

          return true;
        },
      },

      action: function(element, win) {
        UITelemetry.addEvent("action.1", "actionbar", null, "add_search_engine");

        // Engines are added asynch. If required, update SelectionUI on callback.
        SearchEngines.addEngine(element, (result) => {
          if (result) {
            ActionBarHandler._sendActionBarActions(true);
          }
        });
      },
    },

    SHARE: {
      id: "share_action",
      label: Strings.browser.GetStringFromName("contextmenu.share"),
      icon: "drawable://ic_menu_share",
      order: 0,
      floatingOrder: 4,

      selector: {
        matches: function(element, win) {
          if (!ParentalControls.isAllowed(ParentalControls.SHARE)) {
            return false;
          }
          // Allow if selected text exists.
          return (ActionBarHandler._getSelectedText().length > 0);
        },
      },

      action: function(element, win) {
        Messaging.sendRequest({
          type: "Share:Text",
          text: ActionBarHandler._getSelectedText(),
        });

        ActionBarHandler._uninit();
        UITelemetry.addEvent("action.1", "actionbar", null, "share");
      },
    },
  },

  /**
   * Provides UUID service for generating action ID's.
   */
  get _idService() {
    delete this._idService;
    return this._idService = Cc["@mozilla.org/uuid-generator;1"].
      getService(Ci.nsIUUIDGenerator);
  },

  /**
   * The targetElement holds an editable element containing a
   * selection or a caret.
   */
  get _targetElement() {
    if (this._targetElementRef)
      return this._targetElementRef.get();
    return null;
  },

  set _targetElement(element) {
    this._targetElementRef = Cu.getWeakReference(element);
  },

  /**
   * The contentWindow holds the selection, or the targetElement
   * if it's an editable.
   */
  get _contentWindow() {
    if (this._contentWindowRef)
      return this._contentWindowRef.get();
    return null;
  },

  set _contentWindow(aContentWindow) {
    this._contentWindowRef = Cu.getWeakReference(aContentWindow);
  },

  /**
   * If we have an active selection, is it part of a designMode document?
   */
  _isInDesignMode: function(win) {
    return this._selectionID && (win.document.designMode === "on");
  },

  /**
   * Provides the currently selected text, for either an editable,
   * or for the default contentWindow.
   */
  _getSelectedText: function() {
    // Can be called from FindInPageBar "TextSelection:Get", when there
    // is no active selection.
    if (!this._selectionID) {
      return "";
    }

    let selection = this._getSelection();

    // Textarea can contain LF, etc.
    if (this._targetElement instanceof Ci.nsIDOMHTMLTextAreaElement) {
      let flags = Ci.nsIDocumentEncoder.OutputPreformatted |
        Ci.nsIDocumentEncoder.OutputRaw;
      return selection.QueryInterface(Ci.nsISelectionPrivate).
        toStringWithFormat("text/plain", flags, 0);
    }

    // Return explicitly selected text.
    return selection.toString();
  },

  /**
   * Provides the nsISelection for either an editor, or from the
   * default window.
   */
  _getSelection: function(element = this._targetElement, win = this._contentWindow) {
    return (element instanceof Ci.nsIDOMNSEditableElement) ?
      this._getEditor(element).selection :
      win.getSelection();
  },

  /**
   * Returns an nsEditor or nsHTMLEditor.
   */
  _getEditor: function(element = this._targetElement, win = this._contentWindow) {
    if (element instanceof Ci.nsIDOMNSEditableElement) {
      return element.QueryInterface(Ci.nsIDOMNSEditableElement).editor;
    }

    return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation).
               QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIEditingSession).
               getEditorForWindow(win);
  },

  /**
   * Returns a selection controller.
   */
  _getSelectionController: function(element = this._targetElement, win = this._contentWindow) {
    if (element instanceof Ci.nsIDOMNSEditableElement) {
      return this._getEditor(element, win).selectionController;
    }

    return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation).
               QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay).
               QueryInterface(Ci.nsISelectionController);
  },

  /**
   * For selectAll(), provides the editor, or the default window selection Controller.
   */
  _getSelectAllController: function(element = this._targetElement, win = this._contentWindow) {
    let editor = this._getEditor(element, win);
    return (editor) ?
      editor : this._getSelectionController(element, win);
  },

  /**
   * Call / Phone Helper methods.
   */
  _getSelectedPhoneNumber: function() {
    let selectedText = this._getSelectedText().trim();
    return this._isPhoneNumber(selectedText) ?
      selectedText : null;
  },

  _isPhoneNumber: function(selectedText) {
    return (PHONE_REGEX.test(selectedText));
  },
};
