/*
 * Copyright 2015 Google Inc. All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */


void function() {

  /**
   * Asserts that the displayed dialog is in the center of the screen.
   *
   * @param {HTMLDialogElement?} opt_dialog to check, or test default
   */
  function checkDialogCenter(opt_dialog) {
    var d = opt_dialog || dialog;
    var expectedTop = (window.innerHeight - d.offsetHeight) / 2;
    var expectedLeft = (window.innerWidth - d.offsetWidth) / 2;
    var rect = d.getBoundingClientRect();
    assert.closeTo(rect.top, expectedTop, 1, 'top should be nearby');
    assert.closeTo(rect.left, expectedLeft, 1, 'left should be nearby');
  }

  /**
   * Creates a fake KeyboardEvent.
   *
   * @param {number} keyCode to press
   * @param {string?} opt_type to use, default keydown
   * @return {!Event} event
   */
  function createKeyboardEvent(keyCode, opt_type) {
    var ev = document.createEvent('Events');
    ev.initEvent(opt_type || 'keydown', true, true);
    ev.keyCode = keyCode;
    ev.which = keyCode;
    return ev;
  }

  /**
   * Cleans up any passed DOM elements.
   *
   * @param {!Element} el to clean up
   * @return {!Element} the same element, for chaining
   */
  var cleanup = (function() {
    var e = [];
    teardown(function() {
      e.forEach(function(el) {
        try {
          el.close();  // try to close dialogs
        } catch (e) {}
        el.parentElement && el.parentElement.removeChild(el);
      });
      e = [];
    });

    return function(el) {
      e.push(el);
      return el;
    };
  })();

  /**
   * Creates a dialog for testing that will be cleaned up later.
   *
   * @param {string?} opt_content to be used as innerHTML
   */
  function createDialog(opt_content) {
    var dialog = document.createElement('dialog');
    dialog.innerHTML = opt_content || 'Dialog #' + (cleanup.length);
    document.body.appendChild(dialog);
    if (window.location.search == '?force') {
      dialogPolyfill.forceRegisterDialog(dialog);
    } else {
      dialogPolyfill.registerDialog(dialog);
    }
    return cleanup(dialog);
  }

  var dialog;  // global dialog for all tests
  setup(function() {
    dialog = createDialog('Default Dialog');
  });

  suite('basic', function() {
    test('show and close', function() {
      assert.isFalse(dialog.hasAttribute('open'));
      dialog.show();
      assert.isTrue(dialog.hasAttribute('open'));
      assert.isTrue(dialog.open);

      var returnValue = 1234;
      dialog.close(returnValue);
      assert.isFalse(dialog.hasAttribute('open'));
      assert.equal(dialog.returnValue, returnValue);

      dialog.show();
      dialog.close();
      assert.isFalse(dialog.open);
      assert.equal(dialog.returnValue, returnValue);
    });
    test('open property', function() {
      assert.isFalse(dialog.hasAttribute('open'));
      dialog.show();
      assert.isTrue(dialog.hasAttribute('open'));
      assert.isTrue(dialog.open);

      dialog.open = false;
      assert.isFalse(dialog.open);
      assert.isFalse(dialog.hasAttribute('open'),
          'open property should clear attribute');
      assert.throws(dialog.close);

      var overlay = document.querySelector('._dialog_overlay');
      assert.isNull(overlay);
    });
    test('show/showModal interaction', function() {
      assert.isFalse(dialog.hasAttribute('open'));
      dialog.show();

      // If the native dialog is being tested, show/showModal are not already
      // bound, so wrap them in helper methods for throws/doesNotThrow.
      var show = function() { dialog.show(); };
      var showModal = function() { dialog.showModal(); };

      assert.doesNotThrow(show);
      assert.throws(showModal);

      dialog.open = false;
      assert.doesNotThrow(showModal);
      assert.doesNotThrow(show);  // show after showModal does nothing
      assert.throws(showModal);
      // TODO: check dialog is still modal

      assert.isTrue(dialog.open);
    });
    test('setAttribute reflects property', function() {
      dialog.setAttribute('open', '');
      assert.isTrue(dialog.open, 'attribute opens dialog');
    });
    test('changing open to dummy value is ignored', function() {
      dialog.showModal();

      dialog.setAttribute('open', 'dummy, ignored');
      assert.isTrue(dialog.open, 'dialog open with dummy open value');

      var overlay = document.querySelector('._dialog_overlay');
      assert(overlay, 'dialog is still modal');
    });
    test('show/showModal outside document', function() {
      dialog.open = false;
      dialog.parentNode.removeChild(dialog);

      assert.throws(function() { dialog.showModal(); });

      assert.doesNotThrow(function() { dialog.show(); });
      assert.isTrue(dialog.open, 'can open non-modal outside document');
      assert.isFalse(document.body.contains(dialog));
    });
    test('has a11y property', function() {
      assert.equal(dialog.getAttribute('role'), 'dialog', 'role should be dialog');
    });
  });

  suite('DOM', function() {
    setup(function(done) {
      // DOM tests wait for modal to settle, so MutationOberver doesn't coalesce attr changes
      dialog.showModal();
      window.setTimeout(done, 0);
    });
    test('DOM direct removal', function(done) {
      assert.isTrue(dialog.open);
      assert.isNotNull(document.querySelector('.backdrop'));

      var parentNode = dialog.parentNode;
      parentNode.removeChild(dialog);

      // DOMNodeRemoved defers its task a frame (since it occurs before removal, not after). This
      // doesn't effect MutationObserver, just delays the test a frame.
      window.setTimeout(function() {
        assert.isNull(document.querySelector('.backdrop'), 'dialog removal should clear modal');

        assert.isTrue(dialog.open, 'removed dialog should still be open');
        parentNode.appendChild(dialog);

        assert.isTrue(dialog.open, 're-added dialog should still be open');
        assert.isNull(document.querySelector('.backdrop'), 're-add dialog should not be modal');

        done();
      }, 0);
    });
    test('DOM removal inside other element', function(done) {
      var div = cleanup(document.createElement('div'));
      document.body.appendChild(div);
      div.appendChild(dialog);

      document.body.removeChild(div);

      window.setTimeout(function() {
        assert.isNull(document.querySelector('.backdrop'), 'dialog removal should clear modal');
        assert.isTrue(dialog.open, 'removed dialog should still be open');
        done();
      }, 0);
    });
    test('DOM instant remove/add', function(done) {
      var div = cleanup(document.createElement('div'));
      document.body.appendChild(div);
      dialog.parentNode.removeChild(dialog);
      div.appendChild(dialog);

      window.setTimeout(function() {
        assert.isNull(document.querySelector('.backdrop'), 'backdrop should disappear');
        assert.isTrue(dialog.open);
        done();
      }, 0);
    });
  });

  suite('position', function() {
    test('non-modal is not centered', function() {
      var el = cleanup(document.createElement('div'));
      dialog.parentNode.insertBefore(el, dialog);
      var testRect = el.getBoundingClientRect();

      dialog.show();
      var rect = dialog.getBoundingClientRect();

      assert.equal(rect.top, testRect.top, 'dialog should not be centered');
    });
    test('default modal centering', function() {
      dialog.showModal();
      checkDialogCenter();
      assert.ok(dialog.style.top, 'expected top to be set');
      dialog.close();
      assert.notOk(dialog.style.top, 'expected top to be cleared');
    });
    test('modal respects static position', function() {
      dialog.style.top = '10px';
      dialog.showModal();

      var rect = dialog.getBoundingClientRect();
      assert.equal(rect.top, 10);
    });
    test('modal recentering', function() {
      var pX = document.body.scrollLeft;
      var pY = document.body.scrollTop;
      var big = cleanup(document.createElement('div'));
      big.style.height = '200vh';  // 2x view height
      document.body.appendChild(big);

      try {
        var scrollValue = 200;  // don't use incredibly large values
        dialog.showModal();
        dialog.close();

        window.scrollTo(0, scrollValue);
        dialog.showModal();
        checkDialogCenter();  // must be centered, even after scroll
        var rectAtScroll = dialog.getBoundingClientRect();

        // after scroll, we aren't recentered, check offset
        window.scrollTo(0, 0);
        var rect = dialog.getBoundingClientRect();
        assert.closeTo(rectAtScroll.top + scrollValue, rect.top, 1);
      } finally {
        window.scrollTo(pX, pY);
      }
    });
    test('clamped to top of page', function() {
      var big = cleanup(document.createElement('div'));
      big.style.height = '200vh';  // 2x view height
      document.body.appendChild(big);
      document.documentElement.scrollTop = document.documentElement.scrollHeight / 2;

      dialog.style.height = document.documentElement.scrollHeight + 200 + 'px';
      dialog.showModal();

      var visibleRect = dialog.getBoundingClientRect();
      assert.equal(visibleRect.top, 0, 'large dialog should be visible at top of page');

      var style = window.getComputedStyle(dialog);
      assert.equal(style.top, document.documentElement.scrollTop + 'px',
          'large dialog should be absolutely positioned at scroll top');
    });
  });

  suite('backdrop', function() {
    test('backdrop div on modal', function() {
      dialog.showModal();
      var foundBackdrop = document.querySelector('.backdrop');
      assert.isNotNull(foundBackdrop);

      var sibling = dialog.nextElementSibling;
      assert.strictEqual(foundBackdrop, sibling);
    });
    test('no backdrop on non-modal', function() {
      dialog.show();
      assert.isNull(document.querySelector('.backdrop'));
      dialog.close();
    });
    test('backdrop click appears as dialog', function() {
      dialog.showModal();
      var backdrop = dialog.nextElementSibling;

      var clickFired = 0;
      var helper = function(ev) {
        assert.equal(ev.target, dialog);
        ++clickFired;
      };

      dialog.addEventListener('click', helper)
      backdrop.click();
      assert.equal(clickFired, 1);
    });
    test('backdrop click focuses dialog', function() {
      dialog.showModal();
      dialog.tabIndex = 0;

      var input = document.createElement('input');
      input.type = 'text';
      dialog.appendChild(input);

      // TODO: It would be nice to check `input` instead here, but there's no more reliable ways
      // to emulate a browser tab event (Firefox, Chrome etc have made it a security violation).

      var backdrop = dialog.nextElementSibling;
      backdrop.click();
      assert.equal(document.activeElement, dialog);
    });
  });

  suite('form focus', function() {
    test('non-modal inside modal is focusable', function() {
      var sub = createDialog();
      dialog.appendChild(sub);

      var input = document.createElement('input');
      input.type = 'text';
      sub.appendChild(input);

      dialog.showModal();
      sub.show();

      input.focus();
      assert.equal(input, document.activeElement);
    });
    test('clear focus when nothing focusable in modal', function() {
      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      document.body.appendChild(input);
      input.focus();

      var previous = document.activeElement;
      dialog.showModal();
      assert.notEqual(previous, document.activeElement);
    });
    test('default focus on modal', function() {
      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      dialog.appendChild(input);

      var anotherInput = cleanup(document.createElement('input'));
      anotherInput.type = 'text';
      dialog.appendChild(anotherInput);

      dialog.showModal();
      assert.equal(document.activeElement, input);
    });
    test('default focus on non-modal', function() {
      var div = cleanup(document.createElement('div'));
      div.tabIndex = 4;
      dialog.appendChild(div);

      dialog.show();
      assert.equal(document.activeElement, div);
    });
    test('autofocus element chosen', function() {
      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      dialog.appendChild(input);

      var inputAF = cleanup(document.createElement('input'));
      inputAF.type = 'text';
      inputAF.autofocus = true;
      dialog.appendChild(inputAF);

      dialog.showModal();
      assert.equal(document.activeElement, inputAF);
    });
    test('child modal dialog', function() {
      dialog.showModal();

      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      dialog.appendChild(input);
      input.focus();
      assert.equal(document.activeElement, input);

      // NOTE: This is a single sub-test, but all the above tests could be run
      // again in a sub-context (i.e., dialog within dialog).
      var child = createDialog();
      child.showModal();
      assert.notEqual(document.activeElement, input,
          'additional modal dialog should clear parent focus');

      child.close();
      assert.notEqual(document.activeElement, input,
          'parent focus should not be restored');
    });
    test('don\'t scroll anything into focus', function() {
      // https://github.com/GoogleChrome/dialog-polyfill/issues/119

      var div = cleanup(document.createElement('div'));
      document.body.appendChild(div);

      var inner = document.createElement('div');
      inner.style.height = '10000px';
      div.appendChild(inner);

      div.appendChild(dialog);

      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      dialog.appendChild(input);

      var prev = document.documentElement.scrollTop;
      dialog.showModal();
      assert.equal(document.documentElement.scrollTop, prev);
    });
  });

  suite('top layer / inert', function() {
    test('background focus allowed on non-modal', function() {
      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      document.body.appendChild(input);
      input.focus();

      dialog.show();
      assert.notEqual(document.activeElement, input,
        'non-modal dialog should clear focus, even with no dialog content');

      document.body.focus();
      input.focus();
      assert.equal(document.activeElement, input,
          'non-modal should allow background focus');
    });
    test('modal disallows background focus', function() {
      var input = cleanup(document.createElement('input'));
      input.type = 'text';
      document.body.appendChild(input);

      dialog.showModal();
      input.focus();

      if (!document.hasFocus()) {
        // Browsers won't trigger a focus event if they're not in the
        // foreground, so we can't intercept it. However, they'll fire one when
        // restored, before a user can get to any incorrectly focused element.
        console.warn('background focus test requires document focus');
        document.documentElement.focus();
      }
      assert.notEqual(document.activeElement, input,
          'modal should disallow background focus');
    });
    test('overlay is a sibling of topmost dialog', function() {
      var stacking = cleanup(document.createElement('div'));
      stacking.style.opacity = 0.8;  // creates stacking context
      document.body.appendChild(stacking);
      stacking.appendChild(dialog);
      dialog.showModal();

      var overlay = document.querySelector('._dialog_overlay');
      assert.isNotNull(overlay);
      assert.equal(overlay.parentNode, dialog.parentNode);
    });
    test('overlay is between topmost and remaining dialogs', function() {
      dialog.showModal();

      var other = cleanup(createDialog());
      document.body.appendChild(other);
      other.showModal();

      var overlay = document.querySelector('._dialog_overlay');
      assert.isNotNull(overlay);
      assert.equal(overlay.parentNode, other.parentNode);

      assert.isAbove(+other.style.zIndex, +overlay.style.zIndex, 'top-most dialog above overlay');
      assert.isAbove(+overlay.style.zIndex, +dialog.style.zIndex, 'overlay above other dialogs');
    });
  });

  suite('events', function() {
    test('close event', function() {
      var closeFired = 0;
      dialog.addEventListener('close', function() {
        ++closeFired;
      });

      dialog.show();
      assert.equal(closeFired, 0);

      dialog.close();
      assert.equal(closeFired, 1);

      assert.throws(dialog.close);  // can't close already closed dialog
      assert.equal(closeFired, 1);

      dialog.showModal();
      dialog.close();
      assert.equal(closeFired, 2);
    });
    test('cancel event', function() {
      dialog.showModal();
      dialog.dispatchEvent(createKeyboardEvent(27));
      assert.isFalse(dialog.open, 'esc should close modal');

      var cancelFired = 0;
      dialog.addEventListener('cancel', function() {
        ++cancelFired;
      });
      dialog.showModal();
      dialog.dispatchEvent(createKeyboardEvent(27));
      assert.equal(cancelFired, 1, 'expected cancel to be fired');
      assert.isFalse(dialog.open), 'esc should close modal again';

      // Sanity-check that non-modals aren't effected.
      dialog.show();
      dialog.dispatchEvent(createKeyboardEvent(27));
      assert.isTrue(dialog.open, 'esc should only close modal dialog');
      assert.equal(cancelFired, 1);
    });
    test('overlay click is prevented', function() {
      dialog.showModal();

      var overlay = document.querySelector('._dialog_overlay');
      assert.isNotNull(overlay);

      var helper = function(ev) {
        throw Error('body should not be clicked');
      };
      try {
        document.body.addEventListener('click', helper);
        overlay.click();
      } finally {
        document.body.removeEventListener('click', helper);
      }
    });
  });

  suite('form', function() {
    test('method attribute is translated to property', function() {
      var form = document.createElement('form');
      form.method = 'dialog';
      assert.equal(form.method, 'dialog');

      form.method = 'PoSt';
      assert.equal(form.method, 'post');
      assert.equal(form.getAttribute('method'), 'PoSt');
    });
    test('dialog method input', function() {
      var value = 'ExpectedValue' + Math.random();

      var form = document.createElement('form');
      try {
        form.method = 'dialog';
      } catch (e) {
        // Setting the method directly throws an exception in <=IE9.
        form.setAttribute('method', 'dialog');
      }
      dialog.appendChild(form);

      var input = document.createElement('input');
      input.type = 'submit';
      input.value = value;
      form.appendChild(input);

      dialog.show();
      input.focus();  // emulate user focus action
      input.click();

      assert.isFalse(dialog.open);
      assert.equal(dialog.returnValue, value);
    });
    test('dialog with button preventDefault does not trigger submit', function() {
      var form = document.createElement('form');
      form.setAttribute('method', 'dialog');
      dialog.appendChild(form);

      var button = document.createElement('button');
      button.value = 'does not matter';
      form.appendChild(button);
      button.addEventListener('click', function(ev) {
        ev.preventDefault();
      });

      dialog.showModal();
      button.click();

      assert.isTrue(dialog.open, 'dialog should remain open');
      assert.equal(dialog.returnValue, '');
    });
    test('dialog programmatic submit does not change returnValue', function() {
      var form = document.createElement('form');
      form.setAttribute('method', 'dialog');

      dialog.returnValue = 'manually set';  // set before appending
      dialog.appendChild(form);

      dialog.showModal();
      form.submit();
      assert.isFalse(dialog.open);

      assert.equal(dialog.returnValue, 'manually set', 'returnValue should not change');
    });
    test('dialog method button', function() {
      var value = 'ExpectedValue' + Math.random();

      var form = document.createElement('form');
      form.setAttribute('method', 'dialog');
      dialog.appendChild(form);

      var button = document.createElement('button');
      button.value = value;
      form.appendChild(button);

      dialog.showModal();
      button.focus();  // emulate user focus action
      button.click();

      assert.isFalse(dialog.open);
      assert.equal(dialog.returnValue, value);

      // Clear button value, confirm textContent is not used as value.
      button.value = 'blah blah';
      button.removeAttribute('value');
      button.textContent = value;
      dialog.show();
      button.focus();  // emulate user focus action
      button.click();

      assert.equal(dialog.returnValue, button.value,
          'don\'t take button textContent as value');
    });
    test('boring form inside dialog', function() {
      var form = document.createElement('form');
      dialog.appendChild(form);  // don't specify method
      form.addEventListener('submit', function(ev) {
        ev.preventDefault();
      });

      var button = document.createElement('button');
      button.value = 'Moot';
      form.appendChild(button);

      dialog.showModal();
      button.focus();  // emulate user focus action
      button.click();

      assert.isTrue(dialog.open, 'non-dialog form should not close dialog')
      assert(!dialog.returnValue);
    });
    test('type="image" submitter', function() {
      var form = document.createElement('form');
      form.setAttribute('method', 'dialog');
      dialog.appendChild(form);
      dialog.show();

      var image = document.createElement('input');
      image.type = 'image';
      image.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
      image.setAttribute('value', 'image should not accept value');
      form.appendChild(image);
      image.click();

      assert.notEqual(image.getAttribute('value'), dialog.returnValue);
      assert.equal(dialog.returnValue, '0,0');
    });
    test('form submitter across dialogs', function() {
      var form1 = document.createElement('form');
      form1.setAttribute('method', 'dialog');
      dialog.appendChild(form1);

      var button1 = document.createElement('button');
      button1.value = 'from form1: first value';
      form1.appendChild(button1);
      dialog.showModal();

      var dialog2 = createDialog();
      dialog2.returnValue = 'dialog2 default close value';
      var form2 = document.createElement('form');
      form2.setAttribute('method', 'dialog');
      dialog2.appendChild(form2);
      dialog2.showModal();

      button1.click();
      assert.isFalse(dialog.open);

      // nb. this never fires 'submit' so the .returnValue can't be wrong: is there another way
      // to submit a form that doesn't involve a click (enter implicitly 'clicks') or submit?
      form2.submit();
      assert.isFalse(dialog2.open);

      assert.equal(dialog2.returnValue, 'dialog2 default close value',
          'second dialog shouldn\'t reuse formSubmitter');
    });
  });

  suite('order', function() {
    test('non-modal unchanged', function() {
      var one = createDialog();
      var two = createDialog();

      one.style.zIndex = 100;
      two.style.zIndex = 200;
      one.show();
      two.show();

      assert.equal(window.getComputedStyle(one).zIndex, 100);
      assert.equal(window.getComputedStyle(two).zIndex, 200);

      two.close();
      assert.equal(window.getComputedStyle(two).zIndex, 200);
    });
    test('modal stacking order', function() {
      dialog.showModal();

      // Create incorrectly-named dialogs: front has a lower z-index, and back
      // has a higher z-index.
      var front = createDialog();
      var back = createDialog();
      front.style.zIndex = 100;
      back.style.zIndex = 200;

      // Show back first, then front. Thus we expect back to be behind front.
      back.showModal();
      front.showModal();

      var zf = +window.getComputedStyle(front).zIndex;
      var zb = +window.getComputedStyle(back).zIndex;
      assert.isAbove(zf, zb, 'showModal order dictates z-index');

      var backBackdrop = back.nextElementSibling;
      var zbb = +window.getComputedStyle(backBackdrop).zIndex;
      assert.equal(backBackdrop.className, 'backdrop');
      assert.isBelow(zbb, zb, 'backdrop below dialog');

      var frontBackdrop = front.nextElementSibling;
      var zfb = +window.getComputedStyle(frontBackdrop).zIndex
      assert.equal(frontBackdrop.className, 'backdrop');
      assert.isBelow(zfb, zf,' backdrop below dialog');

      assert.isAbove(zfb, zb, 'front backdrop is above back dialog');

      front.close();
      assert.notOk(front.style.zIndex, 'modal close should clear zindex');
    });
  });

  suite('press tab key', function() {
    test('tab key', function() {
      var dialog = createDialog();
      dialog.showModal();

      document.documentElement.dispatchEvent(createKeyboardEvent(9));

      var ev = document.createEvent('Events');
      ev.initEvent('focus', true, true);
      document.documentElement.dispatchEvent(ev);

      dialog.close();
    });
  });
}();
