lib/qr_nbu/validators/iban.ex

defmodule QRNBU.Validators.IBAN do
  @moduledoc """
  Validates Ukrainian IBAN (International Bank Account Number) using the iban_ex library.

  This validator wraps the iban_ex library (developed by the same author) to provide
  NBU-specific validation and extraction functions for Ukrainian IBANs.

  ## Specification

  Ukrainian IBAN format (29 characters):
  - Positions 1-2: Country code "UA"
  - Positions 3-4: Check digits (calculated using MOD-97 algorithm)
  - Positions 5-10: Bank code (MFO - 6 digits)
  - Positions 11-29: Account number (19 characters)

  ## Validation Rules

  1. Must be exactly 29 characters
  2. Must start with "UA"
  3. Must contain only alphanumeric characters
  4. Check digits must be valid (MOD-97 algorithm per ISO 13616)

  ## Examples

      iex> QRNBU.Validators.IBAN.validate("UA303348510000026206114040874")
      {:ok, "UA303348510000026206114040874"}

      iex> QRNBU.Validators.IBAN.validate("ua303348510000026206114040874")
      {:ok, "UA303348510000026206114040874"}  # Normalized to uppercase

      iex> QRNBU.Validators.IBAN.validate("PL60102010260000042270201111")
      {:error, "IBAN must be for Ukraine (country code UA)"}

      iex> QRNBU.Validators.IBAN.validate("UA30334851")
      {:error, "IBAN must be exactly 29 characters for Ukraine"}

  ## References

  - NBU Resolution No. 97, August 19, 2025
  - ISO 13616:2020 IBAN standard
  - Uses iban_ex library: https://hex.pm/packages/iban_ex
  """

  alias IbanEx.Parser

  @country_code "UA"
  @required_length 29

  @doc """
  Validates Ukrainian IBAN format and checksum using iban_ex library.

  The IBAN is automatically normalized (trimmed and uppercased) before validation.

  Returns `{:ok, normalized_iban}` if valid, `{:error, reason}` otherwise.
  """
  @spec validate(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def validate(iban) when is_binary(iban) do
    normalized = iban |> String.trim() |> String.upcase()

    case Parser.parse(normalized) do
      {:ok, %{country_code: @country_code, iban: validated_iban}} ->
        {:ok, validated_iban}

      {:ok, %{country_code: other_code}} ->
        {:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}

      {:error, :invalid_checksum} ->
        {:error, "Invalid IBAN checksum"}

      {:error, :invalid_length} ->
        {:error, "IBAN must be exactly #{@required_length} characters for Ukraine"}

      {:error, :invalid_format} ->
        {:error, "IBAN contains invalid characters"}

      {:error, :unsupported_country_code} ->
        {:error, "Unsupported country code"}

      {:error, reason} when is_atom(reason) ->
        {:error, "IBAN validation failed: #{reason}"}

      {:error, reason} ->
        {:error, to_string(reason)}
    end
  end

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

  @doc """
  Validates and extracts bank code (MFO) from Ukrainian IBAN.

  Uses iban_ex parsing to extract the 6-digit bank identifier code.

  ## Examples

      iex> QRNBU.Validators.IBAN.extract_bank_code("UA303348510000026206114040874")
      {:ok, "334851"}
  """
  @spec extract_bank_code(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def extract_bank_code(iban) when is_binary(iban) do
    normalized = iban |> String.trim() |> String.upcase()

    case Parser.parse(normalized) do
      {:ok, %{country_code: @country_code, bank_code: bank_code}} ->
        {:ok, bank_code}

      {:ok, %{country_code: other_code}} ->
        {:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}

      {:error, reason} ->
        {:error, format_error(reason)}
    end
  end

  @doc """
  Validates and extracts account number from Ukrainian IBAN.

  Uses iban_ex parsing to extract the 19-character account number.

  ## Examples

      iex> QRNBU.Validators.IBAN.extract_account_number("UA303348510000026206114040874")
      {:ok, "0000026206114040874"}
  """
  @spec extract_account_number(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def extract_account_number(iban) when is_binary(iban) do
    normalized = iban |> String.trim() |> String.upcase()

    case Parser.parse(normalized) do
      {:ok, %{country_code: @country_code, account_number: account_number}} ->
        {:ok, account_number}

      {:ok, %{country_code: other_code}} ->
        {:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}

      {:error, reason} ->
        {:error, format_error(reason)}
    end
  end

  @doc """
  Parses a Ukrainian IBAN into its components using iban_ex.

  Returns a map with all IBAN components: iban, country_code, check_digits,
  bank_code, and account_number.

  ## Examples

      iex> QRNBU.Validators.IBAN.parse("UA303348510000026206114040874")
      {:ok, %{
        iban: "UA303348510000026206114040874",
        country_code: "UA",
        check_digits: "30",
        bank_code: "334851",
        account_number: "0000026206114040874"
      }}
  """
  @spec parse(String.t()) :: {:ok, map()} | {:error, String.t()}
  def parse(iban) when is_binary(iban) do
    normalized = iban |> String.trim() |> String.upcase()

    case Parser.parse(normalized) do
      {:ok, %{country_code: @country_code} = parsed} ->
        result = %{
          iban: parsed.iban,
          country_code: parsed.country_code,
          check_digits: parsed.check_digits,
          bank_code: parsed.bank_code,
          account_number: parsed.account_number
        }

        {:ok, result}

      {:ok, %{country_code: other_code}} ->
        {:error, "IBAN must be for Ukraine (country code #{@country_code}), got #{other_code}"}

      {:error, reason} ->
        {:error, format_error(reason)}
    end
  end

  # Format error messages
  defp format_error(:invalid_checksum), do: "Invalid IBAN checksum"

  defp format_error(:invalid_length),
    do: "IBAN must be exactly #{@required_length} characters for Ukraine"

  defp format_error(:invalid_format), do: "IBAN contains invalid characters"
  defp format_error(:unsupported_country_code), do: "Unsupported country code"

  defp format_error(reason) when is_atom(reason),
    do: "IBAN validation failed: #{reason}"

  defp format_error(reason), do: to_string(reason)
end