Skip to main content

lib/chat_code.ex

defmodule GW2.ChatCode do
  @moduledoc ~S"""
  Encodes and decodes Guild Wars 2 chat codes.

  Chat codes are the strings Guild Wars 2 places in chat when you link
  game data, such as items or wardrobe templates. They are wrapped in the
  familiar `[&...]` format and contain Base64-encoded binary payloads.

  This module provides the public entry point for converting between those
  chat code strings and typed Elixir structs.

  ## Supported chat codes

  The library currently supports:

    * `GW2.ChatCode.Item` for item links.
    * `GW2.ChatCode.WardrobeTemplate` for wardrobe template links.

  Guild Wars 2 has other chat code types, such as coins, maps, skills,
  traits, recipes, outfits, build templates, achievements, and travel
  templates. Those types are not implemented yet. Decoding an unsupported
  chat code type returns an error tuple.

  ## Encoding

  Build one of the supported structs and pass it to `encode/1`:

      iex> GW2.ChatCode.encode(%GW2.ChatCode.Item{id: 43766, quantity: 9})
      {:ok, "[&Agn2qgAA]"}

  ## Decoding

  Pass a chat code string to `decode/1` to get a typed struct back:

      iex> GW2.ChatCode.decode("[&Agn2qgAA]")
      {:ok, %GW2.ChatCode.Item{id: 43766, quantity: 9, skin_id: nil, upgrade_ids: []}}

  Unknown, malformed, or unsupported chat codes return an error tuple instead
  of raising.

  Common error reasons are:

    * `:invalid_code` for invalid wrappers, Base64, unsupported chat code types,
      or malformed payloads.
    * `:quantity_out_of_range` when encoding an item quantity outside `0..255`.
    * `:invalid_id` when encoding an id that cannot fit in the chat code format.
    * `:invalid_dyes` when encoding a wardrobe skin with an invalid dye list.
  """
  alias GW2.ChatCode.Header

  @decoders %{
    item: GW2.ChatCode.Item,
    wardrobe_template: GW2.ChatCode.WardrobeTemplate
  }

  @typedoc "A supported decoded chat code struct."
  @type parsed_struct :: GW2.ChatCode.Item.t() | GW2.ChatCode.WardrobeTemplate.t()

  @doc """
  Encodes a supported chat code struct into a Guild Wars 2 chat code string.

  Returns `{:ok, code}` when the struct can be encoded, or `{:error, reason}`
  when one of its fields cannot be represented in a chat code payload.

  ## Examples

      iex> GW2.ChatCode.encode(%GW2.ChatCode.Item{id: 46762})
      {:ok, "[&AgGqtgAA]"}

      iex> GW2.ChatCode.encode(%GW2.ChatCode.Item{id: 1, quantity: 256})
      {:error, :quantity_out_of_range}

  """
  @spec encode(GW2.ChatCode.Item.t()) :: {:ok, binary()} | {:error, atom()}
  def encode(%GW2.ChatCode.Item{} = item), do: do_encode(item, GW2.ChatCode.Item)

  @spec encode(GW2.ChatCode.WardrobeTemplate.t()) :: {:ok, binary()} | {:error, atom()}
  def encode(%GW2.ChatCode.WardrobeTemplate{} = wardrobe_template),
    do: do_encode(wardrobe_template, GW2.ChatCode.WardrobeTemplate)

  @spec encode(parsed_struct()) :: {:ok, binary()} | {:error, atom()}
  defp do_encode(item, encoder) do
    with {:ok, binary} <- encoder.encode(item),
         encoded <- Base.encode64(binary) do
      {:ok, wrap(encoded)}
    else
      {:error, _} = error -> error
      _ -> {:error, :invalid_code}
    end
  end

  @spec wrap(binary()) :: binary()
  defp wrap(encoded) do
    "[&#{encoded}]"
  end

  @doc """
  Decodes a chat code string into a structured value.

  Returns `{:ok, struct}` for supported chat code types. Invalid Base64,
  missing wrappers, unknown chat code types, and malformed payloads return
  an error tuple.

  ## Examples

      iex> GW2.ChatCode.decode("[&AgGqtgAA]")
      {:ok, %GW2.ChatCode.Item{id: 46762, quantity: 1, skin_id: nil, upgrade_ids: []}}

      iex> GW2.ChatCode.decode("not a chat code")
      {:error, :invalid_code}

  """
  @spec decode(binary()) :: {:ok, parsed_struct()} | {:error, atom()}
  def decode(code) do
    with {:ok, binary} <- normalize_code(code),
         {:ok, {type, rest}} <- Header.decode_type(binary),
         {:ok, decoder} <- fetch_decoder(type) do
      decoder.decode(rest)
    end
  end

  @spec fetch_decoder(atom()) :: {:ok, module()} | {:error, :invalid_code}
  defp fetch_decoder(type) do
    case Map.fetch(@decoders, type) do
      {:ok, parser} -> {:ok, parser}
      :error -> {:error, :invalid_code}
    end
  end

  @spec normalize_code(binary()) :: {:ok, binary()} | {:error, :invalid_code}
  defp normalize_code("[&" <> code) do
    case String.split(code, "]", parts: 2) do
      [encoded, ""] -> Base.decode64(encoded)
      _ -> {:error, :invalid_code}
    end
  end

  defp normalize_code(_), do: {:error, :invalid_code}
end