lib/qr_nbu.ex

defmodule QRNBU do
  @moduledoc """
  NBU (National Bank of Ukraine) QR Code generator library.

  This library provides a comprehensive API for generating NBU-compliant QR codes
  for payment systems in Ukraine. It supports all three official NBU QR code versions:

  - **V001**: Plain text format with CRLF line endings (EPC QR code compatible)
  - **V002**: Base64URL encoded format with 13 fields
  - **V003**: Extended Base64URL format with 17 fields (ISO 20022 category purpose)

  ## Quick Start

      # Generate a simple V001 QR code
      iex> {:ok, qr} = QRNBU.generate(:v001, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів"
      ...> })
      iex> String.contains?(qr, "BCD")
      true

      # Generate a V002 QR code with amount
      iex> {:ok, qr} = QRNBU.generate(:v002, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів",
      ...>   amount: Decimal.new("100.50")
      ...> })
      iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
      true

      # Generate a V003 QR code with extended fields
      iex> {:ok, qr} = QRNBU.generate(:v003, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів",
      ...>   amount: Decimal.new("500.00"),
      ...>   category_purpose: "SUPP/REGU"
      ...> })
      iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
      true

  ## Version Selection

  Choose the appropriate version based on your requirements:

  - Use **V001** for compatibility with EPC QR codes and simple payments
  - Use **V002** for modern NBU-compliant payments with Base64URL encoding
  - Use **V003** for advanced features like ISO 20022 category purpose and field locking

  ## Field Reference

  ### Common Fields (All Versions)

  - `:recipient` (required) - Recipient name (1-70 characters)
  - `:iban` (required) - Bank account IBAN (UA + 27 digits)
  - `:recipient_code` (required) - EDRPOU/IPN/Tax ID (6-35 characters)
  - `:purpose` (required) - Payment purpose (1-140 characters)
  - `:amount` (optional) - Payment amount as `Decimal.t()`
  - `:function` (optional) - Function code: `:uct`, `:ict`, or `:xct` (default: `:uct`)
  - `:encoding` (optional) - Character encoding: `:utf8` or `:cp1251` (default: `:utf8`)

  ### V002 Additional Fields

  - `:reference` (optional) - Payment reference number (max 35 characters)

  ### V003 Additional Fields

  - `:reference` (optional) - Payment reference number (max 35 characters)
  - `:unique_recipient_id` (optional) - Unique recipient identifier (max 35 characters)
  - `:category_purpose` (optional) - ISO 20022 category purpose (format: `CCCC/PPPP`)
  - `:display` (optional) - Display text for QR scanner (max 140 characters)
  - `:field_lock` (optional) - Field lock bitmap `0x0000-0xFFFF` (integer)
  - `:invoice_validity` (optional) - Invoice expiration as `NaiveDateTime.t()`
  - `:invoice_creation` (optional) - Invoice creation as `NaiveDateTime.t()`
  - `:digital_signature` (optional) - Digital signature string (max 1000 characters)

  ## Error Handling

  All functions return `{:ok, result}` or `{:error, reason}` tuples.

  Common errors:
  - Missing required fields
  - Invalid field formats (IBAN, tax ID, etc.)
  - Invalid character encoding
  - Date/time validation failures

  ## References

  - NBU Resolution No. 97, August 19, 2025
  - EPC QR Code Guidelines v3.0
  - ISO 20022 External Code Sets
  """

  alias QRNBU.Versions.{V001, V002, V003}

  @type version :: :v001 | :v002 | :v003
  @type qr_data :: map()
  @type qr_string :: String.t()

  @doc """
  Generates an NBU QR code string for the specified version.

  ## Parameters

  - `version` - QR code version: `:v001`, `:v002`, or `:v003`
  - `data` - Map containing QR code fields (see module documentation)

  ## Returns

  - `{:ok, qr_string}` - Successfully generated QR code string
  - `{:error, reason}` - Validation or encoding error

  ## Examples

      iex> {:ok, qr} = QRNBU.generate(:v001, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів"
      ...> })
      iex> is_binary(qr)
      true

      iex> {:ok, qr} = QRNBU.generate(:v002, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів",
      ...>   amount: Decimal.new("100.50"),
      ...>   reference: "INV-001"
      ...> })
      iex> String.starts_with?(qr, "https://qr.bank.gov.ua/")
      true

      iex> QRNBU.generate(:invalid_version, %{})
      {:error, "Invalid version: :invalid_version. Must be :v001, :v002, or :v003"}

      iex> {:error, reason} = QRNBU.generate(:v001, %{})
      iex> is_binary(reason)
      true
  """
  @spec generate(version(), qr_data()) :: {:ok, qr_string()} | {:error, String.t()}
  def generate(version, data)

  def generate(:v001, data) when is_map(data) do
    with {:ok, v001} <- V001.new(data),
         {:ok, qr_string} <- V001.encode(v001) do
      {:ok, qr_string}
    end
  end

  def generate(:v002, data) when is_map(data) do
    with {:ok, v002} <- V002.new(data),
         {:ok, qr_string} <- V002.encode(v002) do
      {:ok, qr_string}
    end
  end

  def generate(:v003, data) when is_map(data) do
    with {:ok, v003} <- V003.new(data),
         {:ok, qr_string} <- V003.encode(v003) do
      {:ok, qr_string}
    end
  end

  def generate(version, _data) when version not in [:v001, :v002, :v003] do
    {:error, "Invalid version: #{inspect(version)}. Must be :v001, :v002, or :v003"}
  end

  def generate(_version, data) do
    {:error, "Data must be a map, got: #{inspect(data)}"}
  end

  @doc """
  Generates an NBU QR code string, raising an exception on error.

  Same as `generate/2` but raises `RuntimeError` instead of returning an error tuple.

  ## Examples

      iex> qr = QRNBU.generate!(:v001, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів"
      ...> })
      iex> is_binary(qr) and String.contains?(qr, "BCD")
      true
  """
  @spec generate!(version(), qr_data()) :: qr_string()
  def generate!(version, data) do
    case generate(version, data) do
      {:ok, qr_string} -> qr_string
      {:error, reason} -> raise RuntimeError, reason
    end
  end

  @doc """
  Detects the version of an NBU QR code string.

  Analyzes the QR code format to determine which version it represents.

  ## Returns

  - `{:ok, :v001}` - Plain text format with BCD marker
  - `{:ok, :v002}` - Base64URL with NBU URL prefix
  - `{:ok, :v003}` - Base64URL with NBU URL prefix (distinguished by field count)
  - `{:error, reason}` - Unable to detect version

  ## Examples

      iex> v001_str = String.duplicate(" ", 23) <> "\\r\\nBCD\\r\\n001\\r\\n"
      iex> QRNBU.detect_version(v001_str)
      {:ok, :v001}

      iex> QRNBU.detect_version("invalid")
      {:error, "Unable to detect QR code version"}
  """
  @spec detect_version(qr_string()) :: {:ok, version()} | {:error, String.t()}
  def detect_version(qr_string) when is_binary(qr_string) do
    cond do
      # V001: Plain text with BCD marker
      String.contains?(qr_string, "\r\nBCD\r\n") ->
        {:ok, :v001}

      # V002: Base64URL with new NBU URL prefix
      String.starts_with?(qr_string, "https://qr.bank.gov.ua/") ->
        detect_base64url_version(qr_string, "https://qr.bank.gov.ua/")

      # V002/V003: Base64URL with legacy NBU URL prefix
      String.starts_with?(qr_string, "https://qr.bank.gov.ua/") ->
        detect_base64url_version(qr_string, "https://qr.bank.gov.ua/")

      true ->
        {:error, "Unable to detect QR code version"}
    end
  end

  def detect_version(_), do: {:error, "QR string must be a binary"}

  # Helper: Distinguish between V002 and V003 by field count
  defp detect_base64url_version(qr_string, prefix) do
    with base64_part <- String.replace_prefix(qr_string, prefix, ""),
         {:ok, decoded} <- QRNBU.Encoders.Base64URL.decode(base64_part) do
      fields = String.split(decoded, "\n")
      field_count = length(fields)

      case field_count do
        13 -> {:ok, :v002}
        17 -> {:ok, :v003}
        _ -> {:error, "Unknown Base64URL format with #{field_count} fields"}
      end
    else
      {:error, _} -> {:error, "Invalid Base64URL encoding"}
    end
  end

  @doc """
  Validates NBU QR code data without generating the QR string.

  Useful for pre-validation before QR code generation.

  ## Parameters

  - `version` - QR code version: `:v001`, `:v002`, or `:v003`
  - `data` - Map containing QR code fields

  ## Returns

  - `:ok` - Data is valid for the specified version
  - `{:error, reason}` - Validation error

  ## Examples

      iex> QRNBU.validate(:v001, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів"
      ...> })
      :ok

      iex> QRNBU.validate(:v001, %{})
      {:error, "Missing required field: recipient"}

      iex> QRNBU.validate(:v003, %{
      ...>   recipient: "ТОВ Компанія",
      ...>   iban: "UA213223130000026007233566001",
      ...>   recipient_code: "12345678",
      ...>   purpose: "Оплата товарів",
      ...>   field_lock: -1
      ...> })
      {:error, "Field lock must be between 0 and 65535"}
  """
  @spec validate(version(), qr_data()) :: :ok | {:error, String.t()}
  def validate(:v001, data) when is_map(data) do
    case V001.new(data) do
      {:ok, _} -> :ok
      {:error, _} = error -> error
    end
  end

  def validate(:v002, data) when is_map(data) do
    case V002.new(data) do
      {:ok, _} -> :ok
      {:error, _} = error -> error
    end
  end

  def validate(:v003, data) when is_map(data) do
    case V003.new(data) do
      {:ok, _} -> :ok
      {:error, _} = error -> error
    end
  end

  def validate(version, _data) when version not in [:v001, :v002, :v003] do
    {:error, "Invalid version: #{inspect(version)}. Must be :v001, :v002, or :v003"}
  end

  def validate(_version, data) do
    {:error, "Data must be a map, got: #{inspect(data)}"}
  end
end