class Money
  module Arithmetic
    # Wrapper for coerced numeric values to distinguish
    # when numeric was on the 1st place in operation.
    CoercedNumeric = Struct.new(:value) do
      # Proxy #zero? method to skip unnecessary typecasts. See #- and #+.
      def zero?
        value.zero?
      end
    end

    # Returns a money object with changed polarity.
    #
    # @return [Money]
    #
    # @example
    #    - Money.new(100) #=> #<Money @fractional=-100>
    def -@
      dup_with(fractional: -fractional)
    end

    # Checks whether two Money objects have the same currency and the same
    # amount. If Money objects have a different currency it will only be true
    # if the amounts are both zero. Checks against objects that are not Money or
    # a subclass will always return false.
    #
    # @param [Money] other_money Value to compare with.
    #
    # @return [Boolean]
    #
    # @example
    #   Money.new(100).eql?(Money.new(101))                #=> false
    #   Money.new(100).eql?(Money.new(100))                #=> true
    #   Money.new(100, "USD").eql?(Money.new(100, "GBP"))  #=> false
    #   Money.new(0, "USD").eql?(Money.new(0, "EUR"))      #=> true
    #   Money.new(100).eql?("1.00")                        #=> false
    def eql?(other_money)
      if other_money.is_a?(Money)
        (fractional == other_money.fractional && currency == other_money.currency) ||
          (fractional == 0 && other_money.fractional == 0)
      else
        false
      end
    end

    # Compares two Money objects. If money objects have a different currency it
    # will attempt to convert the currency.
    #
    # @param [Money] other Value to compare with.
    #
    # @return [Integer]
    #
    # @raise [TypeError] when other object is not Money
    #
    def <=>(other)
      unless other.is_a?(Money)
        return unless other.respond_to?(:zero?) && other.zero?
        return other.is_a?(CoercedNumeric) ? 0 <=> fractional : fractional <=> 0
      end

      # Always allow comparison with zero
      if zero? || other.zero?
        return fractional <=> other.fractional
      end

      other = other.exchange_to(currency)
      fractional <=> other.fractional
    rescue Money::Bank::UnknownRate
    end

    # Uses Comparable's implementation but raises ArgumentError if non-zero
    # numeric value is given.
    def ==(other)
      if other.is_a?(Numeric) && !other.zero?
        raise ArgumentError, 'Money#== supports only zero numerics'
      end
      super
    end

    # Test if the amount is positive. Returns +true+ if the money amount is
    # greater than 0, +false+ otherwise.
    #
    # @return [Boolean]
    #
    # @example
    #   Money.new(1).positive?  #=> true
    #   Money.new(0).positive?  #=> false
    #   Money.new(-1).positive? #=> false
    def positive?
      fractional > 0
    end

    # Test if the amount is negative. Returns +true+ if the money amount is
    # less than 0, +false+ otherwise.
    #
    # @return [Boolean]
    #
    # @example
    #   Money.new(-1).negative? #=> true
    #   Money.new(0).negative?  #=> false
    #   Money.new(1).negative?  #=> false
    def negative?
      fractional < 0
    end

    # @method +(other)
    # Returns a new Money object containing the sum of the two operands' monetary
    # values. If +other_money+ has a different currency then its monetary value
    # is automatically exchanged to this object's currency using +exchange_to+.
    #
    # @param [Money] other Other +Money+ object to add.
    #
    # @return [Money]
    #
    # @example
    #   Money.new(100) + Money.new(100) #=> #<Money @fractional=200>
    #
    # @method -(other)
    # Returns a new Money object containing the difference between the two
    # operands' monetary values. If +other_money+ has a different currency then
    # its monetary value is automatically exchanged to this object's currency
    # using +exchange_to+.
    #
    # @param [Money] other Other +Money+ object to subtract.
    #
    # @return [Money]
    #
    # @example
    #   Money.new(100) - Money.new(99) #=> #<Money @fractional=1>
    [:+, :-].each do |op|
      non_zero_message = lambda do |value|
        "Can't add or subtract a non-zero #{value.class.name} value"
      end

      define_method(op) do |other|
        case other
        when Money
          other = other.exchange_to(currency)
          new_fractional = fractional.public_send(op, other.fractional)
          dup_with(fractional: new_fractional)
        when CoercedNumeric
          raise TypeError, non_zero_message.call(other.value) unless other.zero?
          dup_with(fractional: other.value.public_send(op, fractional))
        when Numeric
          raise TypeError, non_zero_message.call(other) unless other.zero?
          self
        else
          raise TypeError, "Unsupported argument type: #{other.class.name}"
        end
      end
    end

    # Multiplies the monetary value with the given number and returns a new
    # +Money+ object with this monetary value and the same currency.
    #
    # Note that you can't multiply a Money object by an other +Money+ object.
    #
    # @param [Numeric] value Number to multiply by.
    #
    # @return [Money] The resulting money.
    #
    # @raise [TypeError] If +value+ is NOT a number.
    #
    # @example
    #   Money.new(100) * 2 #=> #<Money @fractional=200>
    #
    def *(value)
      value = value.value if value.is_a?(CoercedNumeric)
      if value.is_a? Numeric
        dup_with(fractional: fractional * value)
      else
        raise TypeError, "Can't multiply a #{self.class.name} by a #{value.class.name}'s value"
      end
    end

    # Divides the monetary value with the given number and returns a new +Money+
    # object with this monetary value and the same currency.
    # Can also divide by another +Money+ object to get a ratio.
    #
    # +Money/Numeric+ returns +Money+. +Money/Money+ returns +Float+.
    #
    # @param [Money, Numeric] value Number to divide by.
    #
    # @return [Money] The resulting money if you divide Money by a number.
    # @return [Float] The resulting number if you divide Money by a Money.
    #
    # @example
    #   Money.new(100) / 10            #=> #<Money @fractional=10>
    #   Money.new(100) / Money.new(10) #=> 10.0
    #
    def /(value)
      if value.is_a?(self.class)
        fractional / as_d(value.exchange_to(currency).fractional).to_f
      else
        raise TypeError, 'Can not divide by Money' if value.is_a?(CoercedNumeric)
        dup_with(fractional: fractional / as_d(value))
      end
    end

    # Synonym for +#/+.
    #
    # @param [Money, Numeric] value Number to divide by.
    #
    # @return [Money] The resulting money if you divide Money by a number.
    # @return [Float] The resulting number if you divide Money by a Money.
    #
    # @see #/
    #
    def div(value)
      self / value
    end

    # Divide money by money or fixnum and return array containing quotient and
    # modulus.
    #
    # @param [Money, Integer] val Number to divmod by.
    #
    # @return [Array<Money,Money>,Array<Integer,Money>]
    #
    # @example
    #   Money.new(100).divmod(9)            #=> [#<Money @fractional=11>, #<Money @fractional=1>]
    #   Money.new(100).divmod(Money.new(9)) #=> [11, #<Money @fractional=1>]
    def divmod(val)
      if val.is_a?(Money)
        divmod_money(val)
      else
        divmod_other(val)
      end
    end

    def divmod_money(val)
      cents = val.exchange_to(currency).cents
      quotient, remainder = fractional.divmod(cents)
      [quotient, dup_with(fractional: remainder)]
    end
    private :divmod_money

    def divmod_other(val)
      quotient, remainder = fractional.divmod(as_d(val))
      [dup_with(fractional: quotient), dup_with(fractional: remainder)]
    end
    private :divmod_other

    # Equivalent to +self.divmod(val)[1]+
    #
    # @param [Money, Integer] val Number take modulo with.
    #
    # @return [Money]
    #
    # @example
    #   Money.new(100).modulo(9)            #=> #<Money @fractional=1>
    #   Money.new(100).modulo(Money.new(9)) #=> #<Money @fractional=1>
    def modulo(val)
      divmod(val)[1]
    end

    # Synonym for +#modulo+.
    #
    # @param [Money, Integer] val Number take modulo with.
    #
    # @return [Money]
    #
    # @see #modulo
    def %(val)
      modulo(val)
    end

    # If different signs +self.modulo(val) - val+ otherwise +self.modulo(val)+
    #
    # @param [Money, Integer] val Number to rake remainder with.
    #
    # @return [Money]
    #
    # @example
    #   Money.new(100).remainder(9) #=> #<Money @fractional=1>
    def remainder(val)
      if val.is_a?(Money) && currency != val.currency
        val = val.exchange_to(currency)
      end

      if (fractional < 0 && val < 0) || (fractional > 0 && val > 0)
        self.modulo(val)
      else
        self.modulo(val) - (val.is_a?(Money) ? val : dup_with(fractional: val))
      end
    end

    # Return absolute value of self as a new Money object.
    #
    # @return [Money]
    #
    # @example
    #   Money.new(-100).abs #=> #<Money @fractional=100>
    def abs
      dup_with(fractional: fractional.abs)
    end

    # Test if the money amount is zero.
    #
    # @return [Boolean]
    #
    # @example
    #   Money.new(100).zero? #=> false
    #   Money.new(0).zero?   #=> true
    def zero?
      fractional == 0
    end

    # Test if the money amount is non-zero. Returns this money object if it is
    # non-zero, or nil otherwise, like +Numeric#nonzero?+.
    #
    # @return [Money, nil]
    #
    # @example
    #   Money.new(100).nonzero? #=> #<Money @fractional=100>
    #   Money.new(0).nonzero?   #=> nil
    def nonzero?
      fractional != 0 ? self : nil
    end

    # Used to make Money instance handle the operations when arguments order is reversed
    # @return [Array]
    #
    # @example
    #   2 * Money.new(10) #=> #<Money @fractional=20>
    def coerce(other)
      [self, CoercedNumeric.new(other)]
    end
  end
end
