lib/cldr/unit/base_unit.ex

defmodule Cldr.Unit.BaseUnit do
  @moduledoc """
  Functions to support the base unit calculations
  for a unit.

  Base unit equality is used to determine whether
  a one unit can be converted to another

  """

  alias Cldr.Unit.Conversion
  alias Cldr.Unit.Parser
  alias Cldr.Unit

  @per "_per_"
  @currency_base Cldr.Unit.Parser.currency_base()
  @currencies Cldr.known_currencies()

  @doc """
  Returns the canonical base unit name
  for a unit.

  The base unit is the common unit through which
  conversions are passed.

  ## Arguments

  * `unit_string` is any string representing
    a unit such as `light_year_per_week`.

  ## Returns

  * `{:ok, canonical_base_unit}` or

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

  ## Examples

      iex> Cldr.Unit.Parser.canonical_base_unit "meter"
      {:ok, :meter}

      iex> Cldr.Unit.Parser.canonical_base_unit "meter meter"
      {:ok, :square_meter}

      iex> Cldr.Unit.Parser.canonical_base_unit "meter per kilogram"
      {:ok, "meter_per_kilogram"}

      iex> Cldr.Unit.Parser.canonical_base_unit "yottagram per mile scandinavian"
      {:ok, "kilogram_per_meter"}

  """
  def canonical_base_unit(unit) when is_binary(unit) do
    with {:ok, parsed} <- Parser.parse_unit(unit) do
      canonical_base_unit(parsed)
    end
  end

  # A "per" unit
  def canonical_base_unit({numerator, denominator}) do
    with numerator <- do_canonical_base_unit(numerator),
         denominator <- do_canonical_base_unit(denominator) do
      {numerator, denominator}
      |> merge_unit_names()
      |> sort_base_units()
      |> reduce_powers()
      # |> reduce_factors()
      |> flatten_and_stringify()
      |> Unit.maybe_translatable_unit()
      |> wrap(:ok)
    end
  end

  # A list of conversions
  def canonical_base_unit(numerator) do
    numerator
    |> do_canonical_base_unit()
    |> flatten_and_stringify()
    |> Unit.maybe_translatable_unit()
    |> wrap(:ok)
  end

  def do_canonical_base_unit(numerator) when is_list(numerator) do
    numerator
    |> Enum.map(&canonical_base_subunit/1)
    |> resolve_unit_names()
    |> sort_base_units()
    |> reduce_powers()

    # |> reduce_factors()
  end

  defp canonical_base_subunit({currency, _conversion}) when currency in @currencies do
    [String.downcase(@currency_base <> to_string(currency))]
  end

  defp canonical_base_subunit({_unit_name, %Conversion{base_unit: base_units}}) do
    base_units
    |> parse_base_units()
    |> extract_unit_names()
  end

  defp parse_base_units([prefix, unit]) do
    [[prefix, parse_base_units([unit])]]
  end

  defp parse_base_units([unit]) do
    unit
    |> to_string
    |> Cldr.Unit.normalize_unit_name()
    |> Parser.parse_unit!()
  end

  # Base units are either
  #   A {numerator, denominator} tuple
  #   A list of {unit, base_unit} tuples

  defp extract_unit_names({numerator, denominator}) do
    {extract_keys(numerator), extract_keys(denominator)}
  end

  defp extract_unit_names(numerator) do
    extract_keys(numerator)
  end

  # Extract the base units from the conversion
  # And simplify base units (ie unwrap them)
  defp extract_keys(list) do
    Enum.map(list, fn
      [prefix, conversion] ->
        [prefix, hd(extract_keys(conversion))]

      {_unit, conversion} ->
        conversion
        |> Map.fetch!(:base_unit)
        |> case do
          [unit] -> unit
          [prefix, unit] -> [prefix, unit]
        end
    end)
  end

  # Merge all list elements, starting with the first
  # two until the end of the list

  defp resolve_unit_names([first]) do
    first
  end

  defp resolve_unit_names([first, second | rest]) do
    resolve_unit_names([merge_unit_names(first, second) | rest])
  end

  # Take two list elements and merge them noting that either
  # element might be a "per tuple" represented by a tuple
  defp merge_unit_names({numerator_a, denominator_a}, {numerator_b, denominator_b}) do
    {merge_unit_names(numerator_a, numerator_b), merge_unit_names(denominator_a, denominator_b)}
  end

  defp merge_unit_names({numerator_a, denominator_a}, numerator_b) do
    {merge_unit_names(numerator_a, numerator_b), denominator_a}
  end

  defp merge_unit_names(numerator_a, {numerator_b, denominator_b}) do
    {merge_unit_names(numerator_a, numerator_b), denominator_b}
  end

  defp merge_unit_names(numerator_a, numerator_b) do
    numerator_a ++ numerator_b
  end

  # Final pass for "per" base units
  defp merge_unit_names({{_numerator_a, _denominator_a}, {_numerator_b, _denominator_b}}) do
    raise ArgumentError, "unexpected"
  end

  defp merge_unit_names({{numerator_a, denominator_a}, numerator_b}) do
    {numerator_a, merge_unit_names(numerator_b, denominator_a)}
  end

  defp merge_unit_names({numerator_a, {numerator_b, denominator_b}}) do
    {merge_unit_names(numerator_a, denominator_b), numerator_b}
  end

  defp merge_unit_names(other) do
    other
  end

  # Sort the units in canonical order
  defp sort_base_units({numerator, denominator}) do
    {Enum.sort(numerator, &base_unit_sorter/2), Enum.sort(denominator, &base_unit_sorter/2)}
  end

  defp sort_base_units(numerator) do
    Enum.sort(numerator, &base_unit_sorter/2)
  end

  # Relies on base units only ever being a single unit
  # or a list with two elements being a prefix and a unit except
  # for a currency unit in which case it will be a binary of the
  # form `curr-usd` by the time we get here.  And currency forms
  # always sort at the head of the list.

  defp base_unit_sorter(unit_a, unit_b) when is_atom(unit_a) and is_atom(unit_b) do
    Map.fetch!(base_units_in_order(), unit_a) < Map.fetch!(base_units_in_order(), unit_b)
  end

  defp base_unit_sorter(unit_a, [_prefix, unit_b]) when is_atom(unit_a) do
    Map.fetch!(base_units_in_order(), unit_a) < Map.fetch!(base_units_in_order(), unit_b)
  end

  defp base_unit_sorter([_prefix, unit_a], unit_b) when is_atom(unit_b) do
    Map.fetch!(base_units_in_order(), unit_a) < Map.fetch!(base_units_in_order(), unit_b)
  end

  defp base_unit_sorter([_prefix_a, unit_a], [_prefix_b, unit_b]) do
    Map.fetch!(base_units_in_order(), unit_a) < Map.fetch!(base_units_in_order(), unit_b)
  end

  defp base_unit_sorter(@currency_base <> _currency, _) do
    true
  end

  defp base_unit_sorter(_, @currency_base <> _currency) do
    false
  end

  # Reduce factors. When its a "per" unit then
  # we reduce the common factors.
  # This is important to ensure that base unit
  # comparisons work correctly across different units
  # of the same type.

  # Currently not being used but in the future this
  # might be required.

  @doc false
  def reduce_factors({[], denominator}) do
    {[], denominator}
  end

  def reduce_factors({numerator, []}) do
    {numerator, []}
  end

  # Numerator and denominator cancel each other
  def reduce_factors({[unit | rest_1], [unit | rest_2]}) do
    # |> IO.inspect(label: "1")
    reduce_factors({rest_1, rest_2})
  end

  def reduce_factors({[[:square, unit] | rest_1], [unit | rest_2]}) do
    # |> IO.inspect(label: "2")
    reduce_factors({[unit | rest_1], rest_2})
  end

  def reduce_factors({[unit | rest_1], [[:square, unit] | rest_2]}) do
    # |> IO.inspect(label: "3")
    reduce_factors({rest_1, [unit | rest_2]})
  end

  def reduce_factors({[unit | rest_1], [[:cubic, unit] | rest_2]}) do
    # |> IO.inspect(label: "4")
    reduce_factors({rest_1, [[:square, unit] | rest_2]})
  end

  def reduce_factors({[[:square, unit] | rest_1], [[:square, unit] | rest_2]}) do
    # |> IO.inspect(label: "5")
    reduce_factors({rest_1, rest_2})
  end

  def reduce_factors({[[:cubic, unit] | rest_1], [[:cubic, unit] | rest_2]}) do
    # |> IO.inspect(label: "6")
    reduce_factors({rest_1, rest_2})
  end

  def reduce_factors({[[:cubic, unit] | rest_1], [[:square, unit] | rest_2]}) do
    # |> IO.inspect(label: "7")
    reduce_factors({[unit | rest_1], rest_2})
  end

  def reduce_factors({[[:cubic, unit] | rest_1], [unit | rest_2]}) do
    # |> IO.inspect(label: "8")
    reduce_factors({[[:square, unit] | rest_1], rest_2})
  end

  def reduce_factors({[unit_1 | rest_1], rest_2}) do
    {numerator, denominator} = reduce_factors({rest_1, rest_2})
    # |> IO.inspect(label: "9")
    {[unit_1 | numerator], denominator}
  end

  def reduce_factors(other) do
    # |> IO.inspect(label: "Other")
    other
  end

  # Reduce powers to square and cubic
  defp reduce_powers({numerator, denominator}) do
    {reduce_powers(numerator), reduce_powers(denominator)}
  end

  defp reduce_powers([first]) do
    [first]
  end

  defp reduce_powers([first, first | rest]) do
    reduce_powers([[:square, first] | rest])
  end

  defp reduce_powers([[:square, first], first | rest]) do
    reduce_powers([[:cubic, first] | rest])
  end

  defp reduce_powers([first, [:square, first] | rest]) do
    reduce_powers([[:cubic, first] | rest])
  end

  defp reduce_powers([first | rest]) do
    [first | reduce_powers(rest)]
  end

  # Flaten the list and turn it into a string
  defp flatten_and_stringify({[], denominator}) do
    flatten_and_stringify(denominator)
  end

  defp flatten_and_stringify({numerator, []}) do
    flatten_and_stringify(numerator)
  end

  defp flatten_and_stringify({numerator, denominator}) do
    flatten_and_stringify(numerator) <> @per <> flatten_and_stringify(denominator)
  end

  defp flatten_and_stringify(numerator) do
    numerator
    |> List.flatten()
    |> Enum.map(&to_string/1)
    |> Enum.join("_")
  end

  @doc """
  Returns the canonical base unit name
  for a unit.

  The base unit is the common unit through which
  conversions are passed.

  ## Arguments

  * `unit_string` is any string representing
    a unit such as `light_year_per_week`.

  ## Returns

  * `canonical_base_unit` or

  * raises an exception

  ## Examples

      iex> Cldr.Unit.Parser.canonical_base_unit! "meter"
      :meter

      iex> Cldr.Unit.Parser.canonical_base_unit! "meter meter"
      :square_meter

      iex> Cldr.Unit.Parser.canonical_base_unit! "meter per kilogram"
      "meter_per_kilogram"

      iex> Cldr.Unit.Parser.canonical_base_unit! "yottagram per mile scandinavian"
      "kilogram_per_meter"

  """
  def canonical_base_unit!(unit_string) when is_binary(unit_string) do
    case canonical_base_unit(unit_string) do
      {:ok, unit_name} -> unit_name
      {:error, {exception, reason}} -> raise exception, reason
    end
  end

  # We wrap in a tuple since a nested list can
  # create ambiguous processing in other places

  @doc false
  def wrap([numerator, denominator], tag) do
    {tag, {numerator, denominator}}
  end

  def wrap([numerator], tag) do
    {tag, numerator}
  end

  def wrap(other, tag) do
    {tag, other}
  end

  @base_units_in_order Cldr.Config.units()
                       |> Map.get(:base_units)
                       |> Cldr.Unit.Additional.merge_base_units()
                       |> Enum.map(&elem(&1, 1))
                       |> Enum.with_index()
                       |> Map.new()

  @doc false
  def base_units_in_order do
    @base_units_in_order
  end
end