module Daru
  # rubocop:disable Style/OpMethod

  # Generic class for generating date offsets.
  class DateOffset
    # A Daru::DateOffset object is created by a passing certain options
    # to the constructor, which determine the kind of offset the object
    # will support.
    #
    # You can pass one of the following options followed by their number
    # to the DateOffset constructor:
    #
    # * :secs - Create a seconds offset
    # * :mins - Create a minutes offset
    # * :hours - Create an hours offset
    # * :days  - Create a days offset
    # * :weeks - Create a weeks offset
    # * :months - Create a months offset
    # * :years - Create a years offset
    #
    # Additionaly, passing the `:n` option will apply the offset that many times.
    #
    # @example Usage of DateOffset
    #   # Create an offset of 3 weeks.
    #   offset = Daru::DateOffset.new(weeks: 3)
    #   offset + DateTime.new(2012,5,3)
    #   #=> #<DateTime: 2012-05-24T00:00:00+00:00 ((2456072j,0s,0n),+0s,2299161j)>
    #
    #   # Create an offset of 5 hours
    #   offset = Daru::DateOffset.new(hours: 5)
    #   offset + DateTime.new(2015,3,3,23,5,1)
    #   #=> #<DateTime: 2015-03-04T04:05:01+00:00 ((2457086j,14701s,0n),+0s,2299161j)>
    #
    #   # Create an offset of 2 minutes, applied 5 times
    #   offset = Daru::DateOffset.new(mins: 2, n: 5)
    #   offset + DateTime.new(2011,5,3,3,5)
    #   #=> #<DateTime: 2011-05-03T03:15:00+00:00 ((2455685j,11700s,0n),+0s,2299161j)>
    def initialize opts={}
      n = opts[:n] || 1
      Offsets::LIST.each do |key, klass|
        if opts.key?(key)
          @offset = klass.new(n * opts[key])
          break
        end
      end

      @offset = Offsets::Day.new(7*n*opts[:weeks]) if opts[:weeks]
    end

    # Offset a DateTime forward.
    #
    # @param date_time [DateTime] A DateTime object which is to offset.
    def + date_time
      @offset + date_time
    end

    # Offset a DateTime backward.
    #
    # @param date_time [DateTime] A DateTime object which is to offset.
    def - date_time
      @offset - date_time
    end

    def -@
      NegativeDateOffset.new(self)
    end
  end

  class NegativeDateOffset
    def initialize(offset)
      @offset = offset
    end

    def + date_time
      @offset - date_time
    end

    def - date_time
      @offset + date_time
    end

    def -@
      @offset
    end
  end

  module Offsets
    class DateOffsetType < DateOffset
      # @method initialize
      # Initialize one of the subclasses of DateOffsetType with the number of the times
      # the offset should be applied, which is the supplied as the argument.
      #
      # @param n [Integer] The number of times an offset should be applied.
      def initialize n=1
        @n = n
      end

      def freq_string
        (@n == 1 ? '' : @n.to_s) + self.class::FREQ
      end
    end

    # Private superclass for Offsets with equal inter-frequencies.
    # @abstract
    # @private
    class Tick < DateOffsetType
      def + date_time
        date_time + @n*multiplier
      end

      def - date_time
        date_time - @n*multiplier
      end
    end

    # Create a seconds offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Seconds offset
    #   offset = Daru::Offsets::Second.new(5)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2012-05-01T04:03:05+00:00 ((2456049j,14585s,0n),+0s,2299161j)>
    class Second < Tick
      FREQ = 'S'.freeze

      def multiplier
        1.to_r / 24 / 60 / 60
      end
    end

    # Create a minutes offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Minutes offset
    #   offset = Daru::Offsets::Minute.new(8)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2012-05-01T04:11:00+00:00 ((2456049j,15060s,0n),+0s,2299161j)>
    class Minute < Tick
      FREQ = 'M'.freeze

      def multiplier
        1.to_r / 24 / 60
      end
    end

    # Create an hours offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Hour offset
    #   offset = Daru::Offsets::Hour.new(8)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2012-05-01T12:03:00+00:00 ((2456049j,43380s,0n),+0s,2299161j)>
    class Hour < Tick
      FREQ = 'H'.freeze

      def multiplier
        1.to_r / 24
      end
    end

    # Create an days offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Day offset
    #   offset = Daru::Offsets::Day.new(2)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2012-05-03T04:03:00+00:00 ((2456051j,14580s,0n),+0s,2299161j)>
    class Day < Tick
      FREQ = 'D'.freeze

      def multiplier
        1
      end
    end

    # Create an months offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Month offset
    #   offset = Daru::Offsets::Month.new(5)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2012-10-01T04:03:00+00:00 ((2456202j,14580s,0n),+0s,2299161j)>
    class Month < Tick
      FREQ = 'MONTH'.freeze

      def + date_time
        date_time >> @n
      end

      def - date_time
        date_time << @n
      end
    end

    # Create a years offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a Year offset
    #   offset = Daru::Offsets::Year.new(2)
    #   offset + DateTime.new(2012,5,1,4,3)
    #   #=> #<DateTime: 2014-05-01T04:03:00+00:00 ((2456779j,14580s,0n),+0s,2299161j)>
    class Year < Tick
      FREQ = 'YEAR'.freeze

      def + date_time
        date_time >> @n*12
      end

      def - date_time
        date_time << @n*12
      end
    end

    class Week < DateOffset
      def initialize *args
        @n = args[0].is_a?(Hash) ? 1 : args[0]
        opts = args[-1]
        @weekday = opts[:weekday] || 0
      end

      def + date_time
        wday = date_time.wday
        distance = (@weekday - wday).abs
        if @weekday > wday
          date_time + distance + 7*(@n-1)
        else
          date_time + (7-distance) + 7*(@n -1)
        end
      end

      def - date_time
        wday = date_time.wday
        distance = (@weekday - wday).abs
        if @weekday >= wday
          date_time - ((7 - distance) + 7*(@n -1))
        else
          date_time - (distance + 7*(@n-1))
        end
      end

      def on_offset? date_time
        date_time.wday == @weekday
      end

      def freq_string
        (@n == 1 ? '' : @n.to_s) + 'W' + '-' + Daru::DAYS_OF_WEEK.key(@weekday)
      end
    end

    # Create a month begin offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a MonthBegin offset
    #   offset = Daru::Offsets::MonthBegin.new(2)
    #   offset + DateTime.new(2012,5,5)
    #   #=> #<DateTime: 2012-07-01T00:00:00+00:00 ((2456110j,0s,0n),+0s,2299161j)>
    class MonthBegin < DateOffsetType
      FREQ = 'MB'.freeze

      def + date_time
        @n.times do
          days_in_month = Daru::MONTH_DAYS[date_time.month]
          days_in_month += 1 if date_time.leap? && date_time.month == 2
          date_time += (days_in_month - date_time.day + 1)
        end

        date_time
      end

      def - date_time
        @n.times do
          date_time = date_time << 1 if on_offset?(date_time)
          date_time = DateTime.new(date_time.year, date_time.month, 1,
            date_time.hour, date_time.min, date_time.sec)
        end

        date_time
      end

      def on_offset? date_time
        date_time.day == 1
      end
    end

    # Create a month end offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a MonthEnd offset
    #   offset = Daru::Offsets::MonthEnd.new
    #   offset + DateTime.new(2012,5,5)
    #   #=> #<DateTime: 2012-05-31T00:00:00+00:00 ((2456079j,0s,0n),+0s,2299161j)>
    class MonthEnd < DateOffsetType
      FREQ = 'ME'.freeze

      def + date_time
        @n.times do
          date_time     = date_time >> 1 if on_offset?(date_time)
          days_in_month = Daru::MONTH_DAYS[date_time.month]
          days_in_month += 1 if date_time.leap? && date_time.month == 2

          date_time += (days_in_month - date_time.day)
        end

        date_time
      end

      def - date_time
        @n.times do
          date_time = date_time << 1
          days_in_month = Daru::MONTH_DAYS[date_time.month]
          days_in_month += 1 if date_time.leap? && date_time.month == 2

          date_time += (days_in_month - date_time.day)
        end

        date_time
      end

      def on_offset? date_time
        (date_time + 1).day == 1
      end
    end

    # Create a year begin offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a YearBegin offset
    #   offset = Daru::Offsets::YearBegin.new(3)
    #   offset + DateTime.new(2012,5,5)
    #   #=> #<DateTime: 2015-01-01T00:00:00+00:00 ((2457024j,0s,0n),+0s,2299161j)>
    class YearBegin < DateOffsetType
      FREQ = 'YB'.freeze

      def + date_time
        DateTime.new(date_time.year + @n, 1, 1,
          date_time.hour,date_time.min, date_time.sec)
      end

      def - date_time
        if on_offset?(date_time)
          DateTime.new(date_time.year - @n, 1, 1,
            date_time.hour,date_time.min, date_time.sec)
        else
          DateTime.new(date_time.year - (@n-1), 1, 1)
        end
      end

      def on_offset? date_time
        date_time.month == 1 && date_time.day == 1
      end
    end

    # Create a year end offset
    #
    # @param n [Integer] The number of times an offset should be applied.
    # @example Create a YearEnd offset
    #   offset = Daru::Offsets::YearEnd.new
    #   offset + DateTime.new(2012,5,5)
    #   #=> #<DateTime: 2012-12-31T00:00:00+00:00 ((2456293j,0s,0n),+0s,2299161j)>
    class YearEnd < DateOffsetType
      FREQ = 'YE'.freeze

      def + date_time
        if on_offset?(date_time)
          DateTime.new(date_time.year + @n, 12, 31,
            date_time.hour, date_time.min, date_time.sec)
        else
          DateTime.new(date_time.year + (@n-1), 12, 31,
            date_time.hour, date_time.min, date_time.sec)
        end
      end

      def - date_time
        DateTime.new(date_time.year - 1, 12, 31)
      end

      def on_offset? date_time
        date_time.month == 12 && date_time.day == 31
      end
    end

    LIST = {
      secs: Second,
      mins: Minute,
      hours: Hour,
      days: Day,
      months: Month,
      years: Year
    }.freeze
  end

  # rubocop:enable Style/OpMethod
end