lib/cldr/unit/conversion.ex

defmodule Cldr.Unit.Conversion do
  @moduledoc """
  Unit conversion functions for the units defined
  in `Cldr`.

  """

  @enforce_keys [:factor, :offset, :base_unit]
  defstruct factor: 1,
            offset: 0,
            base_unit: nil

  @type factor :: integer | float
  @type offset :: integer | float

  @type t :: %{
          factor: factor(),
          base_unit: [atom(), ...],
          offset: offset()
        }

  alias Cldr.Unit
  alias Cldr.Unit.BaseUnit

  import Kernel, except: [div: 2]

  @decimal_1 Decimal.new(1)
  @decimal_0 Decimal.new(0)

  @doc """
  Returns the conversion that calculates
  the base unit into another unit or
  and error.

  """
  def conversion_for(unit_1, unit_2) do
    with {:ok, base_unit_1, _conversion_1} <- base_unit_and_conversion(unit_1),
         {:ok, base_unit_2, conversion_2} <- base_unit_and_conversion(unit_2) do
      conversion_for(unit_1, unit_2, base_unit_1, base_unit_2, conversion_2)
    end
  end

  # Base units match so are compatible
  defp conversion_for(_unit_1, _unit_2, base_unit, base_unit, conversion_2) do
    {:ok, conversion_2, :forward}
  end

  # Its invertable so see if that's convertible. Note that
  # there is no difference in the conversion for an inverted
  # conversion. Its only a hint so that in convert_from_base/2
  # we know to divide, not multiple the value.

  defp conversion_for(unit_1, unit_2, base_unit_1, _base_unit_2, {numerator_2, denominator_2}) do
    inverted_conversion = {denominator_2, numerator_2}

    with {:ok, base_unit_2} <- BaseUnit.canonical_base_unit(inverted_conversion) do
      if base_unit_1 == base_unit_2 do
        {:ok, {numerator_2, denominator_2}, :inverted}
      else
        {:error, Unit.incompatible_units_error(unit_1, unit_2)}
      end
    end
  end

  # If the base units don't match, try comparing the unit categories
  # instead.

  defp conversion_for(unit_1, unit_2, _base_unit_1, _base_unit_2, conversion_2) do
    with {:ok, category_1} <- Cldr.Unit.unit_category(unit_1),
         {:ok, category_2} <- Cldr.Unit.unit_category(unit_2) do
      if category_1 == category_2 do
        {:ok, conversion_2, :forward}
      else
        {:error, Unit.incompatible_units_error(unit_1, unit_2)}
      end
    end
  end

  @doc """
  Returns the base unit and the base unit
  conversionfor a given unit.

  ## Argument

  * `unit` is either a `t:Cldr.Unit`, an `atom` or
    a `t:String`

  ## Returns

  * `{:ok, base_unit, conversion}` or

  * `{:error, {exception, reason}}`

  ## Example

      iex> Cldr.Unit.Conversion.base_unit_and_conversion :square_kilometer
      {
        :ok,
        :square_meter,
        [square_kilometer: %Cldr.Unit.Conversion{base_unit: [:square, :meter], factor: 1000000, offset: 0}]
      }

      iex> Cldr.Unit.Conversion.base_unit_and_conversion :square_table
      {:error, {Cldr.UnknownUnitError, "Unknown unit was detected at \\"table\\""}}

  """

  def base_unit_and_conversion(%Unit{base_conversion: conversion}) do
    {:ok, base_unit} = BaseUnit.canonical_base_unit(conversion)
    {:ok, base_unit, conversion}
  end

  def base_unit_and_conversion(unit_name) when is_atom(unit_name) or is_binary(unit_name) do
    with {:ok, _unit, conversion} <- Cldr.Unit.validate_unit(unit_name),
         {:ok, base_unit} <- BaseUnit.canonical_base_unit(conversion) do
      {:ok, base_unit, conversion}
    end
  end

  @doc """
  Convert one unit into another unit of the same
  unit type (length, volume, mass, ...)

  ## Arguments

  * `unit` is any unit returned by `Cldr.Unit.new/2`

  * `to_unit` is any unit name returned by `Cldr.Unit.known_units/0`

  ## Returns

  * a `Unit.t` of the unit type `to_unit` or

  * `{:error, {exception, message}}`

  ## Examples

      iex> Cldr.Unit.convert Cldr.Unit.new!(:mile, 1), :foot
      {:ok, Cldr.Unit.new!(:foot, 5280)}

      iex> Cldr.Unit.convert Cldr.Unit.new!(:mile, 1), :gallon
      {:error, {Cldr.Unit.IncompatibleUnitsError,
        "Operations can only be performed between units with the same base unit. Received :mile and :gallon"}}

  """
  @spec convert(Unit.t(), Unit.unit()) :: {:ok, Unit.t()} | {:error, {module(), String.t()}}

  def convert(%Unit{value: value, base_conversion: from_conversion} = unit, to_unit) do
    with {:ok, to_conversion, maybe_inverted} <- conversion_for(unit, to_unit) do
      converted_value = convert(value, from_conversion, to_conversion, maybe_inverted)
      Unit.new(to_unit, converted_value, usage: unit.usage, format_options: unit.format_options)
    end
  end

  defp convert(value, from, to, maybe_inverted) when is_number(value) or is_map(value) do
    value
    |> convert_to_base(from)
    |> maybe_invert_value(maybe_inverted)
    |> convert_from_base(to)
  end

  def maybe_invert_value(value, :inverted) do
    div(1, value)
  end

  def maybe_invert_value(value, _) do
    value
  end

  # All conversions are ultimately a list of
  # 2-tuples of the unit and conversion struct
  defp convert_to_base(value, {_, %__MODULE__{} = from}) do
    %{factor: from_factor, offset: from_offset} = from

    from_factor
    |> mult(value)
    |> add(from_offset)
  end

  # A per module is a 2-tuple of the numerator and
  # denominator. Both are lists of conversion tuples.
  defp convert_to_base(value, {numerator, denominator}) do
    convert_to_base(@decimal_1, numerator)
    |> div(convert_to_base(@decimal_1, denominator))
    |> mult(value)
  end

  # We recurse over the list of conversions
  # and accumulate the value as we go
  defp convert_to_base(value, []) do
    value
  end

  defp convert_to_base(value, [first | rest]) do
    convert_to_base(value, first) |> convert_to_base(rest)
  end

  # But if we meet a shape of data we don't
  # understand then its a raisable error
  defp convert_to_base(_value, conversion) do
    raise ArgumentError, "Conversion not recognised: #{inspect(conversion)}"
  end

  defp convert_from_base(value, {_, %__MODULE__{} = to}) do
    %{factor: to_factor, offset: to_offset} = to

    value
    |> sub(to_offset)
    |> div(to_factor)
  end

  defp convert_from_base(value, {numerator, denominator}) do
    convert_from_base(@decimal_1, numerator)
    |> div(convert_from_base(@decimal_1, denominator))
    |> mult(value)
  end

  defp convert_from_base(value, []) do
    value
  end

  defp convert_from_base(value, [first | rest]) do
    convert_from_base(value, first) |> convert_from_base(rest)
  end

  defp convert_from_base(_value, conversion) do
    raise ArgumentError, "Conversion not recognised: #{inspect(conversion)}"
  end

  @doc """
  Convert one unit into another unit of the same
  unit type (length, volume, mass, ...) and raises
  on a unit type mismatch

  ## Arguments

  * `unit` is any unit returned by `Cldr.Unit.new/2`

  * `to_unit` is any unit name returned by `Cldr.Unit.known_units/0`

  ## Returns

  * a `Unit.t` of the unit type `to_unit` or

  * raises an exception

  ## Examples

      iex> Cldr.Unit.Conversion.convert!(Cldr.Unit.new!(:celsius, 0), :fahrenheit)
      ...> |> Cldr.Unit.round
      Cldr.Unit.new!(:fahrenheit, 32)

      iex> Cldr.Unit.Conversion.convert!(Cldr.Unit.new!(:fahrenheit, 32), :celsius)
      ...> |> Cldr.Unit.round
      Cldr.Unit.new!(:celsius, 0)

      Cldr.Unit.Conversion.convert Cldr.Unit.new!(:mile, 1), :gallon
      ** (Cldr.Unit.IncompatibleUnitsError) Operations can only be performed between units of the same type. Received :mile and :gallon

  """
  @spec convert!(Unit.t(), Unit.unit()) :: Unit.t() | no_return()

  def convert!(%Unit{} = unit, to_unit) do
    case convert(unit, to_unit) do
      {:error, {exception, reason}} -> raise exception, reason
      {:ok, unit} -> unit
    end
  end

  @doc """
  Convert a unit into its base unit.

  For example, the base unit for `length`
  is `meter`. The base unit is an
  intermediary unit used in all
  conversions.

  ## Arguments

  * `unit` is any unit returned by `Cldr.Unit.new/2`

  ## Returns

  * `unit` converted to its base unit as a `t:Unit.t()` or

  * `{;error, {exception, reason}}` as an error

  ## Example

      iex> unit = Cldr.Unit.new!(:kilometer, 10)
      iex> Cldr.Unit.Conversion.convert_to_base_unit unit
      {:ok, Cldr.Unit.new!(:meter, 10000)}

  """
  def convert_to_base_unit(%Unit{} = unit) do
    with {:ok, base_unit} <- Unit.base_unit(unit) do
      convert(unit, base_unit)
    end
  end

  def convert_to_base_unit(unit) when is_atom(unit) do
    unit
    |> Unit.new!("1.0")
    |> convert_to_base_unit()
  end

  def convert_to_base_unit([unit | _rest]) when is_atom(unit) do
    convert_to_base_unit(unit)
  end

  @doc """
  Convert a unit into its base unit and
  raises on error

  For example, the base unit for `length`
  is `meter`. The base unit is an
  intermediary unit used in all
  conversions.

  ## Arguments

  * `unit` is any unit returned by `Cldr.Unit.new/2`

  ## Returns

  * `unit` converted to its base unit as a `t:Unit.t()` or

  * raises an exception

  ## Example

      iex> unit = Cldr.Unit.new!(:kilometer, 10)
      iex> Cldr.Unit.Conversion.convert_to_base_unit! unit
      Cldr.Unit.new!(:meter, 10000)

  """
  def convert_to_base_unit!(%Unit{} = unit) do
    case convert_to_base_unit(unit) do
      {:error, {exception, reason}} -> raise exception, reason
      {:ok, unit} -> unit
    end
  end

  #### Math helpers for Decimal and integer

  @doc false
  def add(any, 0) do
    maybe_integer(any)
  end

  def add(any, @decimal_0) do
    maybe_integer(any)
  end

  def add(%Decimal{} = a, b) do
    Decimal.add(a, b)
    |> maybe_integer()
  end

  def add(a, %Decimal{} = b) do
    Decimal.add(a, b)
    |> maybe_integer()
  end

  def add(a, b) do
    maybe_integer(a + b)
  end

  @doc false
  def sub(any, 0) do
    maybe_integer(any)
  end

  def sub(any, 0.0) do
    maybe_integer(any)
  end

  def sub(a, %Decimal{} = b) do
    Decimal.sub(a, b)
    |> maybe_integer()
  end

  def sub(%Decimal{} = a, b) do
    Decimal.sub(a, b)
    |> maybe_integer()
  end

  def sub(a, b) do
    maybe_integer(a - b)
  end

  @doc false
  def mult(any, 1) do
    any
  end

  def mult(any, @decimal_1) do
    any
  end

  def mult(1, b) do
    maybe_integer(b)
  end

  def mult(_any, 0) do
    0
  end

  def mult(_any, @decimal_0) do
    0
  end

  def mult(%Decimal{} = a, b) do
    Decimal.mult(a, b)
    |> maybe_integer()
  end

  def mult(a, %Decimal{} = b) do
    Decimal.mult(a, b)
    |> maybe_integer()
  end

  def mult(a, b) do
    (a * b)
    |> maybe_integer()
  end

  def div(_any, 0) do
    0
  end

  def div(_any, @decimal_0) do
    0
  end

  def div(any, 1) do
    maybe_integer(any)
  end

  def div(any, @decimal_1) do
    maybe_integer(any)
  end

  def div(%Decimal{} = a, b) do
    Decimal.div(a, b)
    |> maybe_integer()
  end

  def div(a, %Decimal{} = b) do
    Decimal.div(a, b)
    |> maybe_integer()
  end

  def div(a, b) when is_integer(a) and is_integer(b) do
    integer_div = Kernel.div(a, b)

    if integer_div * b == a do
      integer_div
    else
      Decimal.div(Decimal.new(a), Decimal.new(b))
    end
  end

  @doc false
  def pow(_any, @decimal_0) do
    1
  end

  def pow(1, _any) do
    1
  end

  def pow(a, b) when is_integer(b) do
    Cldr.Math.power(a, b)
    |> maybe_integer
  end

  # Decimal.integer? only on 2.x but we support 1.x
  # so we have to check the hard way

  def maybe_integer(%Decimal{} = a) do
    Decimal.to_integer(a)
  rescue
    FunctionClauseError ->
      a
    ArgumentError ->
      a
  end

  def maybe_integer(a) when is_integer(a) do
    a
  end
end