/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

// Test that increasing / decreasing values in rule view by dragging with
// the mouse works correctly.

const TEST_URI = `
  <style>
    #test {
      padding-top: 10px;
      margin-top: unset;
      margin-bottom: 0px;
      width: 0px;
      border: 1px solid red;
      line-height: 2;
      border-width: var(--12px);
      max-height: +10.2e3vmin;
      min-height: 1% !important;
      font-size: 10Q;
      transform: rotate(45deg);
      margin-left: 28.3em;
      animation-delay: +15s;
      margin-right: -2px;
      padding-bottom: .9px;
      rotate: 90deg;
    }
  </style>
  <div id="test"></div>
`;

const DRAGGABLE_VALUE_CLASSNAME = "ruleview-propertyvalue-draggable";

add_task(async function () {
  await pushPref("devtools.inspector.draggable_properties", true);

  await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));

  const { inspector, view } = await openRuleView();
  await selectNode("#test", inspector);

  testDraggingClassIsAddedWhenNeeded(view);

  // Check that toggling the feature updates the UI immediately.
  await pushPref("devtools.inspector.draggable_properties", false);
  testDraggingClassIsRemovedAfterPrefChange(view);

  await pushPref("devtools.inspector.draggable_properties", true);
  testDraggingClassIsAddedWhenNeeded(view);

  await testDraggingClassIsAddedOnValueUpdate(view);
  await testPressingEscapeWhileDragging(view);
  await testPressingEscapeWhileDraggingWithinDeadzone(view);
  await testUpdateDisabledValue(view);
  await testWidthIncrements(view);
  // This needs to happen last as dragging angles are causing further trouble.
  // This will be fixed as part of Bug 1885232.
  await testIncrementAngleValue(view);
});

const PROPERTIES = [
  {
    name: "border",
    value: "1px solid red",
    shouldBeDraggable: false,
  },
  {
    name: "line-height",
    value: "2",
    shouldBeDraggable: false,
  },
  {
    name: "border-width",
    value: "var(--12px)",
    shouldBeDraggable: false,
  },
  {
    name: "transform",
    value: "rotate(45deg)",
    shouldBeDraggable: false,
  },
  {
    name: "max-height",
    value: "+10.2e3vmin",
    shouldBeDraggable: true,
  },
  {
    name: "min-height",
    value: "1%",
    shouldBeDraggable: true,
  },
  {
    name: "font-size",
    value: "10Q",
    shouldBeDraggable: true,
  },
  {
    name: "margin-left",
    value: "28.3em",
    shouldBeDraggable: true,
  },
  {
    name: "animation-delay",
    value: "+15s",
    shouldBeDraggable: true,
  },
  {
    name: "margin-right",
    value: "-2px",
    shouldBeDraggable: true,
  },
  {
    name: "padding-bottom",
    value: ".9px",
    shouldBeDraggable: true,
  },
  {
    name: "rotate",
    value: "90deg",
    shouldBeDraggable: true,
  },
];

function testDraggingClassIsAddedWhenNeeded(view) {
  info("Testing class is added or not on different property values");
  runIsDraggableTest(view, PROPERTIES);
}

function testDraggingClassIsRemovedAfterPrefChange(view) {
  info("Testing class is removed if the feature is disabled");
  runIsDraggableTest(
    view,
    // Create a temporary copy of the test PROPERTIES, where shouldBeDraggable is
    // always false.
    PROPERTIES.map(prop =>
      Object.assign({}, prop, { shouldBeDraggable: false })
    )
  );
}

async function testIncrementAngleValue(view) {
  info("Testing updating an angle value with the angle swatch span");
  const rotatePropEditor = getTextProperty(view, 1, {
    rotate: "90deg",
  }).editor;
  await runIncrementTest(rotatePropEditor, view, [
    {
      startValue: "90deg",
      expectedEndValue: "100deg",
      distance: 10,
      description: "updating angle value",
    },
  ]);
}

async function testPressingEscapeWhileDragging(view) {
  info("Testing pressing escape while dragging with mouse");
  const marginPropEditor = getTextProperty(view, 1, {
    "margin-bottom": "0px",
  }).editor;
  await runIncrementTest(marginPropEditor, view, [
    {
      startValue: "0px",
      expectedEndValue: "0px",
      expectedEndValueBeforeEscape: "100px",
      escape: true,
      distance: 100,
      description: "Pressing escape to check if value has been reset",
    },
  ]);
}

async function testPressingEscapeWhileDraggingWithinDeadzone(view) {
  info("Testing pressing escape while dragging with mouse within the deadzone");
  const marginPropEditor = getTextProperty(view, 1, {
    "margin-bottom": "0px",
  }).editor;
  await runIncrementTest(marginPropEditor, view, [
    {
      startValue: "0px",
      expectedEndValue: "0px",
      expectedEndValueBeforeEscape: "0px",
      escape: true,
      deadzoneIncluded: true,
      distance: marginPropEditor._DRAGGING_DEADZONE_DISTANCE - 1,
      description: "Pressing escape to check if value has been reset",
    },
  ]);
}

async function testUpdateDisabledValue(view) {
  info("Testing updating a disabled value by dragging mouse");

  const textProperty = getTextProperty(view, 1, { "padding-top": "10px" });
  const editor = textProperty.editor;

  await togglePropStatus(view, textProperty);
  ok(!editor.enable.checked, "Should be disabled");
  await runIncrementTest(editor, view, [
    {
      startValue: "10px",
      expectedEndValue: "110px",
      distance: 100,
      description: "Updating disabled value",
    },
  ]);
  ok(editor.enable.checked, "Should be enabled");
}

async function testWidthIncrements(view) {
  info("Testing dragging the mouse on the width property");

  const marginPropEditor = getTextProperty(view, 1, { width: "0px" }).editor;
  await runIncrementTest(marginPropEditor, view, [
    {
      startValue: "0px",
      expectedEndValue: "20px",
      distance: 20,
      description: "Increasing value while dragging",
    },
    {
      startValue: "20px",
      expectedEndValue: "0px",
      distance: -20,
      description: "Decreasing value while dragging",
    },
    {
      startValue: "0px",
      expectedEndValue: "2px",
      ...getSmallIncrementKey(),
      distance: 20,
      description:
        "Increasing value with small increments by pressing ctrl or alt",
    },
    {
      startValue: "2px",
      expectedEndValue: "202px",
      shift: true,
      distance: 20,
      description: "Increasing value with large increments by pressing shift",
    },
    {
      startValue: "202px",
      expectedEndValue: "402px",
      distance: 200,
      description: "Increasing value with long distance",
    },
    {
      startValue: "402px",
      expectedEndValue: "402px",
      distance: marginPropEditor._DRAGGING_DEADZONE_DISTANCE - 1,
      description: "No change in the deadzone (positive value)",
      deadzoneIncluded: true,
    },
    {
      startValue: "402px",
      expectedEndValue: "402px",
      distance: -1 * (marginPropEditor._DRAGGING_DEADZONE_DISTANCE - 1),
      description: "No change in the deadzone (negative value)",
      deadzoneIncluded: true,
    },
    {
      startValue: "402px",
      expectedEndValue: "403px",
      distance: marginPropEditor._DRAGGING_DEADZONE_DISTANCE + 1,
      description: "Changed by 1 when leaving the deadzone (positive value)",
      deadzoneIncluded: true,
    },
    {
      startValue: "403px",
      expectedEndValue: "402px",
      distance: -1 * (marginPropEditor._DRAGGING_DEADZONE_DISTANCE + 1),
      description: "Changed by 1 when leaving the deadzone (negative value)",
      deadzoneIncluded: true,
    },
  ]);
}

async function testDraggingClassIsAddedOnValueUpdate(view) {
  info("Testing dragging class is added when a supported unit is detected");

  const editor = getTextProperty(view, 1, { "margin-top": "unset" }).editor;
  const valueSpan = editor.valueSpan;
  ok(
    !valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME),
    "Should not be draggable"
  );
  valueSpan.scrollIntoView();
  await setProperty(view, editor.prop, "23em");
  ok(
    valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME),
    "Should be draggable"
  );
}

/**
 * Runs each test and check whether or not the property is draggable
 *
 * @param {CSSRuleView} view
 * @param {{name: string, value: string, shouldBeDraggable: boolean}[]} tests
 */
function runIsDraggableTest(view, tests) {
  for (const test of tests) {
    const property = test;
    info(`Testing ${property.name} with value ${property.value}`);
    const editor = getTextProperty(view, 1, {
      [property.name]: property.value,
    }).editor;
    const valueSpan = editor.valueSpan;
    if (property.shouldBeDraggable) {
      ok(
        valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME),
        "Should be draggable"
      );
    } else {
      ok(
        !valueSpan.classList.contains(DRAGGABLE_VALUE_CLASSNAME),
        "Should not be draggable"
      );
    }
  }
}

/**
 * Runs each test in the tests array by synthesizing a mouse dragging
 *
 * @param  {TextPropertyEditor} editor
 * @param  {CSSRuleView} view
 * @param  {Array} tests
 */
async function runIncrementTest(editor, view, tests) {
  for (const test of tests) {
    await testIncrement(editor, test, view);
  }
  view.debounce.flush();
}

/**
 * Runs an increment test
 *
 * 1. We initialize the TextProperty value with "startValue"
 * 2. We synthesize a mouse dragging of "distance" length
 * 3. We check the value of TextProperty is equal to "expectedEndValue"
 *
 * @param  {TextPropertyEditor} editor
 * @param  {Array} options
 * @param  {string} options.startValue
 * @param  {string} options.expectedEndValue
 * @param  {boolean} options.shift Whether or not we press the shift key
 * @param  {number} options.distance Distance of the dragging
 * @param  {string} options.description
 * @param  {boolean} options.ctrl Small increment key
 * @param  {boolean} options.alt Small increment key for macosx
 * @param  {boolean} options.deadzoneIncluded True if the provided distance
 *         accounts for the deadzone. When false, the deadzone will automatically
 *         be added to the distance.
 */
async function testIncrement(editor, options) {
  info("Running subtest: " + options.description);

  editor.valueSpan.scrollIntoView();
  await setProperty(editor.ruleView, editor.prop, options.startValue);

  is(
    editor.prop.value,
    options.startValue,
    "Value initialized at " + options.startValue
  );

  const onMouseUp = once(editor.valueSpan, "mouseup");

  await synthesizeMouseDragging(editor, options.distance, options);

  // mouseup event not triggered when escape is pressed
  if (!options.escape) {
    info("Waiting mouseup");
    await onMouseUp;
    info("Received mouseup");
  }

  is(
    editor.prop.value,
    options.expectedEndValue,
    "Value changed to " + editor.prop.value
  );
}

/**
 * Synthesizes mouse dragging (mousedown + mousemove + mouseup)
 *
 * @param {TextPropertyEditor} editor
 * @param {number} distance length of the horizontal dragging (negative if dragging left)
 * @param {object} option
 * @param {boolean} option.escape
 * @param {boolean} option.alt
 * @param {boolean} option.shift
 * @param {boolean} option.ctrl
 * @param {boolean} option.deadzoneIncluded
 */
async function synthesizeMouseDragging(editor, distance, options = {}) {
  info(`Start to synthesize mouse dragging (from ${1} to ${1 + distance})`);

  const styleWindow = editor.ruleView.styleWindow;
  const elm = editor.valueSpan;
  const startPosition = [1, 1];

  // Handle the pixel based deadzone.
  const deadzone = editor._DRAGGING_DEADZONE_DISTANCE;
  if (!options.deadzoneIncluded) {
    // Most tests do not care about the deadzone and the provided distance does
    // not account for the deadzone. Add it automatically.
    distance = distance + Math.sign(distance) * deadzone;
  }
  const updateExpected = Math.abs(options.distance) > deadzone;

  const endPosition = [startPosition[0] + distance, startPosition[1]];

  EventUtils.synthesizeMouse(
    elm,
    startPosition[0],
    startPosition[1],
    { type: "mousedown" },
    styleWindow
  );

  // If the drag will not trigger any update, simply wait for 100ms.
  // Otherwise, wait for the next property-updated-by-dragging event and the rule view change.
  const updated = updateExpected
    ? Promise.all([
        editor.ruleView.once("ruleview-changed"),
        editor.ruleView.once("property-updated-by-dragging"),
      ])
    : wait(100);

  EventUtils.synthesizeMouse(
    elm,
    endPosition[0],
    endPosition[1],
    {
      type: "mousemove",
      shiftKey: !!options.shift,
      ctrlKey: !!options.ctrl,
      altKey: !!options.alt,
    },
    styleWindow
  );

  // We wait because the mousemove event listener is throttled to 30ms
  // in the TextPropertyEditor class
  info("waiting for event property-updated-by-dragging");
  await updated;
  ok(true, "received event property-updated-by-dragging");

  if (options.escape) {
    is(
      editor.prop.value,
      options.expectedEndValueBeforeEscape,
      "testing value before pressing escape"
    );
    const onRuleViewChanged = updateExpected
      ? editor.ruleView.once("ruleview-changed")
      : null;
    EventUtils.synthesizeKey("VK_ESCAPE", {}, styleWindow);
    await onRuleViewChanged;
  }

  EventUtils.synthesizeMouse(
    elm,
    endPosition[0],
    endPosition[1],
    {
      type: "mouseup",
    },
    styleWindow
  );

  // If the drag did not trigger any update, mouseup might open an inline editor.
  // Leave the editor.
  const inplaceEditor = styleWindow.document.querySelector(
    ".styleinspector-propertyeditor"
  );
  if (inplaceEditor) {
    const onBlur = once(inplaceEditor, "blur");
    EventUtils.synthesizeKey("VK_ESCAPE", {}, styleWindow);
    await onBlur;
  }

  info("Finish to synthesize mouse dragging");
}

function getSmallIncrementKey() {
  if (AppConstants.platform === "macosx") {
    return { alt: true };
  }
  return { ctrl: true };
}
