lib/localize/inputs/number/components.ex

if Code.ensure_loaded?(Phoenix.Component) and
     Code.ensure_loaded?(Gettext.Backend) do
  defmodule Localize.Inputs.Number.Components do
    @moduledoc """
    HEEx components for locale-aware form input.

    Today: `number_input/1`. More inputs (percentage, ratio,
    dimension, …) will land here over time under the same
    namespace.

    ## Setup

    Add the JS hook in your `assets/js/app.js`:

        import Hooks from "localize_inputs"
        let liveSocket = new LiveSocket("/live", Socket, {
          hooks: { NumberInput: Hooks.NumberInput }
        })

    Install AutoNumeric as a peer dep:

        npm install autonumeric

    ## Tolerance of invalid input

    These components sit on the render path and never raise on
    bad input — the page always renders. Specifically:

    * **Unknown `:locale`** — falls back to `:en` locale data;
      if `:en` itself is unavailable (broken Localize install)
      falls back to an empty `%Localize.Inputs.Number.Symbols{}`
      so attribute reads still resolve to safe defaults.

    * **Unknown `:category`** on `unit_input/1` — falls back
      to an empty `%Localize.Inputs.Number.Unit{}`. The picker
      renders no rows rather than 500ing the page.

    * **Blank or non-numeric `value`** — formatted as `""` in
      the visible input; the hidden ISO carrier stays empty.
      The submit-side parser (`Localize.Inputs.Number.Parser.parse_number/2`)
      returns `{:ok, nil}` for blanks and `{:error, _}` for
      garbage, never raises.

    * **Malformed `:min` / `:max` / `:decimals` bounds** —
      `Localize.Inputs.Number.Validator.validate_number/2`
      uses `Decimal.parse/1` with a `Decimal.new(0)` fallback,
      so a typo in a bound silently passes the bound check
      rather than crashing the validation pipeline. Returns
      `{:error, %ValidationError{}}` for real out-of-range
      values.

    """

    use Phoenix.Component
    use Localize.Message.Sigils, backend: Localize.Inputs.Gettext

    alias Localize.Inputs.Number.{Symbols, Unit}

    @doc """
    Locale-aware plain-number input.

    Renders an `<input type="text" inputmode="decimal">` wrapped
    in a `<div>` that carries `data-` attributes the JS hook
    reads (locale, separators, minus sign, min/max, decimals).
    With AutoNumeric loaded the input live-formats as the user
    types; without it, the server-side parser
    (`Localize.Inputs.Number.Parser.parse_number/2`) accepts whatever
    the user typed on submit.

    The form value submits in the user's *locale-formatted* shape
    — exactly what AutoNumeric was displaying. Parse it on the
    server with `Localize.Inputs.Number.Parser.parse_number/2` (or
    `Localize.Inputs.Number.Changeset.validate_number/3` for an
    Ecto-backed flow). One wire shape regardless of whether the
    JS hook is loaded — no canonical-vs-locale ambiguity.

    ### Arguments

    * `assigns` — see the per-attribute documentation below.

    ### Attributes

    * `:form` — the `Phoenix.HTML.Form` the field belongs to.

    * `:field` — the form field as an atom.

    * `:locale` — display locale. Defaults to
      `Localize.get_locale/0`.

    * `:integer` — when `true`, accept integers only and emit
      `inputmode="numeric"`.

    * `:min`, `:max` — value bounds.

    * `:decimals` — maximum fractional digits.

    * `:align` — `:left` (default), `:right`, or `:center`.

    * `:placeholder` — placeholder text.

    * `:js` — set to `false` to skip the `phx-hook` attribute.

    * `:class`, `:input_class` — extra classes for the wrapper
      and the input.

    ### Returns

    * A `Phoenix.LiveView.Rendered` struct containing the input
      markup.

    ### Examples

        <.number_input form={@form} field={:quantity} integer={true} min={1} max={999} />
        <.number_input form={@form} field={:rating} min={0} max={5} decimals={1} />

    """
    attr(:form, Phoenix.HTML.Form, required: true)
    attr(:field, :atom, required: true)
    attr(:value, :any, default: nil)
    attr(:locale, :string, default: nil)
    attr(:integer, :boolean, default: false)
    attr(:min, :any, default: nil)
    attr(:max, :any, default: nil)
    attr(:decimals, :integer, default: nil)
    attr(:align, :atom, default: :left, values: [:left, :right, :center])
    attr(:placeholder, :string, default: nil)
    attr(:js, :boolean, default: true)
    attr(:class, :string, default: nil)
    attr(:input_class, :string, default: nil)
    attr(:rest, :global, include: ~w(disabled readonly required autofocus))

    def number_input(assigns) do
      assigns = assigns |> assign_common() |> assign_number_value()

      ~H"""
      <div
        class={["number-input-wrapper", @class]}
        data-locale-input="number"
        data-locale={@locale_data.locale}
        data-decimal={@locale_data.decimal}
        data-group={@locale_data.group}
        data-number-system={@locale_data.number_system}
        data-minus={@locale_data.minus_sign}
        data-integer={to_string(@integer)}
        data-decimals={@decimals}
        data-min={value_attr(@min)}
        data-max={value_attr(@max)}
        phx-hook={if @js, do: "NumberInput"}
        id={"#{@id}-wrapper"}
      >
        <input
          type="text"
          inputmode={if @integer, do: "numeric", else: "decimal"}
          name={@name}
          id={@id}
          value={@formatted_value}
          class={["number-input-field", text_align_class(@align), @input_class]}
          autocomplete="off"
          dir="ltr"
          placeholder={@placeholder}
          {@rest}
        />
      </div>
      """
    end

    # ── Internal: shared assigns ──────────────────────────────

    defp assign_common(assigns) do
      locale = assigns[:locale] || Localize.get_locale()

      # Components are render-path code — never raise on an
      # unknown locale. Fall back to `:en` so downstream
      # `locale_data.decimal` etc. still resolves.
      locale_data = safe_number_for_locale(locale)

      field_struct = assigns.form[assigns.field]
      name = field_struct.name
      id = field_struct.id

      assigns
      |> assign(:locale, locale)
      |> assign(:locale_data, locale_data)
      |> assign(:name, name)
      |> assign(:id, id)
      |> assign_new(:placeholder, fn -> nil end)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:input_class, fn -> nil end)
    end

    defp assign_number_value(assigns) do
      explicit = assigns.value
      form_value = (assigns.form[assigns.field] || %{}).value
      raw = explicit || form_value

      assign(assigns, :formatted_value, format_value(raw, assigns.locale))
    end

    # Render the value into the input's `value=` attribute. Three
    # safe outcomes: nil/empty input → empty string; valid number
    # → locale-formatted string via Localize.Number.to_string;
    # unparseable input → empty string (the page still re-renders
    # without crashing). The user's raw text isn't preserved here
    # because the input only ever holds canonical-shape values
    # post-render; live editing is the JS hook's job.
    defp format_value(nil, _locale), do: ""
    defp format_value("", _locale), do: ""

    defp format_value(value, locale) when is_binary(value) do
      case Localize.Inputs.Number.Parser.parse_number(value, locale: locale) do
        {:ok, nil} -> ""
        {:ok, parsed} -> format_value(parsed, locale)
        {:error, _} -> value
      end
    end

    defp format_value(value, locale) do
      case Localize.Number.to_string(value, locale: locale) do
        {:ok, formatted} -> formatted
        {:error, _} -> ""
      end
    end

    defp value_attr(nil), do: nil
    defp value_attr(value), do: to_string(value)

    defp text_align_class(:left), do: "text-left"
    defp text_align_class(:center), do: "text-center"
    defp text_align_class(:right), do: "text-right"

    # ── unit_input + unit_picker ─────────────────────────────

    @doc """
    Locale-aware number + unit-of-measure input.

    Renders a number input paired with a searchable
    `unit_picker/1` for a given measurement category
    (`"length"`, `"volume"`, `"mass"`, …). The picker is
    grouped into **Preferred** (units in the locale's
    measurement system — metric, US, or UK) and **All units**
    (every known unit in the category). Unit display names are
    localized via `Localize.Unit.display_name/2`.

    Submits as a nested map:

        params[field] = %{"amount" => "1.5", "unit" => "meter"}

    Parse on the server with the locale you already have.

    ### Attributes

    * `:form` — the `Phoenix.HTML.Form` the field belongs to.

    * `:field` — the form field as an atom. The amount and unit
      sub-fields submit under `params[field][amount]` and
      `params[field][unit]`.

    * `:category` — the unit category as a string (e.g.
      `"length"`). **Required.** See
      `Localize.Inputs.Number.Unit.known_categories/0`.

    * `:default_unit` — the unit selected by default. If `nil`,
      the first preferred unit for the locale is selected.

    * `:locale` — display locale. Defaults to
      `Localize.get_locale/0`.

    * `:integer`, `:min`, `:max`, `:decimals`, `:align`,
      `:placeholder`, `:js`, `:class`, `:input_class` — passed
      through to the underlying `number_input/1`.

    * `:include_prefixed` — when `true`, the All-units section
      includes SI-prefixed variants (e.g. `decimeter`). The
      default is `false` to keep the list manageable.

    ### Examples

        <.unit_input form={@form} field={:height} category="length" />

        <.unit_input
          form={@form}
          field={:weight}
          category="mass"
          default_unit="kilogram"
          min={0}
        />

    """
    attr(:form, Phoenix.HTML.Form, required: true)
    attr(:field, :atom, required: true)
    attr(:category, :string, required: true)
    attr(:default_unit, :string, default: nil)
    attr(:locale, :string, default: nil)
    attr(:value, :any, default: nil)
    attr(:integer, :boolean, default: false)
    attr(:min, :any, default: nil)
    attr(:max, :any, default: nil)
    attr(:decimals, :integer, default: nil)
    attr(:align, :atom, default: :left, values: [:left, :right, :center])
    attr(:placeholder, :string, default: nil)
    attr(:include_prefixed, :boolean, default: false)
    attr(:js, :boolean, default: true)
    attr(:class, :string, default: nil)
    attr(:input_class, :string, default: nil)
    attr(:picker_class, :string, default: nil)
    attr(:rest, :global, include: ~w(disabled readonly required autofocus))

    def unit_input(assigns) do
      assigns = assigns |> assign_unit_common() |> assign_unit_value()

      ~H"""
      <div
        class={["unit-input-wrapper", @class]}
        id={"#{@id}-wrapper"}
        data-unit-input
        data-category={@category}
      >
        <input
          type="text"
          inputmode={if @integer, do: "numeric", else: "decimal"}
          name={"#{@name_base}[amount]"}
          id={"#{@id}-amount"}
          value={@formatted_amount}
          class={["unit-input-field", text_align_class(@align), @input_class]}
          autocomplete="off"
          dir="ltr"
          placeholder={@placeholder}
          data-locale-input="number"
          data-locale={@locale_data.locale}
          data-decimal={@locale_data.decimal}
          data-group={@locale_data.group}
          data-number-system={@locale_data.number_system}
          data-minus={@locale_data.minus_sign}
          data-integer={to_string(@integer)}
          data-decimals={@decimals}
          data-min={value_attr(@min)}
          data-max={value_attr(@max)}
          phx-hook={if @js, do: "NumberInput"}
          {@rest}
        />
        <.unit_picker
          name={"#{@name_base}[unit]"}
          input_id={"#{@id}-unit"}
          current={@selected_unit}
          category={@category}
          locale={@locale}
          include_prefixed={@include_prefixed}
          class={@picker_class}
          id={"#{@id}-picker"}
        />
      </div>
      """
    end

    @doc """
    Standalone locale-aware unit picker.

    Searchable picker grouped into a **Preferred** section
    (units in the locale's measurement system) and an
    **All units** section (every known unit in the category).
    Selecting a row updates a hidden input that the picker
    serialises on form submission and emits a
    `localize-inputs:unit-change` `CustomEvent` so any enclosing
    `unit_input/1` can react.

    ### Attributes

    * `:current` — the currently-selected unit name (string).
      **Required.**

    * `:category` — the unit category. **Required.**

    * `:locale` — display locale. Defaults to
      `Localize.get_locale/0`.

    * `:form` + `:field` — when given, the hidden value input
      is named `Phoenix.HTML.Form.input_name(form, field)`.

    * `:name` — explicit hidden-input name. Overrides
      `:form`/`:field`. Used by `unit_input/1` to inject a
      nested name like `height[unit]`.

    * `:input_id` — explicit id for the hidden value input.

    * `:include_prefixed` — when `true`, the All-units section
      includes SI-prefixed variants.

    * `:variant` — `:auto` (default), `:dropdown`, or `:sheet`.

    * `:id`, `:class`, `:button_class`, `:overlay_class`,
      `:row_class` — customisation hooks.

    """
    attr(:form, Phoenix.HTML.Form, default: nil)
    attr(:field, :atom, default: nil)

    attr(:name, :string,
      default: nil,
      doc:
        "Explicit hidden-input name. Overrides form+field. Used by `unit_input/1` to inject a nested name like `height[unit]`."
    )

    attr(:input_id, :string, default: nil, doc: "Explicit id for the hidden value input.")
    attr(:current, :string, required: true)
    attr(:category, :string, required: true)
    attr(:locale, :any, default: nil)
    attr(:include_prefixed, :boolean, default: false)
    attr(:variant, :atom, default: :auto, values: [:auto, :dropdown, :sheet])
    attr(:id, :string, default: nil)
    attr(:class, :string, default: nil)
    attr(:button_class, :string, default: nil)
    attr(:overlay_class, :string, default: nil)
    attr(:row_class, :string, default: nil)

    def unit_picker(assigns) do
      assigns = assign_unit_picker(assigns)

      ~H"""
      <div
        class={["unit-picker", @class]}
        id={@id}
        data-unit-picker
        data-locale={@unit_data.locale}
        data-current={@current}
        data-variant={to_string(@variant)}
        data-category={@category}
        phx-hook="UnitPicker"
      >
        <button
          type="button"
          class={["unit-picker-trigger", @button_class]}
          data-unit-picker-trigger
          aria-haspopup="listbox"
          aria-expanded="false"
        >
          <span class="unit-picker-current">{@current_display}</span>
          <span class="unit-picker-caret" aria-hidden="true"></span>
        </button>
        <%= if @hidden_name do %>
          <input
            type="hidden"
            name={@hidden_name}
            id={@hidden_id}
            value={@current}
            data-unit-picker-value
          />
        <% end %>
        <div
          class={["unit-picker-overlay", @overlay_class]}
          data-unit-picker-overlay
          role="dialog"
          aria-label={~t"Choose unit"}
          hidden
        >
          <div class="unit-picker-search-row">
            <input
              type="search"
              class="unit-picker-search"
              data-unit-picker-search
              placeholder={~t"Search unit name or code…"}
              aria-label={~t"Filter units"}
            />
            <button
              type="button"
              class="unit-picker-close"
              data-unit-picker-close
              aria-label={~t"Close unit picker"}
            >×</button>
          </div>
          <ul class="unit-picker-list" role="listbox" data-unit-picker-list>
            <%= for {section_label, rows} <- @sections do %>
              <%= if rows != [] do %>
                <li class="unit-picker-section" role="presentation">{section_label}</li>
                <%= for row <- rows do %>
                  <li
                    class={["unit-picker-row", @row_class]}
                    role="option"
                    tabindex="-1"
                    data-unit-picker-row
                    data-code={row.code}
                    data-name={row.name}
                    aria-selected={if row.code == @current, do: "true"}
                  >
                    <span class="unit-picker-row-name">{row.name}</span>
                    <span class="unit-picker-row-code">{row.code}</span>
                  </li>
                <% end %>
              <% end %>
            <% end %>
            <li class="unit-picker-empty" data-unit-picker-empty hidden>{~t"No matches"}</li>
          </ul>
        </div>
      </div>
      """
    end

    # ── Internal: unit_input assigns ──────────────────────────

    defp assign_unit_common(assigns) do
      locale = assigns[:locale] || Localize.get_locale()

      locale_data = safe_number_for_locale(locale)

      unit_data =
        safe_unit_for_locale(locale,
          category: assigns.category,
          include_prefixed: assigns.include_prefixed
        )

      field_struct = assigns.form[assigns.field]
      name_base = field_struct.name
      id = field_struct.id

      assigns
      |> assign(:locale, locale)
      |> assign(:locale_data, locale_data)
      |> assign(:unit_data, unit_data)
      |> assign(:name_base, name_base)
      |> assign(:id, id)
      |> assign_new(:placeholder, fn -> nil end)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:input_class, fn -> nil end)
      |> assign_new(:picker_class, fn -> nil end)
    end

    defp assign_unit_value(assigns) do
      explicit = assigns.value
      form_value = (assigns.form[assigns.field] || %{}).value

      {amount, unit} = extract_amount_and_unit(explicit || form_value)

      selected_unit = unit || assigns.default_unit || default_unit(assigns.unit_data)

      assigns
      |> assign(:formatted_amount, format_value(amount, assigns.locale))
      |> assign(:selected_unit, selected_unit)
    end

    # unit_input form values come in three shapes:
    #   nil/"" — no value
    #   %{"amount" => ..., "unit" => ...} — fully-formed submit
    #   a bare amount (binary/Decimal/integer) — value only
    defp extract_amount_and_unit(nil), do: {nil, nil}
    defp extract_amount_and_unit(""), do: {nil, nil}

    defp extract_amount_and_unit(%{} = map) do
      amount = Map.get(map, "amount") || Map.get(map, :amount)
      unit = Map.get(map, "unit") || Map.get(map, :unit)
      {blank_to_nil(amount), blank_to_nil(unit)}
    end

    defp extract_amount_and_unit(other), do: {other, nil}

    defp blank_to_nil(""), do: nil
    defp blank_to_nil(value), do: value

    # ── Defensive locale-data wrappers ────────────────────────

    # Components must not raise on an unknown locale; render
    # the input under the `:en` fallback rather than crashing
    # the page. The fallback chain stops at `:en` — if even
    # `:en` errors, that's a Localize installation problem
    # worth surfacing.
    defp safe_number_for_locale(locale) do
      case Symbols.number_for_locale(locale) do
        {:ok, data} ->
          data

        _ ->
          case Symbols.number_for_locale(:en) do
            {:ok, data} -> data
            _ -> %Symbols{}
          end
      end
    end

    defp safe_unit_for_locale(locale, options) do
      case Unit.unit_for_locale(locale, options) do
        {:ok, data} ->
          data

        _ ->
          case Unit.unit_for_locale(:en, options) do
            {:ok, data} -> data
            _ -> %Unit{}
          end
      end
    end

    defp default_unit(%Unit{preferred_units: [{name, _} | _]}), do: name
    defp default_unit(%Unit{all_units: [{name, _} | _]}), do: name
    defp default_unit(_), do: nil

    defp assign_unit_picker(assigns) do
      locale = assigns[:locale] || Localize.get_locale()

      unit_data =
        safe_unit_for_locale(locale,
          category: assigns.category,
          include_prefixed: assigns.include_prefixed
        )

      preferred_rows =
        Enum.map(unit_data.preferred_units, fn {name, display} ->
          %{code: name, name: display}
        end)

      all_rows =
        unit_data.all_units
        |> Enum.reject(fn {name, _} ->
          Enum.any?(unit_data.preferred_units, fn {pname, _} -> pname == name end)
        end)
        |> Enum.map(fn {name, display} -> %{code: name, name: display} end)

      sections = [
        {~t"Preferred", preferred_rows},
        {~t"All units", all_rows}
      ]

      id = assigns[:id] || "unit-picker-#{System.unique_integer([:positive])}"

      # Same three-priority naming scheme as currency_picker:
      # 1) an explicit `name=` attr (used when embedded in
      #    unit_input to inject a nested name like `height[unit]`),
      # 2) a form+field pair (standalone use),
      # 3) nothing — the picker is purely client-side state.
      {hidden_name, hidden_id} =
        case {assigns[:name], assigns[:form], assigns[:field]} do
          {explicit, _, _} when is_binary(explicit) ->
            {explicit, assigns[:input_id] || "#{id}-value"}

          {_, form, field} when not is_nil(form) and not is_nil(field) ->
            {Phoenix.HTML.Form.input_name(form, field), "#{id}-value"}

          _ ->
            {nil, nil}
        end

      current_display =
        case Enum.find(
               unit_data.preferred_units ++ unit_data.all_units,
               fn {name, _} -> name == assigns.current end
             ) do
          {_, display} -> display
          _ -> assigns.current
        end

      assigns
      |> assign(:unit_data, unit_data)
      |> assign(:sections, sections)
      |> assign(:id, id)
      |> assign(:hidden_name, hidden_name)
      |> assign(:hidden_id, hidden_id)
      |> assign(:current_display, current_display)
      |> assign_new(:class, fn -> nil end)
      |> assign_new(:button_class, fn -> nil end)
      |> assign_new(:overlay_class, fn -> nil end)
      |> assign_new(:row_class, fn -> nil end)
    end
  end
end