lib/coder_ring.ex

defmodule CoderRing do
  @moduledoc File.read!("README.md")
  import Ecto.Query
  alias CoderRing.{Code, Memo}
  alias Ecto.Changeset
  require Logger

  # 32 (readable) chars for codes: 2-9, A-Z (minus I and O)
  @chars ~w(V S E 2 9 3 A H 7 Q 6 R 4 B T 5 C L X G J Z F P D W U K Y M 8 N)

  # Default length of base code.
  @default_base_length 4

  # How many codes to insert at a time when populating codes table.
  @chunk_count 100_000

  @doc "Invoke the `name` ring with the given `message`."
  @callback call(name :: atom, message :: any) :: any

  defstruct base_length: @default_base_length, blacklist: nil, memo: nil, name: nil, repo: nil

  @typedoc """
  * `:base_length` - Number of characters to use in the base code.
  * `:blacklist` - Set this to `:english` to use the `Expletive` package's
    English word blacklist. Codes with occurrences of these words will be
    skipped in the database seeding step.
  * `:memo` - State-variable data, synced to the "code_memos" table.
  * `:name` - Name of this coder ring: .
  * `:repo` - Ecto.Repo module to use.
  """
  @type t :: %CoderRing{
          base_length: non_neg_integer,
          blacklist: atom,
          memo: Memo.t() | nil,
          name: atom,
          repo: module
        }

  defmacro __using__(opts) do
    otp_app = opts[:otp_app] || raise ":otp_app option is required."
    module = opts[:module]

    # quote bind_quoted: [module: opts[:module], otp_app: otp_app] do
    quote do
      import unquote(__MODULE__)
      @behaviour unquote(__MODULE__)
      @module unquote(module) || __MODULE__

      @doc """
      List all configured rings.

      Memos will not be loaded. Use `CoderRing.load_memo/1` to fetch state
      from the database.
      """
      @spec rings :: [CoderRing.t()]
      def rings, do: CoderRing.rings(unquote(otp_app), @module)

      @doc "Get a ring by its name."
      @spec ring(atom) :: CoderRing.t() | nil
      def ring(name), do: CoderRing.ring(unquote(otp_app), @module, name)

      # @impl unquote(__MODULE__)
      def call(name, message) do
        # Lame logic where memo is fetched & dropped every time.
        # Deserves to be overridden.
        {reply, _state} =
          name
          |> ring()
          |> CoderRing.load_memo()
          |> CoderRing.invoke(message)

        reply
      end

      @doc """
      Get the next code in the ring.

      ## Options

      * `:bump` - If `true`, the uniquizer will be incremented and the ring
        cycle reset. This should be used during a retry if a duplicate code
        returned. (This should only happen if a previously-used "extra"
        string is used after switching to a different one in between.)
      """
      @spec get_code(atom, keyword) :: String.t()
      def get_code(name, opts \\ []), do: call(name, {:get_code, opts})

      @doc "Reset the ring from the beginning."
      @spec reset(atom) :: :ok
      def reset(name), do: call(name, :reset)

      @doc """
      For each ring, seed its data if it hasn't already been done.

      See `CoderRing.populate/2`.
      """
      @spec populate_rings_if_empty(keyword) :: :ok
      def populate_rings_if_empty(opts \\ []) do
        Enum.each(rings(), &CoderRing.populate_if_empty(&1, opts))
      end

      defoverridable call: 2
    end
  end

  @doc """
  Make a new ring struct.

  ## Options

  * `:name` - Ring name atom. Required.
  * `:base_length` - Number of characters for the base code, 1-4. Default: 4
  * `:repo` - `Ecto.Repo` module to use. Required.
  * `:expletive_blacklist` - `Expletive` blacklist to use: `:english`,
    `:international` or `nil`. Note that, if enabled, the `expletive` package
    must be added as a dependency in your application. Default: `nil`
  """
  @spec new(keyword) :: t
  def new(opts) do
    name = opts[:name] || raise ":name option is required."
    is_atom(name) || raise ":name must be an atom."
    base_length = opts[:base_length] || @default_base_length
    base_length in 1..4 || raise "Only :base_length 1 and 4 are supported."
    repo = opts[:repo] || raise ":repo must be an Ecto.Repo module."
    bl = opts[:expletive_blacklist]
    bl in [nil, :english, :international] || raise "Invalid expletive_blacklist: #{inspect(bl)}"

    %CoderRing{
      base_length: base_length,
      blacklist: bl,
      memo: nil,
      name: name,
      repo: repo
    }
  end

  @doc """
  List all configured rings.

  Memos will be unloaded. Use `CoderRing.load_memo/1` to fetch state from the
  database.
  """
  @spec rings(atom, module) :: [t]
  def rings(otp_app, mod) do
    config = Application.get_env(otp_app, mod) || []

    Enum.map(config[:rings] || [], fn {name, opts} ->
      [name: name, repo: config[:repo]]
      |> Keyword.merge(opts)
      |> new()
    end)
  end

  @doc "Get a ring for `module` under `otp_app` by its `name`."
  @spec ring(atom, module, atom) :: t | nil
  def ring(otp_app, mod, name) do
    Enum.find(rings(otp_app, mod), &(&1.name == name))
  end

  @doc "Invoke the functionality identified by `message`. Memo should be loaded."
  @spec invoke(t, message :: any) :: {reply :: any, t}
  def invoke(ring, :reset) do
    {:ok, do_reset(ring)}
  end

  def invoke(%{memo: memo, name: name, repo: repo} = ring, {:get_code, opts}) do
    bump = opts[:bump] || false
    extra = opts[:extra] || ""

    memo_cs =
      if extra != memo.extra do
        # Caller has new extra string. Start ring over so we have a fresh set.
        reset_memo_change(ring)
      else
        if bump do
          Logger.warn("CoderRing #{name}: Bumping uniquizer.")
          reset_memo_change(ring, memo.uniquizer_num + 1)
        else
          Memo.changeset(memo, %{})
        end
      end

    {:ok, {base, max_pos, uniquizer_num}} =
      repo.transaction(fn ->
        {max, uniquizer_num} = get_max(%{ring | memo: Changeset.apply_changes(memo_cs)})

        r_pos = Enum.random(0..max.position)

        r = repo.one!(from Code, where: [name: ^to_string(name), position: ^r_pos])

        r |> Code.changeset(%{value: max.value}) |> repo.update!()
        max |> Code.changeset(%{value: r.value}) |> repo.update!()

        {r.value, max.position, uniquizer_num}
      end)

    uniquizer = if uniquizer_num == 0, do: "", else: integer_to_string(uniquizer_num - 1)

    code = "#{extra}#{uniquizer}#{base}"

    args = %{extra: extra, uniquizer_num: uniquizer_num, last_max_pos: max_pos}
    memo = memo_cs |> Memo.changeset(args) |> repo.update!()

    {code, %{ring | memo: memo}}
  end

  # Get the next code to be used as "max" and a possibly updated uniquizer_num.
  @spec get_max(t) :: {Code.t(), non_neg_integer}
  def get_max(%{memo: %{uniquizer_num: un, last_max_pos: last_max_pos}, name: name} = ring) do
    {max_pos, un} =
      case last_max_pos do
        0 -> {code_count(ring) - 1, un + 1}
        _ -> {last_max_pos - 1, un}
      end

    {ring.repo.one!(from Code, where: [name: ^to_string(name), position: ^max_pos]), un}
  end

  # Convert a integer to a string, with character set matching @chars.
  @spec integer_to_string(non_neg_integer) :: String.t()
  defp integer_to_string(int) do
    int
    |> Integer.to_string(32)
    |> String.replace(~w(0 1 I O), fn
      "0" -> "X"
      "1" -> "W"
      "I" -> "Y"
      "O" -> "Z"
    end)
  end

  # Get the approximate total number of codes in the ring.
  # (Some may be filtered for profanity.)
  @spec appx_code_count(t) :: non_neg_integer
  defp appx_code_count(%{base_length: base_len}) do
    round(:math.pow(length(@chars), base_len))
  end

  # Get the exact number of codes in the codes table by name.
  @spec code_count(t) :: non_neg_integer
  defp code_count(%{name: name, repo: repo}) do
    repo.aggregate(from(Code, where: [name: ^to_string(name)]), :count)
  end

  # Reset the ring cycle.
  @spec do_reset(t) :: t
  defp do_reset(%{repo: repo} = ring) do
    %{ring | memo: ring |> reset_memo_change() |> repo.update!()}
  end

  # Create a changeset on `memo`, resetting the ring cycle.
  @spec reset_memo_change(t) :: Changeset.t()
  defp reset_memo_change(%{memo: memo} = ring, uniquizer_num \\ 0) do
    lmp = code_count(ring)
    Memo.changeset(memo, %{uniquizer_num: uniquizer_num, last_max_pos: lmp})
  end

  @doc "Load the ring into the database if it isn't already there."
  @spec populate_if_empty(t, keyword) :: t
  def populate_if_empty(ring, opts \\ [])
  def populate_if_empty(%{memo: nil} = ring, opts), do: populate(ring, opts)
  def populate_if_empty(ring, _), do: ring

  @doc "Get the memo from db for the given ring `name`."
  @spec get_memo(t) :: Memo.t() | nil
  def get_memo(%{name: name, repo: repo}) do
    repo.one(from Memo, where: [name: ^to_string(name)])
  end

  @doc "Load the relevant memo from the database into `ring`."
  @spec load_memo(t) :: t
  def load_memo(ring), do: %{ring | memo: get_memo(ring)}

  @doc """
  Load the `ring` into the database.

  All `opts` are passed along to `Ecto.Repo` calls to query and insert.
  """
  @spec populate(t, keyword) :: t
  def populate(%{name: name, repo: repo} = ring, opts \\ []) do
    Logger.info("CoderRing #{name}: Loading appx #{appx_code_count(ring)} codes...")

    # Create the memo record so the code records' foreign keys link up.
    memo = [name: to_string(name)] |> Memo.new() |> repo.insert!(opts)

    count = insert_chunks(ring)

    # last_max_pos will never be count again. This is the last position in
    # the database plus 1 in order to get the counting started correctly.
    memo = memo |> Memo.changeset(%{last_max_pos: count}) |> repo.update!()

    Logger.info("CoderRing #{name}: Ready with #{count} codes.")

    %{ring | memo: memo}
  end

  # Insert all possible codes in batches to conserve memory.
  # Return the total number of code records inserted.
  @spec insert_chunks(t, keyword) :: non_neg_integer
  defp insert_chunks(%{blacklist: bl, name: name, repo: repo} = ring, opts \\ []) do
    expletive_config = bl && Expletive.configure(blacklist: apply(Expletive.Blacklist, bl, []))

    ring
    |> codes_stream()
    |> Stream.chunk_every(@chunk_count)
    |> Enum.reduce(0, fn chunk, count ->
      {values_str, count} =
        Enum.reduce(chunk, {"", count}, fn code, {acc, acc_count} ->
          if expletive_config && Expletive.profane?(code, expletive_config),
            do: {acc, acc_count},
            else: {"('#{name}', #{acc_count}, '#{code}')," <> acc, acc_count + 1}
        end)

      str = String.trim_trailing(values_str, ",")
      repo.query!("INSERT INTO codes (name, position, value) VALUES #{str}", [], opts)

      count
    end)
  end

  @doc """
  For each ring, seed its data if it hasn't already been done.

  See `populate/2`.
  """
  @spec populate_rings_if_empty([t], keyword) :: :ok
  def populate_rings_if_empty(rings, opts \\ []) do
    Enum.each(rings, &populate_if_empty(&1, opts))
  end

  # Create a stream, generating the full list of all possible codes.
  @spec codes_stream(t) :: Enumerable.t()
  def codes_stream(%{base_length: base_length}) do
    base = length(@chars)
    last_idx = base_length - 1

    Stream.map(0..(base ** base_length - 1), fn num ->
      {out, _} =
        Enum.reduce(last_idx..0, {"", num}, fn char, {acc, rem} ->
          char_val = base ** char
          val = div(rem, char_val)
          rem = rem - val * char_val

          {acc <> Enum.at(@chars, val), rem}
        end)

      out
    end)
  end
end