src/derails/reaction_blur/helpers/number_helper.rb
# frozen_string_literal: true

require "active_support/core_ext/hash/keys"
require "active_support/core_ext/string/output_safety"
require "active_support/number_helper"

module ReactionBlur
  module Helpers # :nodoc:
    # = Action View Number \Helpers
    #
    # Provides methods for converting numbers into formatted strings.
    # Methods are provided for phone numbers, currency, percentage,
    # precision, positional notation, file size, and pretty printing.
    #
    # Most methods expect a +number+ argument, and will return it
    # unchanged if can't be converted into a valid number.
    module NumberHelper
      # Raised when argument +number+ param given to the helpers is invalid and
      # the option +:raise+ is set to  +true+.
      class InvalidNumberError < StandardError
        attr_accessor :number
        def initialize(number)
          @number = number
        end
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_phone.
      #
      #   number_to_phone("1234567890")         # => "123-456-7890"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_to_phone("12x34")              # => "12x34"
      #   number_to_phone("12x34", raise: true) # => InvalidNumberError
      #
      def number_to_phone(number, options = {})
        return unless number
        options = options.symbolize_keys

        parse_float(number, true) if options.delete(:raise)
        ERB::Util.html_escape(ActiveSupport::NumberHelper.number_to_phone(number, options))
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_currency.
      #
      #   number_to_currency("1234")               # => "$1234.00"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_to_currency("12x34")              # => "$12x34"
      #   number_to_currency("12x34", raise: true) # => InvalidNumberError
      #
      def number_to_currency(number, options = {})
        delegate_number_helper_method(:number_to_currency, number, options)
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_percentage.
      #
      #   number_to_percentage("99")               # => "99.000%"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_to_percentage("99x")              # => "99x%"
      #   number_to_percentage("99x", raise: true) # => InvalidNumberError
      #
      def number_to_percentage(number, options = {})
        delegate_number_helper_method(:number_to_percentage, number, options)
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_delimited.
      #
      #   number_with_delimiter("1234")               # => "1,234"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_with_delimiter("12x34")              # => "12x34"
      #   number_with_delimiter("12x34", raise: true) # => InvalidNumberError
      #
      def number_with_delimiter(number, options = {})
        delegate_number_helper_method(:number_to_delimited, number, options)
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_rounded.
      #
      #   number_with_precision("1234")               # => "1234.000"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_with_precision("12x34")              # => "12x34"
      #   number_with_precision("12x34", raise: true) # => InvalidNumberError
      #
      def number_with_precision(number, options = {})
        delegate_number_helper_method(:number_to_rounded, number, options)
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_human_size.
      #
      #   number_to_human_size("1234")               # => "1.21 KB"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_to_human_size("12x34")              # => "12x34"
      #   number_to_human_size("12x34", raise: true) # => InvalidNumberError
      #
      def number_to_human_size(number, options = {})
        delegate_number_helper_method(:number_to_human_size, number, options)
      end

      # Delegates to ActiveSupport::NumberHelper#number_to_human.
      #
      #   number_to_human("1234")               # => "1.23 Thousand"
      #
      # Additionally, supports a +:raise+ option that will cause
      # InvalidNumberError to be raised if +number+ is not a valid number:
      #
      #   number_to_human("12x34")              # => "12x34"
      #   number_to_human("12x34", raise: true) # => InvalidNumberError
      #
      def number_to_human(number, options = {})
        delegate_number_helper_method(:number_to_human, number, options)
      end

      private
        def delegate_number_helper_method(method, number, options)
          return unless number
          options = escape_unsafe_options(options.symbolize_keys)

          wrap_with_output_safety_handling(number, options.delete(:raise)) {
            ActiveSupport::NumberHelper.public_send(method, number, options)
          }
        end

        def escape_unsafe_options(options)
          options[:format]          = ERB::Util.html_escape(options[:format]) if options[:format]
          options[:negative_format] = ERB::Util.html_escape(options[:negative_format]) if options[:negative_format]
          options[:separator]       = ERB::Util.html_escape(options[:separator]) if options[:separator]
          options[:delimiter]       = ERB::Util.html_escape(options[:delimiter]) if options[:delimiter]
          options[:unit]            = ERB::Util.html_escape(options[:unit]) if options[:unit] && !options[:unit].html_safe?
          options[:units]           = escape_units(options[:units]) if options[:units] && Hash === options[:units]
          options
        end

        def escape_units(units)
          units.transform_values do |v|
            ERB::Util.html_escape(v)
          end
        end

        def wrap_with_output_safety_handling(number, raise_on_invalid, &block)
          valid_float = valid_float?(number)
          raise InvalidNumberError, number if raise_on_invalid && !valid_float

          formatted_number = yield

          if valid_float || number.html_safe?
            formatted_number.html_safe
          else
            formatted_number
          end
        end

        def valid_float?(number)
          !parse_float(number, false).nil?
        end

        def parse_float(number, raise_error)
          result = Float(number, exception: false)
          raise InvalidNumberError, number if result.nil? && raise_error
          result
        end
    end
  end
end
