

function View(element, calendar, viewName) {
  var t = this;


  // exports
  t.element = element;
  t.calendar = calendar;
  t.name = viewName;
  t.opt = opt;
  t.trigger = trigger;
  t.isEventDraggable = isEventDraggable;
  t.isEventResizable = isEventResizable;
  t.setEventData = setEventData;
  t.clearEventData = clearEventData;
  t.eventEnd = eventEnd;
  t.reportEventElement = reportEventElement;
  t.triggerEventDestroy = triggerEventDestroy;
  t.eventElementHandlers = eventElementHandlers;
  t.showEvents = showEvents;
  t.hideEvents = hideEvents;
  t.eventDrop = eventDrop;
  t.eventResize = eventResize;
  // t.title
  // t.start, t.end
  // t.visStart, t.visEnd


  // imports
  var defaultEventEnd = t.defaultEventEnd;
  var normalizeEvent = calendar.normalizeEvent; // in EventManager
  var reportEventChange = calendar.reportEventChange;


  // locals
  var eventsByID = {}; // eventID mapped to array of events (there can be multiple b/c of repeating events)
  var eventElementsByID = {}; // eventID mapped to array of jQuery elements
  var eventElementCouples = []; // array of objects, { event, element } // TODO: unify with segment system
  var options = calendar.options;



  function opt(name, viewNameOverride) {
    var v = options[name];
    if ($.isPlainObject(v)) {
      return smartProperty(v, viewNameOverride || viewName);
    }
    return v;
  }


  function trigger(name, thisObj) {
    return calendar.trigger.apply(
      calendar,
      [name, thisObj || t].concat(Array.prototype.slice.call(arguments, 2), [t])
    );
  }



  /* Event Editable Boolean Calculations
     ------------------------------------------------------------------------------*/


  function isEventDraggable(event) {
    var source = event.source || {};
    return firstDefined(
      event.startEditable,
      source.startEditable,
      opt('eventStartEditable'),
      event.editable,
      source.editable,
      opt('editable')
    )
    && !opt('disableDragging'); // deprecated
  }


  function isEventResizable(event) { // but also need to make sure the seg.isEnd == true
    var source = event.source || {};
    return firstDefined(
      event.durationEditable,
      source.durationEditable,
      opt('eventDurationEditable'),
      event.editable,
      source.editable,
      opt('editable')
    )
    && !opt('disableResizing'); // deprecated
  }



  /* Event Data
     ------------------------------------------------------------------------------*/


  function setEventData(events) { // events are already normalized at this point
    eventsByID = {};
    var i, len=events.length, event;
    for (i=0; i<len; i++) {
      event = events[i];
      if (eventsByID[event._id]) {
        eventsByID[event._id].push(event);
      }else{
        eventsByID[event._id] = [event];
      }
    }
  }


  function clearEventData() {
    eventsByID = {};
    eventElementsByID = {};
    eventElementCouples = [];
  }


  // returns a Date object for an event's end
  function eventEnd(event) {
    return event.end ? cloneDate(event.end) : defaultEventEnd(event);
  }



  /* Event Elements
     ------------------------------------------------------------------------------*/


  // report when view creates an element for an event
  function reportEventElement(event, element) {
    eventElementCouples.push({ event: event, element: element });
    if (eventElementsByID[event._id]) {
      eventElementsByID[event._id].push(element);
    }else{
      eventElementsByID[event._id] = [element];
    }
  }


  function triggerEventDestroy() {
    $.each(eventElementCouples, function(i, couple) {
      t.trigger('eventDestroy', couple.event, couple.event, couple.element);
    });
  }


  // attaches eventClick, eventMouseover, eventMouseout
  function eventElementHandlers(event, eventElement) {
    eventElement
    .click(function(ev) {
      if (!eventElement.hasClass('ui-draggable-dragging') &&
          !eventElement.hasClass('ui-resizable-resizing')) {
        return trigger('eventClick', this, event, ev);
      }
    })
    .hover(
      function(ev) {
      trigger('eventMouseover', this, event, ev);
    },
    function(ev) {
      trigger('eventMouseout', this, event, ev);
    }
    );
    // TODO: don't fire eventMouseover/eventMouseout *while* dragging is occuring (on subject element)
    // TODO: same for resizing
  }


  function showEvents(event, exceptElement) {
    eachEventElement(event, exceptElement, 'show');
  }


  function hideEvents(event, exceptElement) {
    eachEventElement(event, exceptElement, 'hide');
  }


  function eachEventElement(event, exceptElement, funcName) {
    // NOTE: there may be multiple events per ID (repeating events)
    // and multiple segments per event
    var elements = eventElementsByID[event._id],
    i, len = elements.length;
    for (i=0; i<len; i++) {
      if (!exceptElement || elements[i][0] != exceptElement[0]) {
        elements[i][funcName]();
      }
    }
  }



  /* Event Modification Reporting
     ---------------------------------------------------------------------------------*/


  function eventDrop(e, event, dayDelta, minuteDelta, allDay, ev, ui) {
    var oldAllDay = event.allDay;
    var eventId = event._id;
    moveEvents(eventsByID[eventId], dayDelta, minuteDelta, allDay);
    trigger(
      'eventDrop',
      e,
      event,
      dayDelta,
      minuteDelta,
      allDay,
      function() {
        // TODO: investigate cases where this inverse technique might not work
        moveEvents(eventsByID[eventId], -dayDelta, -minuteDelta, oldAllDay);
        reportEventChange(eventId);
      },
      ev,
      ui
    );
    reportEventChange(eventId);
  }


  function eventResize(e, event, dayDelta, minuteDelta, ev, ui) {
    var eventId = event._id;
    elongateEvents(eventsByID[eventId], dayDelta, minuteDelta);
    trigger(
      'eventResize',
      e,
      event,
      dayDelta,
      minuteDelta,
      function() {
        // TODO: investigate cases where this inverse technique might not work
        elongateEvents(eventsByID[eventId], -dayDelta, -minuteDelta);
        reportEventChange(eventId);
      },
      ev,
      ui
    );
    reportEventChange(eventId);
  }



  /* Event Modification Math
     ---------------------------------------------------------------------------------*/


  function moveEvents(events, dayDelta, minuteDelta, allDay) {
    minuteDelta = minuteDelta || 0;
    for (var e, len=events.length, i=0; i<len; i++) {
      e = events[i];
      if (allDay !== undefined) {
        e.allDay = allDay;
      }
      addMinutes(addDays(e.start, dayDelta, true), minuteDelta);
      if (e.end) {
        e.end = addMinutes(addDays(e.end, dayDelta, true), minuteDelta);
      }
      normalizeEvent(e, options);
    }
  }


  function elongateEvents(events, dayDelta, minuteDelta) {
    minuteDelta = minuteDelta || 0;
    for (var e, len=events.length, i=0; i<len; i++) {
      e = events[i];
      e.end = addMinutes(addDays(eventEnd(e), dayDelta, true), minuteDelta);
      normalizeEvent(e, options);
    }
  }



  // ====================================================================================================
  // Utilities for day "cells"
  // ====================================================================================================
  // The "basic" views are completely made up of day cells.
  // The "agenda" views have day cells at the top "all day" slot.
  // This was the obvious common place to put these utilities, but they should be abstracted out into
  // a more meaningful class (like DayEventRenderer).
  // ====================================================================================================


  // For determining how a given "cell" translates into a "date":
  //
  // 1. Convert the "cell" (row and column) into a "cell offset" (the # of the cell, cronologically from the first).
  //    Keep in mind that column indices are inverted with isRTL. This is taken into account.
  //
  // 2. Convert the "cell offset" to a "day offset" (the # of days since the first visible day in the view).
  //
  // 3. Convert the "day offset" into a "date" (a JavaScript Date object).
  //
  // The reverse transformation happens when transforming a date into a cell.


  // exports
  t.isHiddenDay = isHiddenDay;
  t.skipHiddenDays = skipHiddenDays;
  t.getCellsPerWeek = getCellsPerWeek;
  t.dateToCell = dateToCell;
  t.dateToDayOffset = dateToDayOffset;
  t.dayOffsetToCellOffset = dayOffsetToCellOffset;
  t.cellOffsetToCell = cellOffsetToCell;
  t.cellToDate = cellToDate;
  t.cellToCellOffset = cellToCellOffset;
  t.cellOffsetToDayOffset = cellOffsetToDayOffset;
  t.dayOffsetToDate = dayOffsetToDate;
  t.rangeToSegments = rangeToSegments;


  // internals
  var hiddenDays = opt('hiddenDays') || []; // array of day-of-week indices that are hidden
  var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
  var cellsPerWeek;
  var dayToCellMap = []; // hash from dayIndex -> cellIndex, for one week
  var cellToDayMap = []; // hash from cellIndex -> dayIndex, for one week
  var isRTL = opt('isRTL');


  // initialize important internal variables
  (function() {

    if (opt('weekends') === false) {
      hiddenDays.push(0, 6); // 0=sunday, 6=saturday
    }

    // Loop through a hypothetical week and determine which
    // days-of-week are hidden. Record in both hashes (one is the reverse of the other).
    for (var dayIndex=0, cellIndex=0; dayIndex<7; dayIndex++) {
      dayToCellMap[dayIndex] = cellIndex;
      isHiddenDayHash[dayIndex] = $.inArray(dayIndex, hiddenDays) != -1;
      if (!isHiddenDayHash[dayIndex]) {
        cellToDayMap[cellIndex] = dayIndex;
        cellIndex++;
      }
    }

    cellsPerWeek = cellIndex;
    if (!cellsPerWeek) {
      throw 'invalid hiddenDays'; // all days were hidden? bad.
    }

  })();


  // Is the current day hidden?
  // `day` is a day-of-week index (0-6), or a Date object
  function isHiddenDay(day) {
    if (typeof day == 'object') {
      day = day.getDay();
    }
    return isHiddenDayHash[day];
  }


  function getCellsPerWeek() {
    return cellsPerWeek;
  }


  // Keep incrementing the current day until it is no longer a hidden day.
  // If the initial value of `date` is not a hidden day, don't do anything.
  // Pass `isExclusive` as `true` if you are dealing with an end date.
  // `inc` defaults to `1` (increment one day forward each time)
  function skipHiddenDays(date, inc, isExclusive) {
    inc = inc || 1;
    while (
      isHiddenDayHash[ ( date.getDay() + (isExclusive ? inc : 0) + 7 ) % 7 ]
    ) {
      addDays(date, inc);
    }
  }


  //
  // TRANSFORMATIONS: cell -> cell offset -> day offset -> date
  //

  // cell -> date (combines all transformations)
  // Possible arguments:
  // - row, col
  // - { row:#, col: # }
  function cellToDate() {
    var cellOffset = cellToCellOffset.apply(null, arguments);
    var dayOffset = cellOffsetToDayOffset(cellOffset);
    var date = dayOffsetToDate(dayOffset);
    return date;
  }

  // cell -> cell offset
  // Possible arguments:
  // - row, col
  // - { row:#, col:# }
  function cellToCellOffset(row, col) {
    var colCnt = t.getColCnt();

    // rtl variables. wish we could pre-populate these. but where?
    var dis = isRTL ? -1 : 1;
    var dit = isRTL ? colCnt - 1 : 0;

    if (typeof row == 'object') {
      col = row.col;
      row = row.row;
    }
    var cellOffset = row * colCnt + (col * dis + dit); // column, adjusted for RTL (dis & dit)

    return cellOffset;
  }

  // cell offset -> day offset
  function cellOffsetToDayOffset(cellOffset) {
    var day0 = t.visStart.getDay(); // first date's day of week
    cellOffset += dayToCellMap[day0]; // normlize cellOffset to beginning-of-week
    return Math.floor(cellOffset / cellsPerWeek) * 7 // # of days from full weeks
    + cellToDayMap[ // # of days from partial last week
      (cellOffset % cellsPerWeek + cellsPerWeek) % cellsPerWeek // crazy math to handle negative cellOffsets
    ]
    - day0; // adjustment for beginning-of-week normalization
  }

  // day offset -> date (JavaScript Date object)
  function dayOffsetToDate(dayOffset) {
    var date = cloneDate(t.visStart);
    addDays(date, dayOffset);
    return date;
  }


  //
  // TRANSFORMATIONS: date -> day offset -> cell offset -> cell
  //

  // date -> cell (combines all transformations)
  function dateToCell(date) {
    var dayOffset = dateToDayOffset(date);
    var cellOffset = dayOffsetToCellOffset(dayOffset);
    var cell = cellOffsetToCell(cellOffset);
    return cell;
  }

  // date -> day offset
  function dateToDayOffset(date) {
    return dayDiff(date, t.visStart);
  }

  // day offset -> cell offset
  function dayOffsetToCellOffset(dayOffset) {
    var day0 = t.visStart.getDay(); // first date's day of week
    dayOffset += day0; // normalize dayOffset to beginning-of-week
    return Math.floor(dayOffset / 7) * cellsPerWeek // # of cells from full weeks
    + dayToCellMap[ // # of cells from partial last week
      (dayOffset % 7 + 7) % 7 // crazy math to handle negative dayOffsets
    ]
    - dayToCellMap[day0]; // adjustment for beginning-of-week normalization
  }

  // cell offset -> cell (object with row & col keys)
  function cellOffsetToCell(cellOffset) {
    var colCnt = t.getColCnt();

    // rtl variables. wish we could pre-populate these. but where?
    var dis = isRTL ? -1 : 1;
    var dit = isRTL ? colCnt - 1 : 0;

    var row = Math.floor(cellOffset / colCnt);
    var col = ((cellOffset % colCnt + colCnt) % colCnt) * dis + dit; // column, adjusted for RTL (dis & dit)
    return {
      row: row,
      col: col
    };
  }


  //
  // Converts a date range into an array of segment objects.
  // "Segments" are horizontal stretches of time, sliced up by row.
  // A segment object has the following properties:
  // - row
  // - cols
  // - isStart
  // - isEnd
  //
  function rangeToSegments(startDate, endDate) {
    var rowCnt = t.getRowCnt();
    var colCnt = t.getColCnt();
    var segments = []; // array of segments to return

    // day offset for given date range
    var rangeDayOffsetStart = dateToDayOffset(startDate);
    var rangeDayOffsetEnd = dateToDayOffset(endDate); // exclusive

    // first and last cell offset for the given date range
    // "last" implies inclusivity
    var rangeCellOffsetFirst = dayOffsetToCellOffset(rangeDayOffsetStart);
    var rangeCellOffsetLast = dayOffsetToCellOffset(rangeDayOffsetEnd) - 1;

    // loop through all the rows in the view
    for (var row=0; row<rowCnt; row++) {

      // first and last cell offset for the row
      var rowCellOffsetFirst = row * colCnt;
      var rowCellOffsetLast = rowCellOffsetFirst + colCnt - 1;

      // get the segment's cell offsets by constraining the range's cell offsets to the bounds of the row
      var segmentCellOffsetFirst = Math.max(rangeCellOffsetFirst, rowCellOffsetFirst);
      var segmentCellOffsetLast = Math.min(rangeCellOffsetLast, rowCellOffsetLast);

      // make sure segment's offsets are valid and in view
      if (segmentCellOffsetFirst <= segmentCellOffsetLast) {

        // translate to cells
        var segmentCellFirst = cellOffsetToCell(segmentCellOffsetFirst);
        var segmentCellLast = cellOffsetToCell(segmentCellOffsetLast);

        // view might be RTL, so order by leftmost column
        var cols = [ segmentCellFirst.col, segmentCellLast.col ].sort();

        // Determine if segment's first/last cell is the beginning/end of the date range.
        // We need to compare "day offset" because "cell offsets" are often ambiguous and
        // can translate to multiple days, and an edge case reveals itself when we the
        // range's first cell is hidden (we don't want isStart to be true).
        var isStart = cellOffsetToDayOffset(segmentCellOffsetFirst) == rangeDayOffsetStart;
        var isEnd = cellOffsetToDayOffset(segmentCellOffsetLast) + 1 == rangeDayOffsetEnd; // +1 for comparing exclusively

        segments.push({
          row: row,
          leftCol: cols[0],
          rightCol: cols[1],
          isStart: isStart,
          isEnd: isEnd
        });
      }
    }

    return segments;
  }


}
