// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * EventsView displays a filtered list of all events sharing a source, and
 * a details pane for the selected sources.
 *
 *  +----------------------++----------------+
 *  |      filter box      ||                |
 *  +----------------------+|                |
 *  |                      ||                |
 *  |                      ||                |
 *  |                      ||                |
 *  |                      ||                |
 *  |     source list      ||    details     |
 *  |                      ||    view        |
 *  |                      ||                |
 *  |                      ||                |
 *  |                      ||                |
 *  |                      ||                |
 *  +----------------------++                |
 *  |      action bar      ||                |
 *  +----------------------++----------------+
 *
 * @constructor
 */
function EventsView(tableBodyId, filterInputId, filterCountId,
                    deleteSelectedId, deleteAllId, selectAllId, sortByIdId,
                    sortBySourceTypeId, sortByDescriptionId,
                    tabHandlesContainerId, logTabId, timelineTabId,
                    detailsLogBoxId, detailsTimelineBoxId,
                    topbarId, middleboxId, bottombarId, sizerId) {
  View.call(this);

  // Used for sorting entries with automatically assigned IDs.
  this.maxReceivedSourceId_ = 0;

  // Initialize the sub-views.
  var leftPane = new TopMidBottomView(new DivView(topbarId),
                                      new DivView(middleboxId),
                                      new DivView(bottombarId));

  this.detailsView_ = new DetailsView(tabHandlesContainerId,
                                      logTabId,
                                      timelineTabId,
                                      detailsLogBoxId,
                                      detailsTimelineBoxId);

  this.splitterView_ = new ResizableVerticalSplitView(
      leftPane, this.detailsView_, new DivView(sizerId));

  g_browser.addLogObserver(this);

  this.tableBody_ = document.getElementById(tableBodyId);

  this.filterInput_ = document.getElementById(filterInputId);
  this.filterCount_ = document.getElementById(filterCountId);

  this.filterInput_.addEventListener('search',
      this.onFilterTextChanged_.bind(this), true);

  document.getElementById(deleteSelectedId).onclick =
      this.deleteSelected_.bind(this);

  document.getElementById(deleteAllId).onclick =
      g_browser.deleteAllEvents.bind(g_browser);

  document.getElementById(selectAllId).addEventListener(
      'click', this.selectAll_.bind(this), true);

  document.getElementById(sortByIdId).addEventListener(
      'click', this.sortById_.bind(this), true);

  document.getElementById(sortBySourceTypeId).addEventListener(
      'click', this.sortBySourceType_.bind(this), true);

  document.getElementById(sortByDescriptionId).addEventListener(
      'click', this.sortByDescription_.bind(this), true);

  // Sets sort order and filter.
  this.setFilter_('');

  this.initializeSourceList_();
}

inherits(EventsView, View);

/**
 * Initializes the list of source entries.  If source entries are already,
 * being displayed, removes them all in the process.
 */
EventsView.prototype.initializeSourceList_ = function() {
  this.currentSelectedSources_ = [];
  this.sourceIdToEntryMap_ = {};
  this.tableBody_.innerHTML = '';
  this.numPrefilter_ = 0;
  this.numPostfilter_ = 0;
  this.invalidateFilterCounter_();
  this.invalidateDetailsView_();
};

// How soon after updating the filter list the counter should be updated.
EventsView.REPAINT_FILTER_COUNTER_TIMEOUT_MS = 0;

EventsView.prototype.setGeometry = function(left, top, width, height) {
  EventsView.superClass_.setGeometry.call(this, left, top, width, height);
  this.splitterView_.setGeometry(left, top, width, height);
};

EventsView.prototype.show = function(isVisible) {
  EventsView.superClass_.show.call(this, isVisible);
  this.splitterView_.show(isVisible);
};

EventsView.prototype.getFilterText_ = function() {
  return this.filterInput_.value;
};

EventsView.prototype.setFilterText_ = function(filterText) {
  this.filterInput_.value = filterText;
  this.onFilterTextChanged_();
};

EventsView.prototype.onFilterTextChanged_ = function() {
  this.setFilter_(this.getFilterText_());
};

/**
 * Updates text in the details view when security stripping is toggled.
 */
EventsView.prototype.onSecurityStrippingChanged = function() {
  this.invalidateDetailsView_();
}

/**
 * Sorts active entries first.   If both entries are inactive, puts the one
 * that was active most recently first.  If both are active, uses source ID,
 * which puts longer lived events at the top, and behaves better than using
 * duration or time of first event.
 */
EventsView.compareActive_ = function(source1, source2) {
  if (source1.isActive() && !source2.isActive())
    return -1;
  if (!source1.isActive() && source2.isActive())
    return  1;
  if (!source1.isActive()) {
    var deltaEndTime = source1.getEndTime() - source2.getEndTime();
    if (deltaEndTime != 0) {
      // The one that ended most recently (Highest end time) should be sorted
      // first.
      return -deltaEndTime;
    }
    // If both ended at the same time, then odds are they were related events,
    // started one after another, so sort in the opposite order of their
    // source IDs to get a more intuitive ordering.
    return -EventsView.compareSourceId_(source1, source2);
  }
  return EventsView.compareSourceId_(source1, source2);
};

EventsView.compareDescription_ = function(source1, source2) {
  var source1Text = source1.getDescription().toLowerCase();
  var source2Text = source2.getDescription().toLowerCase();
  var compareResult = source1Text.localeCompare(source2Text);
  if (compareResult != 0)
    return compareResult;
  return EventsView.compareSourceId_(source1, source2);
};

EventsView.compareDuration_ = function(source1, source2) {
  var durationDifference = source2.getDuration() - source1.getDuration();
  if (durationDifference)
    return durationDifference;
  return EventsView.compareSourceId_(source1, source2);
};

/**
 * For the purposes of sorting by source IDs, entries without a source
 * appear right after the SourceEntry with the highest source ID received
 * before the sourceless entry. Any ambiguities are resolved by ordering
 * the entries without a source by the order in which they were received.
 */
EventsView.compareSourceId_ = function(source1, source2) {
  var sourceId1 = source1.getSourceId();
  if (sourceId1 < 0)
    sourceId1 = source1.getMaxPreviousEntrySourceId();
  var sourceId2 = source2.getSourceId();
  if (sourceId2 < 0)
    sourceId2 = source2.getMaxPreviousEntrySourceId();

  if (sourceId1 != sourceId2)
    return sourceId1 - sourceId2;

  // One or both have a negative ID. In either case, the source with the
  // highest ID should be sorted first.
  return source2.getSourceId() - source1.getSourceId();
};

EventsView.compareSourceType_ = function(source1, source2) {
  var source1Text = source1.getSourceTypeString();
  var source2Text = source2.getSourceTypeString();
  var compareResult = source1Text.localeCompare(source2Text);
  if (compareResult != 0)
    return compareResult;
  return EventsView.compareSourceId_(source1, source2);
};

EventsView.prototype.comparisonFuncWithReversing_ = function(a, b) {
  var result = this.comparisonFunction_(a, b);
  if (this.doSortBackwards_)
    result *= -1;
  return result;
};

EventsView.comparisonFunctionTable_ = {
  // sort: and sort:- are allowed
  '':            EventsView.compareSourceId_,
  'active':      EventsView.compareActive_,
  'desc':        EventsView.compareDescription_,
  'description': EventsView.compareDescription_,
  'duration':    EventsView.compareDuration_,
  'id':          EventsView.compareSourceId_,
  'source':      EventsView.compareSourceType_,
  'type':        EventsView.compareSourceType_
};

EventsView.prototype.Sort_ = function() {
  var sourceEntries = [];
  for (var id in this.sourceIdToEntryMap_) {
    // Can only sort items with an actual row in the table.
    if (this.sourceIdToEntryMap_[id].hasRow())
      sourceEntries.push(this.sourceIdToEntryMap_[id]);
  }
  sourceEntries.sort(this.comparisonFuncWithReversing_.bind(this));

  for (var i = sourceEntries.length - 2; i >= 0; --i) {
    if (sourceEntries[i].getNextNodeSourceId() !=
        sourceEntries[i + 1].getSourceId())
      sourceEntries[i].moveBefore(sourceEntries[i + 1]);
  }
};

/**
 * Looks for the first occurence of |directive|:parameter in |sourceText|.
 * Parameter can be an empty string.
 *
 * On success, returns an object with two fields:
 *   |remainingText| - |sourceText| with |directive|:parameter removed,
                       and excess whitespace deleted.
 *   |parameter| - the parameter itself.
 *
 * On failure, returns null.
 */
EventsView.prototype.parseDirective_ = function(sourceText, directive) {
  // Adding a leading space allows a single regexp to be used, regardless of
  // whether or not the directive is at the start of the string.
  sourceText = ' ' + sourceText;
  regExp = new RegExp('\\s+' + directive + ':(\\S*)\\s*', 'i');
  matchInfo = regExp.exec(sourceText);
  if (matchInfo == null)
    return null;

  return {'remainingText': sourceText.replace(regExp, ' ').trim(),
          'parameter': matchInfo[1]};
};

/**
 * Just like parseDirective_, except can optionally be a '-' before or
 * the parameter, to negate it.  Before is more natural, after
 * allows more convenient toggling.
 *
 * Returned value has the additional field |isNegated|, and a leading
 * '-' will be removed from |parameter|, if present.
 */
EventsView.prototype.parseNegatableDirective_ = function(sourceText,
                                                         directive) {
  var matchInfo = this.parseDirective_(sourceText, directive);
  if (matchInfo == null)
    return null;

  // Remove any leading or trailing '-' from the directive.
  var negationInfo = /^(-?)(\S*?)$/.exec(matchInfo.parameter);
  matchInfo.parameter = negationInfo[2];
  matchInfo.isNegated = (negationInfo[1] == '-');
  return matchInfo;
};

/**
 * Parse any "sort:" directives, and update |comparisonFunction_| and
 * |doSortBackwards_|as needed.  Note only the last valid sort directive
 * is used.
 *
 * Returns |filterText| with all sort directives removed, including
 * invalid ones.
 */
EventsView.prototype.parseSortDirectives_ = function(filterText) {
  this.comparisonFunction_ = EventsView.compareSourceId_;
  this.doSortBackwards_ = false;

  while (true) {
    var sortInfo = this.parseNegatableDirective_(filterText, 'sort');
    if (sortInfo == null)
      break;
    var comparisonName = sortInfo.parameter.toLowerCase();
    if (EventsView.comparisonFunctionTable_[comparisonName] != null) {
      this.comparisonFunction_ =
          EventsView.comparisonFunctionTable_[comparisonName];
      this.doSortBackwards_ = sortInfo.isNegated;
    }
    filterText = sortInfo.remainingText;
  }

  return filterText;
};

/**
 * Parse any "is:" directives, and update |filter| accordingly.
 *
 * Returns |filterText| with all "is:" directives removed, including
 * invalid ones.
 */
EventsView.prototype.parseRestrictDirectives_ = function(filterText, filter) {
  while (true) {
    var filterInfo = this.parseNegatableDirective_(filterText, 'is');
    if (filterInfo == null)
      break;
    if (filterInfo.parameter == 'active') {
      if (!filterInfo.isNegated)
        filter.isActive = true;
      else
        filter.isInactive = true;
    }
    filterText = filterInfo.remainingText;
  }
  return filterText;
};

/**
 * Parses all directives that take arbitrary strings as input,
 * and updates |filter| accordingly.  Directives of these types
 * are stored as lists.
 *
 * Returns |filterText| with all recognized directives removed.
 */
EventsView.prototype.parseStringDirectives_ = function(filterText, filter) {
  var directives = ['type', 'id'];
  for (var i = 0; i < directives.length; ++i) {
    while (true) {
      var directive = directives[i];
      var filterInfo = this.parseDirective_(filterText, directive);
      if (filterInfo == null)
        break;
      if (!filter[directive])
        filter[directive] = [];
      filter[directive].push(filterInfo.parameter);
      filterText = filterInfo.remainingText;
    }
  }
  return filterText;
};

/*
 * Converts |filterText| into an object representing the filter.
 */
EventsView.prototype.createFilter_ = function(filterText) {
  var filter = {};
  filterText = filterText.toLowerCase();
  filterText = this.parseRestrictDirectives_(filterText, filter);
  filterText = this.parseStringDirectives_(filterText, filter);
  filter.text = filterText.trim();
  return filter;
};

EventsView.prototype.setFilter_ = function(filterText) {
  var lastComparisonFunction = this.comparisonFunction_;
  var lastDoSortBackwards = this.doSortBackwards_;

  filterText = this.parseSortDirectives_(filterText);

  if (lastComparisonFunction != this.comparisonFunction_ ||
      lastDoSortBackwards != this.doSortBackwards_) {
    this.Sort_();
  }

  this.currentFilter_ = this.createFilter_(filterText);

  // Iterate through all of the rows and see if they match the filter.
  for (var id in this.sourceIdToEntryMap_) {
    var entry = this.sourceIdToEntryMap_[id];
    entry.setIsMatchedByFilter(entry.matchesFilter(this.currentFilter_));
  }
};

/**
 * Repositions |sourceEntry|'s row in the table using an insertion sort.
 * Significantly faster than sorting the entire table again, when only
 * one entry has changed.
 */
EventsView.prototype.InsertionSort_ = function(sourceEntry) {
  // SourceEntry that should be after |sourceEntry|, if it needs
  // to be moved earlier in the list.
  var sourceEntryAfter = sourceEntry;
  while (true) {
    var prevSourceId = sourceEntryAfter.getPreviousNodeSourceId();
    if (prevSourceId == null)
      break;
    var prevSourceEntry = this.sourceIdToEntryMap_[prevSourceId];
    if (this.comparisonFuncWithReversing_(sourceEntry, prevSourceEntry) >= 0)
      break;
    sourceEntryAfter = prevSourceEntry;
  }
  if (sourceEntryAfter != sourceEntry) {
    sourceEntry.moveBefore(sourceEntryAfter);
    return;
  }

  var sourceEntryBefore = sourceEntry;
  while (true) {
    var nextSourceId = sourceEntryBefore.getNextNodeSourceId();
    if (nextSourceId == null)
      break;
    var nextSourceEntry = this.sourceIdToEntryMap_[nextSourceId];
    if (this.comparisonFuncWithReversing_(sourceEntry, nextSourceEntry) <= 0)
      break;
    sourceEntryBefore = nextSourceEntry;
  }
  if (sourceEntryBefore != sourceEntry)
    sourceEntry.moveAfter(sourceEntryBefore);
};

EventsView.prototype.onLogEntryAdded = function(logEntry) {
  var id = logEntry.source.id;

  // Lookup the source.
  var sourceEntry = this.sourceIdToEntryMap_[id];

  if (!sourceEntry) {
    sourceEntry = new SourceEntry(this, this.maxReceivedSourceId_);
    this.sourceIdToEntryMap_[id] = sourceEntry;
    this.incrementPrefilterCount(1);
    if (id > this.maxReceivedSourceId_)
      this.maxReceivedSourceId_ = id;
  }

  sourceEntry.update(logEntry);

  if (sourceEntry.isSelected())
    this.invalidateDetailsView_();

  // TODO(mmenke): Fix sorting when sorting by duration.
  //               Duration continuously increases for all entries that are
  //               still active.  This can result in incorrect sorting, until
  //               Sort_ is called.
  this.InsertionSort_(sourceEntry);
};

/**
 * Returns the SourceEntry with the specified ID, if there is one.
 * Otherwise, returns undefined.
 */
EventsView.prototype.getSourceEntry = function(id) {
  return this.sourceIdToEntryMap_[id];
};

/**
 * Called whenever some log events are deleted.  |sourceIds| lists
 * the source IDs of all deleted log entries.
 */
EventsView.prototype.onLogEntriesDeleted = function(sourceIds) {
  for (var i = 0; i < sourceIds.length; ++i) {
    var id = sourceIds[i];
    var entry = this.sourceIdToEntryMap_[id];
    if (entry) {
      entry.remove();
      delete this.sourceIdToEntryMap_[id];
      this.incrementPrefilterCount(-1);
    }
  }
};

/**
 * Called whenever all log events are deleted.
 */
EventsView.prototype.onAllLogEntriesDeleted = function() {
  this.initializeSourceList_();
};

/**
 * Called when either a log file is loaded or when going back to actively
 * logging events.  In either case, called after clearing the old entries,
 * but before getting any new ones.
 */
EventsView.prototype.onSetIsViewingLogFile = function(isViewingLogFile) {
  // Needed to sort new sourceless entries correctly.
  this.maxReceivedSourceId_ = 0;
};

EventsView.prototype.incrementPrefilterCount = function(offset) {
  this.numPrefilter_ += offset;
  this.invalidateFilterCounter_();
};

EventsView.prototype.incrementPostfilterCount = function(offset) {
  this.numPostfilter_ += offset;
  this.invalidateFilterCounter_();
};

EventsView.prototype.onSelectionChanged = function() {
  this.invalidateDetailsView_();
};

EventsView.prototype.clearSelection = function() {
  var prevSelection = this.currentSelectedSources_;
  this.currentSelectedSources_ = [];

  // Unselect everything that is currently selected.
  for (var i = 0; i < prevSelection.length; ++i) {
    prevSelection[i].setSelected(false);
  }

  this.onSelectionChanged();
};

EventsView.prototype.deleteSelected_ = function() {
  var sourceIds = [];
  for (var i = 0; i < this.currentSelectedSources_.length; ++i) {
    var entry = this.currentSelectedSources_[i];
    sourceIds.push(entry.getSourceId());
  }
  g_browser.deleteEventsBySourceId(sourceIds);
};

EventsView.prototype.selectAll_ = function(event) {
  for (var id in this.sourceIdToEntryMap_) {
    var entry = this.sourceIdToEntryMap_[id];
    if (entry.isMatchedByFilter()) {
      entry.setSelected(true);
    }
  }
  event.preventDefault();
};

EventsView.prototype.unselectAll_ = function() {
  var entries = this.currentSelectedSources_.slice(0);
  for (var i = 0; i < entries.length; ++i) {
    entries[i].setSelected(false);
  }
};

/**
 * If |params| includes a query, replaces the current filter and unselects.
 * all items.
 */
EventsView.prototype.setParameters = function(params) {
  if (params.q) {
    this.unselectAll_();
    this.setFilterText_(params.q);
  }
};

/**
 * If already using the specified sort method, flips direction.  Otherwise,
 * removes pre-existing sort parameter before adding the new one.
 */
EventsView.prototype.toggleSortMethod_ = function(sortMethod) {
  // Remove old sort directives, if any.
  var filterText = this.parseSortDirectives_(this.getFilterText_());

  // If already using specified sortMethod, sort backwards.
  if (!this.doSortBackwards_ &&
      EventsView.comparisonFunctionTable_[sortMethod] ==
          this.comparisonFunction_)
    sortMethod = '-' + sortMethod;

  filterText = 'sort:' + sortMethod + ' ' + filterText;
  this.setFilterText_(filterText.trim());
};

EventsView.prototype.sortById_ = function(event) {
  this.toggleSortMethod_('id');
};

EventsView.prototype.sortBySourceType_ = function(event) {
  this.toggleSortMethod_('source');
};

EventsView.prototype.sortByDescription_ = function(event) {
  this.toggleSortMethod_('desc');
};

EventsView.prototype.modifySelectionArray = function(
    sourceEntry, addToSelection) {
  // Find the index for |sourceEntry| in the current selection list.
  var index = -1;
  for (var i = 0; i < this.currentSelectedSources_.length; ++i) {
    if (this.currentSelectedSources_[i] == sourceEntry) {
      index = i;
      break;
    }
  }

  if (index != -1 && !addToSelection) {
    // Remove from the selection.
    this.currentSelectedSources_.splice(index, 1);
  }

  if (index == -1 && addToSelection) {
    this.currentSelectedSources_.push(sourceEntry);
  }
};

EventsView.prototype.invalidateDetailsView_ = function() {
  this.detailsView_.setData(this.currentSelectedSources_);
};

EventsView.prototype.invalidateFilterCounter_ = function() {
  if (!this.outstandingRepaintFilterCounter_) {
    this.outstandingRepaintFilterCounter_ = true;
    window.setTimeout(this.repaintFilterCounter_.bind(this),
                      EventsView.REPAINT_FILTER_COUNTER_TIMEOUT_MS);
  }
};

EventsView.prototype.repaintFilterCounter_ = function() {
  this.outstandingRepaintFilterCounter_ = false;
  this.filterCount_.innerHTML = '';
  addTextNode(this.filterCount_,
              this.numPostfilter_ + ' of ' + this.numPrefilter_);
};