Components.utils.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: function(aRules)
  {
    aRules.value = [ROLE_HEADING];
    return aRules.value.length;
  },

  preFilter: PREFILTER_INVISIBLE,

  match: function(aAccessible)
  {
    return FILTER_MATCH;
  },

  QueryInterface: XPCOMUtils.generateQI([nsIAccessibleTraversalRule])
}

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

  preFilter: PREFILTER_INVISIBLE | PREFILTER_ARIA_HIDDEN | PREFILTER_TRANSPARENT,

  match: function(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)
{
  this.__proto__ = new invokerChecker(EVENT_VIRTUALCURSOR_CHANGED, aDocAcc);

  this.match = function VCChangedChecker_check(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));

    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")
  ];
}

/**
 * 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.
 */
function setVCPosInvoker(aDocAcc, aPivotMoveMethod, aRule, aIdOrNameOrAcc)
{
  var expectMove = (aIdOrNameOrAcc != false);
  this.invoke = function virtualCursorChangedInvoker_invoke()
  {
    VCChangedChecker.
      storePreviousPosAndOffset(aDocAcc.virtualCursor);
    if (aPivotMoveMethod && aRule) {
      var moved = aDocAcc.virtualCursor[aPivotMoveMethod](aRule);
      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)
    ];
  } 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.
 */
function setVCTextInvoker(aDocAcc, aPivotMoveMethod, aBoundary, aTextOffsets, aIdOrNameOrAcc)
{
  var expectMove = (aIdOrNameOrAcc != false);
  this.invoke = function virtualCursorChangedInvoker_invoke()
  {
    VCChangedChecker.storePreviousPosAndOffset(aDocAcc.virtualCursor);
    SimpleTest.info(aDocAcc.virtualCursor.position);
    var moved = aDocAcc.virtualCursor[aPivotMoveMethod](aBoundary);
    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)
    ];
  } 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)
{
  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')
    ];
  } 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 (var i = 1; i < aSequence.length; i++) {
    var 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 (var i = aSequence.length-2; i >= 0; i--) {
    var 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));

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

  // 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.parentNode.removeChild(aPosNode);
  };

  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 = gAccRetrieval.createAccessiblePivot(getAccessible(aRootNode));
  this.invoke = function removeVCRootInvoker_invoke()
  {
    this.pivot.position = this.pivot.root.firstChild;
    aRootNode.parentNode.removeChild(aRootNode);
  };

  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");
}
