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

goog.module('goog.editor.plugins.AbstractBubblePluginTest');
goog.setTestOnly();

const AbstractBubblePlugin = goog.require('goog.editor.plugins.AbstractBubblePlugin');
const BrowserEvent = goog.require('goog.events.BrowserEvent');
const Bubble = goog.require('goog.ui.editor.Bubble');
const EventType = goog.require('goog.events.EventType');
const FieldMock = goog.require('goog.testing.editor.FieldMock');
const GoogTestingEvent = goog.require('goog.testing.events.Event');
const KeyCodes = goog.require('goog.events.KeyCodes');
const TagName = goog.require('goog.dom.TagName');
const TestHelper = goog.require('goog.testing.editor.TestHelper');
const dom = goog.require('goog.dom');
const events = goog.require('goog.testing.events');
const functions = goog.require('goog.functions');
const style = goog.require('goog.style');
const testSuite = goog.require('goog.testing.testSuite');
const userAgent = goog.require('goog.userAgent');

let testHelper;
let fieldDiv;
const COMMAND = 'base';
let fieldMock;
let bubblePlugin;
let link;
let link2;

/**
 * This is a helper function for setting up the targetElement with a
 * given direction.
 * @param {string} dir The direction of the targetElement, 'ltr' or 'rtl'.
 */
function prepareTargetWithGivenDirection(dir) {
  style.setStyle(document.body, 'direction', dir);

  fieldDiv.style.direction = dir;
  fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>';
  link = fieldDiv.firstChild;

  fieldMock.$replay();
  /** @suppress {visibility} suppression added to enable type checking */
  bubblePlugin.createBubbleContents = (bubbleContainer) => {
    bubbleContainer.innerHTML = '<div style="border:1px solid blue;">B</div>';
    style.setStyle(bubbleContainer, 'border', '1px solid white');
  };
  bubblePlugin.registerFieldObject(fieldMock);
  bubblePlugin.enable(fieldMock);
  bubblePlugin.createBubble(link);
}

/**
 * Similar in intent to mock reset, but implemented by recreating the mock
 * variable. $reset() can't work because it will reset general any-time
 * expectations done in the fieldMock constructor.
 */
function resetFieldMock() {
  fieldMock = new FieldMock();
  /**
   * @suppress {visibility,checkTypes} suppression added to enable type
   * checking
   */
  bubblePlugin.fieldObject = fieldMock;
}

function helpTestCreateBubble(fn = undefined) {
  fieldMock.$replay();
  let numCalled = 0;
  /**
   * @suppress {visibility,duplicate} suppression added to enable type checking
   */
  bubblePlugin.createBubbleContents = (bubbleContainer) => {
    numCalled++;
    assertNotNull('bubbleContainer should not be null', bubbleContainer);
  };
  if (fn) {
    fn();
  }
  bubblePlugin.createBubble(link);
  assertEquals('createBubbleContents should be called', 1, numCalled);
  fieldMock.$verify();
}

/**
 * Sends a tab key event to the bubble.
 * @return {boolean} whether the bubble hanlded the event.
 */
function simulateTabKeyOnBubble() {
  return simulateKeyDownOnBubble(KeyCodes.TAB, false);
}

/**
 * Sends a key event to the bubble.
 * @param {number} keyCode
 * @param {boolean} isCtrl
 * @return {boolean} whether the bubble hanlded the event.
 */
function simulateKeyDownOnBubble(keyCode, isCtrl) {
  // In some browsers (e.g. FireFox) the editable field is marked with
  // designMode on. In the test setting (and not in production setting), the
  // bubble element shares the same window and hence the designMode. In this
  // mode, activeElement remains the <body> and isn't changed along with the
  // focus as a result of tab key.
  if (userAgent.GECKO) {
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.getSharedBubble_()
        .getContentElement()
        .ownerDocument.designMode = 'off';
  }

  const event = new GoogTestingEvent(EventType.KEYDOWN, null);
  event.keyCode = keyCode;
  event.ctrlKey = isCtrl;
  return bubblePlugin.handleKeyDown(event);
}

function assertFocused(element) {
  assertEquals('unexpected focus', element, document.activeElement);
}

function assertNotFocused(element) {
  assertNotEquals('unexpected focus', element, document.activeElement);
}
testSuite({
  setUpPage() {
    fieldDiv = dom.getElement('field');
    const viewportSize = dom.getViewportSize();
    // Some tests depends on enough size of viewport.
    if (viewportSize.width < 600 || viewportSize.height < 440) {
      window.moveTo(0, 0);
      window.resizeTo(640, 480);
    }
  },

  setUp() {
    testHelper = new TestHelper(fieldDiv);
    testHelper.setUpEditableElement();
    fieldMock = new FieldMock();

    /** @suppress {checkTypes} suppression added to enable type checking */
    bubblePlugin = new AbstractBubblePlugin(COMMAND);
    /**
     * @suppress {visibility,checkTypes} suppression added to enable type
     * checking
     */
    bubblePlugin.fieldObject = fieldMock;

    fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>' +
        '<a href="http://www.google.com">Google2</a>';
    link = fieldDiv.firstChild;
    link2 = fieldDiv.lastChild;

    window.scrollTo(0, 0);
    style.setStyle(document.body, 'direction', 'ltr');
    style.setStyle(document.getElementById('field'), 'position', 'static');
  },

  tearDown() {
    bubblePlugin.closeBubble();
    testHelper.tearDownEditableElement();
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testCreateBubble(fn = undefined) {
    helpTestCreateBubble(fn);
    assertTrue(bubblePlugin.getSharedBubble_() instanceof Bubble);

    assertTrue('Bubble should be visible', bubblePlugin.isVisible());
  },

  testOpeningBubbleCallsOnShow() {
    let numCalled = 0;
    this.testCreateBubble(() => {
      /** @suppress {visibility} suppression added to enable type checking */
      bubblePlugin.onShow = () => {
        numCalled++;
      };
    });

    assertEquals('onShow should be called', 1, numCalled);
    fieldMock.$verify();
  },

  testCloseBubble() {
    this.testCreateBubble();

    bubblePlugin.closeBubble();
    assertFalse('Bubble should not be visible', bubblePlugin.isVisible());
    fieldMock.$verify();
  },

  /**
     @suppress {missingProperties,visibility} suppression added to enable type
     checking
   */
  testZindexBehavior() {
    // Don't use the default return values.
    fieldMock.$reset();
    fieldMock.getAppWindow().$anyTimes().$returns(window);
    fieldMock.getEditableDomHelper().$anyTimes().$returns(
        dom.getDomHelper(document));
    fieldMock.getBaseZindex().$returns(2);
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.createBubbleContents = functions.UNDEFINED;
    fieldMock.$replay();

    bubblePlugin.createBubble(link);
    assertEquals(
        '2',
        '' + bubblePlugin.getSharedBubble_().bubbleContainer_.style.zIndex);

    fieldMock.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testNoTwoBubblesOpenAtSameTime() {
    fieldMock.$replay();
    const origClose = goog.bind(bubblePlugin.closeBubble, bubblePlugin);
    let numTimesCloseCalled = 0;
    bubblePlugin.closeBubble = () => {
      numTimesCloseCalled++;
      origClose();
    };
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.createBubbleContents = functions.UNDEFINED;

    bubblePlugin.handleSelectionChangeInternal(link);
    assertEquals(0, numTimesCloseCalled);
    assertEquals(link, bubblePlugin.targetElement_);
    fieldMock.$verify();

    bubblePlugin.handleSelectionChangeInternal(link2);
    assertEquals(1, numTimesCloseCalled);
    assertEquals(link2, bubblePlugin.targetElement_);
    fieldMock.$verify();
  },

  /** @suppress {missingProperties} suppression added to enable type checking */
  testHandleSelectionChangeWithEvent() {
    fieldMock.$replay();
    /** @suppress {checkTypes} suppression added to enable type checking */
    const fakeEvent = new BrowserEvent({type: 'mouseup', target: link});
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.createBubbleContents = functions.UNDEFINED;
    bubblePlugin.handleSelectionChange(fakeEvent);
    assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
    assertEquals(
        'Bubble target should be provided event\'s target', link,
        bubblePlugin.targetElement_);
  },

  /** @suppress {missingProperties} suppression added to enable type checking */
  testHandleSelectionChangeWithTarget() {
    fieldMock.$replay();
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.createBubbleContents = functions.UNDEFINED;
    bubblePlugin.handleSelectionChange(undefined, link2);
    assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
    assertEquals(
        'Bubble target should be provided target', link2,
        bubblePlugin.targetElement_);
  },

  /** Regression test for @bug 2945341 */
  testSelectOneTextCharacterNoError() {
    fieldMock.$replay();
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    /** @suppress {visibility} suppression added to enable type checking */
    bubblePlugin.createBubbleContents = functions.UNDEFINED;
    // Select first char of first link's text node.
    testHelper.select(link.firstChild, 0, link.firstChild, 1);
    // This should execute without js errors.
    bubblePlugin.handleSelectionChange();
    assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
    fieldMock.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testTabKeyEvents() {
    fieldMock.$replay();
    bubblePlugin.enableKeyboardNavigation(true);
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    let nonTabbable1;
    let tabbable1;
    let tabbable2;
    let nonTabbable2;
    /**
     * @suppress {visibility,duplicate} suppression added to enable type
     * checking
     */
    bubblePlugin.createBubbleContents = (container) => {
      nonTabbable1 = dom.createDom(TagName.DIV);
      tabbable1 = dom.createDom(TagName.DIV);
      tabbable2 = dom.createDom(TagName.DIV);
      nonTabbable2 = dom.createDom(TagName.DIV);
      dom.append(container, nonTabbable1, tabbable1, tabbable2, nonTabbable2);
      bubblePlugin.setTabbable(tabbable1);
      bubblePlugin.setTabbable(tabbable2);
    };
    bubblePlugin.handleSelectionChangeInternal(link);
    assertTrue('Bubble should be visible', bubblePlugin.isVisible());

    const tabHandledByBubble = simulateTabKeyOnBubble();
    assertTrue(
        'The action should be handled by the plugin', tabHandledByBubble);
    assertFocused(tabbable1);

    // Tab on the first tabbable. The test framework doesn't easily let us
    // verify the desired behavior - namely, that the second tabbable gets
    // focused - but we verify that the field doesn't get the focus.
    events.fireKeySequence(tabbable1, KeyCodes.TAB);

    fieldMock.$verify();

    // Tabbing on the last tabbable should trigger focus() of the target field.
    resetFieldMock();
    fieldMock.focus();
    fieldMock.$replay();
    events.fireKeySequence(tabbable2, KeyCodes.TAB);
    fieldMock.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testTabKeyEventsWithShiftKey() {
    fieldMock.$replay();
    bubblePlugin.enableKeyboardNavigation(true);
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    let nonTabbable;
    let tabbable1;
    let tabbable2;
    /**
     * @suppress {visibility,duplicate} suppression added to enable type
     * checking
     */
    bubblePlugin.createBubbleContents = (container) => {
      nonTabbable = dom.createDom(TagName.DIV);
      tabbable1 = dom.createDom(TagName.DIV);
      // The test acts only on one tabbable, but we give another one to make
      // sure that the tabbable we act on is not also the last.
      tabbable2 = dom.createDom(TagName.DIV);
      dom.append(container, nonTabbable, tabbable1, tabbable2);
      bubblePlugin.setTabbable(tabbable1);
      bubblePlugin.setTabbable(tabbable2);
    };
    bubblePlugin.handleSelectionChangeInternal(link);

    assertTrue('Bubble should be visible', bubblePlugin.isVisible());

    const tabHandledByBubble = simulateTabKeyOnBubble();
    assertTrue(
        'The action should be handled by the plugin', tabHandledByBubble);
    assertFocused(tabbable1);
    fieldMock.$verify();

    // Shift-tabbing on the first tabbable should trigger focus() of the target
    // field.
    resetFieldMock();
    fieldMock.focus();
    fieldMock.$replay();
    events.fireKeySequence(tabbable1, KeyCodes.TAB, {shiftKey: true});
    fieldMock.$verify();
  },

  /**
     @suppress {visibility,missingProperties} suppression added to enable type
     checking
   */
  testLinksAreTabbable() {
    fieldMock.$replay();
    bubblePlugin.enableKeyboardNavigation(true);
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    let nonTabbable1;
    let nonTabbable2;
    let bubbleLink1;
    let bubbleLink2;
    /**
     * @suppress {visibility,duplicate} suppression added to enable type
     * checking
     */
    bubblePlugin.createBubbleContents = function(container) {
      nonTabbable1 = dom.createDom(TagName.DIV);
      dom.appendChild(container, nonTabbable1);
      bubbleLink1 = this.createLink('linkInBubble1', 'Foo', false, container);
      bubbleLink2 = this.createLink('linkInBubble2', 'Bar', false, container);
      nonTabbable2 = dom.createDom(TagName.DIV);
      dom.appendChild(container, nonTabbable2);
    };
    bubblePlugin.handleSelectionChangeInternal(link);
    assertTrue('Bubble should be visible', bubblePlugin.isVisible());

    const tabHandledByBubble = simulateTabKeyOnBubble();
    assertTrue(
        'The action should be handled by the plugin', tabHandledByBubble);
    assertFocused(bubbleLink1);

    fieldMock.$verify();

    // Tabbing on the last link should trigger focus() of the target field.
    resetFieldMock();
    fieldMock.focus();
    fieldMock.$replay();
    events.fireKeySequence(bubbleLink2, KeyCodes.TAB);
    fieldMock.$verify();
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testTabKeyNoEffectKeyboardNavDisabled() {
    fieldMock.$replay();
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    let bubbleLink;
    /**
     * @suppress {visibility,duplicate} suppression added to enable type
     * checking
     */
    bubblePlugin.createBubbleContents = function(container) {
      bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
    };
    bubblePlugin.handleSelectionChangeInternal(link);

    assertTrue('Bubble should be visible', bubblePlugin.isVisible());

    const tabHandledByBubble = simulateTabKeyOnBubble();
    assertFalse(
        'The action should not be handled by the plugin', tabHandledByBubble);
    assertNotFocused(bubbleLink);

    // Verify that tabbing the link doesn't cause focus of the field.
    events.fireKeySequence(bubbleLink, KeyCodes.TAB);

    fieldMock.$verify();
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testOtherKeyEventNoEffectKeyboardNavEnabled() {
    fieldMock.$replay();
    bubblePlugin.enableKeyboardNavigation(true);
    bubblePlugin.getBubbleTargetFromSelection = functions.identity;
    let bubbleLink;
    /**
     * @suppress {visibility,duplicate} suppression added to enable type
     * checking
     */
    bubblePlugin.createBubbleContents = function(container) {
      bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
    };
    bubblePlugin.handleSelectionChangeInternal(link);

    assertTrue('Bubble should be visible', bubblePlugin.isVisible());

    // Test pressing CTRL + B: this should not have any effect.
    const keyHandledByBubble = simulateKeyDownOnBubble(KeyCodes.B, true);

    assertFalse(
        'The action should not be handled by the plugin', keyHandledByBubble);
    assertNotFocused(bubbleLink);

    fieldMock.$verify();
  },

  /** @suppress {visibility} suppression added to enable type checking */
  testSetTabbableSetsTabIndex() {
    const element1 = dom.createDom(TagName.DIV);
    const element2 = dom.createDom(TagName.DIV);
    element1.setAttribute('tabIndex', '1');

    bubblePlugin.setTabbable(element1);
    bubblePlugin.setTabbable(element2);

    assertEquals('1', element1.getAttribute('tabIndex'));
    assertEquals('0', element2.getAttribute('tabIndex'));
  },

  testDisable() {
    this.testCreateBubble();
    fieldMock.setUneditable(true);
    bubblePlugin.disable(fieldMock);
    bubblePlugin.closeBubble();
  },
});
