lib/qr_nbu/validators/purpose.ex

defmodule QRNBU.Validators.Purpose do
  @moduledoc """
  Validates payment purpose/description according to NBU QR code specifications.

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

  ## Examples

      iex> QRNBU.Validators.Purpose.validate("Оплата за товари згідно рахунку №123", version: 2)
      {:ok, "Оплата за товари згідно рахунку №123"}

      iex> QRNBU.Validators.Purpose.validate("Payment for services rendered under contract", version: 3)
      {:ok, "Payment for services rendered under contract"}

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

      iex> long_purpose = String.duplicate("Х", 141)
      iex> QRNBU.Validators.Purpose.validate(long_purpose, version: 2)
      {:error, "Purpose exceeds maximum length of 140 characters for version 2"}
  """

  @max_length_v1_v2 140
  @max_length_v3 280

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

  @doc """
  Validates payment purpose/description for NBU QR code.

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

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

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

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

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

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

  ## Examples

      iex> QRNBU.Validators.Purpose.max_length(1)
      140

      iex> QRNBU.Validators.Purpose.max_length(2)
      140

      iex> QRNBU.Validators.Purpose.max_length(3)
      280
  """
  @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, "Purpose cannot be empty"}
  defp validate_not_empty(_), do: :ok

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

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

  @spec validate_printable(String.t()) :: :ok | {:error, String.t()}
  defp validate_printable(purpose) 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? =
      purpose
      |> 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, "Purpose contains invalid control characters"}
    else
      :ok
    end
  end

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

  Useful for gracefully handling slightly too-long input.

  ## Examples

      iex> long_purpose = String.duplicate("А", 150)
      iex> QRNBU.Validators.Purpose.truncate(long_purpose, version: 2)
      String.slice(long_purpose, 0, 139)

      iex> QRNBU.Validators.Purpose.truncate("Short purpose", version: 2)
      "Short purpose"
  """
  @spec truncate(String.t(), options()) :: String.t()
  def truncate(purpose, opts \\ [])

  def truncate(purpose, opts) when is_binary(purpose) do
    version = Keyword.get(opts, :version, 2)
    max = max_length(version)

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

  @doc """
  Normalizes purpose by trimming and removing extra whitespace.

  ## Examples

      iex> QRNBU.Validators.Purpose.normalize("  Payment   for   goods  ")
      "Payment for goods"
  """
  @spec normalize(String.t()) :: String.t()
  def normalize(purpose) when is_binary(purpose) do
    purpose
    |> String.trim()
    |> String.replace(~r/\s+/, " ")
  end

  @doc """
  Validates and suggests if a purpose might need a category purpose code (V003).

  Returns `{:ok, purpose}` or `{:warning, purpose, suggestion}` for V003.
  """
  @spec validate_with_suggestion(String.t(), options()) ::
          {:ok, String.t()} | {:warning, String.t(), String.t()} | {:error, String.t()}
  def validate_with_suggestion(purpose, opts \\ [])

  def validate_with_suggestion(purpose, opts) do
    version = Keyword.get(opts, :version, 2)

    case validate(purpose, opts) do
      {:ok, validated} ->
        if version == 3 and should_have_category?(validated) do
          {:warning, validated,
           "Consider adding a category purpose code (ISO 20022) for structured payment reference"}
        else
          {:ok, validated}
        end

      error ->
        error
    end
  end

  # Check if purpose looks like it should have a structured category code
  defp should_have_category?(purpose) do
    # Check for keywords that suggest structured payments
    structured_keywords = [
      "рахунок",
      "invoice",
      "contract",
      "договір",
      "salary",
      "зарплата",
      "pension",
      "пенсія"
    ]

    purpose_lower = String.downcase(purpose)
    Enum.any?(structured_keywords, &String.contains?(purpose_lower, &1))
  end
end