lib/qr_nbu/renderer/logo.ex

defmodule QRNBU.Renderer.Logo do
  @moduledoc """
  Logo embedding functionality for SVG QR codes.

  This module handles embedding logos (UAH sign by default, or custom logos)
  into the center of SVG QR codes. The logo is precisely aligned to QR code
  module boundaries for clean visual integration.

  ## Logo Options

  - `true` - Use the default UAH currency sign logo (with white circle background)
  - `false` - No logo (plain QR code)
  - `"/path/to/logo.svg"` - Custom SVG logo from file path (no background)
  - `{:svg, "<svg>...</svg>"}` - Inline SVG string (no background)

  ## Grid-Aligned Positioning

  The logo is snapped to QR module grid boundaries:
  - Logo size is rounded to an odd number of modules for perfect centering
  - Position coordinates are integers aligned to module edges
  - Occupies approximately 25% of the QR code (within H-level error correction tolerance)

  Example: For a 69x69 module QR code, logo covers 17x17 modules centered at position (26, 26).
  """

  @type logo_option :: boolean() | Path.t() | {:svg, String.t()}

  # Logo size as a fraction of QR code size (25% = 0.25)
  @logo_size_ratio 0.25

  @doc """
  Embeds a logo into an SVG QR code string.

  The logo is precisely aligned to QR module grid boundaries for clean integration.

  ## Parameters

  - `svg_string` - The SVG QR code string
  - `logo_option` - Logo specification (see module docs)
  - `width` - The QR code width (used for reference, positioning uses viewBox)

  ## Returns

  - `{:ok, svg_with_logo}` - SVG string with embedded logo
  - `{:error, reason}` - Error message

  ## Examples

      iex> {:ok, svg} = QRNBU.Renderer.to_svg("test", logo: false)
      iex> {:ok, svg_with_logo} = QRNBU.Renderer.Logo.embed(svg, true, 300)
      iex> String.contains?(svg_with_logo, "uah-logo")
      true
  """
  @spec embed(String.t(), logo_option(), number()) :: {:ok, String.t()} | {:error, String.t()}
  def embed(svg_string, logo_option, width)

  def embed(svg_string, false, _width), do: {:ok, svg_string}

  def embed(svg_string, true, width) do
    with {:ok, logo_svg} <- get_default_logo() do
      embed_logo_into_svg(svg_string, logo_svg, width, :default)
    end
  end

  def embed(svg_string, {:svg, logo_svg}, width) when is_binary(logo_svg) do
    embed_logo_into_svg(svg_string, logo_svg, width, :custom)
  end

  def embed(svg_string, path, width) when is_binary(path) do
    with {:ok, logo_svg} <- read_logo_file(path) do
      embed_logo_into_svg(svg_string, logo_svg, width, :custom)
    end
  end

  def embed(_svg_string, invalid, _width) do
    {:error,
     "Invalid logo option: #{inspect(invalid)}. Expected boolean, path, or {:svg, string}"}
  end

  @doc """
  Returns the path to the default UAH logo.
  """
  @spec default_logo_path() :: String.t()
  def default_logo_path do
    case :code.priv_dir(:qr_nbu) do
      {:error, :bad_name} ->
        # Fallback for development/testing when app isn't started
        Path.join([File.cwd!(), "priv", "assets", "uah.svg"])

      priv_dir ->
        Path.join(priv_dir, "assets/uah.svg")
    end
  end

  # Private functions

  @spec get_default_logo() :: {:ok, String.t()} | {:error, String.t()}
  defp get_default_logo do
    read_logo_file(default_logo_path())
  end

  @spec read_logo_file(Path.t()) :: {:ok, String.t()} | {:error, String.t()}
  defp read_logo_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "Failed to read logo file '#{path}': #{inspect(reason)}"}
    end
  end

  @spec embed_logo_into_svg(String.t(), String.t(), number(), :default | :custom) ::
          {:ok, String.t()} | {:error, String.t()}
  defp embed_logo_into_svg(svg_string, logo_svg, _width, logo_type) do
    # Extract viewBox size (number of QR modules)
    qr_modules = extract_qr_viewbox_size(svg_string)

    # Calculate logo bounds snapped to module grid
    {logo_modules, logo_x, logo_y} = calculate_grid_aligned_bounds(qr_modules)

    # For default logo, calculate circle parameters (also grid-aligned)
    center = div(qr_modules, 2)
    # Circle radius slightly larger than half the logo to provide padding
    circle_radius = div(logo_modules, 2) + 1

    # Build the logo group
    logo_group =
      build_logo_group(logo_svg, logo_x, logo_y, logo_modules, center, circle_radius, logo_type)

    # Insert the logo group before the closing </svg> tag
    svg_with_logo = String.replace(svg_string, ~r/<\/svg>\s*$/, logo_group <> "\n</svg>")

    {:ok, svg_with_logo}
  end

  @spec extract_qr_viewbox_size(String.t()) :: integer()
  defp extract_qr_viewbox_size(svg_string) do
    # Extract viewBox from the QR SVG (format: "0 0 width height")
    # The viewBox dimensions equal the number of QR modules
    case Regex.run(~r/viewBox=["']0\s+0\s+(\d+)\s+(\d+)["']/i, svg_string) do
      [_, w, _h] -> String.to_integer(w)
      _ -> 69
    end
  end

  @spec calculate_grid_aligned_bounds(integer()) :: {integer(), integer(), integer()}
  defp calculate_grid_aligned_bounds(qr_modules) do
    # Calculate target logo size (25% of QR modules)
    target_size = qr_modules * @logo_size_ratio

    # Round to nearest odd integer for perfect centering
    logo_modules = round_to_odd(round(target_size))

    # Calculate centered position (integer coordinates)
    logo_x = div(qr_modules - logo_modules, 2)
    logo_y = div(qr_modules - logo_modules, 2)

    {logo_modules, logo_x, logo_y}
  end

  @spec round_to_odd(integer()) :: integer()
  defp round_to_odd(n) when rem(n, 2) == 0, do: n + 1
  defp round_to_odd(n), do: n

  @spec build_logo_group(
          String.t(),
          integer(),
          integer(),
          integer(),
          integer(),
          integer(),
          :default | :custom
        ) ::
          String.t()
  defp build_logo_group(logo_svg, logo_x, logo_y, logo_size, center, circle_radius, logo_type) do
    # Extract the inner content of the logo SVG (everything between <svg> and </svg>)
    inner_content = extract_svg_inner_content(logo_svg)

    # Get the viewBox dimensions from the original logo
    {viewbox_width, viewbox_height} = extract_viewbox_dimensions(logo_svg)

    # Use "uah-logo" id only for default UAH logo, "custom-logo" for custom logos
    group_id = if logo_type == :default, do: "uah-logo", else: "custom-logo"

    # White circle background only for default UAH logo (custom logos handle their own background)
    background_circle =
      if logo_type == :default do
        ~s(<circle cx="#{center}" cy="#{center}" r="#{circle_radius}" fill="white"/>)
      else
        ""
      end

    # Include xmlns:xlink namespace for logos that use xlink:href references
    # All coordinates are integers aligned to module grid
    """
    <g id="#{group_id}">
      #{background_circle}
      <svg x="#{logo_x}" y="#{logo_y}" width="#{logo_size}" height="#{logo_size}" viewBox="0 0 #{viewbox_width} #{viewbox_height}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
        #{inner_content}
      </svg>
    </g>
    """
  end

  @spec extract_svg_inner_content(String.t()) :: String.t()
  defp extract_svg_inner_content(svg_string) do
    # Remove XML declaration if present
    svg_string = Regex.replace(~r/<\?xml[^?]*\?>\s*/i, svg_string, "")

    # Extract content between opening and closing svg tags
    case Regex.run(~r/<svg[^>]*>(.*)<\/svg>/s, svg_string) do
      [_, inner_content] -> String.trim(inner_content)
      _ -> svg_string
    end
  end

  @spec extract_viewbox_dimensions(String.t()) :: {number(), number()}
  defp extract_viewbox_dimensions(svg_string) do
    # Try to extract viewBox dimensions
    case Regex.run(~r/viewBox=["']([^"']+)["']/i, svg_string) do
      [_, viewbox] ->
        case String.split(viewbox, ~r/\s+/) do
          [_, _, width, height] ->
            {parse_dimension(width), parse_dimension(height)}

          _ ->
            extract_width_height(svg_string)
        end

      _ ->
        extract_width_height(svg_string)
    end
  end

  @spec extract_width_height(String.t()) :: {number(), number()}
  defp extract_width_height(svg_string) do
    width =
      case Regex.run(~r/width=["']([^"']+)["']/i, svg_string) do
        [_, w] -> parse_dimension(w)
        _ -> 100
      end

    height =
      case Regex.run(~r/height=["']([^"']+)["']/i, svg_string) do
        [_, h] -> parse_dimension(h)
        _ -> 100
      end

    {width, height}
  end

  @spec parse_dimension(String.t()) :: number()
  defp parse_dimension(str) do
    # Remove units like "px", "em", etc. and parse as float
    str
    |> String.replace(~r/[a-z%]+$/i, "")
    |> String.trim()
    |> Float.parse()
    |> case do
      {num, _} -> num
      :error -> 100
    end
  end
end