# vim: set noet nosta sw=4 ts=4 ft=ragel :

%%{

#
# Generate the actual code like so:
#    ragel -R -T1 -Ls inputfile.rl
#

machine interval_expression;

########################################################################
### A C T I O N S
########################################################################

action set_mark { mark = p }

action set_valid   { event.instance_variable_set( :@valid, true ) }
action set_invalid { event.instance_variable_set( :@valid, false ) }
action recurring   { event.instance_variable_set( :@recurring, true ) }

action start_time {
        time = event.send( :extract, mark, p - mark )
        event.send( :set_starting, time, :time )
}

action start_interval {
        interval = event.send( :extract, mark, p - mark )
        event.send( :set_starting, interval, :interval )
}

action execute_time {
        time = event.send( :extract, mark, p - mark )
        event.send( :set_interval, time, :time )
}

action execute_interval {
        interval = event.send( :extract, mark, p - mark )
        event.send( :set_interval, interval, :interval )
}

action execute_multiplier {
        multiplier = event.send( :extract, mark, p - mark ).sub( / times/, '' )
        event.instance_variable_set( :@multiplier, multiplier.to_i )
}

action ending_time {
        time = event.send( :extract, mark, p - mark )
        event.send( :set_ending, time, :time )
}

action ending_interval {
        interval = event.send( :extract, mark, p - mark )
        event.send( :set_ending, interval, :interval )
}

########################################################################
### P R E P O S I T I O N S
########################################################################

recur_preposition    = ( 'every' | 'each' | 'per' | 'once' ' per'? ) @recurring;
time_preposition     = 'at' | 'on';
interval_preposition = 'in';

########################################################################
### K E Y W O R D S
########################################################################

interval_times = 
        ( 'milli'? 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' ) 's'?;

start_identifiers  = ( 'start' | 'begin' 'n'? ) 'ing'?;
exec_identifiers   = ('run' | 'exec' 'ute'? | 'do' );
ending_identifiers = ( ('for' | 'until' | 'during') | ('end'|'finish'|'stop'|'complet' 'e'?) 'ing'? );

########################################################################
### T I M E  S P E C S
########################################################################

# 1st
# 202nd
# 2015th
# ...
#
ordinals = (
                ( (digit+ - '1')? '1' 'st' ) |
                ( digit+?
                        ( '1' digit 'th' ) |  # all '11s'
                        ( '2' 'nd' )       |
                        ( '3' 'rd' )       |
                        ( [0456789] 'th' )
                )
        );

# 2014-05-01
# 2014-05-01 15:00
# 2014-05-01 15:00:30
#
fulldate = digit{4} '-' digit{2} '-' digit{2}
        ( space digit{2} ':' digit{2} ( ':' digit{2} )? )?;

# 10am
# 2:45pm
#
time = digit{1,2} ( ':' digit{2} )? ( 'am' | 'pm' );

# union of the above
date_or_time = fulldate | time;

# 20 seconds
# 5 hours
# 1 hour
# 2.5 hours
# an hour
# a minute
# other minute
#
interval = (
                (( 'a' 'n'? | [1-9][0-9]* ( '.' [0-9]+ )? ) | 'other' | ordinals ) space
        )? interval_times;

########################################################################
### A C T I O N   C H A I N S
########################################################################

start_time = date_or_time                         >set_mark %start_time;
start_interval = interval                     >set_mark %start_interval;

start_expression = ( (time_preposition space)? start_time ) |
        ( (interval_preposition space)? start_interval );

execute_time = date_or_time                     >set_mark %/execute_time;
execute_interval = interval                  >set_mark %execute_interval;
execute_multiplier = ( digit+ space 'times' )
        >set_mark %execute_multiplier @recurring;

execute_expression = (
        # regular dates and intervals
                ( time_preposition space execute_time ) |
                ( ( interval_preposition | recur_preposition ) space execute_interval )
        ) | (
        # count + interval (10 times every minute)
                execute_multiplier space ( recur_preposition space )? execute_interval
        ) |
        # count for 'timeboxed' intervals
                execute_multiplier;

ending_time = date_or_time                       >set_mark %ending_time;
ending_interval = interval                   >set_mark %ending_interval;

ending_expression = ( (time_preposition space)? ending_time ) |
        ( (interval_preposition space)? ending_interval );

########################################################################
### M A C H I N E S
########################################################################

Start = (
        start:      start_identifiers space                   -> StartTime,
        StartTime:  start_expression                          -> final
);

Interval = (
        start:
        Decorators: ( exec_identifiers space )?              -> ExecuteTime,
        ExecuteTime: execute_expression                      -> final
);

Ending = (
        start: space ending_identifiers space                -> EndingTime,
        EndingTime: ending_expression                        -> final
);

main := (
                        ( Start space Interval Ending? )   |
                        ( Interval ( space Start )? Ending? ) |
                        ( Interval Ending space Start )
                ) %set_valid @!set_invalid;

}%%

require 'symphony' unless defined?( Symphony ) require 'symphony/metronome' require 'symphony/metronome/mixins'

using Symphony::Metronome::TimeRefinements

### Parse natural English expressions of times and intervals. ### ### in 30 minutes ### once an hour ### every 15 minutes for 2 days ### at 2014-05-01 ### at 2014-04-01 14:00:25 ### at 2pm ### starting at 2pm once a day ### start in 1 hour from now run every 5 seconds end at 11:15pm ### every other hour ### once a day ending in 1 week ### run once a minute for an hour starting in 6 days ### run each hour starting at 2010-01-05 09:00:00 ### 10 times a minute for 2 days ### run 45 times every hour ### 30 times per day ### start at 2010-01-02 run 12 times and end on 2010-01-03 ### starting in an hour from now run 6 times a minute for 2 hours ### beginning a day from now, run 30 times per minute and finish in 2 weeks ### execute 12 times during the next 2 minutes ### class Symphony::Metronome::IntervalExpression

include Comparable,
        Symphony::Metronome::TimeFunctions
extend Loggability

log_to :symphony

# Ragel accessors are injected as class methods/variables for some reason.
%% write data;

# Words/phrases in the expression that we'll strip/ignore before parsing.
COMMON_DECORATORS = [ 'and', 'then', /\s+from now/, 'the next' ];

########################################################################
### C L A S S   M E T H O D S
########################################################################

### Parse a schedule expression +exp+.
###
### Parsing defaults to Time.now(), but if passed a +time+ object,
### all contexual times (2pm) are relative to it.  If you know when
### an expression was generated, you can 'reconstitute' an interval
### object this way.
###
def self::parse( exp, time=nil )

        # Normalize the expression before parsing
        #
        exp = exp.downcase.
                gsub( /(?:[^[a-z][0-9][\.\-:]\s]+)/, '' ).   # . : - a-z 0-9 only
                gsub( Regexp.union(COMMON_DECORATORS), '' ). # remove common decorator words
                gsub( /\s+/, ' ' ).                          # collapse whitespace
                gsub( /([:\-])+/, '\1' ).                    # collapse multiple - or : chars
                gsub( /\.+$/, '' )                           # trailing periods

        event = new( exp, time || Time.now )
        data  = event.instance_variable_get( :@data )

        # Ragel interface variables
        #
        key    = ''
        mark   = 0
        %% write init;
        eof = pe
        %% write exec;

        # Attach final time logic and sanity checks.
        event.send( :finalize )

        return event
end

########################################################################
### I N S T A N C E   M E T H O D S
########################################################################

### Instantiate a new TimeExpression, provided an +expression+ string
### that describes when this event will take place in natural english,
### and a +base+ Time to perform calculations against.
###
private_class_method :new
def initialize( expression, base ) # :nodoc:
        @exp  = expression
        @data = expression.to_s.unpack( 'c*' )
        @base = base

        @valid      = false
        @recurring  = false
        @starting   = nil
        @interval   = nil
        @multiplier = nil
        @ending     = nil
end

######
public
######

# Is the schedule expression parsable?
attr_reader :valid

# Does this event repeat?
attr_reader :recurring

# The valid start time for the schedule (for recurring events)
attr_reader :starting

# The valid end time for the schedule (for recurring events)
attr_reader :ending

# The interval to wait before the event should be acted on.
attr_reader :interval

# An optional interval multipler for expressing counts.
attr_reader :multiplier

### If this interval is on a stack somewhere and ready to
### fire, is it okay to do so based on the specified
### expression criteria?
###
### Returns +true+ if it should fire, +false+ if it should not
### but could at a later attempt, and +nil+ if the interval has
### expired.
###
def fire?
        now = Time.now

        # Interval has expired.
        return nil if self.ending && now > self.ending

        # Interval is not yet in its current time window.
        return false if self.starting - now > 0

        # Looking good.
        return true
end

### Just return the original event expression.
###
def to_s
        return @exp
end

### Inspection string.
###
def inspect
        return ( "<%s:0x%08x valid:%s recur:%s expression:%p " +
                                "starting:%p interval:%p ending:%p>" ) % [
                self.class.name,
                self.object_id * 2,
                self.valid,
                self.recurring,
                self.to_s,
                self.starting,
                self.interval,
                self.ending
        ]
end

### Comparable interface, order by interval, 'soonest' first.
###
def <=>( other )
        return self.interval <=> other.interval
end

#########
protected
#########

### Given a +start+ and +ending+ scanner position,
### return an ascii representation of the data slice.
###
def extract( start, ending )
        slice = @data[ start, ending ]
        return '' unless slice
        return slice.pack( 'c*' )
end

### Parse and set the starting attribute, given a +time_arg+
### string and the +type+ of string (interval or exact time)
###
def set_starting( time_arg, type )
        @starting_args ||= []
        @starting_args << time_arg

        # If we already have seen a start time, it's possible the parser
        # was non-deterministic and this action has been executed multiple
        # times. Re-parse the complete date string, overwriting any previous.
        time_arg = @starting_args.join( ' ' )

        start = self.get_time( time_arg, type )
        @starting = start

        # If start time is expressed as a post-conditional (we've
# already got an end time) we need to recalculate the end
        # as an offset from the start.  The original parsed ending
        # arguments should have already been cached when it was
        # previously set.
        #
        if self.ending && self.recurring
                self.set_ending( *@ending_args )
        end

        return @starting
end

### Parse and set the interval attribute, given a +time_arg+
### string and the +type+ of string (interval or exact time)
###
### Perform consistency and sanity checks before returning an
### integer representing the amount of time needed to sleep before
### firing the event.
###
def set_interval( time_arg, type )
        interval = nil
        if self.starting && type == :time
                raise Symphony::Metronome::TimeParseError, "That doesn't make sense, just use 'at [datetime]' instead"
        else
                interval = self.get_time( time_arg, type )
                interval = interval - @base
        end

        @interval = interval
        return @interval
end

### Parse and set the ending attribute, given a +time_arg+
### string and the +type+ of string (interval or exact time)
###
### Perform consistency and sanity checks before returning a
### Time object.
###
def set_ending( time_arg, type )
        ending = nil

        # Ending dates only make sense for recurring events.
        #
        if self.recurring
                @ending_args = [ time_arg, type ] # squirrel away for post-set starts

                # Make the interval an offset of the start time, instead of now.
                #
                # This is the contextual difference between:
                #   every minute until 6 hours from now (ending based on NOW)
                #   and
                #   starting in a year run every minute for 1 month (ending based on start time)
                #
                if self.starting && type == :interval
                        diff = self.parse_interval( time_arg )
                        ending = self.starting + diff

                # (offset from now)
                #
                else
                        ending = self.get_time( time_arg, type )
                end

                # Check the end time is after the start time.
                #
                if self.starting && ending < self.starting
                        raise Symphony::Metronome::TimeParseError, "recurring event ends before it begins"
                end

        else
                self.log.debug "Ignoring ending date, event is not recurring."
        end

        @ending = ending
        return @ending
end

### Perform finishing logic and final sanity checks before returning
### a parsed object.
###
def finalize
        raise Symphony::Metronome::TimeParseError, "unable to parse expression" unless self.valid

        # Ensure start time is populated.
        #
        unless self.starting
                if self.recurring
                        @starting = @base
                else
                        raise Symphony::Metronome::TimeParseError, "non-deterministic expression" if self.interval.nil?
                        @starting = @base + self.interval
                end
        end

        # Alter the interval if a multiplier was specified.
        #
        if self.multiplier
                if self.ending

                        # Regular 'count' style multipler with end date.
                        # (run 10 times a minute for 2 days)
                        # Just divide the current interval by the count.
                        #
                        if self.interval
                                @interval = self.interval.to_f / self.multiplier

                        # Timeboxed multiplier (start [date] run 10 times end [date])
                        # Evenly spread the interval out over the time window.
                        #
                        else
                                diff = self.ending - self.starting
                                @interval = diff.to_f / self.multiplier
                        end

                # Regular 'count' style multipler (run 10 times a minute)
                # Just divide the current interval by the count.
                #
                else
                        raise Symphony::Metronome::TimeParseError, "An end date or interval is required" unless self.interval
                        @interval = self.interval.to_f / self.multiplier
                end
        end
end

### Given a +time_arg+ string and a type (:interval or :time),
### dispatch to the appropriate parser.
###
def get_time( time_arg, type )
        time = nil

        if type == :interval
                secs = self.parse_interval( time_arg )
                time = @base + secs if secs
        end

        if type == :time
                time = self.parse_time( time_arg )
        end

        raise Symphony::Metronome::TimeParseError, "unable to parse time" if time.nil?
        return time
end

### Parse a +time_arg+ string (anything parsable buy Time.parse())
### into a Time object.
###
def parse_time( time_arg )
        time = Time.parse( time_arg, @base ) rescue nil

        # Generated date is in the past.
        #
        if time && @base > time

                # Ensure future dates for ambiguous times (2pm)
                time = time + 1.day if time_arg.length < 8

                # Still in the past, abandon all hope.
                raise Symphony::Metronome::TimeParseError, "attempt to schedule in the past" if @base > time
        end

        self.log.debug "Parsed %p (time) to: %p" % [ time_arg, time ]
        return time
end

### Parse a +time_arg+ interval string ("30 seconds") into an
### Integer.
###
def parse_interval( interval_arg )
        duration, span = interval_arg.split( /\s+/ )

        # catch the 'a' or 'an' case (ex: "an hour")
        duration = 1 if duration.index( 'a' ) == 0

        # catch the 'other' case, ie: 'every other hour'
        duration = 2 if duration == 'other'

        # catch the singular case (ex: "hour")
        unless span
                span = duration
                duration = 1
        end

        use_milliseconds = span.sub!( 'milli', '' )
        interval = calculate_seconds( duration.to_f, span.to_sym )

        # milliseconds
        interval = duration.to_f / 1000 if use_milliseconds

        self.log.debug "Parsed %p (interval) to: %p" % [ interval_arg, interval ]
        return interval
end

end # class TimeExpression