lib/avatarex.ex

defmodule Avatarex do
  @moduledoc """
  Avatarex is is an elixir package for generating unique, reproducible avatars.

  The package is inspired by [Robohash](https://github.com/e1ven/Robohash)

  Two Avatar sets are natively `Avatarex.Sets.Birdy` and `Avatarex.Sets.Kitty`, but
  additional sets can be created using `Avatarex.Set`.

  Optional parameters:

    otp_app: The parent application to be used to generate the sets path.

    sets_path: The fully qualified sets path or path relative to the otp application's priv directory.
    Will default to "myapp/priv/sets" if unset.

    renders_path: The absolute path to the directory in which to render images.
    Will default to "myapp/priv/renders" if unset or path doesn't exist.

  Logs avatar generation, rendering, and writing of rendered avatar.

  ## Example Usage
      defmodule MyApp.Avatar do
        use Avatarex

        alias Avatarex.Sets.{Birdy, Kitty}

        for {name, module} <- [birdy: Birdy, kitty: Kitty] do
          set name, module
        end
      end
  """

  require Logger

  @spec __before_compile__(env :: Macro.Env.t()) :: Macro.t
  # credo:disable-for-next-line
  defmacro __before_compile__(_env) do
    quote unquote: false do

      set_list = "#{inspect Keyword.keys(@sets)}"

      @doc """
      Generates a reproducible #{__MODULE__} for a given name and set 
      in #{set_list}.

      Returns `%#{__MODULE__}{image: nil, name: ...}`.

      ## Examples

          iex> #{__MODULE__}.generate("user_name", :kitty)
          %#{__MODULE__}{image: nil, name: "bob", set: :kitty, renders_path: ...}

      """
      @spec generate(Avatarex.set, Avatarex.set_module) :: Avatarex.t_unrendered
      for {set, module} <- @sets do
        def generate(name, unquote(set)) do
          Avatarex.generate(name, unquote(module), unquote(set), @renders_path)
        end
      end

      @doc """
      Generates an unreproducible random #{__MODULE__} from a set in #{set_list}.

      Returns `%#{__MODULE__}{image: nil, name: ...}`.

      ## Examples

          #{__MODULE__}.random()
          %#{__MODULE__}{image: nil, name: nil, set: :birdy, renders_path: ...}

      """
      @spec random() :: Avatarex.t_unrendered
      def random do
        {set, module} = Enum.random(@sets)
        Avatarex.random(module, set, @renders_path)
      end

      @doc """
      Generates an unreproducible random #{__MODULE__} for a given set in #{set_list}.

      Returns `%#{__MODULE__}{image: nil, name: ...}`.

      ## Examples

          #{__MODULE__}.random(:kitty)
          %#{__MODULE__}{image: nil, name: nil, set: :kitty, renders_path: ...}

      """
      @spec random(Avatarex.set) :: Avatarex.t_unrendered
      for {set, module} <- @sets do
        def random(unquote(set)) do
          Avatarex.random(unquote(module), unquote(set), @renders_path)
        end
      end

      @doc """
      Generates a random #{__MODULE__} for a set in #{set_list}
      and renders a composite image.

      Returns `%#{__MODULE__}{image: %Vix.Vips.Image{}, set: ...}`.

      ## Examples

          #{__MODULE__}.generate(:kitty)
          %#{__MODULE__}{image: %Vix.Vips.Image{}, name: 5, set: :kitty...}

      """
      @spec render(Avatarex.set) :: Avatarex.t
      for {set, module} <- @sets do
        def render(unquote(set)) do
          Avatarex.render(unquote(module), unquote(set), @renders_path)
        end
      end

      @doc """
      Renders a #{__MODULE__} to form a composite image.

      Returns `%#{__MODULE__}{image:  %Vix.Vips.Image{}, ...}`.

      ## Examples

          #{__MODULE__}.render(%#{__MODULE__}{})
          %#{__MODULE__}{image: %Vix.Vips.Image{}, ...}

      """
      @spec render(Avatarex.t) :: Avatarex.t
      def render(avatar) do
        Avatarex.render(avatar)
      end

      @doc """
      Generates a #{__MODULE__} for a given name and set in #{set_list}
      and renders a composite image

      Returns `%#{__MODULE__}{image:  %Vix.Vips.Image{}, name: ...}`.

      ## Examples

          #{__MODULE__}.write("user_name, :kitty)
          %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

      """
      @spec render(Avatarex.name, Avatarex.set) :: Avatarex.t
      for {set, module} <- @sets do
        def render(name, unquote(set)) do
          Avatarex.render(name, unquote(module), unquote(set), @renders_path)
        end
      end

      @doc """
      Writes a #{__MODULE__} to initialized renders path.

      Returns `%#{__MODULE__}{image:  %Vix.Vips.Image{}, name: ...}`.

      ## Examples

          iex> #{__MODULE__}.write(%#{__MODULE__}{})
          %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

      """
      @spec write(Avatarex.t) :: Avatarex.t
      def write(avatar) do
        Avatarex.write(avatar)
      end
    end
  end

  @spec __using__([opts: String.t]) :: Macro.t
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @before_compile Avatarex

      @app (case opts[:otp_app] do
        nil -> Mix.Project.get().project()[:app]
        app -> app
      end)

      @sets_path (case opts[:sets_path] do
        nil -> "sets"
        path -> path
      end)

      @sets_path (if File.exists?(@sets_path) do
          @sets_path
        else
          @app |> :code.priv_dir |> Path.join(@sets_path)
        end)

      @doc """
      Returns a full path to the default directory for this avatar's sets.

      ## Examples

          sets_path("kitty")
          "../my_app/priv/sets/")

          alias MyApp.Avatar.Set.Robot
          sets_path(Robot)
          ".../my_app/priv/sets/")

      """
      @spec sets_path() :: sets_path :: String.t
      def sets_path(), do: @sets_path

      @renders_path (case opts[:renders_path] do
        nil -> "renders"
        path -> path
      end)

      @renders_path (if File.exists?(@renders_path) do
        @renders_path
      else
        @app |> :code.priv_dir |> Path.join(@renders_path)
      end)

      unless File.exists?(@renders_path), do: File.mkdir_p(@renders_path)

      Module.register_attribute(__MODULE__, :sets, accumulate: true)
      import Avatarex, only: [set: 2]

    end
  end

  @doc """
  Macro that adds the provided {set, module} to the available #{__MODULE__} sets.

  ## Examples

      use Avatarex, renders_path:...

      set {:kitty, Avatarex.Sets.Kitty}

  """
  @spec set(Avatarex.set, Avatarex.set_module) :: Macro.t
  defmacro set(set, module) do
    quote bind_quoted: [module: module, set: set] do
      @sets {set, module}
    end
  end

  defstruct [:image, :name, :set, :renders_path, images: []]

  @typedoc "This type"
  @type t(image) :: %__MODULE__{image: image, name: String.t | pos_integer(), 
                         set: atom, renders_path: String.t, images: [{Avatarex.Set.layer, Avatarex.Set.image_path}]}
  
  @typedoc "An Avatarex type"
  @type t :: t(Vix.Vips.Image.t | nil)

  @typedoc "An unrendered Avatarex type"
  @type t_unrendered :: t(nil)

  @typedoc "A rendered Avatarex type"
  @type t_rendered :: t(Vix.Vips.Image.t)

  @typedoc "A render path"
  @type renders_path :: String.t

  @typedoc "A name used for avatar and subsequent rendered file name"
  @type name :: String.t | pos_integer

  @typedoc "An Avatarex set atom"
  @type set :: atom

  @typedoc "A module which conforms to Avatar Set behaviour"
  @type set_module :: module

  @typedoc "A list of Avatarex Set layer and Avatarex Set image _path tuples"
  @type images :: [{Avatarex.Set.layer, Avatarex.Set.image_path}]

  @typep log_action :: :generate | :render | :write

  @doc """
  Generates a reproducible #{__MODULE__} for a given name, from the module and set specified
  for a set and render path.

  Returns `%#{__MODULE__}{image: ...}`.

  ## Examples

      alias Avatarex.Sets.Kitty
      #{__MODULE__}.generate("user_name", Kitty, :kitty, ".../priv/renders/...")
      %#{__MODULE__}{image: nil, name: "user_name", set: :kitty...}

  """
  @spec generate(Avatarex.name, Avatarex.set_module, Avatarex.set, Avatarex.renders_path) :: Avatarex.t_unrendered
  def generate(name, module, set, renders_path)
      when is_atom(module) and is_binary(name) and is_atom(set) and is_binary(renders_path) do
    hash = :crypto.hash(:sha512, name)
    layers = module.get_layers()
    hash_parts = div(128, Enum.count(layers)) # Future potential for + 1 based on background
    block_size = div(512, hash_parts)

    layers
    |> Enum.zip(for <<block::size(block_size) <- hash>>, do: block)
    |> Enum.map(fn {key, value} ->
      index = rem(value, module.get_image_count(key))
      {key, module.get_image_path_by_index(key, index)}
    end)
    |> construct(set, name, renders_path)
  end

  @doc """
  Generates a random #{__MODULE__} to render path for a given module, set, and render path.

  Returns `%#{__MODULE__}{image: %Vix.Vips.Image{}, name: ...}`.

  ## Examples
      alias Avatarex.Sets.Kitty
      #{__MODULE__}.random(Kitty, :kitty, "priv/render")
      %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

  """
  @spec random(Avatarex.set_module, Avatarex.set, Avatarex.renders_path) :: Avatarex.t_unrendered
  def random(module, set, renders_path)
      when is_atom(module) and is_atom(set) and is_binary(renders_path) do
    module.get_layers()
    |> Enum.reduce([], fn key, acc ->
      module.get_images_paths(key)
      |> Enum.random()
      |> then(&[{key, &1} | acc])
    end)
    |> construct(set, :rand.uniform(24), renders_path)
  end

  @spec construct(Avatarex.images, Avatarex.set, Avatarex.name, Avatarex.renders_path) :: Avatarex.t_unrendered
  defp construct(images, set, name, renders_path) do
    %__MODULE__{images: images, set: set, name: name, renders_path: renders_path}
    |> log(:generate)
  end

  @doc """
  Renders a #{__MODULE__} with a composite image.

  Returns `%#{__MODULE__}{image: ...}`.

  ## Examples
      
      #{__MODULE__}.render(%#{__MODULE__}{images: []})
      %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

  """
  @spec render(Avatarex.t) :: Avatarex.t_rendered
  def render(%__MODULE__{images: images} = avatar) do
    images
    |> Keyword.values()
    |> Enum.reduce(nil, fn
      image, nil -> Image.open!(image)
      image, composite -> Image.compose!(composite, Image.open!(image))
    end)
    |> then(&%__MODULE__{avatar | image: &1})
    |> log(:render)
  end

  @doc """
  Generates an unreproducible random #{__MODULE__} for a set and render path
  and renders the composite image.

  Returns `%#{__MODULE__}{image: ...}`.

  ## Examples

      #{__MODULE__}.render(%#{__MODULE__}{images: _})
      %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

  """
  @spec render(Avatarex.set_module, Avatarex.set, Avatarex.renders_path) :: Avatarex.t_rendered
  def render(module, set, renders_path)
      when is_atom(module) and is_atom(set) and is_binary(renders_path) do
    module |> random(set, renders_path) |> render()
  end

  @doc """
  Generates a reproducible random #{__MODULE__} for a given name and set and 
  renders the composite image.

  Returns `%#{__MODULE__}{image: ...}`.

  ## Examples

      #{__MODULE__}.render("user_name", Avatarex.Sets.Kitty, :kitty, "/renders/...")
      %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

  """
  @spec render(Avatarex.name, Avatarex.set_module, Avatarex.set, Avatarex.renders_path) :: Avatarex.t_rendered
  def render(name, module, set, renders_path)
      when is_atom(module) and is_atom(set) and is_binary(name) and is_binary(renders_path) do
    name |> generate(module, set, renders_path) |> render()
  end

  @doc """
  Renders a #{__MODULE__} composite image and writes the image at the designated
  renders path with the form "{name}_{set}.png". Spaces in {name} are replaced with
  underscores.

  Returns `%#{__MODULE__}{image: %Vix.Vips.Image{}}`.

  ## Examples

      #{__MODULE__}.render("user_name", Avatarex.Sets.Kitty, :kitty, "/renders/...")
      %#{__MODULE__}{image: %Vix.Vips.Image{}, name: "user_name", set: :kitty...}

  """
  @spec write(avatar :: Avatarex.t_unrendered) :: Avatarex.t_rendered
  def write(%__MODULE__{image: nil} = avatar) do
    avatar |> render() |> write()
  end

  @spec write(avatar :: Avatarex.t_rendered) :: Avatarex.t_rendered
  def write(%__MODULE__{image: image, set: set, name: name, renders_path: renders_path} = avatar) do
    "#{name}"
    |> String.replace(~r/[^a-zA-Z0-9-]/, "_")
    |> then(&"#{&1}_#{set}.png")
    |> then(&Path.join(renders_path, &1))
    |> then(&Image.write!(image, &1))
    |> then(&%{avatar | image: &1})
    |> log(:write)
  end

  @spec log(Avatarex.t, action :: Avatarex.log_action) :: Avatarex.t
  defp log(avatar, action) do
    case action do
      :generate -> "Generating a #{avatar.set} avatar"
      :render -> "Rendering #{avatar.set} avatar named #{avatar.name}"
      :write -> "Writing image '#{avatar.name}_#{avatar.set}.png' to renders path"
    end
    |> Logger.info()
    avatar
  end
end