##################  timehash.rb -- brent@mbari.org  #####################
# $Source: /home/cvs/ESP/gen2/software/esp/lib/timehash.rb,v $
# $Id: timehash.rb,v 1.42 2005/11/04 00:39:59 brent Exp $
#
#  The TimeHash class extends hash to handle time based keys more
#  conveniently.
#
#  This file also adds some constants to the Time class and
#  extends the ParseDate modules
#
#  Selected Methods:
#     Time.last => result from previous Time() call in this thread
#     Time(time_key) => aTime
#     th.put (time_key, value)* => TimeHash th
#       time_keys are passed to Time() method
#
#  All other methods are inherited from Hash
#
#  Notes about time_key in the argument lists above:
#   time_key may be specified in a number of forms, however, they are
#   always stored in the TimeHash as type Time.
#
#   time_key above may be an instance of class:
#     Time          =>  stored unmodified in Timehash
#     Numeric       =>  seconds from the start of the epoch
#              Note that (Time.last + Numeric) will yeild a relative time
#     String        =>  parsedate(String) --> absolute time
#                       a leading "+" --> relative to last time stored  
#
#  Absolute time_keys passed as strings are assumed to be later than 
#     Time.last.  This affects how unspecified fields in the string
#     are merged with Time.last.  See examples below.
#
#  Exceptions:
#     All methods raise ArgumentError if the time String specified is not a 
#     valid, unambiguous Time.
#
#  Usage Examples:
#     th=TimeHash.new   => a new, empty TimeHash
#     th.add ("Mar  6 18:05:56 UTC 2003", anObject) => TimeHash th
#         absolute Universal Coordinated Time
#
#     th["3/6/03 18:05", 4] => 4
#         56 seconds earlier on 3/6/03 in the local system timezone
#
#     th["3/6 18:05", 3] => 3
#         the year is always assumed to be the current one
#         unless the resulting time would be before the last one added.
#         In which case, the year is incremented.
#
#     th["Tue"] => 2
#         next Tuesday (3/11/03) at 18:05
#
#     th["Thurs 10:10pm", 1] => 1
#         next Thursday (3/13) at 22:10 local time
#
#     th["22:05", 0] => 0
#         Friday at 10:05pm
#
#     th.add "+25:05 %1", -1, "+:10", -2  => TimeHash th
#         Adds 2 times: 2 days 1 hour & 5 minutes after last time (Sunday) 
#                  and 10 minutes after that 2 day+ delay (later Sunday)
#         Note that relative times ignore any specified timezone or day of week
#              AM/PM indicators must not be used with in relative times
# 
############################################################################

require "parsedate"

class Time
  Minutes = 60
  Hours = 60*Minutes
  Days = 24*Hours
  EPS = 1.000001e-6  #resolution of Time is not quite one microsecond

  #symbolic indices into array returned by Time#toArray
  Sec=0; Min=1; Hour=2; Day=3; Mon=4; Year=5; Wday=6; Yday=7; IsDST=8; Zone=9; USec = 10
  
  def toArray
    to_a << usec
  end
  
  def Time.last
    Thread.current[:lastTime]
  end
  
  def Time.last= moment
    Thread.current[:lastTime]=moment
  end
  
  #determine the daylight saving adjustment increment for the local time zone
  UTCoffset = []
  0.step (366*Time::Days, 91*Time::Days) {|probe|
    UTCoffset << Time.at(probe).utc_offset
  }
  UTCoffset.uniq!
  DaylightAdjustment = (UTCoffset[0]-UTCoffset.last).abs
  if UTCoffset.length > 2
    puts "***Daylight saving adjustments may be inaccurate in this timezone!!"
  end

  def self.showUTCoffset
    print "UTC offset is "
    puts ((UTCoffset.collect {|o|o.to_f/Hours}.join ' or ') + " hours")
    if UTCoffset.length > 2
      puts "Daylight saving adjustments may be inaccurate!"
    end
  end
  
  def addDays days
  # return time exactly 'days' days in the future (or past)
  # adjusting for changes in daylight savings
    t1 = self + days*Days
    return t1 if days==0 || utc?
    adjustment = DaylightAdjustment
    adjustment = -adjustment if days<0
    t1offset = (t1-adjustment).utc_offset
#p adjustment,utc_offset,t1offset
    t1 + (utc_offset-t1offset)
  end
  
  def step (endTime, increment)  # analog to Integer#step
  # execute block with a set of Times beginning at self.
  # each subsequent execution is with time incremented by increment
  # the last execution is with a time <= endTime
    raise ArgumentError, 
       "Time Interval step size cannot be zero" if increment.abs < Time::EPS
    cursor = self
#p self, endTime, increment
    if increment > 0.0
      while cursor <= endTime
        yield cursor
        cursor += increment
      end
    else
      while cursor >= endTime
        yield cursor
        cursor += increment
      end    
    end
    self
  end
  
  def interval (endTime, increment)
  # return an array of Times beginning at self
    result=[]
    endTime = ParseDate.instant(endTime, self)      
    if increment.is_a? String
      cursor = self
      while cursor <= endTime
        result << cursor
        future = ParseDate.instant(increment, cursor)
        if future-cursor<Time::EPS
          break unless ParseDate.relative? increment  #year was specified
          raise ArgumentError, "Time Interval step size cannot be zero"
        end
        cursor = future
      end
    else
      step (endTime, increment) {|t| result << t}
    end
    result
  end
  
end


module ParseDate  #symbolic indices into array returned by parsedate
  Year=0; Mon=1; Mday=2; Hour=3; Min=4; Sec=5; USec=6; Zone=7; Wday=8
  
  # maps elements in parsed date to time array
  ToAfield = [Time::Year, Time::Mon, Time::Day, 
              Time::Hour, Time::Min, Time::Sec, Time::USec,
              Time::Zone, Time::Wday]
  
  ASunday = Time.utc 1984
  
  LocalTZ = []  #make list of local timezones to deal with daylight savings
  0.step (366*Time::Days, 91*Time::Days) {|probe|
    LocalTZ << Time.at(probe).zone.upcase
  }
  LocalTZ.uniq!
  ZonePattern=(LocalTZ|
    ['UTC','GMT','(C|E|M|P)T','MS(D|K)','[A-Z][A-Z]?(S|D|E)T']).join '|'
  
  def self.showTZ
    Time.showUTCoffset
    puts "Local TimeZone is #{LocalTZ.join ' or '}"
  end
  
  def self.parse (dateText, guessYear=false) #=>Array
  # extends parsedate so that yyyy/ddd or /ddd is interpreted as year days
  #   and ":mm" assigns only Min, "hh:" only Hour, "::ss" only seconds
  # also adds "hh:mm:ss.usec" to represent microseconds
  # note: parsedate is confused by forms like:  "1:00 January 2, 1984"
  # this is also confused by some bogus dates such as "January 2, 03::10"
  # 03 is parsed correctly as the year by parsedate, then we parse 03 as the hr  
    parsed = [].fill (nil, Year..Wday)
    upText = dateText.upcase
    if upText[/(\A|\s+|\d+|:|AM|PM)(#{ZonePattern})(\W|\s|\Z)/]
      parsed[Zone] = $2
      upText[$~.begin(0)...$~.end(0)] = " "
#puts upText
    end
    timeText = upText[/(\A|\s+)((\d+\s*(AM|PM))|(\d*:(\d*)(:\d*)?(\.\d*)?\s*(AM|PM)?))/]
#puts timeText
#(1...$~.length).each {|i|print "$#{i}=#{$~[i].inspect}, "} if $~; puts
    if timeText
      minute = $6 && $6.length>0 ? $6 : nil
      second = $7
      fraction = $8
      gotHour = $3 || ($5 && $5[0]!=?:)  #found hh PM? or hh:* PM?
      if gotHour 
        hr = timeText.to_i
        pm = $4 ? $4 : $9
        if pm
          parsed[Min]=parsed[Sec]=0 unless minute || second
          hr %= 12
          hr += 12 if pm=="PM"            
        end
        parsed[Hour] = hr
      end
      parsed[Min] = minute.to_i if minute
      if second
        parsed[Sec] = second[1..-1].to_i if second.length>1
      else
        parsed[Sec] = 0 if gotHour && minute
      end
      if fraction
        parsed[USec] = fraction[1..6].ljust(6).tr(' ','0').to_i if fraction.length>1
      else
        parsed[USec] = 0 if gotHour && minute # && second
      end
#p $~.begin(0)...$~.end(0)
      upText[$~.begin(0)...$~.end(0)] = ""
    end
    if upText[/(\d*)%(\d*)/]
      raise ArgumentError, 
        "Bogus Date: \"#{dateText}\"" if $'.include? "%"
      if $1.length > 0
        year = $1.to_i
        year+= (year>=69 ? 1900:2000) if guessYear && year>=0 && year<100
        parsed[Year] = year
      end
      parsed[Mday] = $2.to_i if $2.length>0
      upText[$~.begin(0)...$~.end(0)] = ""
    end
    parsed2 = parsedate (upText, guessYear)  #parse just the date or weekday
    parsed.each_index { | field |      
      parsed[field] = parsed2[field] unless parsed[field]
    }
    parsed
  end
  
  def self.zoneMethod zone, local=:local, ref=Time.last
    zone=zone.upcase if zone
    case zone
      when "UTC","GMT"
        tz=:utc
      when *LocalTZ
        tz=local
      else
        raise ArgumentError,"Unsupported Time Zone: \"#{zone}\""
    end
  end  
  
  def self.mktime parsed #=>Time
  # convert array returned by parsedate or parse to aTime 
  # if Zone is specified, it must be either a local timezone, UTC or GMT
    year = parsed[Year]
    raise ArgumentError, "No Year specified" unless year 
    tmz = if zone=parsed[Zone]
      zoneMethod zone
    else
      ref=Time.last || Time.now
      ref.utc? ? :utc : :local
    end
    timeMethod = Time.method tmz
    unless parsed[Mon]      #assume we've been given a year day
      jday = parsed[Mday]
      raise ArgumentError, "Missing Month and Day for year #{year}" unless jday
      t = (timeMethod.call *[year,nil,nil,*(parsed[Hour..Sec])]).addDays(jday-1)
      raise ArgumentError, "No Day #{jday} in year #{year}" if year != t.year
    else
      t = timeMethod.call *(parsed[Year..Sec])
    end
    usec=parsed[USec]
    t+=usec*1e-6 if usec
    wday = parsed[Wday]
    return t unless wday and wday==t.wday
    raise ArgumentError,
            t.strftime ("%x falls on a %A -- not ")+
            ASunday.addDays(wday).strftime ("%A")
  end
  
  def self.merge (parsed, givenRef, bump=1) #=>aTime
  # merge parsed fields with a reference time
  # return later time closest to givenRef
    ref = givenRef.dup
    zone=parsed[Zone]
    ref.method(zoneMethod zone, :localtime).call if zone
    merged = parsed.dup
    if parsed[USec]
      offset = parsed[USec] - ref.usec
      offset += 1e6 if offset < bump; bump=0
      ref += offset*1e-6
    end
    if parsed[Sec]
      offset = parsed[Sec] - ref.sec
      offset += 60 if offset < bump; bump=0
      ref += offset
    end
    if parsed[Min]
      offset = parsed[Min] - ref.min
      offset += 60 if offset < bump; bump=0
      ref += offset*Time::Minutes
    end
    if parsed[Hour]
      offset = parsed[Hour] - ref.hour
      offset += 24 if offset < bump; bump=0
      ref += offset*Time::Hours
    end
    base = ref.toArray
    #don't merge month or week day from ref if day of month was specified
    unless parsed[Mday]
      unless parsed[Mon]
        unless parsed[Wday]       # adjust date iff only Wday specified
          base[Time::Wday] = nil  #don't merge day of week if only year changed
        else
          offset = parsed[Wday] - base[Time::Wday]
          offset += 7 if offset < bump; bump=0
          ref = ref.addDays(offset)
          base = ref.toArray
        end
      end
      (0...ToAfield.length).to_a
    else  #Mday was specified...
      unless parsed[Mon]     #if no month specified...
        yday = parsed[Mday]  #Mday must really be a year day
        base[Time::Year] += 1 if yday < base[Time::Yday]+bump
      else  #both month and day were specified
        month = base[Time::Mon]
        month += 1 if parsed[Mday] < base[Time::Day]+bump; bump=0
        base[Time::Year] += 1 if parsed[Mon] < month+bump
      end
      (Hour...Wday).to_a << Year
    end.each { | field |      
      merged[field] = base[ToAfield[field]] unless merged[field]
    }
#p parsed,merged
    mktime merged
  end

  def self.after (offset, ref=Time.last) #=>aTime
  # Define time delayed relative to ref
  # offset may be numeric number of seconds
  #        or a string suitable for parsing by ParseDate.parse
  #        in this string: Month names and PM/AM indicators should not be used
  #        specifying Day of Week will raise ArgumentError
#p offset, ref
    raise ArgumentError, "Time.last is undefined" unless ref
    if offset.kind_of? String
      parsed = parse (offset, false)      
      raise ArgumentError,
        "Bogus duration or delay: \"#{offset}\"" if parsed.nitems < 1
      raise ArgumentError, 
        "Delay may not contain weekday: "+ASunday.addDays(parsed[Wday]).
        strftime ("%A") if parsed[Wday]
      raise ArgumentError,
        "Delay may not contain letters: "+offset if offset[/[a-z]/i]
      #months and years vary in duration!
      if parsed[Year] or parsed[Mon]
        base = ref.toArray
        adjusted = []  #remap Time#to_a to fields returned by ParseDate
        (Year..Zone).each { | field | adjusted[field] = base[ToAfield[field]] }
        adjusted[Year] += parsed[Year] if parsed[Year]
        if parsed[Mon]
          months = (adjusted[Mon]-1)+parsed[Mon]
          adjusted[Year] += months / 12
          adjusted[Mon] = (months % 12)+1
        end
        ref = mktime adjusted
      end
      offset = 0
      offset += parsed[Sec] if parsed[Sec]
      offset += parsed[Min]*Time::Minutes if parsed[Min]
      offset += parsed[Hour]*Time::Hours if parsed[Hour]
      return (ref+offset).addDays(parsed[Mday]) if parsed[Mday]
    else
      raise TypeError,
        "Cannot convert #{offset} to a delay" unless offset.is_a? Numeric
    end
    ref+offset    
  end

  def self.fromString (timeText, ref=nil, bump=1)
    parsedFields = ParseDate.parse (timeText, true)
    raise ArgumentError,
      "Bogus Time: \"#{timeText}\"" if parsedFields.nitems < 1
    if ref 
      merge parsedFields, ref, bump
    else
      mktime parsedFields
    end
  end
  
  def self.abstime (time, ref=nil) #=>Time
  #return a new absolute Time derived from the specified time
    return time.dup if time.is_a? Time
    return Time.at(time) if time.kind_of? Numeric
    raise TypeError,
      "Cannot convert #{time.type} to Time" unless time.is_a? String
    fromString (time, ref)
  end
  
  def self.relative? timeString
  #return nil if timeString is an absolute time
    timeString[/\A\s*\+/]
    $'  #return string following the + mark if timeString is an offset
  end
  
  def self.instant(time, ref=Time.last) #=>aTime
  #return a new absolute Time derived from the specified time or offset
  #if time is a String and first printing character is +, treat time as offset
    return abstime(time) unless time.is_a? String
    (offset=relative? time) ? after(offset,ref) : abstime(time, ref)
  end

  def self.interval (str, ref=Time.last) #=>anArray of Time
  # parse string containing a time interval declaration into
  # an array of Time
  #   every [duration] from [time] until [time]
  # All keywords except from are required.  Keywords may be permuted
  # returns nil if no keywords found
    str[/from\s+(.*?)(\s+(every|until)|\Z)/i]
    from=$1
    orFrom = ("from|" if from)
    str[/every\s+(.*?)(\s+(#{orFrom}until)|\Z)/i]
    every=$1
    str[/until\s+(.*?)(\s+(#{orFrom}every)|\Z)/i]
    til=$1
    unless every && til
      raise ArgumentError, 
        "Interval requires 'every' and 'until' keywords: "+str if
        every||from||til
      return nil
    end
    from = instant(every, ref) unless from
    instant(from).interval(til,every)
  end
  
  def self.from_s timeString, ref=Time.last
    span = interval (timeString, ref)
    return span if span
    (offset=relative? timeString) ? after(offset,ref) : abstime(timeString, ref)
  end
      
  def self.to_time(time, ref=Time.last) #=>aTime
  #like instant below, but may return an Time Array to represent an interval
    if time.is_a? String
      from_s time, ref
    else
      abstime time
    end
  end

end


class TimeHash < Hash   #a Hash where all keys are of type Time

  def store (timeKey, value)
  # Add an association in this TimeHash
    super (Time(timeKey), value)
  end
  alias_method :[]=, :store
  
  def [] (timeKey)
  # retrieve an association in this TimeHash
    super (Time(timeKey))
  end
  
  @@missing = Object  #an object that is out of any caller's scope
  def fetch (timeKey, aDefObject=@@missing, &block)
    if aDefObject!=@@missing 
      super(Time(timeKey), aDefObject, &block)
    else
      super(Time(timeKey), &block)
    end
  end
  
  def add (*arg)
  # arg is a linear array of association pairs.  The "even" elements
  # are converted to type Time to serve as keys in the resulting hash
  # Note that hash literals are not used as arguments.  This ensures
  # that the hash keys are processed in the order in which
  # they appear in the source text
    raise ArgumentError, 
      "Must specify an EVEN number of arguments!" if arg.length % 2 != 0
    0.step (arg.length-1, 2) {|i|
      t = Time(arg[i])
      action = arg[i.next]
      if t.is_a? Array  #associate all times in array with this action
        t.each {|time| store (time,action)}
      else
        store (t, action)
      end
    }
    self
  end

  def show (f = $defout)
  # display a time hash as a sorted list for convienent viewing
    f.puts sort
  end

  def first
  # the key with the earliest time
    return nil unless earliest = keys[0]
    each_key { |t|
                earliest = t if t < earliest
             }
    earliest
  end

  def last
  # the key with the latest time    
    return nil unless latest = keys[0]
    each_key { |t|
                latest = t if t > latest
             }
    latest
  end 
end

class Numeric
  def time
    ParseDate.abstime self
  end
end

class String
  def time ref=Time.last
    ParseDate.from_s self, ref
  end
end

def Time (time, ref=Time.last) #=>aTime
#return a new absolute Time derived from the specified time or offset
#if time is a String and first printing character is +, treat time as an offset
  result = ParseDate.to_time(time, ref)
  Time.last = (result.kind_of? Array) ? result.last : result
  result  
end

