defmodule Plausible.Cache.Adapter do
  @moduledoc """
  Interface for the underlying cache implementation.
  Currently: ConCache

  Using the Adapter module directly, the user must ensure that the relevant
  processes are available to use, which is normally done via the child specification.
  """

  require Logger

  @spec child_specs(atom(), atom(), Keyword.t()) :: [Supervisor.child_spec()]
  def child_specs(name, child_id, opts \\ [])
      when is_atom(name) and is_atom(child_id) and is_list(opts) do
    partitions = partitions(name)

    if partitions == 1 do
      [child_spec(name, child_id, opts)]
    else
      Enum.map(1..partitions, fn partition ->
        partition_name = String.to_atom("#{name}_#{partition}")
        partition_child_id = String.to_atom("#{child_id}_#{partition}")

        child_spec(partition_name, partition_child_id, opts)
      end)
    end
  end

  @spec child_spec(atom(), atom(), Keyword.t()) :: Supervisor.child_spec()
  def child_spec(name, child_id, opts \\ [])
      when is_atom(name) and is_atom(child_id) and is_list(opts) do
    cache_name = Keyword.get(opts, :cache_name, name)
    child_id = Keyword.get(opts, :child_id, child_id)
    ttl_check_interval = Keyword.get(opts, :ttl_check_interval, false)

    opts =
      opts
      |> Keyword.put(:name, cache_name)
      |> Keyword.put(:ttl_check_interval, ttl_check_interval)

    Supervisor.child_spec(
      {ConCache, opts},
      id: child_id
    )
  end

  @spec size(atom()) :: non_neg_integer() | nil
  def size(cache_name) do
    cache_name
    |> get_names()
    |> Enum.map(&ConCache.size/1)
    |> Enum.sum()
  catch
    :exit, _ -> nil
  end

  @spec get(atom(), any()) :: any()
  def get(cache_name, key) do
    full_cache_name = get_name(cache_name, key)
    ConCache.get(full_cache_name, key)
  catch
    :exit, _ ->
      Logger.error("Error retrieving key from '#{inspect(cache_name)}'")
      nil
  end

  @spec get(atom(), any(), (-> any())) :: any()
  def get(cache_name, key, fallback_fn) do
    full_cache_name = get_name(cache_name, key)
    ConCache.dirty_get_or_store(full_cache_name, key, fallback_fn)
  catch
    :exit, _ ->
      Logger.error("Error retrieving key from '#{inspect(cache_name)}'")
      nil
  end

  @spec fetch(atom(), any(), (-> any())) :: any()
  def fetch(cache_name, key, fallback_fn) do
    full_cache_name = get_name(cache_name, key)
    ConCache.dirty_fetch_or_store(full_cache_name, key, fallback_fn)
  catch
    :exit, _ ->
      Logger.error("Error fetching key from '#{inspect(cache_name)}'")
      nil
  end

  @spec put(atom(), any(), any()) :: any()
  def put(cache_name, key, value, _opts \\ []) do
    full_cache_name = get_name(cache_name, key)
    :ok = ConCache.dirty_put(full_cache_name, key, value)

    value
  catch
    :exit, _ ->
      Logger.error("Error putting a key to '#{cache_name}'")
      nil
  end

  @spec put_many(atom(), [any()]) :: :ok
  def put_many(cache_name, items) when is_list(items) do
    items
    |> Enum.group_by(fn {key, _} -> get_name(cache_name, key) end)
    |> Enum.each(fn {full_cache_name, items} ->
      true = :ets.insert(ConCache.ets(full_cache_name), items)
    end)

    :ok
  catch
    :exit, _ ->
      Logger.error("Error putting keys to '#{cache_name}'")
      :ok
  end

  @spec delete(atom(), any()) :: :ok
  def delete(cache_name, key) do
    full_cache_name = get_name(cache_name, key)
    ConCache.dirty_delete(full_cache_name, key)
  catch
    :exit, _ ->
      Logger.error("Error deleting a key in '#{cache_name}'")
      :ok
  end

  @spec keys(atom()) :: Enumerable.t()
  def keys(cache_name) do
    cache_name
    |> get_names()
    |> Enum.reduce([], fn full_cache_name, stream ->
      Stream.concat(stream, get_keys(full_cache_name))
    end)
  catch
    :exit, _ ->
      Logger.error("Error retrieving key from '#{inspect(cache_name)}'")
      []
  end

  @spec get_names(atom()) :: [atom()]
  def get_names(cache_name) do
    partitions = partitions(cache_name)

    if partitions == 1 do
      [cache_name]
    else
      Enum.map(1..partitions, &String.to_existing_atom("#{cache_name}_#{&1}"))
    end
  end

  defp get_keys(full_cache_name) do
    ets = ConCache.ets(full_cache_name)

    Stream.resource(
      fn -> :ets.first(ets) end,
      fn
        :"$end_of_table" -> {:halt, nil}
        prev_key -> {[prev_key], :ets.next(ets, prev_key)}
      end,
      fn _ -> :ok end
    )
  end

  defp get_name(cache_name, key) do
    partitions = partitions(cache_name)

    if partitions == 1 do
      cache_name
    else
      chosen_partition = :erlang.phash2(key, partitions) + 1
      String.to_existing_atom("#{cache_name}_#{chosen_partition}")
    end
  end

  defp partitions(cache_name) do
    Application.get_env(:plausible, __MODULE__)[cache_name][:partitions] || 1
  end

  def cache2list(full_cache_name) do
    :ets.tab2list(ConCache.ets(full_cache_name))
  end
end
