lib/qr_nbu/field_lock.ex

defmodule QRNBU.FieldLock do
  @moduledoc """
  Human-readable field lock bitmask builder for NBU QR Code V003.

  Field lock controls which fields can be edited by the payer when processing
  a QR code payment. Setting a bit to 1 prevents the payer from modifying that field.

  ## Quick Start

      # Lock all fields except amount
      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount]) |> FieldLock.to_integer()
      65279

      # Get preset for fixed-amount payments
      iex> alias QRNBU.FieldLock
      iex> FieldLock.preset(:fixed_payment) |> FieldLock.to_integer()
      65535

      # Build custom lock
      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.lock([:recipient, :iban]) |> FieldLock.to_integer()
      51455

  ## NBU Requirement

  Per NBU specification (Додаток 4, розділ IV, пункт 14), fields 1-5, 11, 14-17
  must ALWAYS be locked. This module automatically enforces this requirement.

  ## Field Reference

  | Field # | Name                | Editable | Description                    |
  |---------|---------------------|----------|--------------------------------|
  | 1       | service_mark        | No       | BCD marker                     |
  | 2       | format_version      | No       | Version (003)                  |
  | 3       | encoding            | No       | Character encoding             |
  | 4       | function            | No       | UCT/ICT/XCT                    |
  | 5       | unique_recipient_id | No       | Reserved                       |
  | 6       | recipient           | Yes      | Recipient name                 |
  | 7       | iban                | Yes      | Account number                 |
  | 8       | amount              | Yes      | Payment amount                 |
  | 9       | recipient_code      | Yes      | EDRPOU/Tax ID                  |
  | 10      | category_purpose    | Yes      | ISO 20022 category             |
  | 11      | reference           | No       | Invoice reference              |
  | 12      | purpose             | Yes      | Payment purpose text           |
  | 13      | display             | Yes      | Display text                   |
  | 14      | field_lock          | No       | This field                     |
  | 15      | invoice_validity    | No       | Expiration datetime            |
  | 16      | invoice_creation    | No       | Creation datetime              |
  """

  import Bitwise

  @enforce_keys [:value]
  defstruct [:value]

  # Bit positions for each field
  # Per NBU spec: "Цей номер відповідає номеру біта (починаючи з наймолодшого)"
  # Field number N corresponds to bit N in the bitmask.
  # Example from spec: all_except(amount) = 0xFEFF, which is 0xFFFF with bit 8 cleared.
  # So field 8 (amount) = bit 8, meaning field N = bit N.
  # Note: Field 16 uses bit 0 since bit 16 would overflow 16-bit range.
  @field_bits %{
    # Field 1 = bit 1
    service_mark: 1,
    # Field 2 = bit 2
    format_version: 2,
    # Field 3 = bit 3
    encoding: 3,
    # Field 4 = bit 4
    function: 4,
    # Field 5 = bit 5
    unique_recipient_id: 5,
    # Field 6 = bit 6
    recipient: 6,
    # Field 7 = bit 7
    iban: 7,
    # Field 8 = bit 8
    amount: 8,
    # Field 9 = bit 9
    recipient_code: 9,
    # Field 10 = bit 10
    category_purpose: 10,
    # Field 11 = bit 11
    reference: 11,
    # Field 12 = bit 12
    purpose: 12,
    # Field 13 = bit 13
    display: 13,
    # Field 14 = bit 14
    field_lock: 14,
    # Field 15 = bit 15
    invoice_validity: 15,
    # Field 16 = bit 0 (wraps since bit 16 overflows)
    invoice_creation: 0
  }

  # Fields that MUST always be locked per NBU spec (1-5, 11, 14-17)
  # Note: Field 17 (digital_signature) is beyond 16 bits, handled separately
  @mandatory_fields [
    :service_mark,
    :format_version,
    :encoding,
    :function,
    :unique_recipient_id,
    :reference,
    :field_lock,
    :invoice_validity,
    :invoice_creation
  ]

  # Pre-calculated mandatory bits
  # Per NBU spec, fields 1-5, 11, 14-16 must always be locked.
  # Field N = bit N, except field 16 = bit 0 (overflow wrap).
  # Bits: 0 (field 16), 1-5 (fields 1-5), 11 (field 11), 14-15 (fields 14-15)
  # = 1 + 2 + 4 + 8 + 16 + 32 + 2048 + 16384 + 32768 = 51263 = 0xC83F
  @mandatory_bits 0xC83F

  # Fields that CAN be unlocked (editable by payer)
  @editable_fields [
    :recipient,
    :iban,
    :amount,
    :recipient_code,
    :category_purpose,
    :purpose,
    :display
  ]

  # All field bits set = 0xFFFF
  @all_bits 0xFFFF

  @type t :: %__MODULE__{value: non_neg_integer()}

  @type field ::
          :service_mark
          | :format_version
          | :encoding
          | :function
          | :unique_recipient_id
          | :recipient
          | :iban
          | :amount
          | :recipient_code
          | :category_purpose
          | :reference
          | :purpose
          | :display
          | :field_lock
          | :invoice_validity
          | :invoice_creation

  @type preset_name ::
          :minimal | :fixed_payment | :editable_amount | :editable_purpose | :flexible

  # ============================================================================
  # Constructors
  # ============================================================================

  @doc """
  Creates a new FieldLock with only mandatory fields locked.

  This is the most permissive valid configuration - all editable fields
  can be modified by the payer.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> fl = FieldLock.new()
      iex> FieldLock.to_integer(fl)
      51263

      iex> alias QRNBU.FieldLock
      iex> fl = FieldLock.new()
      iex> FieldLock.to_hex(fl)
      "C83F"
  """
  @spec new() :: t()
  def new, do: %__MODULE__{value: @mandatory_bits}

  @doc """
  Creates a FieldLock with ALL fields locked.

  Equivalent to `0xFFFF`. The payer cannot modify any field.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.to_integer()
      65535

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.to_hex()
      "FFFF"
  """
  @spec all() :: t()
  def all, do: %__MODULE__{value: @all_bits}

  @doc """
  Creates a FieldLock from an existing integer value.

  Returns `{:ok, field_lock}` if valid, or `{:error, reason}` if invalid.
  The value must be between 0 and 65535, and mandatory fields will be
  automatically locked if not already set.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> {:ok, fl} = FieldLock.from_integer(0xFEFF)
      iex> FieldLock.to_integer(fl)
      65279

      iex> alias QRNBU.FieldLock
      iex> FieldLock.from_integer(70000)
      {:error, "Value must be between 0 and 65535"}

      iex> alias QRNBU.FieldLock
      iex> {:ok, fl} = FieldLock.from_integer(0)
      iex> FieldLock.to_integer(fl)
      51263
  """
  @spec from_integer(integer()) :: {:ok, t()} | {:error, String.t()}
  def from_integer(value) when is_integer(value) and value >= 0 and value <= @all_bits do
    # Ensure mandatory bits are always set
    {:ok, %__MODULE__{value: value ||| @mandatory_bits}}
  end

  def from_integer(value) when is_integer(value) do
    {:error, "Value must be between 0 and 65535"}
  end

  def from_integer(_), do: {:error, "Value must be an integer"}

  @doc """
  Creates a FieldLock from an existing integer value, raising on error.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.from_integer!(0xFEFF) |> FieldLock.to_hex()
      "FEFF"
  """
  @spec from_integer!(integer()) :: t()
  def from_integer!(value) do
    case from_integer(value) do
      {:ok, fl} -> fl
      {:error, reason} -> raise ArgumentError, reason
    end
  end

  # ============================================================================
  # Convenience Builders
  # ============================================================================

  @doc """
  Creates a FieldLock with all fields locked EXCEPT the specified ones.

  Mandatory fields remain locked regardless of the input.
  This is the most common way to create a field lock for payments
  where you want to allow the payer to edit specific fields.

  ## Examples

      # Allow only amount to be edited
      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount]) |> FieldLock.to_integer()
      65279

      # Allow amount and purpose to be edited
      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount, :purpose]) |> FieldLock.to_integer()
      61183

      # Attempting to unlock mandatory fields has no effect
      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:service_mark]) |> FieldLock.to_integer()
      65535
  """
  @spec all_except([field()]) :: t()
  def all_except(fields) when is_list(fields) do
    all() |> unlock(fields)
  end

  @doc """
  Creates a FieldLock with only mandatory fields locked, then adds the specified ones.

  Use this when you want maximum payer flexibility but need to lock
  specific additional fields.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.only([:recipient, :iban]) |> FieldLock.to_integer()
      51455

      iex> alias QRNBU.FieldLock
      iex> FieldLock.only([]) |> FieldLock.to_integer()
      51263
  """
  @spec only([field()]) :: t()
  def only(fields) when is_list(fields) do
    new() |> lock(fields)
  end

  # ============================================================================
  # Presets
  # ============================================================================

  @doc """
  Returns a preset FieldLock for common payment scenarios.

  ## Available Presets

  - `:minimal` - Only mandatory fields locked (maximum payer flexibility)
  - `:fixed_payment` - All fields locked (fixed invoice, no changes allowed)
  - `:editable_amount` - All except amount locked (common for variable payments)
  - `:editable_purpose` - All except purpose locked
  - `:flexible` - Mandatory + recipient info locked; amount, purpose, display editable

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.preset(:fixed_payment) |> FieldLock.to_hex()
      "FFFF"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.preset(:editable_amount) |> FieldLock.to_hex()
      "FEFF"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.preset(:minimal) |> FieldLock.to_hex()
      "C83F"
  """
  @spec preset(preset_name()) :: t()
  def preset(:minimal), do: new()
  def preset(:fixed_payment), do: all()
  def preset(:editable_amount), do: all_except([:amount])
  def preset(:editable_purpose), do: all_except([:purpose])

  def preset(:flexible) do
    # Lock recipient info, allow amount/purpose/display to be edited
    new() |> lock([:recipient, :iban, :recipient_code, :category_purpose])
  end

  # ============================================================================
  # Field Operations
  # ============================================================================

  @doc """
  Locks the specified field(s) (adds to existing locks).

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.lock(:amount) |> FieldLock.to_integer()
      51519

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.lock([:amount, :recipient]) |> FieldLock.to_integer()
      51583
  """
  @spec lock(t(), field() | [field()]) :: t()
  def lock(%__MODULE__{} = fl, fields) when is_list(fields) do
    Enum.reduce(fields, fl, &lock(&2, &1))
  end

  def lock(%__MODULE__{value: value} = fl, field) when is_atom(field) do
    case Map.get(@field_bits, field) do
      nil -> fl
      bit -> %{fl | value: value ||| 1 <<< bit}
    end
  end

  @doc """
  Unlocks the specified field(s) (removes from locks).

  Cannot unlock mandatory fields - they remain locked regardless.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.unlock(:amount) |> FieldLock.to_integer()
      65279

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.unlock([:amount, :purpose]) |> FieldLock.to_integer()
      61183

      # Cannot unlock mandatory fields
      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.unlock(:service_mark) |> FieldLock.to_integer()
      65535
  """
  @spec unlock(t(), field() | [field()]) :: t()
  def unlock(%__MODULE__{} = fl, fields) when is_list(fields) do
    Enum.reduce(fields, fl, &unlock(&2, &1))
  end

  def unlock(%__MODULE__{value: value} = fl, field) when is_atom(field) do
    # Cannot unlock mandatory fields
    if field in @mandatory_fields do
      fl
    else
      case Map.get(@field_bits, field) do
        nil -> fl
        bit -> %{fl | value: value &&& ~~~(1 <<< bit)}
      end
    end
  end

  @doc """
  Checks if a specific field is locked.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.locked?(:amount)
      false

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.locked?(:service_mark)
      true

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.locked?(:amount)
      true
  """
  @spec locked?(t(), field()) :: boolean()
  def locked?(%__MODULE__{value: value}, field) when is_atom(field) do
    case Map.get(@field_bits, field) do
      nil -> false
      bit -> (value &&& 1 <<< bit) != 0
    end
  end

  @doc """
  Checks if a specific field is unlocked (editable).

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.unlocked?(:amount)
      true

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.unlocked?(:amount)
      false
  """
  @spec unlocked?(t(), field()) :: boolean()
  def unlocked?(%__MODULE__{} = fl, field), do: not locked?(fl, field)

  # ============================================================================
  # Conversion
  # ============================================================================

  @doc """
  Converts FieldLock to integer for use in QR data.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.to_integer()
      65535

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.to_integer()
      51263
  """
  @spec to_integer(t()) :: non_neg_integer()
  def to_integer(%__MODULE__{value: value}), do: value

  @doc """
  Converts FieldLock to uppercase hex string (without 0x prefix).

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.to_hex()
      "FFFF"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.to_hex()
      "C83F"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount]) |> FieldLock.to_hex()
      "FEFF"
  """
  @spec to_hex(t()) :: String.t()
  def to_hex(%__MODULE__{value: value}) do
    value
    |> Integer.to_string(16)
    |> String.pad_leading(4, "0")
  end

  # ============================================================================
  # Introspection
  # ============================================================================

  @doc """
  Returns list of all currently locked fields.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.locked_fields()
      [:invoice_creation, :service_mark, :format_version, :encoding, :function, :unique_recipient_id, :reference, :field_lock, :invoice_validity]

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.locked_fields() |> length()
      16
  """
  @spec locked_fields(t()) :: [field()]
  def locked_fields(%__MODULE__{} = fl) do
    @field_bits
    |> Map.keys()
    |> Enum.filter(&locked?(fl, &1))
    |> Enum.sort_by(&Map.get(@field_bits, &1))
  end

  @doc """
  Returns list of all currently unlocked (editable) fields.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.unlocked_fields()
      [:recipient, :iban, :amount, :recipient_code, :category_purpose, :purpose, :display]

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.unlocked_fields()
      []

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount]) |> FieldLock.unlocked_fields()
      [:amount]
  """
  @spec unlocked_fields(t()) :: [field()]
  def unlocked_fields(%__MODULE__{} = fl) do
    @field_bits
    |> Map.keys()
    |> Enum.reject(&locked?(fl, &1))
    |> Enum.sort_by(&Map.get(@field_bits, &1))
  end

  @doc """
  Returns a human-readable description of the field lock.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all() |> FieldLock.describe()
      "0xFFFF: All fields locked"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.new() |> FieldLock.describe()
      "0xC83F: Editable fields: recipient, iban, amount, recipient_code, category_purpose, purpose, display"

      iex> alias QRNBU.FieldLock
      iex> FieldLock.all_except([:amount]) |> FieldLock.describe()
      "0xFEFF: Editable fields: amount"
  """
  @spec describe(t()) :: String.t()
  def describe(%__MODULE__{} = fl) do
    hex = "0x" <> to_hex(fl)
    unlocked = unlocked_fields(fl)

    case unlocked do
      [] ->
        "#{hex}: All fields locked"

      fields ->
        field_names = fields |> Enum.map(&Atom.to_string/1) |> Enum.join(", ")
        "#{hex}: Editable fields: #{field_names}"
    end
  end

  @doc """
  Returns list of all available field names.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.fields() |> length()
      16
  """
  @spec fields() :: [field()]
  def fields, do: Map.keys(@field_bits) |> Enum.sort_by(&Map.get(@field_bits, &1))

  @doc """
  Returns list of mandatory (always-locked) field names.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.mandatory_fields()
      [:invoice_creation, :service_mark, :format_version, :encoding, :function, :unique_recipient_id, :reference, :field_lock, :invoice_validity]
  """
  @spec mandatory_fields() :: [field()]
  def mandatory_fields, do: @mandatory_fields |> Enum.sort_by(&Map.get(@field_bits, &1))

  @doc """
  Returns list of editable field names.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.editable_fields()
      [:recipient, :iban, :amount, :recipient_code, :category_purpose, :purpose, :display]
  """
  @spec editable_fields() :: [field()]
  def editable_fields, do: @editable_fields |> Enum.sort_by(&Map.get(@field_bits, &1))

  @doc """
  Returns the bit position for a given field name.

  ## Examples

      iex> alias QRNBU.FieldLock
      iex> FieldLock.bit_position(:amount)
      8

      iex> alias QRNBU.FieldLock
      iex> FieldLock.bit_position(:service_mark)
      1

      iex> alias QRNBU.FieldLock
      iex> FieldLock.bit_position(:unknown)
      nil
  """
  @spec bit_position(field()) :: non_neg_integer() | nil
  def bit_position(field), do: Map.get(@field_bits, field)

  # ============================================================================
  # Protocol Implementations
  # ============================================================================

  defimpl Inspect do
    def inspect(%QRNBU.FieldLock{value: value}, _opts) do
      hex = value |> Integer.to_string(16) |> String.pad_leading(4, "0")
      "#QRNBU.FieldLock<0x#{hex}>"
    end
  end

  defimpl String.Chars do
    def to_string(%QRNBU.FieldLock{} = fl) do
      QRNBU.FieldLock.to_hex(fl)
    end
  end
end