module Statistics
  class << self
    def included(base)
      base.extend(HasStats)
    end

    def default_filters(filters)
      ActiveRecord::Base.instance_eval { @filter_all_on = filters }
    end

    def supported_calculations
      [:average, :count, :maximum, :minimum, :sum]
    end
  end

  # This extension provides the ability to define statistics for reporting purposes
  module HasStats

    # OPTIONS:
    #
    #* +average+, +count+, +sum+, +maximum+, +minimum+ - Only one of these keys is passed, which
    #   one depends on the type of operation. The value is an array of named scopes to scope the
    #   operation by (+:all+ should be used if no scopes are to be applied)
    #* +column_name+ - The SQL column to perform the operation on (default: +id+)
    #* +filter_on+ - A hash with keys that represent filters. The with values in the has are rules
    #   on how to generate the query for the correspond filter.
    #* +cached_for+ - A duration for how long to cache this specific statistic
    #
    #   Additional options can also be passed in that would normally be passed to an ActiveRecord
    #   +calculate+ call, like +conditions+, +joins+, etc
    #
    # EXAMPLE:
    #
    #  class MockModel < ActiveRecord::Base
    #
    #    named_scope :my_scope, :conditions => 'value > 5'
    #
    #    define_statistic "Basic Count", :count => :all
    #    define_statistic "Basic Sum", :sum => :all, :column_name => 'amount'
    #    define_statistic "Chained Scope Count", :count => [:all, :my_scope]
    #    define_statistic "Default Filter", :count => :all
    #    define_statistic "Custom Filter", :count => :all, :filter_on => { :channel => 'channel = ?', :start_date => 'DATE(created_at) > ?' }
    #    define_statistic "Cached", :count => :all, :filter_on => { :channel => 'channel = ?', :blah => 'blah = ?' }, :cache_for => 1.second
    #  end
    def define_statistic(name, options)
      method_name = name.to_s.gsub(" ", "").underscore + "_stat"

      @statistics ||= {}
      @filter_all_on ||= ActiveRecord::Base.instance_eval { @filter_all_on }
      @statistics[name] = method_name

      options = { :column_name => :id }.merge(options)

      calculation = options.keys.find {|opt| Statistics::supported_calculations.include?(opt)}
      calculation ||= :count

      # We must use the metaclass here to metaprogrammatically define a class method
      (class<<self; self; end).instance_eval do
        define_method(method_name) do |filters|
          # check the cache before running a query for the stat
          cached_val = Rails.cache.read("#{self.name}#{method_name}#{filters}") if options[:cache_for]
          return cached_val unless cached_val.nil?

          scoped_options = Marshal.load(Marshal.dump(options))

          filters.each do |key, value|
            unless value.nil?
              sql = ((@filter_all_on || {}).merge(scoped_options[:filter_on] || {}))[key].gsub("?", "'#{value}'")
              sql = sql.gsub("%t", "#{table_name}")
              sql_frag = send(:sanitize_sql_for_conditions, sql)
              case
                when sql_frag.nil? then nil
                when scoped_options[:conditions].nil? then scoped_options[:conditions] = sql_frag
                when scoped_options[:conditions].is_a?(Array) then scoped_options[:conditions][0].concat(" AND #{sql_frag}")
                when scoped_options[:conditions].is_a?(String) then scoped_options[:conditions].concat(" AND #{sql_frag}")
              end
            end
          end if filters.is_a?(Hash)

          base = self
          # chain named scopes
          scopes = Array(scoped_options[calculation])
          scopes.each do |scope|
            base = base.send(scope)
          end if scopes != [:all]
          stat_value = base.send(calculation, scoped_options[:column_name], sql_options(scoped_options))

          # cache stat value
          Rails.cache.write("#{self.name}#{method_name}#{filters}", stat_value, :expires_in => options[:cache_for]) if options[:cache_for]

          stat_value
        end
      end
    end

    # Defines a statistic using a block that has access to all other defined statistics
    #
    # EXAMPLE:
    # class MockModel < ActiveRecord::Base
    #   define_statistic "Basic Count", :count => :all
    #   define_statistic "Basic Sum", :sum => :all, :column_name => 'amount'
    #   define_calculated_statistic "Total Profit"
    #     defined_stats('Basic Sum') * defined_stats('Basic Count')
    #   end
    def define_calculated_statistic(name, &block)
      method_name = name.to_s.gsub(" ", "").underscore + "_stat"

      @statistics ||= {}
      @statistics[name] = method_name

      (class<<self; self; end).instance_eval do
        define_method(method_name) do |filters|
          @filters = filters
          yield
        end
      end
    end

    # returns an array containing the names/keys of all defined statistics
    def statistics_keys
      @statistics.keys
    end

    # Calculates all the statistics defined for this AR class and returns a hash with the values.
    # There is an optional parameter that is a hash of all values you want to filter by.
    #
    # EXAMPLE:
    # MockModel.statistics
    # MockModel.statistics(:user_type => 'registered', :user_status => 'active')
    def statistics(filters = {}, except = nil)
      (@statistics || {}).inject({}) do |stats_hash, stat|
        stats_hash[stat.first] = send(stat.last, filters) if stat.last != except
        stats_hash
      end
    end

    # returns a single statistic based on the +stat_name+ paramater passed in and
    # similarly to the +statistics+ method, it also can take filters.
    #
    # EXAMPLE:
    # MockModel.get_stat('Basic Count')
    # MockModel.get_stat('Basic Count', :user_type => 'registered', :user_status => 'active')
    def get_stat(stat_name, filters = {})
      send(@statistics[stat_name], filters) if @statistics[stat_name]
    end

    # to keep things DRY anything that all statistics need to be filterable by can be defined
    # seperatly using this method
    #
    # EXAMPLE:
    #
    # class MockModel < ActiveRecord::Base
    #   define_statistic "Basic Count", :count => :all
    #   define_statistic "Basic Sum", :sum => :all, :column_name => 'amount'
    #
    #   filter_all_stats_on(:user_id, "user_id = ?")
    # end
    def filter_all_stats_on(name, cond)
      @filter_all_on ||= {}
      @filter_all_on[name] = cond
    end

    private

      def defined_stats(name)
        get_stat(name, @filters)
      end

      def sql_options(options)
        Statistics::supported_calculations.each do |deletable|
          options.delete(deletable)
        end
        options.delete(:column_name)
        options.delete(:filter_on)
        options.delete(:cache_for)
        options
      end
  end
end

ActiveRecord::Base.send(:include, Statistics)
