# -*- encoding: utf-8 -*-
# -*- frozen_string_literal: true -*-
# -*- warn_indent: true -*-

module UnitMeasurements
  # @abstract
  # The +UnitMeasurements::Measurement+ is the abstract class and serves as superclass
  # for all the unit groups. It includes several modules that provide mathematical
  # operations, comparison, conversion, formatting, and other functionalities.
  #
  # This class provides a comprehensive set of methods and functionality for working
  # with measurements in different units. It includes robust error handling and
  # supports conversion between units. Additionally, it ensures that measurements
  # are consistently represented.
  #
  # You should not directly initialize a +Measurement+ instance. Instead, create
  # specialized measurement types by subclassing +Measurement+ and providing
  # specific units and conversions through the +build+ method defined in the
  # +UnitMeasurements+ module.
  #
  # @see Arithmetic
  # @see Comparison
  # @see Conversion
  # @see Formatter
  # @see Math
  # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
  # @since 1.0.0
  class Measurement
    include Arithmetic
    include Comparison
    include Conversion
    include Formatter
    include Math

    # Regular expression to match conversion strings.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    CONVERSION_STRING_REGEXP = /(.+?)\s?(?:\s+(?:in|to|as)\s+(.+)|\z)/i.freeze

    # Quantity of the measurement.
    #
    # @return [Numeric] Quantity of the measurement.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    attr_reader :quantity

    # The unit associated with the measurement.
    #
    # @return [Unit] The +unit+ instance associated with the measurement.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    attr_reader :unit

    # Initializes a new instance of +Measurement+ with a specified +quantity+
    # and +unit+.
    #
    # This method is intended to be overridden by subclasses and serves as a
    # placeholder for common initialization logic. It raises an error if called
    # directly on the abstract +Measurement+ class.
    #
    # @example Initializing the measurement with scientific number and unit:
    #   UnitMeasurements::Length.new(BigDecimal(2), "km")
    #   => 2.0 km
    #
    #   UnitMeasurements::Length.new("2e+2", "km")
    #   => 200.0 km
    #
    #   UnitMeasurements::Length.new("2e²", "km")
    #   => 200.0 km
    #
    #   UnitMeasurements::Length.new("2e⁻²", "km")
    #   => 0.02 km
    #
    # @example Initializing the measurement with complex number and unit:
    #   UnitMeasurements::Length.new(Complex(2, 3), "km")
    #   => 2+3i km
    #
    #   UnitMeasurements::Length.new("2+3i", "km")
    #   => 2.0+3.0i km
    #
    # @example Initializing the measurement with rational or mixed rational number and unit:
    #   UnitMeasurements::Length.new(Rational(2, 3), "km")
    #   => 0.6666666666666666 km
    #
    #   UnitMeasurements::Length.new(2/3r, "km")
    #   => 2/3 km
    #
    #   UnitMeasurements::Length.new("2/3", "km")
    #   => 0.6666666666666666 km
    #
    #   UnitMeasurements::Length.new("½", "km")
    #   => 0.5 km
    #
    #   UnitMeasurements::Length.new("2 ½", "km")
    #   => 2.5 km
    #
    # @example Initializing the measurement with ratio and unit:
    #   UnitMeasurements::Length.new("1:2", "km")
    #   => 0.5 km
    #
    # @param [Numeric|String] quantity The quantity of the measurement.
    # @param [String|Unit] unit The unit of the measurement.
    #
    # @raise [BlankQuantityError] If +quantity+ is blank.
    # @raise [BlankUnitError] If +unit+ is blank.
    #
    # @see BlankQuantityError
    # @see BlankUnitError
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def initialize(quantity, unit)
      raise BlankQuantityError if quantity.blank?
      raise BlankUnitError if unit.blank?

      @quantity = convert_quantity(quantity)
      @unit = unit_from_unit_or_name!(unit)
    end

    # Converts the measurement to a +target_unit+ and returns new instance of the
    # measurement.
    #
    # When +use_cache+ value is true, conversion factor between units are checked
    # in cache file of the unit group. If cached conversion factor is present in
    # the cache file, it is used for conversion otherwise conversion factor is
    # stored in the cache before converting the measurement to the +target_unit+.
    #
    # @example
    #   UnitMeasurements::Length.new(1, "m").convert_to("cm")
    #   => 100.0 cm
    #
    #   UnitMeasurements::Length.new(1, "m").convert_to("mm", use_cache: true)
    #   => 1000.0 cm
    #
    # @param [String|Symbol] target_unit
    #   The target unit for conversion.
    # @param [TrueClass|FalseClass] use_cache
    #   Indicates whether to use cached conversion factors.
    #
    # @return [Measurement]
    #   A new +Measurement+ instance with the converted +quantity+ and
    #   +target unit+.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def convert_to(target_unit, use_cache: false)
      target_unit = unit_from_unit_or_name!(target_unit)
      return self if target_unit == unit

      conversion_factor = calculate_conversion_factor(target_unit, use_cache)

      self.class.new((quantity * conversion_factor), target_unit)
    end
    alias_method :to, :convert_to
    alias_method :in, :convert_to
    alias_method :as, :convert_to

    # Converts the measurement to a +target_unit+ and updates the current instance.
    #
    # @example
    #   UnitMeasurements::Length.new(1, "m").convert_to!("cm")
    #   => 100.0 cm
    #
    #   UnitMeasurements::Length.new(1, "m").convert_to!("mm", use_cache: true)
    #   => 1000.0 mm
    #
    # @param [String|Symbol] target_unit
    #   The target unit for conversion.
    # @param [TrueClass|FalseClass] use_cache
    #   Indicates whether to use cached conversion factors.
    #
    # @return [Measurement]
    #   The current +Measurement+ instance with updated +quantity+ and +unit+.
    #
    # @see #convert_to
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def convert_to!(target_unit, use_cache: false)
      measurement = convert_to(target_unit, use_cache: use_cache)
      @quantity, @unit = measurement.quantity, measurement.unit

      self
    end
    alias_method :to!, :convert_to!
    alias_method :in!, :convert_to!
    alias_method :as!, :convert_to!

    # Converts the measurement to its primitive unit and returns a new instance
    # of the +Measurement+.
    #
    # The method first retrieves the primitive unit of the unit group associated
    # with the measurement. If the primitive unit is not set, it raises a
    # +MissingPrimitiveUnitError+.
    #
    # @example
    #   UnitMeasurements::Length.new(1, "m").to_primitive
    #   => 1 m
    #
    #   UnitMeasurements::Length.new(1, "cm").to_primitive
    #   => 0.01 m
    #
    # @param [TrueClass|FalseClass] use_cache
    #   Indicates whether to use cached conversion factors.
    # @return [Measurement]
    #   A new +Measurement+ instance representing the measurement in its
    #   primitive unit.
    #
    # @raise [MissingPrimitiveUnitError]
    #   If the primitive unit is not set for the unit group associated with the
    #   measurement.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 5.13.0
    def to_primitive(use_cache: false)
      primitive_unit = self.class.primitive
      raise MissingPrimitiveUnitError if primitive_unit.nil?

      convert_to(primitive_unit, use_cache: use_cache)
    end
    alias_method :in_primitive, :to_primitive
    alias_method :as_primitive, :to_primitive

    # Returns an object representation of the +Measurement+.
    #
    # @param [TrueClass|FalseClass] dump If +true+, returns the dump representation.
    #
    # @return [Object] An object representation of the +Measurement+.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def inspect(dump: false)
      dump ? super() : to_s
    end

    # Returns a string representation of the +Measurement+.
    #
    # @return [String] A string representation of the +Measurement+.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def to_s
      "#{quantity} #{unit}"
    end

    # Returns the +quantity+ of the +measurement+.
    #
    # @return [Numeric] Quantity of the measurement.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.5.0
    def quantity
      case @quantity
      when Rational
        @quantity.denominator == 1 ? @quantity.numerator : @quantity
      else
        @quantity
      end
    end

    class << self
      extend Forwardable

      # Methods delegated from the unit group.
      def_delegators :unit_group, :primitive, :units, :cache_file, :unit_names,
                     :unit_with_name_and_aliases, :unit_names_with_aliases,
                     :unit_for, :unit_for!, :defined?, :unit_or_alias?, :[],
                     :units_for, :units_for!, :systems

      # Parses an input string and returns a +Measurement+ instance depending on
      # the input string. This method first normalizes the +input+ internally,
      # using the +Normalizer+ before parsing it using the +Parser+.
      #
      # You can separate *source* and *target* units from each other in +input+
      # using +to+, +in+, or +as+.
      #
      # If only the source unit is provided, it returns a new +Measurement+
      # instance with the quantity in the source unit. If both source and target
      # units are provided in the input string, it returns a new +Measurement+
      # instance with the quantity converted to the target unit.
      #
      # @example Parsing string representing a complex number and source unit:
      #   UnitMeasurements::Length.parse("2+3i km")
      #   => 2.0+3.0i km
      #
      # @example Parsing string representing a complex number, source, and target units:
      #   UnitMeasurements::Length.parse("2+3i km in m")
      #   => 2000.0+3000.0i m
      #
      # @example Parsing string representing a rational or mixed rational number and source unit:
      #   UnitMeasurements::Length.parse("½ km")
      #   => 0.5 km
      #
      #   UnitMeasurements::Length.parse("2/3 km")
      #   => 0.666666666666667 km
      #
      #   UnitMeasurements::Length.parse("2 ½ km")
      #   => 2.5 km
      #
      #   UnitMeasurements::Length.parse("2 1/2 km")
      #   => 2.5 km
      #
      # @example Parsing string representing a rational or mixed rational number, source, and target units:
      #   UnitMeasurements::Length.parse("½ km to m")
      #   => 500.0 km
      #
      #   UnitMeasurements::Length.parse("2/3 km to m")
      #   => 666.666666666667 m
      #
      #   UnitMeasurements::Length.parse("2 ½ km in m")
      #   => 2500.0 m
      #
      #   UnitMeasurements::Length.parse("2 1/2 km as m")
      #   => 2500.0 m
      #
      # @example Parsing string representing a scientific number and source unit:
      #   UnitMeasurements::Length.parse("2e² km")
      #   => 200.0 km
      #
      #   UnitMeasurements::Length.parse("2e+2 km")
      #   => 200.0 km
      #
      #   UnitMeasurements::Length.parse("2e⁻² km")
      #   => 0.02 km
      #
      # @example Parsing string representing a scientific number, source, and target units:
      #
      #   UnitMeasurements::Length.parse("2e+2 km to m")
      #   => 200000.0 m
      #
      #   UnitMeasurements::Length.parse("2e⁻² km as m")
      #   => 20.0 m
      #
      # @example Parsing string representing a ratio and source unit:
      #   UnitMeasurements::Length.parse("1:2 km")
      #   => 0.5 km
      #
      # @example Parsing string representing a ratio, source, and target units:
      #   UnitMeasurements::Length.parse("1:2 km in m")
      #   => 500.0 m
      #
      # @param [String] input The input string to be parsed.
      # @param [TrueClass|FalseClass] use_cache
      #   Indicates whether to use cached conversion factors.
      #
      # @return [Measurement] The +Measurement+ instance.
      #
      # @see Parser
      # @see Normalizer
      # @see CONVERSION_STRING_REGEXP
      # @see ._parse
      # @see #convert_to
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def parse(input, use_cache: false)
        input = Normalizer.normalize(input)
        source, target = input.match(CONVERSION_STRING_REGEXP)&.captures

        target ? _parse(source).convert_to(target, use_cache: use_cache) : _parse(source)
      end

      # Returns the +Cache+ instance for the unit group to store and retrieve
      # conversion factors.
      #
      # @return [Cache] The +Cache+ instance.
      #
      # @example
      #   UnitMeasurements::Length.cached
      #   => #<UnitMeasurements::Cache:0x00007fe407249750>
      #
      # @see Cache
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 5.2.0
      def cached
        @cached ||= Cache.new(self)
      end

      # Clears the cached conversion factors of the unit group.
      #
      # @return [void]
      #
      # @example
      #   UnitMeasurements::Length.clear_cache
      #
      # @see Cache#clear_cache
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 5.2.0
      def clear_cache
        cached.clear_cache
      end

      # Calculates the ratio between two units.
      #
      # This method takes a source unit and a target unit, and returns the ratio
      # between them as a string representation.
      #
      # @example Calculating the ratio between 'in' and 'ft':
      #   UnitMeasurements::Length.ratio("in", "ft")
      #   => "12.0 in/ft"
      #
      #   UnitMeasurements::Length.ratio(UnitMeasurements::Length.unit_for("in"), "ft")
      #   => "12.0 in/ft"
      #
      # @param [Unit|String|Symbol] source_unit
      #   The source unit for the ratio calculation.
      # @param [Unit|String|Symbol] target_unit
      #   The target unit for the ratio calculation.
      #
      # @return [String] The ratio between the source and target units.
      #
      # @raise [UnitError]
      #   If either the source unit or the target unit is not found in the unit group.
      #
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 5.11.0
      def ratio(source_unit, target_unit)
        source_unit = source_unit.is_a?(Unit) ? source_unit : unit_for!(source_unit)
        target_unit = target_unit.is_a?(Unit) ? target_unit : unit_for!(target_unit)

        source_quantity = 1
        target_quantity = new(source_quantity, target_unit).convert_to(source_unit).quantity

        "#{target_quantity} #{source_unit}/#{target_unit}"
      end

      private

      # @private
      # The class attribute representing an instance of +UnitGroup+.
      #
      # @return [UnitGroup] An instance of +UnitGroup+.
      #
      # @raise [BaseError]
      #   If directly invoked on +Measurement+ rather than its subclasses.
      #
      # @see UnitGroup
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def unit_group
        raise BaseError, "`Measurement` does not have a `unit_group` instance. You cannot directly use `Measurement`. Instead, build a new unit group by calling `UnitMeasurements.build`."
      end

      # @private
      # Parses the normalized string to return the +Measurement+ instance.
      #
      # @param [String] string String to be parsed.
      #
      # @return [Measurement] The +Measurement+ instance.
      #
      # @see Parser.parse
      # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
      # @since 1.0.0
      def _parse(string)
        quantity, unit = Parser.parse(string)

        new(quantity, unit)
      end
    end

    private

    # @private
    # Converts the measurement quantity to a suitable format for internal use.
    #
    # @param [Numeric|String] quantity The quantity of the measurement.
    #
    # @return [Numeric] The converted quantity.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def convert_quantity(quantity)
      case quantity
      when Float
        BigDecimal(quantity, Float::DIG)
      when Integer
        Rational(quantity)
      when String
        quantity = Normalizer.normalize(quantity)
        quantity, _ = Parser.parse(quantity)

        quantity
      else
        quantity
      end
    end

    # @private
    # Returns the +Unit+ instance associated with the +value+ provided.
    #
    # @param [String|Unit] value
    #   The value representing a unit name or +Unit+ instance.
    #
    # @return [Unit] The +Unit+ instance associated with +value+.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 1.0.0
    def unit_from_unit_or_name!(value)
      value.is_a?(Unit) ? value : self.class.unit_for!(value)
    end

    # Calculates the conversion factor between the current unit and the target
    # unit.
    #
    # If caching is enabled and a cached factor is available, it will be used.
    # Otherwise, the conversion factor will be computed and, if caching is
    # enabled, stored in the cache.
    #
    # @param [Unit] target_unit The target unit for conversion.
    # @param [TrueClass|FalseClass] use_cache
    #   Indicates whether caching should be used.
    #
    # @return [Numeric] The conversion factor.
    #
    # @see Unit
    # @see #convert_to
    #
    # @note If caching is enabled, the calculated conversion factor will be stored in the cache.
    #
    # @author {Harshal V. Ladhe}[https://shivam091.github.io/]
    # @since 5.2.0
    def calculate_conversion_factor(target_unit, use_cache)
      use_cache = (UnitMeasurements.configuration.use_cache || use_cache)

      if use_cache && (cached_factor = self.class.cached.get(unit.name, target_unit.name))
        cached_factor
      else
        factor = unit.conversion_factor / target_unit.conversion_factor
        self.class.cached.set(unit.name, target_unit.name, factor) if use_cache
        factor
      end
    end
  end
end