ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");

// //////////////////////////////////////////////////////////////////////////////
// Constants

const PREFILTER_INVISIBLE = nsIAccessibleTraversalRule.PREFILTER_INVISIBLE;
const PREFILTER_ARIA_HIDDEN = nsIAccessibleTraversalRule.PREFILTER_ARIA_HIDDEN;
const PREFILTER_TRANSPARENT = nsIAccessibleTraversalRule.PREFILTER_TRANSPARENT;
const FILTER_MATCH = nsIAccessibleTraversalRule.FILTER_MATCH;
const FILTER_IGNORE = nsIAccessibleTraversalRule.FILTER_IGNORE;
const FILTER_IGNORE_SUBTREE = nsIAccessibleTraversalRule.FILTER_IGNORE_SUBTREE;
const CHAR_BOUNDARY = nsIAccessiblePivot.CHAR_BOUNDARY;
const WORD_BOUNDARY = nsIAccessiblePivot.WORD_BOUNDARY;

const NS_ERROR_NOT_IN_TREE = 0x80780026;
const NS_ERROR_INVALID_ARG = 0x80070057;

// //////////////////////////////////////////////////////////////////////////////
// Traversal rules

/**
 * Rule object to traverse all focusable nodes and text nodes.
 */
var HeadersTraversalRule =
{
  getMatchRoles(aRules) {
    aRules.value = [ROLE_HEADING];
    return aRules.value.length;
  },

  preFilter: PREFILTER_INVISIBLE,

  match(aAccessible) {
    return FILTER_MATCH;
  },

  QueryInterface: XPCOMUtils.generateQI([nsIAccessibleTraversalRule])
};

/**
 * Traversal rule for all focusable nodes or leafs.
 */
var ObjectTraversalRule =
{
  getMatchRoles(aRules) {
    aRules.value = [];
    return 0;
  },

  preFilter: PREFILTER_INVISIBLE | PREFILTER_ARIA_HIDDEN | PREFILTER_TRANSPARENT,

  match(aAccessible) {
    var rv = FILTER_IGNORE;
    var role = aAccessible.role;
    if (hasState(aAccessible, STATE_FOCUSABLE) &&
        (role != ROLE_DOCUMENT && role != ROLE_INTERNAL_FRAME))
      rv = FILTER_IGNORE_SUBTREE | FILTER_MATCH;
    else if (aAccessible.childCount == 0 &&
             role != ROLE_STATICTEXT && aAccessible.name.trim())
      rv = FILTER_MATCH;

    return rv;
  },

  QueryInterface: XPCOMUtils.generateQI([nsIAccessibleTraversalRule])
};

// //////////////////////////////////////////////////////////////////////////////
// Virtual state invokers and checkers

/**
 * A checker for virtual cursor changed events.
 */
function VCChangedChecker(aDocAcc, aIdOrNameOrAcc, aTextOffsets, aPivotMoveMethod,
                          aIsFromUserInput) {
  this.__proto__ = new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc);

  this.match = function VCChangedChecker_match(aEvent) {
    var event = null;
    try {
      event = aEvent.QueryInterface(nsIAccessibleVirtualCursorChangeEvent);
    } catch (e) {
      return false;
    }

    var expectedReason = VCChangedChecker.methodReasonMap[aPivotMoveMethod] ||
      nsIAccessiblePivot.REASON_NONE;

    return event.reason == expectedReason;
  };

  this.check = function VCChangedChecker_check(aEvent) {
    SimpleTest.info("VCChangedChecker_check");

    var event = null;
    try {
      event = aEvent.QueryInterface(nsIAccessibleVirtualCursorChangeEvent);
    } catch (e) {
      SimpleTest.ok(false, "Does not support correct interface: " + e);
    }

    var position = aDocAcc.virtualCursor.position;
    var idMatches = position && position.DOMNode.id == aIdOrNameOrAcc;
    var nameMatches = position && position.name == aIdOrNameOrAcc;
    var accMatches = position == aIdOrNameOrAcc;

    SimpleTest.ok(idMatches || nameMatches || accMatches, "id or name matches",
                  "expecting " + aIdOrNameOrAcc + ", got '" +
                  prettyName(position));

    SimpleTest.is(aEvent.isFromUserInput, aIsFromUserInput,
                  "Expected user input is " + aIsFromUserInput + "\n");

    if (aTextOffsets) {
      SimpleTest.is(aDocAcc.virtualCursor.startOffset, aTextOffsets[0],
                    "wrong start offset");
      SimpleTest.is(aDocAcc.virtualCursor.endOffset, aTextOffsets[1],
                    "wrong end offset");
    }

    var prevPosAndOffset = VCChangedChecker.
      getPreviousPosAndOffset(aDocAcc.virtualCursor);

    if (prevPosAndOffset) {
      SimpleTest.is(event.oldAccessible, prevPosAndOffset.position,
                    "previous position does not match");
      SimpleTest.is(event.oldStartOffset, prevPosAndOffset.startOffset,
                    "previous start offset does not match");
      SimpleTest.is(event.oldEndOffset, prevPosAndOffset.endOffset,
                    "previous end offset does not match");
    }
  };
}

VCChangedChecker.prevPosAndOffset = {};

VCChangedChecker.storePreviousPosAndOffset =
  function storePreviousPosAndOffset(aPivot) {
  VCChangedChecker.prevPosAndOffset[aPivot] =
    {position: aPivot.position,
     startOffset: aPivot.startOffset,
     endOffset: aPivot.endOffset};
};

VCChangedChecker.getPreviousPosAndOffset =
  function getPreviousPosAndOffset(aPivot) {
  return VCChangedChecker.prevPosAndOffset[aPivot];
};

VCChangedChecker.methodReasonMap = {
  "moveNext": nsIAccessiblePivot.REASON_NEXT,
  "movePrevious": nsIAccessiblePivot.REASON_PREV,
  "moveFirst": nsIAccessiblePivot.REASON_FIRST,
  "moveLast": nsIAccessiblePivot.REASON_LAST,
  "setTextRange": nsIAccessiblePivot.REASON_TEXT,
  "moveNextByText": nsIAccessiblePivot.REASON_TEXT,
  "movePreviousByText": nsIAccessiblePivot.REASON_TEXT,
  "moveToPoint": nsIAccessiblePivot.REASON_POINT
};

/**
 * Set a text range in the pivot and wait for virtual cursor change event.
 *
 * @param aDocAcc         [in] document that manages the virtual cursor
 * @param aTextAccessible [in] accessible to set to virtual cursor's position
 * @param aTextOffsets    [in] start and end offsets of text range to set in
 *                        virtual cursor.
 */
function setVCRangeInvoker(aDocAcc, aTextAccessible, aTextOffsets) {
  this.invoke = function virtualCursorChangedInvoker_invoke() {
    VCChangedChecker.
      storePreviousPosAndOffset(aDocAcc.virtualCursor);
    SimpleTest.info(prettyName(aTextAccessible) + " " + aTextOffsets);
    aDocAcc.virtualCursor.setTextRange(aTextAccessible,
                                       aTextOffsets[0],
                                       aTextOffsets[1]);
  };

  this.getID = function setVCRangeInvoker_getID() {
    return "Set offset in " + prettyName(aTextAccessible) +
      " to (" + aTextOffsets[0] + ", " + aTextOffsets[1] + ")";
  };

  this.eventSeq = [
    new VCChangedChecker(aDocAcc, aTextAccessible, aTextOffsets, "setTextRange", true)
  ];
}

/**
 * Move the pivot and wait for virtual cursor change event.
 *
 * @param aDocAcc          [in] document that manages the virtual cursor
 * @param aPivotMoveMethod [in] method to test (ie. "moveNext", "moveFirst", etc.)
 * @param aRule            [in] traversal rule object
 * @param aIdOrNameOrAcc   [in] id, accessible or accessible name to expect
 *                         virtual cursor to land on after performing move method.
 *                         false if no move is expected.
 * @param aIsFromUserInput [in] set user input flag when invoking method, and
 *                         expect it in the event.
 */
function setVCPosInvoker(aDocAcc, aPivotMoveMethod, aRule, aIdOrNameOrAcc,
                         aIsFromUserInput) {
  // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
  var expectMove = aIdOrNameOrAcc != false;
  this.invoke = function virtualCursorChangedInvoker_invoke() {
    VCChangedChecker.
      storePreviousPosAndOffset(aDocAcc.virtualCursor);
    if (aPivotMoveMethod && aRule) {
      var moved = false;
      switch (aPivotMoveMethod) {
        case "moveFirst":
        case "moveLast":
          moved = aDocAcc.virtualCursor[aPivotMoveMethod](aRule,
            aIsFromUserInput === undefined ? true : aIsFromUserInput);
          break;
        case "moveNext":
        case "movePrevious":
          moved = aDocAcc.virtualCursor[aPivotMoveMethod](aRule,
            aDocAcc.virtualCursor.position, false,
            aIsFromUserInput === undefined ? true : aIsFromUserInput);
          break;
      }
      SimpleTest.is(!!moved, !!expectMove,
                    "moved pivot with " + aPivotMoveMethod +
                    " to " + aIdOrNameOrAcc);
    } else {
      aDocAcc.virtualCursor.position = getAccessible(aIdOrNameOrAcc);
    }
  };

  this.getID = function setVCPosInvoker_getID() {
    return "Do " + (expectMove ? "" : "no-op ") + aPivotMoveMethod;
  };

  if (expectMove) {
    this.eventSeq = [
      new VCChangedChecker(aDocAcc, aIdOrNameOrAcc, null, aPivotMoveMethod,
        aIsFromUserInput === undefined ? !!aPivotMoveMethod : aIsFromUserInput)
    ];
  } else {
    this.eventSeq = [];
    this.unexpectedEventSeq = [
      new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc)
    ];
  }
}

/**
 * Move the pivot by text and wait for virtual cursor change event.
 *
 * @param aDocAcc          [in] document that manages the virtual cursor
 * @param aPivotMoveMethod [in] method to test (ie. "moveNext", "moveFirst", etc.)
 * @param aBoundary        [in] boundary constant
 * @param aTextOffsets     [in] start and end offsets of text range to set in
 *                         virtual cursor.
 * @param aIdOrNameOrAcc   [in] id, accessible or accessible name to expect
 *                         virtual cursor to land on after performing move method.
 *                         false if no move is expected.
 * @param aIsFromUserInput [in] set user input flag when invoking method, and
 *                         expect it in the event.
 */
function setVCTextInvoker(aDocAcc, aPivotMoveMethod, aBoundary, aTextOffsets,
                          aIdOrNameOrAcc, aIsFromUserInput) {
  // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
  var expectMove = aIdOrNameOrAcc != false;
  this.invoke = function virtualCursorChangedInvoker_invoke() {
    VCChangedChecker.storePreviousPosAndOffset(aDocAcc.virtualCursor);
    SimpleTest.info(aDocAcc.virtualCursor.position);
    var moved = aDocAcc.virtualCursor[aPivotMoveMethod](aBoundary,
      aIsFromUserInput === undefined);
    SimpleTest.is(!!moved, !!expectMove,
                  "moved pivot by text with " + aPivotMoveMethod +
                  " to " + aIdOrNameOrAcc);
  };

  this.getID = function setVCPosInvoker_getID() {
    return "Do " + (expectMove ? "" : "no-op ") + aPivotMoveMethod + " in " +
      prettyName(aIdOrNameOrAcc) + ", " + boundaryToString(aBoundary) +
      ", [" + aTextOffsets + "]";
  };

  if (expectMove) {
    this.eventSeq = [
      new VCChangedChecker(aDocAcc, aIdOrNameOrAcc, aTextOffsets, aPivotMoveMethod,
        aIsFromUserInput === undefined ? true : aIsFromUserInput)
    ];
  } else {
    this.eventSeq = [];
    this.unexpectedEventSeq = [
      new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc)
    ];
  }
}


/**
 * Move the pivot to the position under the point.
 *
 * @param aDocAcc        [in] document that manages the virtual cursor
 * @param aX             [in] screen x coordinate
 * @param aY             [in] screen y coordinate
 * @param aIgnoreNoMatch [in] don't unset position if no object was found at
 *                       point.
 * @param aRule          [in] traversal rule object
 * @param aIdOrNameOrAcc [in] id, accessible or accessible name to expect
 *                       virtual cursor to land on after performing move method.
 *                       false if no move is expected.
 */
function moveVCCoordInvoker(aDocAcc, aX, aY, aIgnoreNoMatch,
                            aRule, aIdOrNameOrAcc) {
  // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
  var expectMove = aIdOrNameOrAcc != false;
  this.invoke = function virtualCursorChangedInvoker_invoke() {
    VCChangedChecker.
      storePreviousPosAndOffset(aDocAcc.virtualCursor);
    var moved = aDocAcc.virtualCursor.moveToPoint(aRule, aX, aY,
                                                  aIgnoreNoMatch);
    SimpleTest.ok((expectMove && moved) || (!expectMove && !moved),
                  "moved pivot");
  };

  this.getID = function setVCPosInvoker_getID() {
    return "Do " + (expectMove ? "" : "no-op ") + "moveToPoint " + aIdOrNameOrAcc;
  };

  if (expectMove) {
    this.eventSeq = [
      new VCChangedChecker(aDocAcc, aIdOrNameOrAcc, null, "moveToPoint", true)
    ];
  } else {
    this.eventSeq = [];
    this.unexpectedEventSeq = [
      new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc)
    ];
  }
}

/**
 * Change the pivot modalRoot
 *
 * @param aDocAcc         [in] document that manages the virtual cursor
 * @param aModalRootAcc   [in] accessible of the modal root, or null
 * @param aExpectedResult [in] error result expected. 0 if expecting success
 */
function setModalRootInvoker(aDocAcc, aModalRootAcc, aExpectedResult) {
  this.invoke = function setModalRootInvoker_invoke() {
    var errorResult = 0;
    try {
      aDocAcc.virtualCursor.modalRoot = aModalRootAcc;
    } catch (x) {
      SimpleTest.ok(
        x.result, "Unexpected exception when changing modal root: " + x);
      errorResult = x.result;
    }

    SimpleTest.is(errorResult, aExpectedResult,
                  "Did not get expected result when changing modalRoot");
  };

  this.getID = function setModalRootInvoker_getID() {
    return "Set modalRoot to " + prettyName(aModalRootAcc);
  };

  this.eventSeq = [];
  this.unexpectedEventSeq = [
    new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc)
  ];
}

/**
 * Add invokers to a queue to test a rule and an expected sequence of element ids
 * or accessible names for that rule in the given document.
 *
 * @param aQueue     [in] event queue in which to push invoker sequence.
 * @param aDocAcc    [in] the managing document of the virtual cursor we are
 *                   testing
 * @param aRule      [in] the traversal rule to use in the invokers
 * @param aModalRoot [in] a modal root to use in this traversal sequence
 * @param aSequence  [in] a sequence of accessible names or element ids to expect
 *                   with the given rule in the given document
 */
function queueTraversalSequence(aQueue, aDocAcc, aRule, aModalRoot, aSequence) {
  aDocAcc.virtualCursor.position = null;

  // Add modal root (if any)
  aQueue.push(new setModalRootInvoker(aDocAcc, aModalRoot, 0));

  aQueue.push(new setVCPosInvoker(aDocAcc, "moveFirst", aRule, aSequence[0]));

  for (let i = 1; i < aSequence.length; i++) {
    let invoker =
      new setVCPosInvoker(aDocAcc, "moveNext", aRule, aSequence[i]);
    aQueue.push(invoker);
  }

  // No further more matches for given rule, expect no virtual cursor changes.
  aQueue.push(new setVCPosInvoker(aDocAcc, "moveNext", aRule, false));

  for (let i = aSequence.length - 2; i >= 0; i--) {
    let invoker =
      new setVCPosInvoker(aDocAcc, "movePrevious", aRule, aSequence[i]);
    aQueue.push(invoker);
  }

  // No previous more matches for given rule, expect no virtual cursor changes.
  aQueue.push(new setVCPosInvoker(aDocAcc, "movePrevious", aRule, false));

  aQueue.push(new setVCPosInvoker(aDocAcc, "moveLast", aRule,
                                  aSequence[aSequence.length - 1]));

  // No further more matches for given rule, expect no virtual cursor changes.
  aQueue.push(new setVCPosInvoker(aDocAcc, "moveNext", aRule, false));

  // set isFromUserInput to false, just to test..
  aQueue.push(new setVCPosInvoker(aDocAcc, "moveFirst", aRule, aSequence[0], false));

  // No previous more matches for given rule, expect no virtual cursor changes.
  aQueue.push(new setVCPosInvoker(aDocAcc, "movePrevious", aRule, false));

  // Remove modal root (if any).
  aQueue.push(new setModalRootInvoker(aDocAcc, null, 0));
}

/**
 * A checker for removing an accessible while the virtual cursor is on it.
 */
function removeVCPositionChecker(aDocAcc, aHiddenParentAcc) {
  this.__proto__ = new invokerChecker(EVENT_REORDER, aHiddenParentAcc);

  this.check = function removeVCPositionChecker_check(aEvent) {
    var errorResult = 0;
    try {
      aDocAcc.virtualCursor.moveNext(ObjectTraversalRule);
    } catch (x) {
      errorResult = x.result;
    }
    SimpleTest.is(
      errorResult, NS_ERROR_NOT_IN_TREE,
      "Expecting NOT_IN_TREE error when moving pivot from invalid position.");
  };
}

/**
 * Put the virtual cursor's position on an object, and then remove it.
 *
 * @param aDocAcc     [in] document that manages the virtual cursor
 * @param aPosNode    [in] DOM node to hide after virtual cursor's position is
 *                    set to it.
 */
function removeVCPositionInvoker(aDocAcc, aPosNode) {
  this.accessible = getAccessible(aPosNode);
  this.invoke = function removeVCPositionInvoker_invoke() {
    aDocAcc.virtualCursor.position = this.accessible;
    aPosNode.remove();
  };

  this.getID = function removeVCPositionInvoker_getID() {
    return "Bring virtual cursor to accessible, and remove its DOM node.";
  };

  this.eventSeq = [
    new removeVCPositionChecker(aDocAcc, this.accessible.parent)
  ];
}

/**
 * A checker for removing the pivot root and then calling moveFirst, and
 * checking that an exception is thrown.
 */
function removeVCRootChecker(aPivot) {
  this.__proto__ = new invokerChecker(EVENT_REORDER, aPivot.root.parent);

  this.check = function removeVCRootChecker_check(aEvent) {
    var errorResult = 0;
    try {
      aPivot.moveLast(ObjectTraversalRule);
    } catch (x) {
      errorResult = x.result;
    }
    SimpleTest.is(
      errorResult, NS_ERROR_NOT_IN_TREE,
      "Expecting NOT_IN_TREE error when moving pivot from invalid position.");
  };
}

/**
 * Create a pivot, remove its root, and perform an operation where the root is
 * needed.
 *
 * @param aRootNode [in] DOM node of which accessible will be the root of the
 *                       pivot. Should have more than one child.
 */
function removeVCRootInvoker(aRootNode) {
  this.pivot = gAccService.createAccessiblePivot(getAccessible(aRootNode));
  this.invoke = function removeVCRootInvoker_invoke() {
    this.pivot.position = this.pivot.root.firstChild;
    aRootNode.remove();
  };

  this.getID = function removeVCRootInvoker_getID() {
    return "Remove root of pivot from tree.";
  };

  this.eventSeq = [
    new removeVCRootChecker(this.pivot)
  ];
}

/**
 * A debug utility for writing proper sequences for queueTraversalSequence.
 */
function dumpTraversalSequence(aPivot, aRule) {
  var sequence = [];
  if (aPivot.moveFirst(aRule)) {
    do {
      sequence.push("'" + prettyName(aPivot.position) + "'");
    } while (aPivot.moveNext(aRule));
  }
  SimpleTest.info("\n[" + sequence.join(", ") + "]\n");
}
