#!/usr/bin/env ruby

# NB: use of redis.keys probably indicates we should maintain a data
# structure to avoid the need for this type of query

require 'set'
require 'ice_cube'
require 'flapjack/data/entity'
require 'flapjack/data/notification_rule'
require 'flapjack/data/tag'
require 'flapjack/data/tag_set'

module Flapjack

  module Data

    class Contact

      attr_accessor :id, :first_name, :last_name, :email, :media, :media_intervals, :media_rollup_thresholds, :pagerduty_credentials

      TAG_PREFIX = 'contact_tag'
      ALL_MEDIA  = ['email', 'sms', 'jabber', 'pagerduty']

      def self.all(options = {})
        raise "Redis connection not set" unless redis = options[:redis]

        redis.keys('contact:*').inject([]) {|ret, k|
          k =~ /^contact:(.*)$/
          id = $1
          contact = self.find_by_id(id, :redis => redis)
          ret << contact if contact
          ret
        }.sort_by {|c| [c.last_name, c.first_name]}
      end

      def self.find_by_id(contact_id, options = {})
        raise "Redis connection not set" unless redis = options[:redis]
        raise "No id value passed" unless contact_id
        logger = options[:logger]

        # sanity check
        return unless redis.hexists("contact:#{contact_id}", 'first_name')

        contact = self.new(:id => contact_id, :redis => redis, :logger => logger)
        contact.refresh
        contact
      end

      def self.add(contact_data, options = {})
        raise "Redis connection not set" unless redis = options[:redis]
        contact_id = contact_data['id']
        raise "Contact id value not provided" if contact_id.nil?

        if contact = self.find_by_id(contact_id, :redis => redis)
          contact.delete!
        end

        self.add_or_update(contact_id, contact_data, :redis => redis)
        if contact = self.find_by_id(contact_id, :redis => redis)
          contact.notification_rules # invoke to create general rule
        end
        contact
      end

      def self.delete_all(options = {})
        raise "Redis connection not set" unless redis = options[:redis]

        self.all(:redis => redis).each do |contact|
          contact.delete!
        end
      end

      # ensure that instance variables match redis state
      # TODO may want to make this protected/private, it's only
      # used in this class
      def refresh
        self.first_name, self.last_name, self.email =
          @redis.hmget("contact:#{@id}", 'first_name', 'last_name', 'email')
        self.media = @redis.hgetall("contact_media:#{@id}")
        self.media_intervals = @redis.hgetall("contact_media_intervals:#{self.id}")
        self.media_rollup_thresholds = @redis.hgetall("contact_media_rollup_thresholds:#{self.id}")

        # similar to code in instance method pagerduty_credentials
        if service_key = @redis.hget("contact_media:#{@id}", 'pagerduty')
          self.pagerduty_credentials =
            @redis.hgetall("contact_pagerduty:#{@id}").merge('service_key' => service_key)
        end
      end

      def update(contact_data)
        self.class.add_or_update(@id, contact_data, :redis => @redis)
        self.refresh
      end

      def delete!
        # remove entity & check registrations -- ugh, this will be slow.
        # rather than check if the key is present we'll just request its
        # deletion anyway, fewer round-trips
        @redis.keys('contacts_for:*').each do |cfk|
          @redis.srem(cfk, self.id)
        end

        @redis.del("drop_alerts_for_contact:#{self.id}")
        dafc = @redis.keys("drop_alerts_for_contact:#{self.id}:*")
        @redis.del(*dafc) unless dafc.empty?

        # TODO if implemented, alerts_by_contact & alerts_by_check:
        # list all alerts from all matched keys, remove them from
        # the main alerts sorted set, remove all alerts_by sorted sets
        # for the contact

        # remove this contact from all tags it's marked with
        self.delete_tags(*self.tags.to_a)

        # remove all associated notification rules
        self.notification_rules.each do |nr|
          self.delete_notification_rule(nr)
        end

        @redis.del("contact:#{self.id}", "contact_media:#{self.id}",
                   "contact_media_intervals:#{self.id}",
                   "contact_media_rollup_thresholds:#{self.id}",
                   "contact_tz:#{self.id}", "contact_pagerduty:#{self.id}")
      end

      def pagerduty_credentials
        return unless service_key = @redis.hget("contact_media:#{self.id}", 'pagerduty')
        @redis.hgetall("contact_pagerduty:#{self.id}").
          merge('service_key' => service_key)
      end

      def set_pagerduty_credentials(details)
        @redis.hset("contact_media:#{self.id}", 'pagerduty', details['service_key'])
        @redis.hmset("contact_pagerduty:#{self.id}",
                     *['subdomain', 'username', 'password'].collect {|f| [f, details[f]]})
      end

      # NB ideally contacts_for:* keys would scope the entity and check by an
      # input source, for namespacing purposes
      def entities(options = {})
        @redis.keys('contacts_for:*').inject({}) {|ret, k|
          if @redis.sismember(k, self.id)
            if k =~ /^contacts_for:([a-zA-Z0-9][a-zA-Z0-9\.\-]*[a-zA-Z0-9])(?::(\w+))?$/
              entity_id = $1
              check = $2

              entity = nil

              if ret.has_key?(entity_id)
                entity = ret[entity_id][:entity]
              else
                entity = Flapjack::Data::Entity.find_by_id(entity_id, :redis => @redis)
                ret[entity_id] = {
                  :entity => entity
                }
                # using a set to ensure unique check values
                ret[entity_id][:checks] = Set.new if options[:checks]
                ret[entity_id][:tags] = entity.tags if entity && options[:tags]
              end

              if options[:checks]
                # if not registered for the check, then was registered for
                # the entity, so add all checks
                ret[entity_id][:checks] |= (check || (entity ? entity.check_list : []))
              end
            end
          end
          ret
        }.values
      end

      def name
        [(self.first_name || ''), (self.last_name || '')].join(" ").strip
      end

      # return an array of the notification rules of this contact
      def notification_rules(opts = {})
        rules = @redis.smembers("contact_notification_rules:#{self.id}").inject([]) do |ret, rule_id|
          unless (rule_id.nil? || rule_id == '')
            ret << Flapjack::Data::NotificationRule.find_by_id(rule_id, :redis => @redis)
          end
          ret
        end
        if rules.all? {|r| r.is_specific? } # also true if empty
          rule = self.add_notification_rule({
              :entities           => [],
              :tags               => Flapjack::Data::TagSet.new([]),
              :time_restrictions  => [],
              :warning_media      => ALL_MEDIA,
              :critical_media     => ALL_MEDIA,
              :warning_blackhole  => false,
              :critical_blackhole => false,
            }, :logger => opts[:logger])
          rules.unshift(rule)
        end
        rules
      end

      def add_notification_rule(rule_data, opts = {})
        if logger = opts[:logger]
          logger.debug("add_notification_rule: contact_id: #{self.id} (#{self.id.class})")
        end
        Flapjack::Data::NotificationRule.add(rule_data.merge(:contact_id => self.id),
          :redis => @redis, :logger => opts[:logger])
      end

      def delete_notification_rule(rule)
        @redis.srem("contact_notification_rules:#{self.id}", rule.id)
        @redis.del("notification_rule:#{rule.id}")
      end

      # how often to notify this contact on the given media
      # return 15 mins if no value is set
      def interval_for_media(media)
        interval = @redis.hget("contact_media_intervals:#{self.id}", media)
        (interval.nil? || (interval.to_i <= 0)) ? (15 * 60) : interval.to_i
      end

      def set_interval_for_media(media, interval)
        return if 'pagerduty'.eql?(media)
        if interval.nil?
          @redis.hdel("contact_media_intervals:#{self.id}", media)
          return
        end
        @redis.hset("contact_media_intervals:#{self.id}", media, interval)
        self.media_intervals = @redis.hgetall("contact_media_intervals:#{self.id}")
      end

      def rollup_threshold_for_media(media)
        threshold = @redis.hget("contact_media_rollup_thresholds:#{self.id}", media)
        (threshold.nil? || (threshold.to_i <= 0 )) ? nil : threshold.to_i
      end

      def set_rollup_threshold_for_media(media, threshold)
        return if 'pagerduty'.eql?(media)
        if threshold.nil?
          @redis.hdel("contact_media_rollup_thresholds:#{self.id}", media)
          return
        end
        @redis.hset("contact_media_rollup_thresholds:#{self.id}", media, threshold)
        self.media_rollup_thresholds = @redis.hgetall("contact_media_rollup_thresholds:#{self.id}")
      end

      def set_address_for_media(media, address)
        return if 'pagerduty'.eql?(media)
        @redis.hset("contact_media:#{self.id}", media, address)
        self.media = @redis.hgetall("contact_media:#{@id}")
      end

      def remove_media(media)
        @redis.hdel("contact_media:#{self.id}", media)
        @redis.hdel("contact_media_intervals:#{self.id}", media)
        @redis.hdel("contact_media_rollup_thresholds:#{self.id}", media)
        if media == 'pagerduty'
          @redis.del("contact_pagerduty:#{self.id}")
        end
      end

      # drop notifications for
      def drop_notifications?(opts = {})
        media    = opts[:media]
        check    = opts[:check]
        state    = opts[:state]

        # build it and they will come
        @redis.exists("drop_alerts_for_contact:#{self.id}") ||
          (media && @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}")) ||
          (media && check &&
            @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}:#{check}")) ||
          (media && check && state &&
            @redis.exists("drop_alerts_for_contact:#{self.id}:#{media}:#{check}:#{state}"))
      end

      def update_sent_alert_keys(opts = {})
        media  = opts[:media]
        check  = opts[:check]
        state  = opts[:state]
        delete = !! opts[:delete]
        key = "drop_alerts_for_contact:#{self.id}:#{media}:#{check}:#{state}"
        if delete
          @redis.del(key)
        else
          @redis.set(key, 'd')
          @redis.expire(key, self.interval_for_media(media))
          # TODO: #182 - update the alert history keys
        end
      end

      def drop_rollup_notifications_for_media?(media)
        @redis.exists("drop_rollup_alerts_for_contact:#{self.id}:#{media}")
      end

      def update_sent_rollup_alert_keys_for_media(media, opts = {})
        delete = !! opts[:delete]
        key = "drop_rollup_alerts_for_contact:#{self.id}:#{media}"
        if delete
          @redis.del(key)
        else
          @redis.set(key, 'd')
          @redis.expire(key, self.interval_for_media(media))
        end
      end

      def add_alerting_check_for_media(media, check)
        @redis.zadd("contact_alerting_checks:#{self.id}:media:#{media}", Time.now.to_i, check)
      end

      def remove_alerting_check_for_media(media, check)
        @redis.zrem("contact_alerting_checks:#{self.id}:media:#{media}", check)
      end

      # removes any checks that are in ok, scheduled or unscheduled maintenance
      # from the alerting checks set for the given media
      # returns the number of checks removed
      def clean_alerting_checks_for_media(media)
        key = "contact_alerting_checks:#{self.id}:media:#{media}"
        cleaned = 0
        alerting_checks_for_media(media).each do |check|
          entity_check = Flapjack::Data::EntityCheck.for_event_id(check, :redis => @redis)
          next unless Flapjack::Data::EntityCheck.state_for_event_id?(check, :redis => @redis) == 'ok' ||
            Flapjack::Data::EntityCheck.in_unscheduled_maintenance_for_event_id?(check, :redis => @redis) ||
            Flapjack::Data::EntityCheck.in_scheduled_maintenance_for_event_id?(check, :redis => @redis) ||
            !entity_check.contacts.map {|c| c.id}.include?(self.id)

          # FIXME: why can't i get this logging when called from notifier (notification.rb)?
          @logger.debug("removing from alerting checks for #{self.id}/#{media}: #{check}") if @logger
          remove_alerting_check_for_media(media, check)
          cleaned += 1
        end
        cleaned
      end

      def alerting_checks_for_media(media)
        @redis.zrange("contact_alerting_checks:#{self.id}:media:#{media}", 0, -1)
      end

      def count_alerting_checks_for_media(media)
        @redis.zcard("contact_alerting_checks:#{self.id}:media:#{media}")
      end

      # FIXME
      # do a mixin with the following tag methods, they will be the same
      # across all objects we allow tags on

      # return the set of tags for this contact
      def tags
        @tags ||= Flapjack::Data::TagSet.new( @redis.keys("#{TAG_PREFIX}:*").inject([]) {|memo, tag|
          if Flapjack::Data::Tag.find(tag, :redis => @redis).include?(@id.to_s)
            memo << tag.sub(/^#{TAG_PREFIX}:/, '')
          end
          memo
        } )
      end

      # adds tags to this contact
      def add_tags(*enum)
        enum.each do |t|
          Flapjack::Data::Tag.create("#{TAG_PREFIX}:#{t}", [@id], :redis => @redis)
          tags.add(t)
        end
      end

      # removes tags from this contact
      def delete_tags(*enum)
        enum.each do |t|
          tag = Flapjack::Data::Tag.find("#{TAG_PREFIX}:#{t}", :redis => @redis)
          tag.delete(@id)
          tags.delete(t)
        end
      end

      # return a list of media enabled for this contact
      # eg [ 'email', 'sms' ]
      def media_list
        @redis.hkeys("contact_media:#{self.id}")
      end

      # return the timezone of the contact, or the system default if none is set
      # TODO cache?
      def timezone(opts = {})
        logger = opts[:logger]

        tz_string = @redis.get("contact_tz:#{self.id}")
        tz = opts[:default] if (tz_string.nil? || tz_string.empty?)

        if tz.nil?
          begin
            tz = ActiveSupport::TimeZone.new(tz_string)
          rescue ArgumentError
            if logger
              logger.warn("Invalid timezone string set for contact #{self.id} or TZ (#{tz_string}), using 'UTC'!")
            end
            tz = ActiveSupport::TimeZone.new('UTC')
          end
        end
        tz
      end

      # sets or removes the timezone for the contact
      def timezone=(tz)
        if tz.nil?
          @redis.del("contact_tz:#{self.id}")
        else
          # ActiveSupport::TimeZone or String
          @redis.set("contact_tz:#{self.id}",
            tz.respond_to?(:name) ? tz.name : tz )
        end
      end

      def to_json(*args)
        { "id"         => self.id,
          "first_name" => self.first_name,
          "last_name"  => self.last_name,
          "email"      => self.email,
          "tags"       => self.tags.to_a }.to_json
      end

    private

      def initialize(options = {})
        raise "Redis connection not set" unless @redis = options[:redis]
        @id     = options[:id]
        @logger = options[:logger]
      end

      # NB: should probably be called in the context of a Redis multi block; not doing so
      # here as calling classes may well be adding/updating multiple records in the one
      # operation
      def self.add_or_update(contact_id, contact_data, options = {})
        raise "Redis connection not set" unless redis = options[:redis]

        # TODO check that the rest of this is safe for the update case
        redis.hmset("contact:#{contact_id}",
                    *['first_name', 'last_name', 'email'].collect {|f| [f, contact_data[f]]})

        unless contact_data['media'].nil?
          redis.del("contact_media:#{contact_id}")
          redis.del("contact_media_intervals:#{contact_id}")
          redis.del("contact_media_rollup_thresholds:#{contact_id}")
          redis.del("contact_pagerduty:#{contact_id}")

          contact_data['media'].each_pair {|medium, details|
            case medium
            when 'pagerduty'
              redis.hset("contact_media:#{contact_id}", medium, details['service_key'])
              redis.hmset("contact_pagerduty:#{contact_id}",
                          *['subdomain', 'username', 'password'].collect {|f| [f, details[f]]})
            else
              redis.hset("contact_media:#{contact_id}", medium, details['address'])
              redis.hset("contact_media_intervals:#{contact_id}", medium, details['interval']) if details['interval']
              redis.hset("contact_media_rollup_thresholds:#{contact_id}", medium, details['rollup_threshold']) if details['rollup_threshold']
            end
          }
        end
      end

    end

  end

end