lib/qr_nbu/validators/recipient.ex

defmodule QRNBU.Validators.Recipient do
  @moduledoc """
  Validates recipient name/description according to NBU QR code specifications.

  ## Rules
  - V001/V002: Maximum 70 characters
  - V003: Maximum 140 characters
  - Cannot be empty or contain only whitespace
  - Should contain valid printable characters

  ## Examples

      iex> QRNBU.Validators.Recipient.validate("ТОВ Приватбанк", version: 2)
      {:ok, "ТОВ Приватбанк"}

      iex> QRNBU.Validators.Recipient.validate("Фізична особа-підприємець Іваненко Іван Іванович", version: 3)
      {:ok, "Фізична особа-підприємець Іваненко Іван Іванович"}

      iex> QRNBU.Validators.Recipient.validate("", version: 2)
      {:error, "Recipient cannot be empty"}

      iex> long_name = String.duplicate("А", 71)
      iex> QRNBU.Validators.Recipient.validate(long_name, version: 2)
      {:error, "Recipient exceeds maximum length of 70 characters for version 2"}
  """

  @max_length_v1_v2 70
  @max_length_v3 140

  @type version :: 1 | 2 | 3
  @type options :: [version: version()]

  @doc """
  Validates recipient name/description for NBU QR code.

  Options:
  - `version`: QR code version (1, 2, or 3). Defaults to 2.

  Returns `{:ok, trimmed_recipient}` or `{:error, reason}`.
  """
  @spec validate(String.t(), options()) :: {:ok, String.t()} | {:error, String.t()}
  def validate(recipient, opts \\ [])

  def validate(recipient, opts) when is_binary(recipient) do
    version = Keyword.get(opts, :version, 2)
    trimmed = String.trim(recipient)

    with :ok <- validate_not_empty(trimmed),
         :ok <- validate_length(trimmed, version),
         :ok <- validate_printable(trimmed) do
      {:ok, trimmed}
    end
  end

  def validate(_, _), do: {:error, "Recipient must be a string"}

  @doc """
  Gets the maximum length for recipient based on version.

  ## Examples

      iex> QRNBU.Validators.Recipient.max_length(1)
      70

      iex> QRNBU.Validators.Recipient.max_length(2)
      70

      iex> QRNBU.Validators.Recipient.max_length(3)
      140
  """
  @spec max_length(version()) :: pos_integer()
  def max_length(version) when version in [1, 2], do: @max_length_v1_v2
  def max_length(3), do: @max_length_v3

  @spec validate_not_empty(String.t()) :: :ok | {:error, String.t()}
  defp validate_not_empty(""), do: {:error, "Recipient cannot be empty"}
  defp validate_not_empty(_), do: :ok

  @spec validate_length(String.t(), version()) :: :ok | {:error, String.t()}
  defp validate_length(recipient, version) do
    max = max_length(version)
    length = String.length(recipient)

    if length <= max do
      :ok
    else
      {:error, "Recipient exceeds maximum length of #{max} characters for version #{version}"}
    end
  end

  @spec validate_printable(String.t()) :: :ok | {:error, String.t()}
  defp validate_printable(recipient) do
    # Check for ASCII control characters (codepoints U+0000-U+001F, U+007F-U+009F)
    # We check each codepoint directly since Elixir regex doesn't support \uXXXX
    has_control_char? =
      recipient
      |> String.to_charlist()
      |> Enum.any?(fn codepoint ->
        (codepoint >= 0x0000 and codepoint <= 0x001F) or
          (codepoint >= 0x007F and codepoint <= 0x009F)
      end)

    if has_control_char? do
      {:error, "Recipient contains invalid control characters"}
    else
      :ok
    end
  end

  @doc """
  Truncates recipient to maximum length for the specified version.

  Useful for gracefully handling slightly too-long input.

  ## Examples

      iex> long_name = String.duplicate("А", 80)
      iex> QRNBU.Validators.Recipient.truncate(long_name, version: 2)
      String.slice(long_name, 0, 69)

      iex> QRNBU.Validators.Recipient.truncate("Short name", version: 2)
      "Short name"
  """
  @spec truncate(String.t(), options()) :: String.t()
  def truncate(recipient, opts \\ []) when is_binary(recipient) do
    version = Keyword.get(opts, :version, 2)
    max = max_length(version)

    recipient
    |> String.trim()
    |> String.slice(0, max - 1)
  end

  @doc """
  Normalizes recipient name by trimming and removing extra whitespace.

  ## Examples

      iex> QRNBU.Validators.Recipient.normalize("  ТОВ   Приватбанк  ")
      "ТОВ Приватбанк"
  """
  @spec normalize(String.t()) :: String.t()
  def normalize(recipient) when is_binary(recipient) do
    recipient
    |> String.trim()
    |> String.replace(~r/\s+/, " ")
  end
end