module Groupdate
  class SeriesBuilder
    attr_reader :period, :time_zone, :day_start, :week_start, :options

    CHECK_PERIODS = [:day, :week, :month, :quarter, :year]

    def initialize(period:, time_zone:, day_start:, week_start:, **options)
      @period = period
      @time_zone = time_zone
      @week_start = week_start
      @day_start = day_start
      @options = options
      @round_time = {}
      @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
    end

    def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
      series = generate_series(data, multiple_groups, group_index)
      series = handle_multiple(data, series, multiple_groups, group_index)

      verified_data = {}
      series.each do |k|
        verified_data[k] = data.delete(k)
      end

      # this is a fun one
      # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
      # Other databases and Active Support return the 1st hour (as expected)
      # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
      # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
      # SQLServer good: SELECT CAST('2013-11-03 01:00:00' AS DATETIME2(3)) AT TIME ZONE 'Pacific Standard Time' AT TIME ZONE 'UTC'
      # Ruby not good: Time.parse("2013-11-03 01:00:00")
      # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
      # we need to account for this here
      if series_default && CHECK_PERIODS.include?(period)
        data.each do |k, v|
          key = multiple_groups ? k[group_index] : k
          # TODO only do this for PostgreSQL
          # this may mask some inconsistent time zone errors
          # but not sure there's a better approach
          if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
            key -= 1.hour
            if multiple_groups
              k[group_index]  = key
            else
              k = key
            end
            verified_data[k] = v
          elsif key != round_time(key)
            # only need to show what database returned since it will cast in Ruby time zone
            # raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}: #{round_time(key)}"
          end
        end
      end

      unless entire_series?(series_default)
        series = series.select { |k| verified_data[k] }
      end

      value = 0
      result = Hash[series.map do |k|
        value = verified_data[k] || (@options[:carry_forward] && value) || default_value
        key =
          if multiple_groups
            k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
          else
            key_format.call(k)
          end

        [key, value]
      end]

      result
    end

    def format_series_label(series_label)
      series_label = round_time(series_label) if series_label.respond_to?(:to_time)
      key_format.call(series_label)
    end

    def round_time(time)
      time = time.to_time.in_time_zone(time_zone)

      if day_start != 0
        # apply day_start to a time object that's not affected by DST
        time = change_zone.call(time, utc)
        time -= day_start.seconds
      end

      time =
        case period
        when :second
          time.change(usec: 0)
        when :minute
          time.change(sec: 0)
        when :hour
          time.change(min: 0)
        when :day
          time.beginning_of_day
        when :week
          time.beginning_of_week(@week_start_key)
        when :month
          time.beginning_of_month
        when :quarter
          time.beginning_of_quarter
        when :year
          time.beginning_of_year
        when :hour_of_day
          time.hour
        when :minute_of_hour
          time.min
        when :day_of_week
          time.days_to_week_start(@week_start_key)
        when :day_of_month
          time.day
        when :month_of_year
          time.month
        when :day_of_year
          time.yday
        else
          raise Groupdate::Error, "Invalid period"
        end

      if day_start != 0 && time.is_a?(Time)
        time += day_start.seconds
        time = change_zone.call(time, time_zone)
      end

      time
    end

    def change_zone
      @change_zone ||= begin
        if ActiveSupport::VERSION::STRING >= "5.2"
          ->(time, zone) { time.change(zone: zone) }
        else
          # TODO make more efficient
          ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
        end
      end
    end

    def time_range
      @time_range ||= begin
        time_range = options[:range]
        if time_range.is_a?(Range) && time_range.first.is_a?(Date)
          # convert range of dates to range of times
          last = time_range.last.in_time_zone(time_zone)
          last += 1.day unless time_range.exclude_end?
          time_range = Range.new(time_range.first.in_time_zone(time_zone), last, true)
        elsif !time_range && options[:last]
          if period == :quarter
            step = 3.months
          elsif 1.respond_to?(period)
            step = 1.send(period)
          else
            raise ArgumentError, "Cannot use last option with #{period}"
          end
          if step
            # loop instead of multiply to change start_at - see #151
            start_at = now
            (options[:last].to_i - 1).times do
              start_at -= step
            end

            time_range =
              if options[:current] == false
                round_time(start_at - step)...round_time(now)
              else
                # extend to end of current period
                round_time(start_at)...(round_time(now) + step)
              end
          end
        end
        time_range
      end
    end

    private

    def now
      @now ||= time_zone.now
    end

    def generate_series(data, multiple_groups, group_index)
      case period
      when :day_of_week
        0..6
      when :hour_of_day
        0..23
      when :minute_of_hour
        0..59
      when :day_of_month
        1..31
      when :day_of_year
        1..366
      when :month_of_year
        1..12
      else
        time_range = self.time_range
        time_range =
          if time_range.is_a?(Range)
            time_range
          else
            # use first and last values
            sorted_keys =
              if multiple_groups
                data.keys.map { |k| k[group_index] }.sort
              else
                data.keys.sort
              end

            tr = sorted_keys.first..sorted_keys.last
            if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
              tr = tr.first...round_time(now)
            end
            tr
          end

        if time_range.begin
          series = [round_time(time_range.begin)]

          if period == :quarter
            step = 3.months
          else
            step = 1.send(period)
          end

          last_step = series.last
          day_start_hour = day_start / 3600
          loop do
            next_step = last_step + step
            next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
            break unless time_range.cover?(next_step)

            if next_step == last_step
              last_step += step
              next
            end
            series << next_step
            last_step = next_step
          end

          series
        else
          []
        end
      end
    end

    def key_format
      @key_format ||= begin
        locale = options[:locale] || I18n.locale
        use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates

        if options[:format]
          if options[:format].respond_to?(:call)
            options[:format]
          else
            sunday = time_zone.parse("2014-03-02 00:00:00")
            lambda do |key|
              case period
              when :hour_of_day
                key = sunday + key.hours + day_start.seconds
              when :minute_of_hour
                key = sunday + key.minutes + day_start.seconds
              when :day_of_week
                key = sunday + key.days + (week_start + 1).days
              when :day_of_month
                key = Date.new(2014, 1, key).to_time
              when :month_of_year
                key = Date.new(2014, key, 1).to_time
              end
              I18n.localize(key, format: options[:format], locale: locale)
            end
          end
        elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
          lambda { |k| k.to_date }
        else
          lambda { |k| k }
        end
      end
    end

    def handle_multiple(data, series, multiple_groups, group_index)
      reverse = options[:reverse]

      if multiple_groups
        keys = data.keys.map { |k| k[0...group_index] + k[(group_index + 1)..-1] }.uniq
        series = series.to_a.reverse if reverse
        keys.flat_map do |k|
          series.map { |s| k[0...group_index] + [s] + k[group_index..-1] }
        end
      elsif reverse
        series.to_a.reverse
      else
        series
      end
    end

    def entire_series?(series_default)
      options.key?(:series) ? options[:series] : series_default
    end

    def utc
      @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
    end
  end
end
