/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview Base class for bubble plugins.
 */

goog.provide('goog.editor.plugins.LinkBubble');
goog.provide('goog.editor.plugins.LinkBubble.Action');

goog.require('goog.a11y.aria.Announcer');
goog.require('goog.a11y.aria.LivePriority');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.functions');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.ui.editor.messages');
goog.require('goog.uri.utils');
goog.require('goog.window');
goog.requireType('goog.events.BrowserEvent');



/**
 * Property bubble plugin for links.
 * @param {...!goog.editor.plugins.LinkBubble.Action} var_args List of
 *     extra actions supported by the bubble.
 * @constructor
 * @extends {goog.editor.plugins.AbstractBubblePlugin}
 */
goog.editor.plugins.LinkBubble = function(var_args) {
  'use strict';
  goog.editor.plugins.LinkBubble.base(this, 'constructor');

  /**
   * List of extra actions supported by the bubble.
   * @type {Array<!goog.editor.plugins.LinkBubble.Action>}
   * @private
   */
  this.extraActions_ = Array.prototype.slice.call(arguments);

  /**
   * List of spans corresponding to the extra actions.
   * @type {Array<!Element>}
   * @private
   */
  this.actionSpans_ = [];

  /**
   * A list of whitelisted URL schemes which are safe to open.
   * @type {Array<string>}
   * @private
   */
  this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];

  /** @private @const {!goog.a11y.aria.Announcer} */
  this.announcer_ = new goog.a11y.aria.Announcer();
  this.registerDisposable(this.announcer_);
};
goog.inherits(
    goog.editor.plugins.LinkBubble, goog.editor.plugins.AbstractBubblePlugin);


/** @const @private {string} */
goog.editor.plugins.LinkBubble.DISABLE_LINK_BUBBLE_DATA_ATTRIBUTE_ = 'data-dlb';


/**
 * Element id for the link text.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.LINK_TEXT_ID_ = 'tr_link-text';


/**
 * Element id for the test link span.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_ = 'tr_test-link-span';


/**
 * Element id for the test link.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.TEST_LINK_ID_ = 'tr_test-link';


/**
 * Element id for the change link span.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_ = 'tr_change-link-span';


/**
 * Element id for the link.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_ = 'tr_change-link';


/**
 * Element id for the delete link span.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_ = 'tr_delete-link-span';


/**
 * Element id for the delete link.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.DELETE_LINK_ID_ = 'tr_delete-link';


/**
 * Element id for the link bubble wrapper div.
 * type {string}
 * @private
 */
goog.editor.plugins.LinkBubble.LINK_DIV_ID_ = 'tr_link-div';


/**
 * @desc Text label for link that lets the user click it to see where the link
 *     this bubble is for point to.
 */
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK =
    goog.getMsg('Go to link: ');


/**
 * @desc Label that pops up a dialog to change the link.
 */
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE = goog.getMsg('Change');


/**
 * @desc Label that allow the user to remove this link.
 */
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE = goog.getMsg('Remove');


/**
 * @desc Message shown in a link bubble when the link is not a valid url.
 */
goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE =
    goog.getMsg('invalid url');


/** @desc Screen reader announcement that a link has been removed. */
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE_ANNOUNCEMENT =
    goog.getMsg('Removed link.');


/**
 * @param {!Element} targetElement
 * @return {boolean}
 * @private
 */
goog.editor.plugins.LinkBubble.shouldShowLinkBubble_ = function(targetElement) {
  'use strict';
  return !targetElement.hasAttribute(
      goog.editor.plugins.LinkBubble.DISABLE_LINK_BUBBLE_DATA_ATTRIBUTE_);
};


/**
 * Whether to stop leaking the page's url via the referrer header when the
 * link text link is clicked.
 * @type {boolean}
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks_ = false;


/**
 * Whether to block opening links with a non-whitelisted URL scheme.
 * @type {boolean}
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.blockOpeningUnsafeSchemes_ = true;


/**
 * Tells the plugin to stop leaking the page's url via the referrer header when
 * the link text link is clicked. When the user clicks on a link, the
 * browser makes a request for the link url, passing the url of the current page
 * in the request headers. If the user wants the current url to be kept secret
 * (e.g. an unpublished document), the owner of the url that was clicked will
 * see the secret url in the request headers, and it will no longer be a secret.
 * Calling this method will not send a referrer header in the request, just as
 * if the user had opened a blank window and typed the url in themselves.
 */
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks = function() {
  'use strict';
  // TODO(user): Right now only 2 plugins have this API to stop
  // referrer leaks. If more plugins need to do this, come up with a way to
  // enable the functionality in all plugins at once. Same thing for
  // setBlockOpeningUnsafeSchemes and associated functionality.
  this.stopReferrerLeaks_ = true;
};


/**
 * Tells the plugin whether to block URLs with schemes not in the whitelist.
 * If blocking is enabled, this plugin will not linkify the link in the bubble
 * popup.
 * @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
 *     schemes.
 */
goog.editor.plugins.LinkBubble.prototype.setBlockOpeningUnsafeSchemes =
    function(blockOpeningUnsafeSchemes) {
  'use strict';
  this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
};


/**
 * Sets a whitelist of allowed URL schemes that are safe to open.
 * Schemes should all be in lowercase. If the plugin is set to block opening
 * unsafe schemes, user-entered URLs will be converted to lowercase and checked
 * against this list. The whitelist has no effect if blocking is not enabled.
 * @param {Array<string>} schemes String array of URL schemes to allow (http,
 *     https, etc.).
 */
goog.editor.plugins.LinkBubble.prototype.setSafeToOpenSchemes = function(
    schemes) {
  'use strict';
  this.safeToOpenSchemes_ = schemes;
};


/** @override */
goog.editor.plugins.LinkBubble.prototype.getTrogClassId = function() {
  'use strict';
  return 'LinkBubble';
};


/** @override */
goog.editor.plugins.LinkBubble.prototype.isSupportedCommand = function(
    command) {
  'use strict';
  return command == goog.editor.Command.UPDATE_LINK_BUBBLE;
};


/** @override */
goog.editor.plugins.LinkBubble.prototype.execCommandInternal = function(
    command, var_args) {
  'use strict';
  if (command == goog.editor.Command.UPDATE_LINK_BUBBLE) {
    this.updateLink_();
  }
};


/**
 * Updates the href in the link bubble with a new link.
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.updateLink_ = function() {
  'use strict';
  var targetEl = this.getTargetElement();
  if (targetEl) {
    this.closeBubble();
    this.createBubble(targetEl);
  }
};


/**
 * @override
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.LinkBubble.prototype.getBubbleTargetFromSelection =
    function(selectedElement) {
  'use strict';
  var bubbleTarget = goog.dom.getAncestorByTagNameAndClass(
      selectedElement, goog.dom.TagName.A);

  if (!bubbleTarget) {
    // See if the selection is touching the right side of a link, and if so,
    // show a bubble for that link.  The check for "touching" is very brittle,
    // and currently only guarantees that it will pop up a bubble at the
    // position the cursor is placed at after the link dialog is closed.
    // NOTE(robbyw): This assumes this method is always called with
    // selected element = range.getContainerElement().  Right now this is true,
    // but attempts to re-use this method for other purposes could cause issues.
    // TODO(robbyw): Refactor this method to also take a range, and use that.
    var range = this.getFieldObject().getRange();
    if (range && range.isCollapsed() && range.getStartOffset() == 0) {
      var startNode = range.getStartNode();
      var previous = startNode.previousSibling;
      if (previous && previous.tagName == goog.dom.TagName.A) {
        bubbleTarget = previous;
      }
    }
  }

  return /** @type {Element} */ (bubbleTarget);
};


/**
 * Set the optional function for getting the "test" link of a url.
 * @param {function(string) : string} func The function to use.
 */
goog.editor.plugins.LinkBubble.prototype.setTestLinkUrlFn = function(func) {
  'use strict';
  /** @suppress {strictMissingProperties} Added to tighten compiler checks */
  this.testLinkUrlFn_ = func;
};


/**
 * Returns the target element url for the bubble.
 * @return {string} The url href.
 * @protected
 */
goog.editor.plugins.LinkBubble.prototype.getTargetUrl = function() {
  'use strict';
  // Get the href-attribute through getAttribute() rather than the href property
  // because Google-Toolbar on Firefox with "Send with Gmail" turned on
  // modifies the href-property of 'mailto:' links but leaves the attribute
  // untouched.
  return this.getTargetElement().getAttribute('href') || '';
};


/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleType = function() {
  'use strict';
  return String(goog.dom.TagName.A);
};


/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleTitle = function() {
  'use strict';
  return goog.ui.editor.messages.MSG_LINK_CAPTION;
};


/**
 * Returns the message to display for testing a link.
 * @return {string} The message for testing a link.
 * @protected
 */
goog.editor.plugins.LinkBubble.prototype.getTestLinkMessage = function() {
  'use strict';
  return goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK;
};

/** @override */
goog.editor.plugins.LinkBubble.prototype.handleSelectionChangeInternal =
    function(selectedElement) {
  'use strict';
  if (selectedElement) {
    var bubbleTarget = this.getBubbleTargetFromSelection(selectedElement);
    if (bubbleTarget &&
        !goog.editor.plugins.LinkBubble.shouldShowLinkBubble_(bubbleTarget)) {
      return false;
    }
  }

  return goog.editor.plugins.LinkBubble.base(
      this, 'handleSelectionChangeInternal', selectedElement);
};


/**
 * @override
 * @suppress {missingProperties} dom_ isn't declared
 */
goog.editor.plugins.LinkBubble.prototype.createBubbleContents = function(
    bubbleContainer) {
  'use strict';
  var linkObj = this.getLinkToTextObj_();

  // Create linkTextSpan, show plain text for e-mail address or truncate the
  // text to <= 48 characters so that property bubbles don't grow too wide and
  // create a link if URL.  Only linkify valid links.
  // TODO(robbyw): Repalce this color with a CSS class.
  var color = linkObj.valid ? 'black' : 'red';
  var shouldOpenUrl = this.shouldOpenUrl(linkObj.linkText);
  var linkTextSpan;
  if (goog.editor.Link.isLikelyEmailAddress(linkObj.linkText) ||
      !linkObj.valid || !shouldOpenUrl) {
    linkTextSpan = this.dom_.createDom(
        goog.dom.TagName.SPAN, {
          id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
          style: 'color:' + color
        },
        this.dom_.createTextNode(linkObj.linkText));
  } else {
    var testMsgSpan = this.dom_.createDom(
        goog.dom.TagName.SPAN,
        {id: goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_},
        this.getTestLinkMessage());
    linkTextSpan = this.dom_.createDom(
        goog.dom.TagName.SPAN, {
          id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
          style: 'color:' + color
        },
        '');
    var linkText = goog.string.truncateMiddle(linkObj.linkText, 48);
    // Actually creates a pseudo-link that can't be right-clicked to open in a
    // new tab, because that would avoid the logic to stop referrer leaks.
    this.createLink(
        goog.editor.plugins.LinkBubble.TEST_LINK_ID_,
        this.dom_.createTextNode(linkText).data, this.testLink, linkTextSpan);
  }

  var changeLinkSpan = this.createLinkOption(
      goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_);
  this.createLink(
      goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_,
      goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE,
      this.showLinkDialog_, changeLinkSpan);

  // This function is called multiple times - we have to reset the array.
  this.actionSpans_ = [];
  for (var i = 0; i < this.extraActions_.length; i++) {
    var action = this.extraActions_[i];
    var actionSpan = this.createLinkOption(action.spanId_);
    this.actionSpans_.push(actionSpan);
    this.createLink(action.linkId_, action.message_, function() {
      'use strict';
      action.actionFn_(this.getTargetUrl());
    }, actionSpan);
  }

  var removeLinkSpan = this.createLinkOption(
      goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_);
  this.createLink(
      goog.editor.plugins.LinkBubble.DELETE_LINK_ID_,
      goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE, this.deleteLink_,
      removeLinkSpan);

  this.onShow();

  var bubbleContents = this.dom_.createDom(
      goog.dom.TagName.DIV, {id: goog.editor.plugins.LinkBubble.LINK_DIV_ID_},
      testMsgSpan || '', linkTextSpan, changeLinkSpan);

  for (i = 0; i < this.actionSpans_.length; i++) {
    bubbleContents.appendChild(this.actionSpans_[i]);
  }
  bubbleContents.appendChild(removeLinkSpan);

  goog.dom.appendChild(bubbleContainer, bubbleContents);
};


/**
 * Tests the link by opening it in a new tab/window. Should be used as the
 * click event handler for the test pseudo-link.
 * @param {!Event=} opt_event If passed in, the event will be stopped.
 * @protected
 */
goog.editor.plugins.LinkBubble.prototype.testLink = function(opt_event) {
  'use strict';
  goog.window.open(
      this.getTestLinkAction_(),
      {'target': '_blank', 'noreferrer': this.stopReferrerLeaks_},
      this.getFieldObject().getAppWindow());
  if (opt_event) {
    opt_event.stopPropagation();
    opt_event.preventDefault();
  }
};


/**
 * Returns whether the URL should be considered invalid.  This always returns
 * false in the base class, and should be overridden by subclasses that wish
 * to impose validity rules on URLs.
 * @param {string} url The url to check.
 * @return {boolean} Whether the URL should be considered invalid.
 */
goog.editor.plugins.LinkBubble.prototype.isInvalidUrl = goog.functions.FALSE;


/**
 * Gets the text to display for a link, based on the type of link
 * @return {!Object} Returns an object of the form:
 *     {linkText: displayTextForLinkTarget, valid: ifTheLinkIsValid}.
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.getLinkToTextObj_ = function() {
  'use strict';
  var isError;
  var targetUrl = this.getTargetUrl();

  if (this.isInvalidUrl(targetUrl)) {
    targetUrl = goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE;
    isError = true;
  } else if (goog.editor.Link.isMailto(targetUrl)) {
    targetUrl = targetUrl.substring(7);  // 7 == "mailto:".length
  }

  return {linkText: targetUrl, valid: !isError};
};


/**
 * Shows the link dialog.
 * @param {goog.events.BrowserEvent} e The event.
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.showLinkDialog_ = function(e) {
  'use strict';
  // Needed when this occurs due to an ENTER key event, else the newly created
  // dialog manages to have its OK button pressed, causing it to disappear.
  e.preventDefault();

  this.getFieldObject().execCommand(
      goog.editor.Command.MODAL_LINK_EDITOR,
      new goog.editor.Link(
          /** @type {HTMLAnchorElement} */ (this.getTargetElement()), false));
  this.closeBubble();
};


/**
 * Deletes the link associated with the bubble
 * @param {goog.events.BrowserEvent} e The event.
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.deleteLink_ = function(e) {
  'use strict';
  // Needed when this occurs due to an ENTER key event, else the editor receives
  // the key press and inserts a newline.
  e.preventDefault();

  this.getFieldObject().dispatchBeforeChange();

  var link = this.getTargetElement();
  var child = link.lastChild;
  goog.dom.flattenElement(link);

  var restoreScrollPosition = this.saveScrollPosition();
  var range = goog.dom.Range.createFromNodeContents(child);
  range.collapse(false);
  range.select();

  this.closeBubble();

  this.getFieldObject().dispatchChange();
  this.getFieldObject().focus();
  restoreScrollPosition();

  this.announcer_.say(
      goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE_ANNOUNCEMENT,
      goog.a11y.aria.LivePriority.ASSERTIVE);
};


/**
 * Sets the proper state for the action links.
 * @protected
 * @override
 * @suppress {missingProperties} dom_ is not declared
 */
goog.editor.plugins.LinkBubble.prototype.onShow = function() {
  'use strict';
  var linkDiv =
      this.dom_.getElement(goog.editor.plugins.LinkBubble.LINK_DIV_ID_);
  if (linkDiv) {
    var testLinkSpan =
        this.dom_.getElement(goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_);
    if (testLinkSpan) {
      var url = this.getTargetUrl();
      goog.style.setElementShown(testLinkSpan, !goog.editor.Link.isMailto(url));
    }

    for (var i = 0; i < this.extraActions_.length; i++) {
      var action = this.extraActions_[i];
      var actionSpan = this.dom_.getElement(action.spanId_);
      if (actionSpan) {
        goog.style.setElementShown(
            actionSpan, action.toShowFn_(this.getTargetUrl()));
      }
    }
  }
};


/**
 * Gets the url for the bubble test link.  The test link is the link in the
 * bubble the user can click on to make sure the link they entered is correct.
 * @return {string} The url for the bubble link href.
 * @private
 * @suppress {strictMissingProperties} Added to tighten compiler checks
 */
goog.editor.plugins.LinkBubble.prototype.getTestLinkAction_ = function() {
  'use strict';
  var targetUrl = this.getTargetUrl();
  return this.testLinkUrlFn_ ? this.testLinkUrlFn_(targetUrl) : targetUrl;
};


/**
 * Checks whether the plugin should open the given url in a new window.
 * @param {string} url The url to check.
 * @return {boolean} If the plugin should open the given url in a new window.
 * @protected
 */
goog.editor.plugins.LinkBubble.prototype.shouldOpenUrl = function(url) {
  'use strict';
  return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
};


/**
 * Determines whether or not a url has a scheme which is safe to open.
 * Schemes like javascript are unsafe due to the possibility of XSS.
 * @param {string} url A url.
 * @return {boolean} Whether the url has a safe scheme.
 * @private
 */
goog.editor.plugins.LinkBubble.prototype.isSafeSchemeToOpen_ = function(url) {
  'use strict';
  var scheme = goog.uri.utils.getScheme(url) || 'http';
  return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
};



/**
 * Constructor for extra actions that can be added to the link bubble.
 * @param {string} spanId The ID for the span showing the action.
 * @param {string} linkId The ID for the link showing the action.
 * @param {string} message The text for the link showing the action.
 * @param {function(string):boolean} toShowFn Test function to determine whether
 *     to show the action for the given URL.
 * @param {function(string):void} actionFn Action function to run when the
 *     action is clicked.  Takes the current target URL as a parameter.
 * @constructor
 * @final
 */
goog.editor.plugins.LinkBubble.Action = function(
    spanId, linkId, message, toShowFn, actionFn) {
  'use strict';
  this.spanId_ = spanId;
  this.linkId_ = linkId;
  this.message_ = message;
  this.toShowFn_ = toShowFn;
  this.actionFn_ = actionFn;
};
