/** var $D = pulp.date.getInstance; */
pulp.Modules.date = '$D';

(function() {
  
  var $p = pulp.base;
  //
  // pre-calculate the number of milliseconds in a day
  //
  var day = 24 * 60 * 60 * 1000;
  //
  // set up integers and functions for adding to a date or subtracting two dates
  //
  var multipliers = {
    millisecond: 1,
    second: 1000,
    minute: 60 * 1000,
    hour: 60 * 60 * 1000,
    day: day,
    week: 7 * day,
    month: {
      /**
       * Add a number of months
       * 
       * @param {Date} d  JavaScript Date object to be manipulated
       * @param {Number} number  The number of months to add (use negative number to subtract
       * @return void
       * @ignore
       */
      add: function(d, number) {
        // use JavaScript Date oddity that allows adding an arbitrary number of months, even wrapping years
        var newD = new Date(d.getFullYear(), d.getMonth() + number, d.getDate());
        d.setYear(newD.getFullYear());
        d.setMonth(newD.getMonth());
      },
      /**
       * Calculate the number of months between two Date objects (decimal to the nearest day)
       * 
       * @param {Date} d1 JavaScript Date object to be manipulated
       * @param {Date} d2 JavaScript Date object to be manipulated
       * @return {Number}
       * @ignore
       */      
      diff: function(d1, d2) {
        // get the number of years
        var diffYears = d1.getFullYear() - d2.getFullYear();
        // get the number of remaining months
        var diffMonths = d1.getMonth() - d2.getMonth() + (diffYears * 12);
        // get the number of remaining days
        var diffDays = d1.getDate() - d2.getDate();
        // return the month difference with the days difference as a decimal
        return diffMonths + (diffDays / 30);
      }
    },    
    year: {
      /**
       * Add a number of years
       * 
       * @param {Date} d  JavaScript Date object to be manipulated
       * @param {Number} number  The number of years to add
       * @return {Number}
       * @ignore
       */       
      add: function(d, number) {
        d.setYear(d.getFullYear() + Math[number > 0 ? 'floor' : 'ceil'](number));
      },
      /**
       * Calculate the number of years between two Date objects (decimal to the nearest day)
       * 
       * @param {Date} d1 JavaScript Date object to be manipulated
       * @param {Date} d2 JavaScript Date object to be manipulated
       * @return {Number}
       * @ignore
       */       
      diff: function(d1, d2) {
        return multipliers.month.diff(d1, d2) / 12;
      }
    }    
  };
  //
  // alias each multiplier with an 's' to allow 'year' and 'years' for example
  //
  $p.each('millisecond second minute hour day month year', function(unit) {
    multipliers[unit + 's'] = multipliers[unit];
  });  
  /**
   * Wrapper class for Date objects
   * 
   * @constructor
   * @requires base
   * @example
   *   var $D = pulp.date.getInstance;
   *   $D('2009-08-15 08:03:00').add(1, 'month').strftime('%m/%d/%Y'); // 09/15/2009
   */
  pulp.date = function(DateObj) {
    /**
     * The raw Date object
     * @type {Date}
     */
    this.raw = DateObj;
  };
  $p.extend(pulp.date.prototype, /** @lends pulp.date# */{
    /**
     * Add an arbitrary amount to the currently stored date
     *
     * @param {Number} number    
     * @param {String} unit
     * @return {Date}
     * @chainable   
     */
    add: function(number, unit) {
      var factor = multipliers[unit] || multipliers.day;
      // check timezone start in case we pass over a daylight-savings period
      var tzStart = this.getTimezoneOffset();
      if (typeof factor == 'number') {
        this.setTime(this.getTime() + (factor * number));
      } else {
        factor.add(this, number);
      }
      // adjust if starting timezone is different than ending time zone
      var tzEnd = this.getTimezoneOffset();
      if ((/^months?$/).test(unit) && tzStart != tzEnd) {
        this.setTime(this.getTime() + multipliers.minute * (tzStart - tzEnd));
        
      } else if ((/^days?$/).test(unit) && tzStart != tzEnd) {
        this.setTime(this.getTime() + multipliers.minute * (tzEnd - tzStart));        
      }
      return this;
    },
    /**
     * Find the difference between the current and another date
     *
     * @param {String|Date|Number|pulp.date} dateObj
     * @param {String} unit
     * @param {Boolean} allowDecimal
     * @return {Number}
     */
    diff: function(dateObj, unit, allowDecimal) {
      // ensure we have a Date object
      dateObj = $D(dateObj);
      if (dateObj === null) {
        return null;
      }
      // get the multiplying factor integer or factor function
      var factor = multipliers[unit] || multipliers.day;
      // check timezone start in case we pass over a daylight-savings period
      var tzStart = this.getTimezoneOffset();
      if (typeof factor == 'number') {
        // multiply
        var unitDiff = (this.getTime() - dateObj.getTime()) / factor;
      } else {
        // run function
        var unitDiff = factor.diff(this, dateObj);
      }
      // adjust if starting timezone is different than ending time zone
      var tzEnd = this.getTimezoneOffset();
      if ((/^months?$/).test(unit) && tzStart != tzEnd) {
        unitDiff += multipliers.minute * (tzStart - tzEnd);
        
      } else if ((/^days?$/).test(unit) && tzStart != tzEnd) {
        unitDiff += multipliers.minute * (tzEnd - tzStart);
      }      
      // if decimals are not allowed, round toward zero
      return (allowDecimal ? unitDiff : Math[unitDiff > 0 ? 'floor' : 'ceil'](unitDiff));      
    },
    /**
     * Return a proper two-digit year integer
     *
     * @return {Number}
     */
    getShortYear: function() {
      return this.raw.getYear() % 100;
    },
    /**
     * Get the number of the current month, 1-12
     *
     * @return {Number}
     */
    getMonthNumber: function() {
      return this.raw.getMonth() + 1;
    },
    /**
     * Get the name of the current month
     *
     * @return {String}
     */
    getMonthName: function() {
      return pulp.date.MONTHNAMES[this.raw.getMonth()];
    },
    /**
     * Get the abbreviated name of the current month
     *
     * @return {String}
     */
    getAbbrMonthName: function() {
      return pulp.date.ABBR_MONTHNAMES[this.raw.getMonth()];
    },
    /**
     * Get the name of the current week day
     *
     * @return {String}
     */    
    getDayName: function() {
      return pulp.date.DAYNAMES[this.raw.getDay()];
    },
    /**
     * Get the abbreviated name of the current week day
     *
     * @return {String}
     */    
    getAbbrDayName: function() {
      return pulp.date.ABBR_DAYNAMES[this.raw.getDay()];
    },
    /**
     * Get the ordinal string associated with the day of the month (i.e. st, nd, rd, th)
     *
     * @return {String}
     */    
    getDayOrdinal: function() {
      return pulp.date.ORDINALNAMES[this.raw.getDate() % 10];
    },
    /**
     * Get the current hour on a 12-hour scheme
     *
     * @return {Number}
     */
    getHours12: function() {
      var hours = this.raw.getHours();
      return hours > 12 ? hours - 12 : (hours == 0 ? 12 : hours);
    },
    /**
     * Get the AM or PM for the current time
     *
     * @return {String}
     */
    getAmPm: function() {
      return this.raw.getHours() >= 12 ? 'PM' : 'AM';
    },
    /**
     * Get the current date as a Unix timestamp
     *
     * @return {Number}
     */
    getUnix: function() {
      return Math.round(this.raw.getTime() / 1000, 0);
    },
    /**
     * Get the GMT offset in hours and minutes (e.g. +06:30)
     *
     * @return {String}
     */
    getGmtOffset: function() {
      // get offset in minutes instead of seconds
      var hours = this.raw.getTimezoneOffset() / 60;
      // decide if we are ahead of or behind GMT
      var prefix = hours < 0 ? '+' : '-';
      hours = Math.abs(hours);
      // add the +/- to the padded number of hours to : to the padded minutes
      return prefix + pulp.date._zeroPad(Math.floor(hours), 2) + ':' + pulp.date._zeroPad((hours % 1) * 60, 2);
    },
    /**
     * Get the browser-reported name for the current timezone (e.g. MDT, Mountain Daylight Time)
     *
     * @return {String}
     */
    getTimezoneName: function() {
      var match = /(?:\((.+)\)$| ([A-Z]{3}) )/.exec(this.raw.toString());
      return match[1] || match[2] || 'GMT' + this.raw.getGmtOffset();
    },
    /**
     * Convert the current date to an 8-digit integer (%Y%m%d)
     *
     * @return {Number}
     */
    toYmdInt: function() {
      return (this.raw.getFullYear() * 10000) + (this.raw.getMonth() * 100) + this.raw.getDate();
    },  
    /**
     * Create a copy of a date object
     *
     * @return {pulp.date}
     */    
    clone: function() {
      return $D(this.getTime());
    }
  });

  // TODO: add JSDoc comments for all these methods
  // setters should return the date object
  $p.each('setDate setMonth setFullYear setYear setHours setMinutes setSeconds setMilliseconds setTime ' +
    'setUTCDate setUTCMonth setUTCFullYear setUTCHours setUTCMinutes setUTCSeconds setUTCMilliseconds'
  , function(method) {
    pulp.date.prototype[method] = function() {
      if (this.raw instanceof Date) { // for some reason, this check prevents the "Date.prototype.valueOf called on incompatible Window" TypeError
        Date.prototype[method].apply(this.raw, Array.prototype.slice.call(arguments, 0));
        return this;
      }
    };
  });
 
  // getters should return the value
  $p.each('getDate getDay getMonth getFullYear getYear getHours getMinutes getSeconds getMilliseconds getTime getTimezoneOffset ' +
     'getUTCDate getUTCDay getUTCMonth getUTCFullYear getUTCHours getUTCMinutes getUTCSeconds getUTCMilliseconds ' +
     'toString toGMTString toLocaleString valueOf'
   , function(method) {
     pulp.date.prototype[method] = function() {
       if (this.raw instanceof Date) { // for some reason, this check prevents the "Date.prototype.valueOf called on incompatible Window" TypeError
         return Date.prototype[method].apply(this.raw, Array.prototype.slice.call(arguments, 0));
       }
     };
   });
 

  //
  // Add static methods to the date object
  //
  $p.extend(pulp.date, /** @scope pulp.date */{
    /**
     * The heart of the date functionality: returns a date object if given a convertable value
     *
     * @param {String|Date|Number|pulp.date}  date
     * @return {pulp.date|NaN}
     */
    getInstance: function(date) {   
      // allow instantiation like native Date object
      var a = arguments;
      switch (a.length) {
        case 0: return new pulp.date(new Date());
        case 3: return new pulp.date(new Date(a[0], a[1], a[2]));
        case 4: return new pulp.date(new Date(a[0], a[1], a[2], a[3]));
        case 5: return new pulp.date(new Date(a[0], a[1], a[2], a[3], a[4]));
        case 6: return new pulp.date(new Date(a[0], a[1], a[2], a[3], a[4], a[5]));
        case 7: return new pulp.date(new Date(a[0], a[1], a[2], a[3], a[4], a[5], a[6]));
      }
      // case for 1 argument
      // If the passed value is already a pulp.date object, return it
      if (date instanceof pulp.date) {
        return date;
      }
      // If the passed value is a Date object, just wrap it
      if (date instanceof Date) {
        return new pulp.date(date);
      }
      // If the passed value is a number, interpret it as ms
      if (typeof date == 'number') {
        return new pulp.date(new Date(date));
      }
      // interpret date as a string
      date = $p.trim($p.castAsString(date));
      var result;
      $p.each(pulp.date.patterns, function(pattern) {
        if (typeof pattern == 'function') {        
          var obj = pattern(date);
          // the function should return an instance of pulp.date if it recognizes the string
          if (obj instanceof pulp.date) {
            result = obj;
            throw pulp.Break;
          }        
        } else if (date.match(pattern[0])) {
          // we know this format
          if (pattern[1]) {
            // there is a replacement to make before Date.parse can handle it
            date = date.replace(pattern[0], pattern[1]);
          }
          result = new pulp.date(new Date(Date.parse(date)));
          throw pulp.Break;
        }
      });
      // date may still not be recognized
      return result || NaN;
    },
    /**
     * Return a JavaScript timestamp given a date string, unix timestamp, Date object, pulp.date object.
     * (similar to Date.parse())
     * 
     * @param {String|Date|Number|pulp.date} d
     * @return {pulp.date|NaN}
     */
    parse: function(d) {
      d = $D(d);
      return isNaN(d) ? d : d.raw.getTime();
    },
    /**
     * Wrapper for Date.UTC()
     * 
     * @param {Number} Y  Four-digit year
     * @param {Number} M  Month 0-11
     * @param {Number} D  Day
     * @param {Number} [H=0]  Hour
     * @param {Number} [i=0]  Minute
     * @param {Number} [s=0]  Second
     * @return {Number} 
     */
    UTC: function(Y, M, D, H, i, s) {
      return Date.UTC(Y, M, D, H, i, s);
    },
    /**
     * @constant  Full month names in the desired language
     * @type {Array}
     */
    MONTHNAMES      : 'January February March April May June July August September October November December'.split(' '),
    /**
     * @constant  Abbreviated month names in the desired language
     * @type {Array}
     */    
    ABBR_MONTHNAMES : 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' '),
    /**
     * @constant  Full names of the days of the week in the desired language
     * @type {Array}
     */    
    DAYNAMES        : 'Sunday Monday Tuesday Wednesday Thursday Friday Saturday'.split(' '),
    /**
     * @constant  Abbreviated names of the days of the week in the desired language
     * @type {Array}
     */    
    ABBR_DAYNAMES   : 'Sun Mon Tue Wed Thu Fri Sat'.split(' '),
    /**
     * @constant  Ordinal suffixes in the desired language (e.g. 1st, 2nd, 3rd)
     * @type {Array}
     */        
    ORDINALNAMES    : 'th st nd rd th th th th th th'.split(' '),
    /**
     * @constant  Shortcut for full ISO-8601 date conversion
     * @type {String}
     */
    ISO: '%Y-%m-%dT%H:%M:%S.%N%G',
    /**
     * @constant  Shortcut for SQL-type formatting
     * @type {String}
     */
    SQL: '%Y-%m-%d %H:%M:%S',
    /**
     * Get the number of days in the given month
     *
     * @param {Number} year  Four-digit year
     * @param {Number} month  Month number 1-12
     * @return {Number}  The number of days in the month
     */
    daysInMonth: function(year, month) {
      if (month == 2)
        return new Date(year, 1, 29).getDate() == 29 ? 29 : 28;
      return [undefined,31,undefined,31,30,31,30,31,31,30,31,30,31][month];
    },
    /**
     * @param {String} name  The name of the formatter function on pulp.date
     * @param {String} defaultFormat  The format to use if none is given
     * @return 
     */
    addFormatter: function(name, defaultFormat) {
      //
      // take a date instance and a format code and return the formatted value
      //
      var shortcuts = pulp.date[name].shortcuts;
      var codes = pulp.date[name].codes;
      var matcher = pulp.date[name].matcher;
      var getter, nbr, source, result, match;
      var DateProto = pulp.date.prototype;
      /** @ignore */
      pulp.date.prototype['_' + name + 'ConvertCode'] = function(code) {      
        if (shortcuts[code]) {
          // process any shortcuts recursively
          return this[name](shortcuts[code]);
          
        } else {
          // get the format code function and toPaddedString() argument
          getter = (codes[code] || '').split('.');
          nbr = this['get' + getter[0]] ? this['get' + getter[0]]() : '';
          // run toPaddedString() if specified
          if (getter[1]) {
            nbr = pulp.date._zeroPad(nbr, getter[1]);
          }
          // prepend the leading character
          return nbr;
        }  
      };
      /** @ignore */
      pulp.date.prototype[name] = function(formatStr) {
        // default the format string to year-month-day
        source = formatStr || defaultFormat;
        result = '';
        // replace each format code
        while (source.length > 0) {
          if ((match = source.match(matcher))) {          
            result += source.slice(0, match.index);
            result += (match[1] || '') + this['_' + name + 'ConvertCode'](match[2]);
//console.log(match);            
            source = source.slice(match.index + match[0].length);
          } else {
            result += source;
            source = '';
          }
        }
        return result;
      };
      return $D;
    },
    /**
     * Add leading zeros
     * 
     * @param {Number} number  The number to which to prepend zeros
     * @param {Number} digits  The total length to output
     * @return {String}
     */
    _zeroPad: function(number, digits) {
      number = String(number);
      while (number.length < digits) {
        number = '0' + number;
      }
      return number;
    } 
  });
  
  var $D = pulp.date.getInstance;
  
  /**
   * @namespace  2-part regex matcher for format codes
   *
   * first match must be the character before the code (to account for escaping)
   * second match must be the format code character(s)
   */
  pulp.date.strftime = {
    matcher: /()%(#?(%|[a-z]))/i,
    
    //
    // format codes for strftime
    //
    // each code must be an array where the first member is the name of a Date.prototype function
    // and optionally a second member indicating the number to pass to Number#toPaddedString()
    //
    codes: {
      // year
      Y: 'FullYear',
      y: 'ShortYear.2',
      // month
      m: 'MonthNumber.2',
   '#m': 'MonthNumber',
      B: 'MonthName',
      b: 'AbbrMonthName',
      // day
      d: 'Date.2',
   '#d': 'Date',
      e: 'Date',
      A: 'DayName',
      a: 'AbbrDayName',
      w: 'Day',
      o: 'DayOrdinal',
      // hours
      H: 'Hours.2',
   '#H': 'Hours',
      I: 'Hours12.2',
   '#I': 'Hours12',
      p: 'AmPm',
      // minutes
      M: 'Minutes.2',
   '#M': 'Minutes',
      // seconds
      S: 'Seconds.2',
   '#S': 'Seconds',
      s: 'Unix',
      // milliseconds
      N: 'Milliseconds.3',
   '#N': 'Milliseconds',
      // timezone
      O: 'TimezoneOffset',
      Z: 'TimezoneName',
      G: 'GmtOffset'  
    },
    //
    // shortcuts that will be translated into their longer version
    //
    // be sure that format shortcuts do not refer to themselves: this will cause an infinite loop
    //
    shortcuts: {
      // date
      F: '%Y-%m-%d',
      // time
      T: '%H:%M:%S',
      X: '%H:%M:%S',
      // local format date
      x: '%m/%d/%y',
      D: '%m/%d/%y',
      // local format extended
   '#c': '%a %b %e %H:%M:%S %Y',
      // local format short
      v: '%e-%b-%Y',
      R: '%H:%M',
      r: '%I:%M:%S %p',
      // tab and newline
      t: '\t',
      n: '\n',
    '%': '%'
    }
  };
  
  // add the strftime functions that use the codes and shortcuts above
  pulp.date.addFormatter('strftime', '%Y-%m-%d');
  /**
   * @namespace  A list of conversion patterns (array arguments sent directly to gsub)
   * Add, remove or splice a patterns to customize date parsing ability
   */
  pulp.date.patterns = {
    
    us: // US-style time (1/31/1980 or 1-31-1980)
    [( /^(1[0-2]|0?\d)\s*[\/-]\s*(3[01]|[0-2]?\d)\s*[\/-]\s*([1-9]\d{3})$/ ), '$1/$2/$3'],
    //   ^day                    ^month                     ^year
    //   \1                      \2                         \3
    
    world: // World time (31.1.1980 or 31.01.1980)
    [( /^(3[01]|[0-2]?\d)\s*\.\s*(1[0-2]|0?\d)\s*\.\s*([1-9]\d{3})$/ ), '$2/$1/$3'],
    //   ^day                    ^month               ^year
    //   \1                      \2                   \3
        
    iso: // ISO-style time (1980-1-31 or 1980-01-31)
    [( /^([1-9]\d{3})\s*-\s*(1[0-2]|0?\d)\s*-\s*(3[01]|[0-2]?\d)$/ ), '$2/$3/$1'],
    //   ^year              ^month              ^day    
    //   \1                 \2                  \3
    
    dayNamedMonth: // Named month (31 Jan, 1980)
    [( /^(3[01]|[0-2]\d)(?:st|nd|rd|th)?\s*(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*,?\s*([1-9]\d{3})$/i )],  
    //  ^day                               ^month name                                                 ^year
    //  \1                                  \2                                                          \4
    
    namedMonthDay: // Named month (Jan 31, 1980)
    [( /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s*(\d|[0-2]\d|3[01])(?:st|nd|rd|th)?\s*,?\s*([1-9]\d{3})$/i )],
    //   ^month name                                               ^day                                      ^year
    //   \1                                                        \2                                        \3    
    
    chicago: // Chicago (31-Jan-1980)
    [( /^(3[01]|[0-2]?\d)[ -](jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*[ -]?([1-9]\d{3})$/i ), '$2 $1 $3'],
    //   ^day of month        ^month text                                                 ^year
    //   \1                   \2                                                          \3
    
    hour24:
    function(str) { // 24-hour time (23:59:59)
      var match = str.match( /^(?:(.+)\s+)?([0-1]\d|2[0-3])(?:\s*\:\s*([0-5]\d))?(?:\s*\:\s*([0-5]\d))?(?:\s+(.+))?$/i );
      //                      ^opt. date   ^hour           ^opt. minute          ^opt. second          ^opt. date
      //                      \1           \2              \3                    \4                    \5
      if (match) {
        if (match[1] || match[5]) {
          // date string
          var d = $D(match[1] || match[5]);
//console.log('converting date string ' + (match[1] || match[5]), d.raw);          
          if (isNaN(d)) {
            return;
          }
        } else {
          var d = $D();
          d.setMilliseconds(0);
        }
        d.setHours(parseFloat(match[2]), parseFloat(match[3] || 0), parseFloat(match[4] || 0));
        return d;
      }
    },
    
    hour12: // 12-hour time (11:59:59pm)
    function(str) {
      var match = str.match( /^(?:(.+?)\s+)?(?:at\s+)?([1-9]|1[012])(?:\s*\:\s*([0-5]\d))?(?:\s*\:\s*([0-5]\d))?\s*(am|pm)(?:\s+(.+))?$/i );
      //                      ^optional date str.    ^hour         ^opt. minute          ^opt. second             ^am or pm  ^opt. date
      //                      \1                     \2            \3                    \4                       \5         \6
      if (match) {
        if (match[1] || match[6]) {
          // date string
          var d = $D(match[1] || match[6]);
          if (isNaN(d)) {
            return;
          }
        } else {
          var d = $D();
          d.setMilliseconds(0);
        }
        var hour = parseFloat(match[2]);
        hour = match[5].toLowerCase() == 'am' ? (hour == 12 ? 0 : hour) : (hour == 12 ? 12 : hour + 12);
        d.setHours(hour, parseFloat(match[3] || 0), parseFloat(match[4] || 0));
        return d;
      }
    },
    
    namedMonthYear: // Text with no year (January 4th, July the 4th)
    function (str) {
      var match = str.match( /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(?:the\s+)?(3[01]|[0-2]?\d)(?:st|nd|rd|th)?$/i );
      //                       ^month name                                                          ^day
      //                       \1                                                                   \2
      if (match) {
        return $D(match[1] + ' ' + match[2] + ' ' + new Date().getFullYear());
      }
    }    
  };

})();