lib/qr_nbu/encoders/formatter.ex

defmodule QRNBU.Encoders.Formatter do
  @moduledoc """
  Formats NBU QR code data strings for different versions.

  Handles the specific format requirements for each version:
  - **V001**: Plain text format with CRLF line endings
  - **V002**: Base64URL format with prefix
  - **V003**: Base64URL format with prefix (extended fields)

  ## NBU QR Code Prefixes

  - V002: `https://qr.bank.gov.ua/`
  - V003: `https://qr.bank.gov.ua/`
  """

  alias QRNBU.Encoders.{Charset, Base64URL}

  @v001_prefix String.duplicate(" ", 23)
  @v002_prefix "https://qr.bank.gov.ua/"
  @v003_prefix "https://qr.bank.gov.ua/"

  @crlf "\r\n"
  @lf "\n"

  @type version :: 1 | 2 | 3
  @type encoding :: :utf8 | :cp1251
  @type function_code :: :uct | :ict | :xct

  @doc """
  Formats QR data for V001 (plain text with CRLF).

  ## Format Structure

  ```
  [23 spaces]\\r\\n
  BCD\\r\\n
  001\\r\\n
  1\\r\\n
  UCT\\r\\n
  \\r\\n
  {recipient}\\r\\n
  {iban}\\r\\n
  {amount with UAH prefix}\\r\\n
  {recipient_code}\\r\\n
  \\r\\n
  \\r\\n
  {purpose}\\r\\n
  \\r\\n
  ```

  ## Parameters

  - `data` - Map with fields: recipient, iban, amount, recipient_code, purpose, function
  - `opts` - Options (encoding)

  ## Returns

  `{:ok, formatted_string}` or `{:error, reason}`.
  """
  @spec format_v001(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
  def format_v001(data, opts \\ []) do
    # V001 MUST use UTF-8 encoding (code "1") per NBU spec
    # V001 MUST use UCT function code per NBU spec
    _encoding = Keyword.get(opts, :encoding, :utf8)

    with {:ok, recipient, _} <- Charset.encode(data.recipient, :utf8),
         {:ok, purpose, _} <- Charset.encode(data.purpose, :utf8) do
      lines = [
        @v001_prefix,
        "BCD",
        "001",
        "1",
        "UCT",
        "",
        recipient,
        data.iban,
        format_amount(data[:amount]),
        data.recipient_code,
        "",
        "",
        purpose,
        ""
      ]

      result = Enum.join(lines, @crlf)
      {:ok, result}
    end
  end

  @doc """
  Formats QR data for V002 (Base64URL with prefix).

  ## Format Structure

  ```
  https://qr.bank.gov.ua/{base64url_encoded_data}
  ```

  ## Data String Structure

  Fields separated by LF (0x0A):
  1. Service label (BCD)
  2. Version format (002)
  3. Encoding code (1 or 2)
  4. Function code (UCT only)
  5. Empty (reserved)
  6. Recipient name
  7. IBAN
  8. Amount (optional, with UAH prefix)
  9. Recipient code (EDRPOU/Tax ID)
  10. Empty (reserved)
  11. Empty (reserved)
  12. Purpose
  13. Display (optional)

  ## Parameters

  - `data` - Map with fields: recipient, iban, amount, recipient_code, purpose, function
  - `opts` - Options (encoding)

  ## Returns

  `{:ok, qr_string}` or `{:error, reason}`.
  """
  @spec format_v002(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
  def format_v002(data, opts \\ []) do
    # V002 MUST use UCT function code per NBU spec
    encoding = Keyword.get(opts, :encoding, :utf8)

    with {:ok, recipient, enc_code} <- Charset.encode(data.recipient, encoding),
         {:ok, purpose, _} <- Charset.encode(data.purpose, encoding) do
      # Optional text fields
      display = maybe_encode(data[:display], encoding)

      fields = [
        "BCD",
        "002",
        "#{enc_code}",
        "UCT",
        "",
        recipient,
        data.iban,
        format_amount(data[:amount]),
        data.recipient_code,
        "",
        "",
        purpose,
        display || ""
      ]

      data_string = Enum.join(fields, @lf)
      encoded = Base64URL.encode(data_string)

      {:ok, @v002_prefix <> encoded}
    end
  end

  @doc """
  Formats QR data for V003 (Base64URL with prefix and extended fields).

  ## Format Structure

  ```
  https://qr.bank.gov.ua/{base64url_encoded_data}
  ```

  ## Data String Structure (17 fields)

  Fields separated by LF (0x0A):
  1. Service label (BCD)
  2. Version format (003)
  3. Encoding code (1 or 2)
  4. Function code (UCT/ICT/XCT)
  5. Empty (reserved)
  6. Recipient name
  7. IBAN
  8. Amount (optional, with UAH prefix)
  9. Recipient code (EDRPOU/Tax ID)
  10. Category/Purpose (ISO 20022)
  11. Reference (optional)
  12. Purpose
  13. Display text (optional)
  14. Field lock (optional, hex)
  15. Invoice validity datetime (optional)
  16. Invoice creation datetime (optional)
  17. Digital signature (reserved for future)

  ## Parameters

  - `data` - Map with all V003 fields
  - `opts` - Options (encoding)

  ## Returns

  `{:ok, qr_string}` or `{:error, reason}`.
  """
  @spec format_v003(map(), keyword()) :: {:ok, String.t()} | {:error, String.t()}
  def format_v003(data, opts \\ []) do
    encoding = Keyword.get(opts, :encoding, :utf8)
    function_code = data[:function] || :uct

    with {:ok, recipient, enc_code} <- Charset.encode(data.recipient, encoding),
         {:ok, purpose, _} <- Charset.encode(data.purpose, encoding) do
      # Optional text fields
      category_purpose = maybe_encode(data[:category_purpose], encoding)
      display = maybe_encode(data[:display], encoding)
      reference = maybe_encode(data[:reference], encoding)

      fields = [
        "BCD",
        "003",
        "#{enc_code}",
        function_code_string(function_code),
        "",
        recipient,
        data.iban,
        format_amount(data[:amount]),
        data.recipient_code,
        category_purpose || "",
        reference || "",
        purpose,
        display || "",
        format_field_lock(data[:field_lock]),
        format_datetime(data[:invoice_validity]),
        format_datetime(data[:invoice_creation]),
        data[:digital_signature] || ""
      ]

      data_string = Enum.join(fields, @lf)
      encoded = Base64URL.encode(data_string)

      {:ok, @v003_prefix <> encoded}
    end
  end

  # Helper: Convert function code atom to string
  @spec function_code_string(function_code()) :: String.t()
  defp function_code_string(:uct), do: "UCT"
  defp function_code_string(:ict), do: "ICT"
  defp function_code_string(:xct), do: "XCT"

  # Helper: Format amount as string with UAH currency prefix (removes trailing zeros)
  @spec format_amount(Decimal.t() | nil) :: String.t()
  defp format_amount(nil), do: ""

  defp format_amount(%Decimal{} = amount) do
    # NBU spec: "Перед сумою повинні розміщуватися три великі літери коду валюти"
    # NBU spec: "Сума має бути якомога коротшою відповідно до результуючого коду"
    # - If no kopiykas: no decimal point (e.g., "UAH100" not "UAH100.00")
    # - If kopiykas exist: exactly 2 digits (e.g., "UAH100.50" not "UAH100.5")
    str = Decimal.to_string(amount, :normal)

    formatted_number =
      case String.split(str, ".") do
        [integer_part] ->
          # No decimal point, return as-is
          integer_part

        [integer_part, "00"] ->
          # No kopiykas, remove decimal point
          integer_part

        [integer_part, "0"] ->
          # No kopiykas, remove decimal point
          integer_part

        [integer_part, decimal_part] when byte_size(decimal_part) == 1 ->
          # Single digit kopiykas, pad to 2 digits (e.g., "100.5" → "100.50")
          "#{integer_part}.#{decimal_part}0"

        [integer_part, decimal_part] ->
          # Two digit kopiykas, keep as-is
          "#{integer_part}.#{decimal_part}"
      end

    "UAH#{formatted_number}"
  end

  # Helper: Format field lock as 4-digit hex
  # Accepts both integer values and QRNBU.FieldLock structs
  @spec format_field_lock(non_neg_integer() | QRNBU.FieldLock.t() | nil) :: String.t()
  defp format_field_lock(nil), do: ""

  defp format_field_lock(%QRNBU.FieldLock{} = field_lock) do
    field_lock
    |> QRNBU.FieldLock.to_integer()
    |> format_field_lock()
  end

  defp format_field_lock(value) when is_integer(value) and value >= 0 and value <= 0xFFFF do
    value
    |> Integer.to_string(16)
    |> String.pad_leading(4, "0")
    |> String.upcase()
  end

  # Helper: Format datetime as YYMMDDHHMMSS
  @spec format_datetime(NaiveDateTime.t() | nil) :: String.t()
  defp format_datetime(nil), do: ""

  defp format_datetime(%NaiveDateTime{} = dt) do
    year = rem(dt.year, 100) |> Integer.to_string() |> String.pad_leading(2, "0")
    month = dt.month |> Integer.to_string() |> String.pad_leading(2, "0")
    day = dt.day |> Integer.to_string() |> String.pad_leading(2, "0")
    hour = dt.hour |> Integer.to_string() |> String.pad_leading(2, "0")
    minute = dt.minute |> Integer.to_string() |> String.pad_leading(2, "0")
    second = dt.second |> Integer.to_string() |> String.pad_leading(2, "0")

    "#{year}#{month}#{day}#{hour}#{minute}#{second}"
  end

  # Helper: Encode optional text field
  @spec maybe_encode(String.t() | nil, encoding()) :: String.t() | nil
  defp maybe_encode(nil, _encoding), do: nil
  defp maybe_encode("", _encoding), do: ""

  defp maybe_encode(text, encoding) do
    case Charset.encode(text, encoding) do
      {:ok, encoded, _} -> encoded
      {:error, _} -> ""
    end
  end
end