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

this.EXPORTED_SYMBOLS = ['JSTreeSelection'];

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cr = Components.results;
var Cu = Components.utils;

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

/**
 * Partial nsITreeSelection implementation so that we can have nsMsgDBViews that
 *  exist only for message display but do not need to be backed by a full
 *  tree view widget.  This could also hopefully be used for more xpcshell unit
 *  testing of the FolderDisplayWidget.  It might also be useful for creating
 *  transient selections when right-click selection happens.
 *
 * Our current limitations:
 * - We do not support any single selection modes.  This is mainly because we
 *   need to look at the box object for that and we don't want to do it.
 * - Timed selection.  Our expected consumers don't use it.
 *
 * Our current laziness:
 * - We aren't very precise about invalidation when it would be potentially
 *   complicated.  The theory is that if there is a tree box object, it's
 *   probably native and the XPConnect overhead is probably a lot more than
 *   any potential savings, at least for now when the tree display is
 *   generally C++ XPCOM backed rather than JS XPCOM backed.  Also, we
 *   aren't intended to actually be used with a real tree display; you should
 *   be using the C++ object in that case!
 *
 * If documentation is omitted for something, it is because we have little to
 *  add to the documentation of nsITreeSelection and really hope that our
 *  documentation tool will copy-down that documentation.
 *
 * This implementation attempts to mimic the behavior of nsTreeSelection.  In
 *  a few cases, this leads to potentially confusing actions.  I attempt to note
 *  when we are doing this and why we do it.
 *
 * Unit test is in mailnews/base/util/test_jsTreeSelection.js
 */
function JSTreeSelection(aTreeBoxObject) {
  this._treeBoxObject = aTreeBoxObject;

  this._currentIndex = null;
  this._shiftSelectPivot = null;
  this._ranges = [];
  this._count = 0;

  this._selectEventsSuppressed = false;
}
JSTreeSelection.prototype = {
  /**
   * The current nsITreeBoxObject, appropriately QueryInterfaced.  May be null.
   */
  _treeBoxObject: null,

  /**
   * Where the focus rectangle (that little dotted thing) shows up.  Just
   *  because something is focused does not mean it is actually selected.
   */
  _currentIndex: null,
  /**
   * The view index where the shift is anchored when it is not (conceptually)
   *  the same as _currentIndex.  This only happens when you perform a ranged
   *  selection.  In that case, the start index of the ranged selection becomes
   *  the shift pivot (and the _currentIndex becomes the end of the ranged
   *  selection.)
   * It gets cleared whenever the selection changes and it's not the result of
   *  a call to rangedSelect.
   */
  _shiftSelectPivot: null,
  /**
   * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping,
   *  non-adjacent 'tuples' sort in ascending order.
   */
  _ranges: [],
  /**
   * The number of currently selected rows.
   */
  _count: 0,

  // In the case of the stand-alone message window, there's no tree, but
  // there's a view.
  _view: null,

  get tree() {
    return this._treeBoxObject;
  },
  set tree(aTreeBoxObject) {
    this._treeBoxObject = aTreeBoxObject;
  },

  set view(aView) {
    this._view = aView;
  },
  /**
   * Although the nsITreeSelection documentation doesn't say, what this method
   *  is supposed to do is check if the seltype attribute on the XUL tree is any
   *  of the following: "single" (only a single row may be selected at a time,
   *  "cell" (a single cell may be selected), or "text" (the row gets selected
   *  but only the primary column shows up as selected.)
   *
   * @return false because we don't support single-selection.
   */
  get single() {
    return false;
  },

  _updateCount: function JSTreeSelection__updateCount() {
    this._count = 0;
    for (let [low, high] of this._ranges) {
      this._count += high - low + 1;
    }
  },

  get count() {
    return this._count;
  },

  isSelected: function JSTreeSelection_isSelected(aViewIndex) {
    for (let [low, high] of this._ranges) {
      if (aViewIndex >= low && aViewIndex <= high)
        return true;
    }
    return false;
  },

  /**
   * Select the given row.  It does nothing if that row was already selected.
   */
  select: function JSTreeSelection_select(aViewIndex) {
    // current index will provide our effective shift pivot
    this._shiftSelectPivot = null;
    this.currentIndex = aViewIndex;

    if (this._count == 1 && this._ranges[0][0] == aViewIndex)
      return;

    this._count = 1;
    this._ranges = [[aViewIndex, aViewIndex]];

    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();

    this._fireSelectionChanged();
  },

  timedSelect: function JSTreeSelection_timedSelect(aIndex, aDelay) {
    throw new Error("We do not implement timed selection.");
  },

  toggleSelect: function JSTreeSelection_toggleSelect(aIndex) {
    this.currentIndex = aIndex;
    // If nothing's selected, select aIndex
    if (this._count == 0) {
      this._count = 1;
      this._ranges = [[aIndex, aIndex]];
    }
    else for (let [iTupe, [low, high]] of this._ranges.entries()) {
      // below the range? add it to the existing range or create a new one
      if (aIndex < low) {
        this._count++;
        // is it just below an existing range? (range fusion only happens in the
        //  high case, not here.)
        if (aIndex == low - 1) {
          this._ranges[iTupe][0] = aIndex;
          break;
        }
        // then it gets its own range
        this._ranges.splice(iTupe, 0, [aIndex, aIndex]);
        break;
      }
      // in the range?  will need to either nuke, shrink, or split the range to
      //  remove it
      if (aIndex >= low && aIndex <= high) {
        this._count--;
        // nuke
        if (aIndex == low && aIndex == high)
          this._ranges.splice(iTupe, 1);
        // lower shrink
        else if (aIndex == low)
          this._ranges[iTupe][0] = aIndex + 1;
        // upper shrink
        else if (aIndex == high)
          this._ranges[iTupe][1] = aIndex - 1;
        // split
        else
          this._ranges.splice(iTupe, 1, [low, aIndex - 1], [aIndex + 1, high]);
        break;
      }
      // just above the range?  fuse into the range, and possibly the next
      //  range up.
      if (aIndex == high + 1) {
        this._count++;
        // see if there is another range and there was just a gap of one between
        //  the two ranges.
        if ((iTupe + 1 < this._ranges.length) &&
            (this._ranges[iTupe+1][0] == aIndex + 1)) {
          // yes, merge the ranges
          this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe+1][1]]);
          break;
        }
        // nope, no merge required, just update the range
        this._ranges[iTupe][1] = aIndex;
        break;
      }
      // otherwise we need to keep going
    }

    if (this._treeBoxObject)
      this._treeBoxObject.invalidateRow(aIndex);
    this._fireSelectionChanged();
  },

  /**
   * @param aRangeStart If omitted, it implies a shift-selection is happening,
   *     in which case we use _shiftSelectPivot as the start if we have it,
   *     _currentIndex if we don't, and if we somehow didn't have a
   *     _currentIndex, we use the range end.
   * @param aRangeEnd Just the inclusive end of the range.
   * @param aAugment Does this set a new selection or should it be merged with
   *     the existing selection?
   */
  rangedSelect: function JSTreeSelection_rangedSelect(aRangeStart, aRangeEnd,
                                                      aAugment) {
    if (aRangeStart == -1) {
      if (this._shiftSelectPivot != null)
        aRangeStart = this._shiftSelectPivot;
      else if (this._currentIndex != null)
        aRangeStart = this._currentIndex;
      else
        aRangeStart = aRangeEnd;
    }

    this._shiftSelectPivot = aRangeStart;
    this.currentIndex = aRangeEnd;

    // enforce our ordering constraint for our ranges
    if (aRangeStart > aRangeEnd)
      [aRangeStart, aRangeEnd] = [aRangeEnd, aRangeStart];

    // if we're not augmenting, then this is really easy.
    if (!aAugment) {
      this._count = aRangeEnd - aRangeStart + 1;
      this._ranges = [[aRangeStart, aRangeEnd]];
      if (this._treeBoxObject)
        this._treeBoxObject.invalidate();
      this._fireSelectionChanged();
      return;
    }

    // Iterate over our existing set of ranges, finding the 'range' of ranges
    //  that our new range overlaps or simply obviates.
    // Overlap variables track blocks we need to keep some part of, Nuke
    //  variables are for blocks that get spliced out.  For our purposes, all
    //  overlap blocks are also nuke blocks.
    let lowOverlap, lowNuke, highNuke, highOverlap;
    // in case there is no overlap, also figure an insertionPoint
    let insertionPoint = this._ranges.length; // default to the end
    for (let [iTupe, [low, high]] of this._ranges.entries()) {
      // If it's completely include the range, it should be nuked
      if (aRangeStart <= low && aRangeEnd >= high) {
        if (lowNuke == null) // only the first one we see is the low one
          lowNuke = iTupe;
        highNuke = iTupe;
      }
      // If our new range start is inside a range or is adjacent, it's overlap
      if (aRangeStart >= low - 1 && aRangeStart <= high + 1 &&
          lowOverlap == null)
        lowOverlap = lowNuke = highNuke = iTupe;
      // If our new range ends inside a range or is adjacent, it's overlap
      if (aRangeEnd >= low - 1 && aRangeEnd <= high + 1) {
        highOverlap = highNuke = iTupe;
        if (lowNuke == null)
          lowNuke = iTupe;
      }

      // we're done when no more overlap is possible
      if (aRangeEnd < low) {
        insertionPoint = iTupe;
        break;
      }
    }

    if (lowOverlap != null)
      aRangeStart = Math.min(aRangeStart, this._ranges[lowOverlap][0]);
    if (highOverlap != null)
      aRangeEnd = Math.max(aRangeEnd, this._ranges[highOverlap][1]);
    if (lowNuke != null)
      this._ranges.splice(lowNuke, highNuke - lowNuke + 1,
                          [aRangeStart, aRangeEnd]);
    else
      this._ranges.splice(insertionPoint, 0, [aRangeStart, aRangeEnd]);

    this._updateCount();
    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
    this._fireSelectionChanged();
  },

  /**
   * This is basically RangedSelect but without insertion of a new range and we
   *  don't need to worry about adjacency.
   * Oddly, nsTreeSelection doesn't fire a selection changed event here...
   */
  clearRange: function JSTreeSelection_clearRange(aRangeStart, aRangeEnd) {
    // Iterate over our existing set of ranges, finding the 'range' of ranges
    //  that our clear range overlaps or simply obviates.
    // Overlap variables track blocks we need to keep some part of, Nuke
    //  variables are for blocks that get spliced out.  For our purposes, all
    //  overlap blocks are also nuke blocks.
    let lowOverlap, lowNuke, highNuke, highOverlap;
    for (let [iTupe, [low, high]] of this._ranges.entries()) {
      // If we completely include the range, it should be nuked
      if (aRangeStart <= low && aRangeEnd >= high) {
        if (lowNuke == null) // only the first one we see is the low one
          lowNuke = iTupe;
        highNuke = iTupe;
      }
      // If our new range start is inside a range, it's nuke and maybe overlap
      if (aRangeStart >= low && aRangeStart <= high && lowNuke == null) {
        lowNuke = highNuke = iTupe;
        // it's only overlap if we don't match at the low end
        if (aRangeStart > low)
          lowOverlap = iTupe;
      }
      // If our new range ends inside a range, it's nuke and maybe overlap
      if (aRangeEnd >= low && aRangeEnd <= high) {
        highNuke = iTupe;
        // it's only overlap if we don't match at the high end
        if (aRangeEnd < high)
          highOverlap = iTupe;
        if (lowNuke == null)
          lowNuke = iTupe;
      }

      // we're done when no more overlap is possible
      if (aRangeEnd < low)
        break;
    }
    // nothing to do since there's nothing to nuke
    if (lowNuke == null)
      return;
    let args = [lowNuke, highNuke - lowNuke + 1];
    if (lowOverlap != null)
      args.push([this._ranges[lowOverlap][0], aRangeStart - 1]);
    if (highOverlap != null)
      args.push([aRangeEnd + 1, this._ranges[highOverlap][1]]);
    this._ranges.splice.apply(this._ranges, args);

    this._updateCount();
    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
    // note! nsTreeSelection doesn't fire a selection changed event, so neither
    //  do we, but it seems like we should
  },

  /**
   * nsTreeSelection always fires a select notification when the range is
   *  cleared, even if there is no effective chance in selection.
   */
  clearSelection: function JSTreeSelection_clearSelection() {
    this._shiftSelectPivot = null;
    this._count = 0;
    this._ranges = [];
    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
    this._fireSelectionChanged();
  },

  /**
   * Not even nsTreeSelection implements this.
   */
  invertSelection: function JSTreeSelection_invertSelection() {
    throw new Error("Who really was going to use this?");
  },

  /**
   * Select all with no rows is a no-op, otherwise we select all and notify.
   */
  selectAll: function JSTreeSelection_selectAll() {
    if (!this._view)
      return;

    let view = this._view;
    let rowCount = view.rowCount;

    // no-ops-ville
    if (!rowCount)
      return;

    this._count = rowCount;
    this._ranges = [[0, rowCount - 1]];

    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
    this._fireSelectionChanged();
  },

  getRangeCount: function JSTreeSelection_getRangeCount() {
    return this._ranges.length;
  },
  getRangeAt: function JSTreeSelection_getRangeAt(aRangeIndex, aMinObj,
                                                  aMaxObj) {
    if (aRangeIndex < 0 || aRangeIndex > this._ranges.length)
      throw new Exception("Try a real range index next time.");
    [aMinObj.value, aMaxObj.value] = this._ranges[aRangeIndex];
  },

  invalidateSelection: function JSTreeSelection_invalidateSelection() {
    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
  },

  /**
   * Helper method to adjust points in the face of row additions/removal.
   * @param aPoint The point, null if there isn't one, or an index otherwise.
   * @param aDeltaAt The row at which the change is happening.
   * @param aDelta The number of rows added if positive, or the (negative)
   *     number of rows removed.
   */
  _adjustPoint: function JSTreeSelection__adjustPoint(aPoint, aDeltaAt,
                                                      aDelta) {
    // if there is no point, no change
    if (aPoint == null)
      return aPoint;
    // if the point is before the change, no change
    if (aPoint < aDeltaAt)
      return aPoint;
    // if it's a deletion and it includes the point, clear it
    if (aDelta < 0 && aPoint >= aDeltaAt && (aPoint + aDelta < aDeltaAt))
      return null;
    // (else) the point is at/after the change, compensate
    return aPoint + aDelta;
  },
  /**
   * Find the index of the range, if any, that contains the given index, and
   *  the index at which to insert a range if one does not exist.
   *
   * @return A tuple containing: 1) the index if there is one, null otherwise,
   *     2) the index at which to insert a range that would contain the point.
   */
  _findRangeContainingRow:
      function JSTreeSelection__findRangeContainingRow(aIndex) {
    for (let [iTupe, [low, high]] of this._ranges.entries()) {
      if (aIndex >= low && aIndex <= high)
        return [iTupe, iTupe];
      if (aIndex < low)
        return [null, iTupe];
    }
    return [null, this._ranges.length];
  },


  /**
   * When present, a list of calls made to adjustSelection.  See
   *  |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|.
   */
  _adjustSelectionLog: null,
  /**
   * Start logging calls to adjustSelection made against this instance.  You
   *  would do this because you are replacing an existing selection object
   *  with this instance for the purposes of creating a transient selection.
   *  Of course, you want the original selection object to be up-to-date when
   *  you go to put it back, so then you can call replayAdjustSelectionLog
   *  with that selection object and everything will be peachy.
   */
  logAdjustSelectionForReplay:
      function JSTreeSelection_logAdjustSelectionForReplay() {
    this._adjustSelectionLog = [];
  },
  /**
   * Stop logging calls to adjustSelection and replay the existing log against
   *  aSelection.
   *
   * @param aSelection {nsITreeSelection}.
   */
  replayAdjustSelectionLog:
      function JSTreeSelection_replayAdjustSelectionLog(aSelection) {
    if (this._adjustSelectionLog.length) {
      // Temporarily disable selection events because adjustSelection is going
      //  to generate an event each time otherwise, and better 1 event than
      //  many.
      aSelection.selectEventsSuppressed = true;
      for (let [index, count] of this._adjustSelectionLog) {
        aSelection.adjustSelection(index, count);
      }
      aSelection.selectEventsSuppressed = false;
    }
    this._adjustSelectionLog = null;
  },

  adjustSelection: function JSTreeSelection_adjustSelection(aIndex, aCount) {
    // nothing to do if there is no actual change
    if (!aCount)
      return;

    if (this._adjustSelectionLog)
      this._adjustSelectionLog.push([aIndex, aCount]);

    // adjust our points
    this._shiftSelectPivot = this._adjustPoint(this._shiftSelectPivot,
                                               aIndex, aCount);
    this._currentIndex = this._adjustPoint(this._currentIndex, aIndex, aCount);

    // If we are adding rows, we want to split any range at aIndex and then
    //  translate all of the ranges above that point up.
    if (aCount > 0) {
      let [iContain, iInsert] = this._findRangeContainingRow(aIndex);
      if (iContain != null) {
        let [low, high] = this._ranges[iContain];
        // if it is the low value, we just want to shift the range entirely, so
        //  do nothing (and keep iInsert pointing at it for translation)
        // if it is not the low value, then there must be at least two values so
        //  we should split it and only translate the new/upper block
        if (aIndex != low) {
          this._ranges.splice(iContain, 1, [low, aIndex - 1], [aIndex, high]);
          iInsert++;
        }
      }
      // now translate everything from iInsert on up
      for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) {
        let [low, high] = this._ranges[iTrans];
        this._ranges[iTrans] = [low + aCount, high + aCount];
      }
      // invalidate and fire selection change notice
      if (this._treeBoxObject)
        this._treeBoxObject.invalidate();
      this._fireSelectionChanged();
      return;
    }

    // If we are removing rows, we are basically clearing the range that is
    //  getting deleted and translating everyone above the remaining point
    //  downwards.  The one trick is we may have to merge the lowest translated
    //  block.
    let saveSuppress = this.selectEventsSuppressed;
    this.selectEventsSuppressed = true;
    this.clearRange(aIndex, aIndex - aCount - 1);
    // translate
    let iTrans = this._findRangeContainingRow(aIndex)[1];
    for (; iTrans < this._ranges.length; iTrans++) {
      let [low, high] = this._ranges[iTrans];
      // for the first range, low may be below the index, in which case it
      //  should not get translated
      this._ranges[iTrans] = [(low >= aIndex) ? low + aCount : low,
                              high + aCount];
    }
    // we may have to merge the lowest translated block because it may now be
    //  adjacent to the previous block
    if (iTrans > 0 && iTrans < this._ranges.length &&
        this._ranges[iTrans-1][1] == this_ranges[iTrans][0]) {
      this._ranges[iTrans-1][1] = this._ranges[iTrans][1];
      this._ranges.splice(iTrans, 1);
    }

    if (this._treeBoxObject)
      this._treeBoxObject.invalidate();
    this.selectEventsSuppressed = saveSuppress;
  },

  get selectEventsSuppressed() {
    return this._selectEventsSuppressed;
  },
  /**
   * Control whether selection events are suppressed.  For consistency with
   *  nsTreeSelection, we always generate a selection event when a value of
   *  false is assigned, even if the value was already false.
   */
  set selectEventsSuppressed(aSuppress) {
    this._selectEventsSuppressed = aSuppress;
    if (!aSuppress)
      this._fireSelectionChanged();
  },

  /**
   * Note that we bypass any XUL "onselect" handler that may exist and go
   *  straight to the view.  If you have a tree, you shouldn't be using us,
   *  so this seems aboot right.
   */
  _fireSelectionChanged: function JSTreeSelection__fireSelectionChanged() {
    // don't fire if we are suppressed; we will fire when un-suppressed
    if (this.selectEventsSuppressed)
      return;
    let view;
    if (this._treeBoxObject && this._treeBoxObject.view)
      view = this._treeBoxObject.view;
    else
      view = this._view;

    // We might not have a view if we're in the middle of setting up things
    if (view) {
      view = view.QueryInterface(Ci.nsITreeView);
      view.selectionChanged();
    }
  },

  get currentIndex() {
    if (this._currentIndex == null)
      return -1;
    return this._currentIndex;
  },
  /**
   * Sets the current index.  Other than updating the variable, this just
   *  invalidates the tree row if we have a tree.
   * The real selection object would send a DOM event we don't care about.
   */
  set currentIndex(aIndex) {
    if (aIndex == this.currentIndex)
      return;

    this._currentIndex = (aIndex != -1) ? aIndex : null;
    if (this._treeBoxObject)
      this._treeBoxObject.invalidateRow(aIndex);
  },

  currentColumn: null,

  get shiftSelectPivot() {
    return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1;
  },

  QueryInterface: XPCOMUtils.generateQI(
    [Ci.nsITreeSelection]),

  /*
   * Functions after this aren't part of the nsITreeSelection interface.
   */

  /**
   * Duplicate this selection on another nsITreeSelection. This is useful
   * when you would like to discard this selection for a real tree selection.
   * We assume that both selections are for the same tree.
   *
   * @note We don't transfer the correct shiftSelectPivot over.
   * @note This will fire a selectionChanged event on the tree view.
   *
   * @param aSelection an nsITreeSelection to duplicate this selection onto
   */
  duplicateSelection: function JSTreeSelection_duplicateSelection(aSelection) {
    aSelection.selectEventsSuppressed = true;
    aSelection.clearSelection();
    for (let [iTupe, [low, high]] of this._ranges.entries())
      aSelection.rangedSelect(low, high, iTupe > 0);

    aSelection.currentIndex = this.currentIndex;
    // This will fire a selectionChanged event
    aSelection.selectEventsSuppressed = false;
  },
};
