/* 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/. */

// This test frequently takes longer than the allowed time.
requestLongerTimeout(2);

const tabmail = document.getElementById("tabmail");
registerCleanupFunction(() => {
  tabmail.closeOtherTabs(tabmail.tabInfo[0]);
});

// Increase this value to slow the test down if you want to see what it is doing.
const MOUSE_DELAY = 0;

let win, doc;

let resizingEvents = 0;
let resizedEvents = 0;
let collapsedEvents = 0;
let expandedEvents = 0;

// This object keeps the test simple by removing the differences between
// horizontal and vertical, and which pane is controlled by the splitter.
const testRunner = {
  outer: null, // The container for the splitter and panes.
  splitter: null, // The splitter.
  resizedIsBefore: null, // Whether resized is before the splitter.
  resized: null, // The pane the splitter controls the size of.
  fill: null, // The pane that splitter doesn't control.
  dimension: null, // Which dimension the splitter resizes.

  getSize(element) {
    return element.getBoundingClientRect()[this.dimension];
  },

  assertElementSizes(size, msg = "") {
    Assert.equal(
      this.getSize(this.resized),
      size,
      `Resized element should take up the expected ${this.dimension}: ${msg}`
    );
    Assert.equal(
      this.getSize(this.fill),
      500 - size,
      `Fill element should take up the rest of the ${this.dimension}: ${msg}`
    );
  },

  assertSplitterSize(size, msg = "") {
    Assert.equal(
      this.splitter[this.dimension],
      size,
      `Splitter ${this.dimension} should match expected ${size}: ${msg}`
    );
  },

  get minSizeProperty() {
    return this.dimension == "width" ? "minWidth" : "minHeight";
  },

  get maxSizeProperty() {
    return this.dimension == "width" ? "maxWidth" : "maxHeight";
  },

  get collapseSizeAttribute() {
    return this.dimension == "width" ? "collapse-width" : "collapse-height";
  },

  setCollapseSize(size) {
    this.splitter.setAttribute(this.collapseSizeAttribute, size);
  },

  clearCollapseSize() {
    this.splitter.removeAttribute(this.collapseSizeAttribute);
  },

  /**
   * Wait for the splitter to resize its target element.
   *
   * @param {number} targetSize - Target size for the element resized by the
   *   splitter.
   */
  async waitForSizeTo(targetSize) {
    info(`Expecting resize to ${targetSize}px...`);
    const mutationObserver = BrowserTestUtils.waitForMutationCondition(
      this.splitter.parentNode,
      {
        attributes: true,
        attributeFilter: ["style"],
      },
      () => this.getSize(this.resized) == targetSize
    );
    await mutationObserver;
  },

  /**
   * Wait for the mouse move lock in the splitter to be released, so we can
   * affect the splitter again. Will time out after a few seconds.
   *
   * As opposed to waitForCondition this uses requestAnimationFrame to wait,
   * since that's what's used to release the mouse move lock.
   */
  async waitForMousemoveLock() {
    const MAX_WAIT = 2000;
    const waitEnd = Date.now() + MAX_WAIT;
    if (this.splitter._mouseMoveBlocked) {
      info("Waiting for mouse move lock to be released...");
    }
    while (this.splitter._mouseMoveBlocked && Date.now() < waitEnd) {
      await new Promise(resolve => win.requestAnimationFrame(resolve));
    }
    if (Date.now() > waitEnd) {
      console.error("Timed out waiting for mouse move lock to be released!");
    }
  },

  async synthMouse(position, type = "mousemove", otherPosition = 50) {
    let x, y;
    if (!this.resizedIsBefore) {
      position = 500 - position;
    }
    if (this.dimension == "width") {
      [x, y] = [position, otherPosition];
    } else {
      [x, y] = [otherPosition, position];
    }
    EventUtils.synthesizeMouse(
      this.splitter.parentNode,
      x,
      y,
      { type, buttons: 1 },
      win
    );

    if (MOUSE_DELAY) {
      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      await new Promise(resolve => setTimeout(resolve, MOUSE_DELAY));
    }

    if (type == "mousemove") {
      await this.waitForMousemoveLock();
    } else {
      // Use the request animation frame of the tab, since that's also what the
      // splitter uses.
      await new Promise(resolve => win.requestAnimationFrame(resolve));
    }
  },
};

add_setup(async function () {
  const tab = tabmail.openTab("contentTab", {
    url: "chrome://mochitests/content/browser/comm/mail/base/test/widgets/files/paneSplitter.xhtml",
  });

  await BrowserTestUtils.browserLoaded(tab.browser);
  tab.browser.focus();

  win = tab.browser.contentWindow;
  doc = win.document;

  win.addEventListener("splitter-resizing", () => resizingEvents++);
  win.addEventListener("splitter-resized", () => resizedEvents++);
  win.addEventListener("splitter-collapsed", () => collapsedEvents++);
  win.addEventListener("splitter-expanded", () => expandedEvents++);

  window.windowUtils.disableNonTestMouseEvents(true);

  registerCleanupFunction(() => {
    window.windowUtils.disableNonTestMouseEvents(false);
  });
});

add_task(async function testHorizontalBefore() {
  const outer = doc.getElementById("horizontal-before");
  const resized = outer.querySelector(".resized");
  const splitter = outer.querySelector(`hr[is="pane-splitter"]`);
  const fill = outer.querySelector(".fill");

  Assert.equal(resized.clientWidth, 200);
  Assert.equal(fill.clientWidth, 300);
  Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");

  splitter.scrollIntoView({
    behavior: "instant",
    block: "nearest",
  });

  testRunner.outer = outer;
  testRunner.splitter = splitter;
  testRunner.resizedIsBefore = true;
  testRunner.resized = resized;
  testRunner.fill = fill;
  testRunner.dimension = "width";

  await subtestDrag();
  await subtestDragSizeBounds();
  await subtestDragAutoCollapse();
  await subtestCollapseExpand();
});

add_task(async function testHorizontalAfter() {
  const outer = doc.getElementById("horizontal-after");
  const fill = outer.querySelector(".fill");
  const splitter = outer.querySelector(`hr[is="pane-splitter"]`);
  const resized = outer.querySelector(".resized");

  Assert.equal(fill.clientWidth, 300);
  Assert.equal(resized.clientWidth, 200);
  Assert.equal(win.getComputedStyle(splitter).cursor, "ew-resize");

  splitter.scrollIntoView({
    behavior: "instant",
    block: "nearest",
  });

  testRunner.outer = outer;
  testRunner.splitter = splitter;
  testRunner.resizedIsBefore = false;
  testRunner.resized = resized;
  testRunner.fill = fill;
  testRunner.dimension = "width";

  await subtestDrag();
  await subtestDragSizeBounds();
  await subtestDragAutoCollapse();
  await subtestCollapseExpand();
});

add_task(async function testVerticalBefore() {
  const outer = doc.getElementById("vertical-before");
  const resized = outer.querySelector(".resized");
  const splitter = outer.querySelector(`hr[is="pane-splitter"]`);
  const fill = outer.querySelector(".fill");

  Assert.equal(resized.clientHeight, 200);
  Assert.equal(fill.clientHeight, 300);
  Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");

  splitter.scrollIntoView({
    behavior: "instant",
    block: "nearest",
  });

  testRunner.outer = outer;
  testRunner.splitter = splitter;
  testRunner.resizedIsBefore = true;
  testRunner.resized = resized;
  testRunner.fill = fill;
  testRunner.dimension = "height";

  await subtestDrag();
  await subtestDragSizeBounds();
  await subtestDragAutoCollapse();
  await subtestCollapseExpand();
});

add_task(async function testVerticalAfter() {
  const outer = doc.getElementById("vertical-after");
  const fill = outer.querySelector(".fill");
  const splitter = outer.querySelector(`hr[is="pane-splitter"]`);
  const resized = outer.querySelector(".resized");

  testRunner.outer = outer;
  testRunner.splitter = splitter;
  testRunner.resizedIsBefore = false;
  testRunner.resized = resized;
  testRunner.fill = fill;
  testRunner.dimension = "height";

  Assert.equal(fill.clientHeight, 300);
  Assert.equal(resized.clientHeight, 200);
  Assert.equal(win.getComputedStyle(splitter).cursor, "ns-resize");

  splitter.scrollIntoView({
    behavior: "instant",
    block: "nearest",
  });

  await subtestDrag();
  await subtestDragSizeBounds();
  await subtestDragAutoCollapse();
  await subtestCollapseExpand();
});

async function subtestDrag() {
  info("subtestDrag");
  resizingEvents = 0;
  resizedEvents = 0;

  const originalPosition = testRunner.getSize(testRunner.resized);
  let position = 200;

  await testRunner.synthMouse(position, "mousedown");

  await testRunner.synthMouse(position, "mousemove", 25);
  Assert.equal(resizingEvents, 0, "moving up the splitter does nothing");
  await testRunner.synthMouse(position, "mousemove", 75);
  Assert.equal(resizingEvents, 0, "moving down the splitter does nothing");

  position--;
  await testRunner.synthMouse(position);
  Assert.equal(resizingEvents, 0, "moving 1px does nothing");

  position--;
  await testRunner.synthMouse(position);
  Assert.equal(resizingEvents, 0, "moving 2px does nothing");

  position--;
  await testRunner.synthMouse(position);
  Assert.equal(resizingEvents, 1, "a resizing event fired");

  // Drag in steps to the left-hand/top end.
  for (; position >= 0; position -= 50) {
    const mutationObserver = testRunner.waitForSizeTo(position);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(position);
  }

  // Drag beyond the left-hand/top end.
  position = -50;
  await testRunner.synthMouse(position);
  testRunner.assertElementSizes(0);

  // Drag in steps to the right-hand/bottom end.
  for (let pos = 0; pos <= 500; pos += 50) {
    const mutationObserver = testRunner.waitForSizeTo(pos);
    await testRunner.synthMouse(pos);
    await mutationObserver;
    testRunner.assertElementSizes(pos);
  }

  // Drag beyond the right-hand/bottom end.
  position = 550;
  await testRunner.synthMouse(position);
  testRunner.assertElementSizes(500);

  // Drop.
  position = 400;
  Assert.equal(resizingEvents, 1, "no more resizing events fired");
  Assert.equal(resizedEvents, 0, "no resized events fired");
  await testRunner.synthMouse(position);
  await testRunner.synthMouse(position, "mouseup");
  testRunner.assertElementSizes(400);
  Assert.equal(resizingEvents, 1, "no more resizing events fired");
  Assert.equal(resizedEvents, 1, "a resized event fired");

  // Pick up again.
  await testRunner.synthMouse(position, "mousedown");

  // Move.
  for (; position >= originalPosition; position -= 50) {
    await testRunner.synthMouse(position);
  }

  // Drop.
  Assert.equal(resizingEvents, 2, "a resizing event fired");
  Assert.equal(resizedEvents, 1, "no more resized events fired");
  await testRunner.synthMouse(position, "mouseup");
  testRunner.assertElementSizes(originalPosition);
  Assert.equal(resizingEvents, 2, "no more resizing events fired");
  Assert.equal(resizedEvents, 2, "a resized event fired");
}

async function subtestDragSizeBounds() {
  info("subtestDragSizeBounds");

  const { splitter, resized, fill, minSizeProperty, maxSizeProperty } =
    testRunner;

  // Various min or max sizes to set on the resized and fill elements.
  // NOTE: the sum of the max sizes is greater than 500px.
  // Moreover, the resized element's min size is below 200px, and the max size
  // above it. Similarly, the fill element's min size is below 300px. This
  // ensures that the initial sizes of 200px and 300px are within their
  // respective min-max bounds.
  // NOTE: We do not set a max size on the fill element. The grid layout does
  // not handle this. Nor is it an expected usage of the splitter.
  for (const [minResized, min] of [
    [null, 0],
    ["100.5px", 100.5],
  ]) {
    for (const [maxResized, expectMax1] of [
      [null, 500],
      ["360px", 360],
    ]) {
      for (const [minFill, expectMax2] of [
        [null, 500],
        ["148px", 352],
      ]) {
        info(`Bounds [${minResized}, ${maxResized}] and [${minFill}, none]`);
        const max = Math.min(expectMax1, expectMax2);
        info(`Overall bound [${min}px, ${max}px]`);

        // Construct a set of positions we are interested in.
        const roundMin = Math.floor(min);
        const roundMax = Math.ceil(max);
        const positionSet = [-50, 150, 350, 550];
        // Include specific positions around the minimum and maximum points.
        positionSet.push(roundMin - 1, roundMin, roundMin + 1);
        positionSet.push(roundMax - 1, roundMax, roundMax + 1);
        positionSet.sort();

        // Reset the splitter.
        splitter.width = null;
        splitter.height = null;

        resized.style[minSizeProperty] = minResized;
        resized.style[maxSizeProperty] = maxResized;
        fill.style[minSizeProperty] = minFill;

        testRunner.assertElementSizes(200, "initial position");
        await testRunner.synthMouse(200, "mousedown");

        for (const position of positionSet) {
          const size = Math.min(Math.max(position, min), max);
          const mutationObserver = testRunner.waitForSizeTo(size);
          await testRunner.synthMouse(position);
          await mutationObserver;
          testRunner.assertElementSizes(size, `Moved forward to ${position}`);
          testRunner.assertSplitterSize(size, `Moved forward to ${position}`);
        }

        await testRunner.synthMouse(500);
        await testRunner.synthMouse(500, "mouseup");
        testRunner.assertElementSizes(max, "positioned at max");
        testRunner.assertSplitterSize(max, "positioned at max");

        // Reverse.
        await testRunner.synthMouse(max, "mousedown");

        for (const position of positionSet.reverse()) {
          const size = Math.min(Math.max(position, min), max);
          const mutationObserver = testRunner.waitForSizeTo(size);
          await testRunner.synthMouse(position);
          await mutationObserver;
          testRunner.assertElementSizes(size, `Moved backward to ${position}`);
          testRunner.assertSplitterSize(size, `Moved backward to ${position}`);
        }

        await testRunner.synthMouse(0);
        await testRunner.synthMouse(0, "mouseup");
        testRunner.assertElementSizes(min, "positioned at min");
        testRunner.assertSplitterSize(min, "positioned at min");
      }
    }
  }

  // Reset.
  splitter.width = null;
  splitter.height = null;
  resized.style[minSizeProperty] = null;
  resized.style[maxSizeProperty] = null;
  fill.style[minSizeProperty] = null;
}

async function subtestDragAutoCollapse() {
  info("subtestDragAutoCollapse");
  testRunner.setCollapseSize(78);

  collapsedEvents = 0;
  expandedEvents = 0;

  const { splitter } = testRunner;

  const originalPosition = 200;

  // Drag in steps toward the left-hand/top end.
  await testRunner.synthMouse(200, "mousedown");
  for (const position of [180, 160, 140, 120, 100, 80, 78]) {
    const mutationObserver = testRunner.waitForSizeTo(position);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(
      position,
      `Should have ${position} size at ${position}`
    );
    Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
  }

  // For the first 20 pixels inside the minimum size, nothing happens.
  for (const position of [74, 68, 64, 60, 58]) {
    const mutationObserver = testRunner.waitForSizeTo(78);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(
      78,
      `Should be at collapse-size at ${position}`
    );
    Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
  }

  // Then the pane collapses.
  await testRunner.synthMouse(57);
  Assert.equal(collapsedEvents, 1, "collapsed event fired");
  for (const position of [57, 55, 51, 40, 20, 0, -20]) {
    await testRunner.synthMouse(position);
    testRunner.assertElementSizes(0, `Should have no size at ${position}`);
    Assert.ok(splitter.isCollapsed, `Should be collapsed at ${position}`);
  }

  await testRunner.synthMouse(-20, "mouseup");
  testRunner.assertElementSizes(
    0,
    "Should be at min size after releasing mouse"
  );
  Assert.ok(splitter.isCollapsed, "Should be collapsed after releasing mouse");

  // Drag it from the collapsed state.
  await testRunner.synthMouse(0, "mousedown");
  for (const position of [0, 8, 16, 19]) {
    await testRunner.synthMouse(position);
    testRunner.assertElementSizes(
      0,
      `Should still have no size at ${position}`
    );
    Assert.ok(splitter.isCollapsed, `Should still be collapsed at ${position}`);
  }

  const expandedEventPromise = BrowserTestUtils.waitForEvent(
    win,
    "splitter-expanded"
  );
  // Then the pane expands. For the first 20 pixels, nothing happens.
  await testRunner.synthMouse(20);
  await expandedEventPromise;
  Assert.equal(expandedEvents, 1, "expanded event fired");
  for (const position of [40, 60, 78]) {
    const mutationObserver = testRunner.waitForSizeTo(78);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(
      78,
      `Should expand to collapse-size at ${position}`
    );
    Assert.ok(
      !splitter.isCollapsed,
      `Should no longer be collapsed at ${position}`
    );
  }

  for (const position of [79, 100, 120, 200, 250, 300, 400, 450]) {
    const mutationObserver = testRunner.waitForSizeTo(position);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(
      position,
      `Should have ${position} size at ${position}`
    );
    Assert.ok(!splitter.isCollapsed, `Should not be collapsed at ${position}`);
  }

  await testRunner.synthMouse(450, "mouseup");
  testRunner.assertElementSizes(
    450,
    "Should be at final size after releasing mouse"
  );
  Assert.ok(
    !splitter.isCollapsed,
    "Should not be collapsed after releasing mouse"
  );

  // Test that collapse and expand can happen in the same drag.
  await testRunner.synthMouse(450, "mousedown");
  let position;
  let expectedSize;
  for ([position, expectedSize] of [
    [58, 78],
    [57, 0],
    [58, 78],
    [57, 0],
  ]) {
    await testRunner.synthMouse(position);
    testRunner.assertElementSizes(
      expectedSize,
      `Should have ${expectedSize} size at ${position}`
    );
  }
  Assert.equal(collapsedEvents, 3, "collapsed events fired");
  Assert.equal(expandedEvents, 2, "expanded events fired");
  await testRunner.synthMouse(position, "mouseup");

  // Test that expansion from collapsed reverts to normal behaviour after
  // dragging out to the minimum size.
  await testRunner.synthMouse(0, "mousedown");
  for ([position, expectedSize] of [
    [0, 0],
    [10, 0],
    [20, 78],
    [40, 78],
    [60, 78],
    [40, 0],
    [60, 78],
    [40, 0],
    [80, 80],
    [100, 100],
  ]) {
    const mutationObserver = testRunner.waitForSizeTo(expectedSize);
    await testRunner.synthMouse(position);
    await mutationObserver;
    testRunner.assertElementSizes(
      expectedSize,
      `Should have ${expectedSize} size at ${position}`
    );
  }
  Assert.equal(collapsedEvents, 5, "collapsed events fired");
  Assert.equal(expandedEvents, 5, "expanded events fired");
  await testRunner.synthMouse(position, "mouseup");

  // Restore the original position.
  await testRunner.synthMouse(position, "mousedown");
  position = originalPosition;
  await testRunner.synthMouse(position);
  await testRunner.synthMouse(position, "mouseup");
  testRunner.assertElementSizes(originalPosition);

  testRunner.clearCollapseSize();
}

async function subtestCollapseExpand() {
  info("subtestCollapseExpand");
  collapsedEvents = 0;
  expandedEvents = 0;

  const { splitter } = testRunner;

  const originalSize = testRunner.getSize(testRunner.resized);

  // Collapse.
  Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
  Assert.equal(collapsedEvents, 0, "no collapsed events have fired");

  splitter.collapse();
  testRunner.assertElementSizes(0);
  Assert.ok(splitter.isCollapsed, "splitter is collapsed");
  Assert.equal(collapsedEvents, 1, "a collapsed event fired");

  splitter.collapse();
  Assert.ok(splitter.isCollapsed, "splitter is collapsed");
  Assert.equal(collapsedEvents, 1, "no more collapsed events have fired");

  // Expand.
  splitter.expand();
  testRunner.assertElementSizes(originalSize);
  Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
  Assert.equal(expandedEvents, 1, "an expanded event fired");

  splitter.expand();
  Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
  Assert.equal(expandedEvents, 1, "no more expanded events have fired");

  collapsedEvents = 0;
  expandedEvents = 0;

  // Collapse again. Then drag to expand.
  splitter.collapse();
  Assert.equal(collapsedEvents, 1, "a collapsed event fired");

  testRunner.setCollapseSize(78);

  await testRunner.synthMouse(0, "mousedown");
  await testRunner.synthMouse(200);
  await testRunner.synthMouse(200, "mouseup");
  testRunner.assertElementSizes(200);
  Assert.ok(!splitter.isCollapsed, "splitter is not collapsed");
  Assert.equal(expandedEvents, 1, "an expanded event fired");

  testRunner.clearCollapseSize();
}
