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

"use strict";

/* import-globals-from ../../mochitest/role.js */
/* import-globals-from ../../mochitest/states.js */
loadScripts(
  { name: "role.js", dir: MOCHITESTS_DIR },
  { name: "states.js", dir: MOCHITESTS_DIR }
);

function testValueChangedEventData(
  macIface,
  data,
  expectedId,
  expectedChangeValue,
  expectedEditType,
  expectedWordAtLeft
) {
  is(
    data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"),
    expectedId,
    "Correct AXTextChangeElement"
  );
  is(
    data.AXTextStateChangeType,
    AXTextStateChangeTypeEdit,
    "Correct AXTextStateChangeType"
  );

  let changeValues = data.AXTextChangeValues;
  is(changeValues.length, 1, "One element in AXTextChangeValues");
  is(
    changeValues[0].AXTextChangeValue,
    expectedChangeValue,
    "Correct AXTextChangeValue"
  );
  is(
    changeValues[0].AXTextEditType,
    expectedEditType,
    "Correct AXTextEditType"
  );

  let textMarker = changeValues[0].AXTextChangeValueStartMarker;
  ok(textMarker, "There is a AXTextChangeValueStartMarker");
  let range = macIface.getParameterizedAttributeValue(
    "AXLeftWordTextMarkerRangeForTextMarker",
    textMarker
  );
  let str = macIface.getParameterizedAttributeValue(
    "AXStringForTextMarkerRange",
    range,
    "correct word before caret"
  );
  is(str, expectedWordAtLeft);
}

// Return true if the first given object a subset of the second
function isSubset(subset, superset) {
  if (typeof subset != "object" || typeof superset != "object") {
    return superset == subset;
  }

  for (let [prop, val] of Object.entries(subset)) {
    if (!isSubset(val, superset[prop])) {
      return false;
    }
  }

  return true;
}

function matchWebArea(expectedId, expectedInfo) {
  return (iface, data) => {
    if (!data) {
      return false;
    }

    let textChangeElemID =
      data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier");

    return (
      iface.getAttributeValue("AXRole") == "AXWebArea" &&
      textChangeElemID == expectedId &&
      isSubset(expectedInfo, data)
    );
  };
}

function matchInput(expectedId, expectedInfo) {
  return (iface, data) => {
    if (!data) {
      return false;
    }

    return (
      iface.getAttributeValue("AXDOMIdentifier") == expectedId &&
      isSubset(expectedInfo, data)
    );
  };
}

async function synthKeyAndTestSelectionChanged(
  synthKey,
  synthEvent,
  expectedId,
  expectedSelectionString,
  expectedSelectionInfo
) {
  let selectionChangedEvents = Promise.all([
    waitForMacEventWithInfo(
      "AXSelectedTextChanged",
      matchWebArea(expectedId, expectedSelectionInfo)
    ),
    waitForMacEventWithInfo(
      "AXSelectedTextChanged",
      matchInput(expectedId, expectedSelectionInfo)
    ),
  ]);

  EventUtils.synthesizeKey(synthKey, synthEvent);
  let [webareaEvent, inputEvent] = await selectionChangedEvents;
  is(
    inputEvent.data.AXTextChangeElement.getAttributeValue("AXDOMIdentifier"),
    expectedId,
    "Correct AXTextChangeElement"
  );

  let rangeString = inputEvent.macIface.getParameterizedAttributeValue(
    "AXStringForTextMarkerRange",
    inputEvent.data.AXSelectedTextMarkerRange
  );
  is(
    rangeString,
    expectedSelectionString,
    `selection has correct value (${expectedSelectionString})`
  );

  let rangeBounds = inputEvent.macIface.getParameterizedAttributeValue(
    "AXBoundsForTextMarkerRange",
    inputEvent.data.AXSelectedTextMarkerRange
  );

  ok(
    rangeBounds.origin && rangeBounds.size && rangeBounds.size[0],
    "Selection range has bounds"
  );

  is(
    webareaEvent.macIface.getAttributeValue("AXDOMIdentifier"),
    "body",
    "Input event target is top-level WebArea"
  );
  rangeString = webareaEvent.macIface.getParameterizedAttributeValue(
    "AXStringForTextMarkerRange",
    inputEvent.data.AXSelectedTextMarkerRange
  );
  is(
    rangeString,
    expectedSelectionString,
    `selection has correct value (${expectedSelectionString}) via top document`
  );

  return inputEvent;
}

function testSelectionEventLeftChar(event, expectedChar) {
  const selStart = event.macIface.getParameterizedAttributeValue(
    "AXStartTextMarkerForTextMarkerRange",
    event.data.AXSelectedTextMarkerRange
  );
  const selLeft = event.macIface.getParameterizedAttributeValue(
    "AXPreviousTextMarkerForTextMarker",
    selStart
  );
  const leftCharRange = event.macIface.getParameterizedAttributeValue(
    "AXTextMarkerRangeForUnorderedTextMarkers",
    [selLeft, selStart]
  );
  const leftCharString = event.macIface.getParameterizedAttributeValue(
    "AXStringForTextMarkerRange",
    leftCharRange
  );
  is(leftCharString, expectedChar, "Left character is correct");
}

function testSelectionEventLine(event, expectedLine) {
  const selStart = event.macIface.getParameterizedAttributeValue(
    "AXStartTextMarkerForTextMarkerRange",
    event.data.AXSelectedTextMarkerRange
  );
  const lineRange = event.macIface.getParameterizedAttributeValue(
    "AXLineTextMarkerRangeForTextMarker",
    selStart
  );
  const lineString = event.macIface.getParameterizedAttributeValue(
    "AXStringForTextMarkerRange",
    lineRange
  );
  is(lineString, expectedLine, "Line is correct");
}

async function synthKeyAndTestValueChanged(
  synthKey,
  synthEvent,
  expectedId,
  expectedTextSelectionId,
  expectedChangeValue,
  expectedEditType,
  expectedWordAtLeft
) {
  let valueChangedEvents = Promise.all([
    waitForMacEvent(
      "AXSelectedTextChanged",
      matchWebArea(expectedTextSelectionId, {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      })
    ),
    waitForMacEvent(
      "AXSelectedTextChanged",
      matchInput(expectedTextSelectionId, {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      })
    ),
    waitForMacEventWithInfo(
      "AXValueChanged",
      matchWebArea(expectedId, {
        AXTextStateChangeType: AXTextStateChangeTypeEdit,
        AXTextChangeValues: [
          {
            AXTextChangeValue: expectedChangeValue,
            AXTextEditType: expectedEditType,
          },
        ],
      })
    ),
    waitForMacEventWithInfo(
      "AXValueChanged",
      matchInput(expectedId, {
        AXTextStateChangeType: AXTextStateChangeTypeEdit,
        AXTextChangeValues: [
          {
            AXTextChangeValue: expectedChangeValue,
            AXTextEditType: expectedEditType,
          },
        ],
      })
    ),
  ]);

  EventUtils.synthesizeKey(synthKey, synthEvent);
  let [, , webareaEvent, inputEvent] = await valueChangedEvents;

  testValueChangedEventData(
    webareaEvent.macIface,
    webareaEvent.data,
    expectedId,
    expectedChangeValue,
    expectedEditType,
    expectedWordAtLeft
  );
  testValueChangedEventData(
    inputEvent.macIface,
    inputEvent.data,
    expectedId,
    expectedChangeValue,
    expectedEditType,
    expectedWordAtLeft
  );
}

async function focusIntoInput(accDoc, inputId, innerContainerId) {
  let selectionId = innerContainerId ? innerContainerId : inputId;
  let input = getNativeInterface(accDoc, inputId);
  ok(!input.getAttributeValue("AXFocused"), "input is not focused");
  ok(input.isAttributeSettable("AXFocused"), "input is focusable");
  let events = Promise.all([
    waitForMacEvent(
      "AXFocusedUIElementChanged",
      iface => iface.getAttributeValue("AXDOMIdentifier") == inputId
    ),
    waitForMacEventWithInfo(
      "AXSelectedTextChanged",
      matchWebArea(selectionId, {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      })
    ),
    waitForMacEventWithInfo(
      "AXSelectedTextChanged",
      matchInput(selectionId, {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      })
    ),
  ]);
  input.setAttributeValue("AXFocused", true);
  await events;
}

async function focusIntoInputAndType(accDoc, inputId, innerContainerId) {
  let selectionId = innerContainerId ? innerContainerId : inputId;
  await focusIntoInput(accDoc, inputId, innerContainerId);

  async function testTextInput(
    synthKey,
    expectedChangeValue,
    expectedWordAtLeft
  ) {
    await synthKeyAndTestValueChanged(
      synthKey,
      null,
      inputId,
      selectionId,
      expectedChangeValue,
      AXTextEditTypeTyping,
      expectedWordAtLeft
    );
  }

  await testTextInput("h", "h", "h");
  await testTextInput("e", "e", "he");
  await testTextInput("l", "l", "hel");
  await testTextInput("l", "l", "hell");
  await testTextInput("o", "o", "hello");
  await testTextInput(" ", " ", "hello");
  // You would expect this to be useless but this is what VO
  // consumes. I guess it concats the inserted text data to the
  // word to the left of the marker.
  await testTextInput("w", "w", " ");
  await testTextInput("o", "o", "wo");
  await testTextInput("r", "r", "wor");
  await testTextInput("l", "l", "worl");
  await testTextInput("d", "d", "world");

  async function testTextDelete(expectedChangeValue, expectedWordAtLeft) {
    await synthKeyAndTestValueChanged(
      "KEY_Backspace",
      null,
      inputId,
      selectionId,
      expectedChangeValue,
      AXTextEditTypeDelete,
      expectedWordAtLeft
    );
  }

  await testTextDelete("d", "worl");
  await testTextDelete("l", "wor");

  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    null,
    selectionId,
    "",
    {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
      AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
    }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    { shiftKey: true },
    selectionId,
    "o",
    {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
      AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
      AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
    }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    { shiftKey: true },
    selectionId,
    "wo",
    {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
      AXTextSelectionDirection: AXTextSelectionDirectionPrevious,
      AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
    }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    null,
    selectionId,
    "",
    { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    { shiftKey: true, metaKey: true },
    selectionId,
    "hello ",
    {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
      AXTextSelectionDirection: AXTextSelectionDirectionBeginning,
      AXTextSelectionGranularity: AXTextSelectionGranularityLine,
    }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowLeft",
    null,
    selectionId,
    "",
    { AXTextStateChangeType: AXTextStateChangeTypeSelectionMove }
  );
  await synthKeyAndTestSelectionChanged(
    "KEY_ArrowRight",
    { shiftKey: true, altKey: true },
    selectionId,
    "hello",
    {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionExtend,
      AXTextSelectionDirection: AXTextSelectionDirectionNext,
      AXTextSelectionGranularity: AXTextSelectionGranularityWord,
    }
  );
}

// Test text input
addAccessibleTask(
  `<a href="#">link</a> <input id="input">`,
  async (browser, accDoc) => {
    await focusIntoInputAndType(accDoc, "input");
  },
  { topLevel: true, iframe: true, remoteIframe: true }
);

// Test content editable
addAccessibleTask(
  `<div id="input" contentEditable="true" tabindex="0" role="textbox" aria-multiline="true"><div id="inner"><br /></div></div>`,
  async (browser, accDoc) => {
    const inner = getNativeInterface(accDoc, "inner");
    const editableAncestor = inner.getAttributeValue("AXEditableAncestor");
    is(
      editableAncestor.getAttributeValue("AXDOMIdentifier"),
      "input",
      "Editable ancestor is input"
    );
    await focusIntoInputAndType(accDoc, "input");
  }
);

// Test input that gets role::EDITCOMBOBOX
addAccessibleTask(`<input type="text" id="box">`, async (browser, accDoc) => {
  const box = getNativeInterface(accDoc, "box");
  const editableAncestor = box.getAttributeValue("AXEditableAncestor");
  is(
    editableAncestor.getAttributeValue("AXDOMIdentifier"),
    "box",
    "Editable ancestor is box itself"
  );
  await focusIntoInputAndType(accDoc, "box");
});

// Test multiline caret control in a text area
addAccessibleTask(
  `<textarea id="input" cols="15">one two three four five six seven eight</textarea>`,
  async (browser, accDoc) => {
    await focusIntoInput(accDoc, "input");

    await synthKeyAndTestSelectionChanged("KEY_ArrowRight", null, "input", "", {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      AXTextSelectionDirection: AXTextSelectionDirectionNext,
      AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
    });

    await synthKeyAndTestSelectionChanged("KEY_ArrowDown", null, "input", "", {
      AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
      AXTextSelectionDirection: AXTextSelectionDirectionNext,
      AXTextSelectionGranularity: AXTextSelectionGranularityLine,
    });

    await synthKeyAndTestSelectionChanged(
      "KEY_ArrowLeft",
      { metaKey: true },
      "input",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionBeginning,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );

    await synthKeyAndTestSelectionChanged(
      "KEY_ArrowRight",
      { metaKey: true },
      "input",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionEnd,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
  },
  { topLevel: true, iframe: true, remoteIframe: true }
);

/**
 * Test that the caret returns the correct marker when it is positioned after
 * the last character (to facilitate appending text).
 */
addAccessibleTask(
  `<input id="input" value="abc">`,
  async function (browser, docAcc) {
    await focusIntoInput(docAcc, "input");

    let event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowRight",
      null,
      "input",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
      }
    );
    testSelectionEventLeftChar(event, "a");
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowRight",
      null,
      "input",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
      }
    );
    testSelectionEventLeftChar(event, "b");
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowRight",
      null,
      "input",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityCharacter,
      }
    );
    testSelectionEventLeftChar(event, "c");
  },
  { chrome: true, topLevel: true }
);

/**
 * Test that the caret returns the correct line when the caret is at the start
 * of the line.
 */
addAccessibleTask(
  `
<textarea id="hard">ab
cd
ef

gh
</textarea>
<div role="textbox" id="wrapped" contenteditable style="width: 1ch;">a b c</div>
  `,
  async function (browser, docAcc) {
    let hard = getNativeInterface(docAcc, "hard");
    await focusIntoInput(docAcc, "hard");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 0);
    let event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "hard",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "cd");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 1);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "hard",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "ef");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 2);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "hard",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 3);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "hard",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "gh");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 4);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "hard",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "");
    is(hard.getAttributeValue("AXInsertionPointLineNumber"), 5);

    let wrapped = getNativeInterface(docAcc, "wrapped");
    await focusIntoInput(docAcc, "wrapped");
    is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 0);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "wrapped",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "b ");
    is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 1);
    event = await synthKeyAndTestSelectionChanged(
      "KEY_ArrowDown",
      null,
      "wrapped",
      "",
      {
        AXTextStateChangeType: AXTextStateChangeTypeSelectionMove,
        AXTextSelectionDirection: AXTextSelectionDirectionNext,
        AXTextSelectionGranularity: AXTextSelectionGranularityLine,
      }
    );
    testSelectionEventLine(event, "c");
    is(wrapped.getAttributeValue("AXInsertionPointLineNumber"), 2);
  },
  { chrome: true, topLevel: true }
);
