# encoding: UTF-8
require 'mauve/person'
require 'mauve/notifiers'

module Mauve
  # This class provides an execution context for the code found in 'during'
  # blocks in the configuration file.  This code specifies when an alert
  # should cause notifications to be generated, and can access @time and
  # \@alert variables.  There are also some helper methods to provide
  # oft-needed functionality such as hours_in_day.
  #
  # e.g. to send alerts only between 10 and 11 am:
  #
  #   during = Proc.new { @time.hour == 10 }
  #   
  # ... later on ...
  #   
  #   DuringRunner.new(Time.now, my_alert, &during).now?
  #
  # ... or to ask when an alert will next be cued ...
  #
  #   DuringRunner.new(Time.now, my_alert, &during).find_next
  #
  # which will return a Time object, or nil if the time period will
  # not be valid again, at least not in the next week.
  #
  class DuringRunner

    attr_reader :time, :alert, :during

    #
    # Sets up the class
    #
    # @param [Time] time The time we're going to test
    # @param [Mauve::Alert or Nilclass] alert The alert that we can use in tests.
    # @param [Proc] during The Proc that is evaluated inside the instance to decide if now is now!
    # @raise [ArgumentError] if +time+ is not a Time
    #
    def initialize(time, alert=nil, &during)
      raise ArgumentError.new("Must supply time (not #{time.inspect})") unless time.is_a?(Time)
      @time = time
      @alert = alert
      @during = during || Proc.new { true }
      @logger = Log4r::Logger.new "Mauve::DuringRunner"
      @now_cache = Hash.new
    end
    
    # This evaluates the +during+ block, returning the result.
    #
    # @param [Time] t Set the time at which to evaluate the +during+ block.
    # @return [Boolean]
    def now?(t=@time)
      #
      # Use the cache if we've already worked out what happens at time t.
      #
      return @now_cache[t] if @now_cache.has_key?(t)
     
      #
      # Store the test time in an instance variable so the test knows what time
      # we're testing against.
      #
      @test_time = t

      #
      # Store the answer in our cache and return.
      #
      @now_cache[t] = (instance_eval(&@during) ? true : false)
    end

    # This finds the next occurance of the +during+ block evaluating to true.
    # It returns nil if an occurence cannot be found within the next 8 days.
    #
    # @param [Integer] after Skip time forward this many seconds before starting
    # @return [Time or nil]
    #
    def find_next(after = 0)
      t = @time+after
      #
      # If the condition is true after x seconds, return the time in x seconds.
      #
      return t if self.now?(t)

      #
      # Otherwise calculate when the condition is next true.
      #
      step = 3600
      while t <= @time + 8.days
        #
        # If we're currently OK, and we won't be OK after the next step (or
        # vice-versa) decrease step size, and try again
        #
        if false == self.now?(t) and true == self.now?(t+step)
          #
          # Unless we're on the smallest step, try a smaller one.
          #
          if step == 1
            t += step
            break 
          end

          step /= 60
          next
        end

        #
        # Decrease the time by the step size if we're currently OK.
        #
        t += step
      end 
      
      return t if self.now?(t)
      
      nil # never again
    end
    
    protected

    # Test to see if a people_list is empty.
    #
    # @param [String] people_list People list to query
    # @return [Boolean]
    #
    def no_one_in(people_list)
      return true unless Configuration.current.people_lists.has_key?(people_list)
      @test_time = @time if @test_time.nil?

      return Configuration.current.people_lists[people_list].people(@test_time).empty?
    end

    # Returns true if the current hour is in the list of hours given.
    # 
    # @param [Array] hours List of hours (as Integers)
    # @return [Boolean]
    def hours_in_day(*hours)
      @test_time = @time if @test_time.nil?
      x_in_list_of_y(@test_time.hour, hours.flatten)
    end   
 
    # Returns true if the current day is in the list of days given
    #
    # @param [Array] days List of days (as Integers)
    # @return [Boolean]
    def days_in_week(*days)
      @test_time = @time if @test_time.nil?
      x_in_list_of_y(@test_time.wday, days.flatten)
    end
    
    # Tests if the alert has not been acknowledged within a certain time.
    #
    # @param [Integer] seconds Number of seconds
    # @return [Boolean]
    def unacknowledged(seconds)
      @test_time = @time if @test_time.nil?
      @alert &&
        @alert.raised? &&
        !@alert.acknowledged? &&
        (@test_time - @alert.raised_at) >= seconds
    end
    
    # Tests if the alert has raised for a certain time.
    #
    # @param [Integer] seconds Number of seconds
    # @return [Boolean]
    def raised_for(seconds)
      @test_time = @time if @test_time.nil?
      @alert &&
        @alert.raised? &&
        (@test_time - @alert.raised_at) >= seconds
    end


    # Checks to see if x is contained in y
    #
    # @param [Array] y Array to search for +x+
    # @param [Object] x
    # @return [Boolean]
    def x_in_list_of_y(x,y)
      y.any? do |range|
        if range.respond_to?("include?")
          range.include?(x)
        else
          range == x
        end
      end
    end

    # Test to see if we're in working hours.  See Time#working_hours?
    #
    # @return [Boolean]
    def working_hours?
      @test_time = @time if @test_time.nil?
      @test_time.working_hours?
    end

    #
    # Return true if today is a bank holiday
    #
    def bank_holiday?
      @test_time = @time if @test_time.nil?
      @test_time.bank_holiday?
    end

    # Test to see if we're in the dead zone.  See Time#dead_zone?
    #
    # @return [Boolean]
    def dead_zone?
      @test_time = @time if @test_time.nil?
      @test_time.dead_zone?
    end

    # 
    # Return true if we're in daytime_hours.  See Time#daytime_hours?
    #
    def daytime_hours?
      @test_time = @time if @test_time.nil?
      @test_time.daytime_hours?
    end
  end
  
  # A Notification is an instruction to notify a person, or a list of people,
  # at a particular alert level, on a periodic basis, and optionally under
  # certain conditions specified by a block of code.
  #
  class Notification

    attr_reader :during, :every, :level, :person

    # Set up a new notification
    #
    # @param [Array] person List of Mauve::Person to notify
    # @param [Symbol] level Level at which to notify
    def initialize(person)
      @person = person
      @during = nil
      @every = nil
      @level = nil
    end

    # @return [String]
    def to_s
      "#<Notification:of #{person} at level #{level} every #{every}>"
    end

    # @return Log4r::Logger 
    def logger ;  Log4r::Logger.new self.class.to_s ; end

    def during=(arg)
      @during = arg
    end

    def every=(arg)
      @every = arg
    end

    def level=(arg)
      @level = arg
    end

    def person=(arg)
      @person = arg
    end

    # Push a notification on to the queue for this alert.  The Mauve::Notifier
    # will then pop it off and do the notification in a separate thread.
    #
    # @param [Mauve::Alert or Mauve::AlertChanged] alert The alert in question
    # @param [Array] already_sent_to A list of people that have already received this alert.
    #
    # @return [Array] The list of people that have received this alert.
    def notify(alert, already_sent_to = [], during_runner = nil)

      if person.nil? 
        logger.warn "No person found in for notification #{list}"
        return
      end

      # Set up a during_runner
      during_runner ||= DuringRunner.new(Time.now, alert, &self.during)

      # Should we notify at all?
      return already_sent_to unless during_runner.now?

      case person
        when Person
          [person]
        when PeopleList
          person.people
        else
          logger.warn "Unable to notify #{person} (unrecognised class #{person.class})"
          []
      end.flatten.uniq.each do |person|
        #
        # A bit of alert de-bouncing.
        #
        if already_sent_to.include?(person.username)
          logger.info("Already sent notification of #{alert} to #{person.username}")
        else
          person.send_alert(level, alert)
          already_sent_to << person.username
        end
      end

      return already_sent_to
    end
   
    # Work out when this notification should next get sent.  Nil will be
    # returned if the alert is not raised.
    #
    # @param [Mauve::Alert] alert The alert in question
    # @return [Time or nil] The time a reminder should get sent, or nil if it
    #   should never get sent again.
    def remind_at_next(alert, during_runner = nil)
      return nil unless alert.raised?

      # Set up a during_runner
      during_runner ||= DuringRunner.new(Time.now, alert, &self.during)

      if during_runner.now?
        return during_runner.find_next(every)
      else
        return during_runner.find_next()
      end

    end

  end

end