/* 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";

/* exported createTextLeafPoint, DIRECTION_NEXT, DIRECTION_PREVIOUS,
 BOUNDARY_FLAG_DEFAULT, BOUNDARY_FLAG_INCLUDE_ORIGIN,
 BOUNDARY_FLAG_STOP_IN_EDITABLE, BOUNDARY_FLAG_SKIP_LIST_ITEM_MARKER,
 readablePoint, testPointEqual, textBoundaryGenerator, testBoundarySequence,
 isFinalValueCorrect, isFinalValueCorrect, testInsertText, testDeleteText,
 testCopyText, testPasteText, testCutText, testSetTextContents,
 textAttrRangesMatch */

// Load the shared-head file first.
Services.scriptloader.loadSubScript(
  "chrome://mochitests/content/browser/accessible/tests/browser/shared-head.js",
  this
);

// Loading and common.js from accessible/tests/mochitest/ for all tests, as
// well as promisified-events.js.

/* import-globals-from ../../mochitest/role.js */

loadScripts(
  { name: "common.js", dir: MOCHITESTS_DIR },
  { name: "text.js", dir: MOCHITESTS_DIR },
  { name: "role.js", dir: MOCHITESTS_DIR },
  { name: "promisified-events.js", dir: MOCHITESTS_DIR }
);

const DIRECTION_NEXT = Ci.nsIAccessibleTextLeafPoint.DIRECTION_NEXT;
const DIRECTION_PREVIOUS = Ci.nsIAccessibleTextLeafPoint.DIRECTION_PREVIOUS;

const BOUNDARY_FLAG_DEFAULT =
  Ci.nsIAccessibleTextLeafPoint.BOUNDARY_FLAG_DEFAULT;
const BOUNDARY_FLAG_INCLUDE_ORIGIN =
  Ci.nsIAccessibleTextLeafPoint.BOUNDARY_FLAG_INCLUDE_ORIGIN;
const BOUNDARY_FLAG_STOP_IN_EDITABLE =
  Ci.nsIAccessibleTextLeafPoint.BOUNDARY_FLAG_STOP_IN_EDITABLE;
const BOUNDARY_FLAG_SKIP_LIST_ITEM_MARKER =
  Ci.nsIAccessibleTextLeafPoint.BOUNDARY_FLAG_SKIP_LIST_ITEM_MARKER;

function createTextLeafPoint(acc, offset) {
  let accService = Cc["@mozilla.org/accessibilityService;1"].getService(
    nsIAccessibilityService
  );

  return accService.createTextLeafPoint(acc, offset);
}

// Converts an nsIAccessibleTextLeafPoint into a human/machine
// readable tuple with a readable accessible and the offset within it.
// For a point text leaf it would look like this: ["hello", 2],
// For a point in an empty input it would look like this ["input#name", 0]
function readablePoint(point) {
  const readableLeaf = acc => {
    let tagName = getAccessibleTagName(acc);
    if (tagName && !tagName.startsWith("_moz_generated")) {
      let domNodeID = getAccessibleDOMNodeID(acc);
      if (domNodeID) {
        return `${tagName}#${domNodeID}`;
      }
      return tagName;
    }

    return acc.name;
  };

  return [readableLeaf(point.accessible), point.offset];
}

function sequenceEqual(val, expected, msg) {
  Assert.deepEqual(val, expected, msg);
}

// eslint-disable-next-line camelcase
function sequenceEqualTodo(val, expected, msg) {
  todo_is(JSON.stringify(val), JSON.stringify(expected), msg);
}

function pointsEqual(pointA, pointB) {
  return (
    pointA.offset == pointB.offset && pointA.accessible == pointB.accessible
  );
}

function testPointEqual(pointA, pointB, msg) {
  is(pointA.offset, pointB.offset, `Offset mismatch - ${msg}`);
  is(pointA.accessible, pointB.accessible, `Accessible mismatch - ${msg}`);
}

function* textBoundaryGenerator(
  firstPoint,
  boundaryType,
  direction,
  flags = BOUNDARY_FLAG_DEFAULT
) {
  // Our start point should be inclusive of the given point.
  let nextLeafPoint = firstPoint.findBoundary(
    boundaryType,
    direction,
    flags | BOUNDARY_FLAG_INCLUDE_ORIGIN
  );
  let textLeafPoint = null;

  do {
    textLeafPoint = nextLeafPoint;
    yield textLeafPoint;
    nextLeafPoint = textLeafPoint.findBoundary(boundaryType, direction, flags);
  } while (!pointsEqual(textLeafPoint, nextLeafPoint));
}

// This function takes FindBoundary arguments and an expected sequence
// of boundary points formatted with readablePoint.
// For example, word starts would look like this:
// [["one two", 0], ["one two", 4], ["one two", 7]]
function testBoundarySequence(
  startPoint,
  boundaryType,
  direction,
  expectedSequence,
  msg,
  options = {}
) {
  let sequence = [
    ...textBoundaryGenerator(
      startPoint,
      boundaryType,
      direction,
      options.flags ? options.flags : BOUNDARY_FLAG_DEFAULT
    ),
  ];
  (options.todo ? sequenceEqualTodo : sequenceEqual)(
    sequence.map(readablePoint),
    expectedSequence,
    msg
  );
}

///////////////////////////////////////////////////////////////////////////////
// Editable text

async function waitForCopy(browser) {
  await BrowserTestUtils.waitForContentEvent(browser, "copy", false, () => {
    return true;
  });

  let clipboardData = await invokeContentTask(browser, [], async () => {
    let text = await content.navigator.clipboard.readText();
    return text;
  });

  return clipboardData;
}

async function isFinalValueCorrect(
  browser,
  acc,
  expectedTextLeafs,
  msg = "Value is correct"
) {
  let value =
    acc.role == ROLE_ENTRY
      ? acc.value
      : await invokeContentTask(browser, [], () => {
          return content.document.body.textContent;
        });

  let [before, text, after] = expectedTextLeafs;
  let finalValue =
    before && after && !text
      ? [before, after].join(" ")
      : [before, text, after].join("");

  is(value.replace("\xa0", " "), finalValue, msg);
}

function waitForTextChangeEvents(acc, eventSeq) {
  let events = eventSeq.map(eventType => {
    return [eventType, acc];
  });

  if (acc.role == ROLE_ENTRY) {
    events.push([EVENT_TEXT_VALUE_CHANGE, acc]);
  }

  return waitForEvents(events);
}

async function testSetTextContents(acc, text, staticContentOffset, events) {
  acc.QueryInterface(nsIAccessibleEditableText);
  let evtPromise = waitForTextChangeEvents(acc, events);
  acc.setTextContents(text);
  let evt = (await evtPromise)[0];
  evt.QueryInterface(nsIAccessibleTextChangeEvent);
  is(evt.start, staticContentOffset);
}

async function testInsertText(
  acc,
  textToInsert,
  insertOffset,
  staticContentOffset
) {
  acc.QueryInterface(nsIAccessibleEditableText);

  let evtPromise = waitForTextChangeEvents(acc, [EVENT_TEXT_INSERTED]);
  acc.insertText(textToInsert, staticContentOffset + insertOffset);
  let evt = (await evtPromise)[0];
  evt.QueryInterface(nsIAccessibleTextChangeEvent);
  is(evt.start, staticContentOffset + insertOffset);
}

async function testDeleteText(
  acc,
  startOffset,
  endOffset,
  staticContentOffset
) {
  acc.QueryInterface(nsIAccessibleEditableText);

  let evtPromise = waitForTextChangeEvents(acc, [EVENT_TEXT_REMOVED]);
  acc.deleteText(
    staticContentOffset + startOffset,
    staticContentOffset + endOffset
  );
  let evt = (await evtPromise)[0];
  evt.QueryInterface(nsIAccessibleTextChangeEvent);
  is(evt.start, staticContentOffset + startOffset);
}

async function testCopyText(
  acc,
  startOffset,
  endOffset,
  staticContentOffset,
  browser,
  aExpectedClipboard = null
) {
  acc.QueryInterface(nsIAccessibleEditableText);
  let copied = waitForCopy(browser);
  acc.copyText(
    staticContentOffset + startOffset,
    staticContentOffset + endOffset
  );
  let clipboardText = await copied;
  if (aExpectedClipboard != null) {
    is(clipboardText, aExpectedClipboard, "Correct text in clipboard");
  }
}

async function testPasteText(acc, insertOffset, staticContentOffset) {
  acc.QueryInterface(nsIAccessibleEditableText);
  let evtPromise = waitForTextChangeEvents(acc, [EVENT_TEXT_INSERTED]);
  acc.pasteText(staticContentOffset + insertOffset);

  let evt = (await evtPromise)[0];
  evt.QueryInterface(nsIAccessibleTextChangeEvent);
  // XXX: In non-headless mode pasting text produces several text leaves
  // and the offset is not what we expect.
  // is(evt.start, staticContentOffset + insertOffset);
}

async function testCutText(acc, startOffset, endOffset, staticContentOffset) {
  acc.QueryInterface(nsIAccessibleEditableText);
  let evtPromise = waitForTextChangeEvents(acc, [EVENT_TEXT_REMOVED]);
  acc.cutText(
    staticContentOffset + startOffset,
    staticContentOffset + endOffset
  );

  let evt = (await evtPromise)[0];
  evt.QueryInterface(nsIAccessibleTextChangeEvent);
  is(evt.start, staticContentOffset + startOffset);
}

/**
 * Check if the given ranges match the ranges in the given text accessible which
 * expose the given attribute names/values.
 */
function textAttrRangesMatch(acc, ranges, attrs) {
  acc.QueryInterface(nsIAccessibleText);
  let offset = 0;
  let expectedRanges = [...ranges];
  let charCount = acc.characterCount;
  while (offset < charCount) {
    let start = {};
    let end = {};
    let attributes = acc.getTextAttributes(false, offset, start, end);
    offset = end.value;
    let matched = true;
    for (const attr in attrs) {
      try {
        if (attributes.getStringProperty(attr) != attrs[attr]) {
          // Attribute value doesn't match.
          matched = false;
        }
      } catch (err) {
        // Attribute doesn't exist.
        matched = false;
      }
    }
    if (!matched) {
      continue;
    }
    let expected = expectedRanges.shift();
    if (!expected || expected[0] != start.value || expected[1] != end.value) {
      return false;
    }
  }

  return !expectedRanges.length;
}
