lib/localize/inputs/number/validator.ex

defmodule Localize.Inputs.Number.Validator do
  @moduledoc """
  Server-side validation for parsed number values.

  Pure Elixir, no Ecto dependency. The Ecto changeset bridge is
  in `Localize.Inputs.Number.Changeset`.

  """

  alias Localize.Inputs.ValidationError

  @doc """
  Validates a parsed number against bounds, precision, and
  required-ness.

  ### Arguments

  * `value` is a `Decimal`, integer, or `nil`.

  * `options` is a keyword list of options.

  ### Options

  * `:required` — when `true`, `nil` is rejected.

  * `:min` — minimum allowed value (any numeric form the parser
    accepts).

  * `:max` — maximum allowed value.

  * `:decimals` — maximum number of fractional digits.

  ### Returns

  * `:ok` when every check passes.

  * `{:error, %Localize.Inputs.ValidationError{errors: [{atom(),
    String.t()}]}}` with one entry per failing check, in the
    order `:required`, `:min`, `:max`, `:decimals`.
    `Localize.Inputs.Number.Changeset.validate_number/3` unpacks the
    entries into per-field changeset errors.

  ### Examples

      iex> Localize.Inputs.Number.Validator.validate_number(Decimal.new("5"), min: 1, max: 10)
      :ok

      iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} =
      ...>   Localize.Inputs.Number.Validator.validate_number(Decimal.new("15"), max: 10)
      iex> errors
      [{:max, "must be at most 10"}]

      iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} =
      ...>   Localize.Inputs.Number.Validator.validate_number(nil, required: true)
      iex> errors
      [{:required, "is required"}]

  """
  @spec validate_number(term(), Keyword.t()) :: :ok | {:error, ValidationError.t()}
  def validate_number(value, options \\ []) do
    errors =
      []
      |> check_required(value, options)
      |> check_range(value, options)
      |> check_decimals(value, options)
      |> Enum.reverse()

    if errors == [], do: :ok, else: {:error, ValidationError.exception(errors: errors)}
  end

  @doc """
  Validates a unit-of-measure form submission.

  Accepts the `%{"amount" => ..., "unit" => ...}` map shape that
  `Localize.Inputs.Number.Components.unit_input/1` submits. Checks that
  the amount passes `validate_number/2` and that the unit is a
  known unit in the given category.

  ### Arguments

  * `value` — a `%{"amount", "unit"}` map (string or atom keys),
    a bare numeric value, `nil`, or `""`.

  * `options` is a keyword list of options.

  ### Options

  * `:category` — the unit category as a string (e.g. `"length"`).
    **Required.** The submitted unit is checked against
    `Localize.Inputs.Number.Unit.all_unit_names/2`.

  * `:required` — when `true`, `nil` amount is rejected.

  * `:min`, `:max`, `:decimals` — forwarded to `validate_number/2`.

  ### Returns

  * `:ok` on success.

  * `{:error, ValidationError.t()}` on any failure — combined
    amount-validation errors plus a `{:unit, "..."}` error if
    the unit is missing or not in the category.

  ### Examples

      iex> Localize.Inputs.Number.Validator.validate_unit(
      ...>   %{"amount" => Decimal.new("1.75"), "unit" => "meter"},
      ...>   category: "length"
      ...> )
      :ok

      iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} =
      ...>   Localize.Inputs.Number.Validator.validate_unit(
      ...>     %{"amount" => Decimal.new("1.75"), "unit" => "bogon"},
      ...>     category: "length"
      ...>   )
      iex> Keyword.get(errors, :unit) =~ "bogon"
      true

      iex> {:error, %Localize.Inputs.ValidationError{errors: errors}} =
      ...>   Localize.Inputs.Number.Validator.validate_unit(
      ...>     %{"amount" => Decimal.new("70"), "unit" => "kilogram"},
      ...>     category: "length"
      ...>   )
      iex> Keyword.get(errors, :unit) =~ "mass"
      true

  """
  @spec validate_unit(term(), Keyword.t()) :: :ok | {:error, ValidationError.t()}
  def validate_unit(value, options \\ []) do
    category = Keyword.fetch!(options, :category)

    {amount, unit} =
      case value do
        nil ->
          {nil, nil}

        "" ->
          {nil, nil}

        %{} = map ->
          {Map.get(map, "amount") || Map.get(map, :amount),
           Map.get(map, "unit") || Map.get(map, :unit)}

        bare ->
          {bare, nil}
      end

    errors =
      []
      |> check_required(amount, options)
      |> check_range(amount, options)
      |> check_decimals(amount, options)
      |> check_unit(unit, category, options)
      |> Enum.reverse()

    if errors == [], do: :ok, else: {:error, ValidationError.exception(errors: errors)}
  end

  defp check_unit(errors, nil, _category, options) do
    if Keyword.get(options, :required, false) do
      [{:unit, "unit is required"} | errors]
    else
      errors
    end
  end

  defp check_unit(errors, unit, category, _options) when is_binary(unit) do
    # Authoritative validity check: ask Localize.Unit to construct
    # one. CLDR's `known_units_by_category/0` only lists base units —
    # SI-prefixed variants like "millimeter" aren't there but are
    # valid via `Localize.Unit.new/2`. After constructing, verify
    # the unit's category matches the one the caller expected.
    case Localize.Unit.new(0, unit) do
      {:ok, parsed} ->
        case Localize.Unit.unit_category(parsed) do
          {:ok, ^category} ->
            errors

          {:ok, actual} ->
            [
              {:unit, "#{inspect(unit)} is a #{actual} unit, not #{category}"}
              | errors
            ]

          _ ->
            [
              {:unit, "#{inspect(unit)} is not recognised as a #{category} unit"}
              | errors
            ]
        end

      {:error, _} ->
        [{:unit, "#{inspect(unit)} is not a known #{category} unit"} | errors]
    end
  end

  defp check_unit(errors, _, _, _), do: errors

  defp check_required(errors, nil, options) do
    if Keyword.get(options, :required, false) do
      [{:required, "is required"} | errors]
    else
      errors
    end
  end

  defp check_required(errors, _value, _options), do: errors

  defp check_range(errors, nil, _options), do: errors

  defp check_range(errors, value, options) do
    errors
    |> maybe_check_min(value, Keyword.get(options, :min))
    |> maybe_check_max(value, Keyword.get(options, :max))
  end

  defp maybe_check_min(errors, _value, nil), do: errors

  defp maybe_check_min(errors, value, min) do
    if compare(value, min) == :lt do
      [{:min, "must be at least #{describe(min)}"} | errors]
    else
      errors
    end
  end

  defp maybe_check_max(errors, _value, nil), do: errors

  defp maybe_check_max(errors, value, max) do
    if compare(value, max) == :gt do
      [{:max, "must be at most #{describe(max)}"} | errors]
    else
      errors
    end
  end

  defp check_decimals(errors, nil, _options), do: errors

  defp check_decimals(errors, value, options) do
    case Keyword.get(options, :decimals) do
      nil ->
        errors

      max_decimals ->
        if decimal_places(value) > max_decimals do
          [{:decimals, "must have at most #{max_decimals} fractional digits"} | errors]
        else
          errors
        end
    end
  end

  defp compare(%Decimal{} = a, %Decimal{} = b), do: Decimal.compare(a, b)
  defp compare(%Decimal{} = a, b), do: Decimal.compare(a, to_decimal(b))
  defp compare(a, %Decimal{} = b), do: Decimal.compare(to_decimal(a), b)

  defp compare(a, b) when is_integer(a) and is_integer(b) do
    cond do
      a < b -> :lt
      a > b -> :gt
      true -> :eq
    end
  end

  defp compare(a, b), do: Decimal.compare(to_decimal(a), to_decimal(b))

  defp to_decimal(value) when is_integer(value), do: Decimal.new(value)

  defp to_decimal(value) when is_binary(value) do
    # `Decimal.new/1` raises on malformed input. Caller bounds
    # (`:min`, `:max`) flow from app code so should be well-
    # formed, but defend against typos: return `Decimal.new(0)`
    # for unparseable strings so the comparison silently
    # passes rather than crashing the validation pipeline.
    case Decimal.parse(value) do
      {decimal, ""} -> decimal
      _ -> Decimal.new(0)
    end
  end

  defp to_decimal(value) when is_float(value), do: Decimal.from_float(value)
  defp to_decimal(_), do: Decimal.new(0)

  defp describe(value), do: to_string(value)

  defp decimal_places(%Decimal{exp: exp}) when exp < 0, do: -exp
  defp decimal_places(%Decimal{}), do: 0
  defp decimal_places(value) when is_integer(value), do: 0

  defp decimal_places(value) when is_binary(value) do
    case String.split(value, ".") do
      [_, fraction] -> String.length(fraction)
      _ -> 0
    end
  end

  defp decimal_places(_), do: 0
end