defmodule Phoenix.React.Cache do
  @moduledoc """
  Cache for React Component rendering

  Cache key is a tuple of component, props and static flag

  Remove expired cache every 60 seconds
  """
  use GenServer

  @ets_table_name :react_component_cache

  @default_ttl Application.compile_env(:phoenix_react_server, Phoenix.React, [])
               |> Keyword.get(:cache_ttl, 3600)

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  @impl true
  @spec init(any()) :: {:ok, %{}}
  def init(_) do
    state = %{}
    ensure_started()
    schedule_work()
    {:ok, state}
  end

  @impl true
  def handle_info(:gc, state) do
    ts = :os.system_time(:seconds)

    match_spec = [
      {{:"$1", :_, :"$2"}, [{:<, :"$2", ts}], [:"$1"]}
    ]

    :ets.select(@ets_table_name, match_spec)
    |> Enum.each(fn key -> :ets.delete(@ets_table_name, key) end)

    schedule_work()
    {:noreply, state}
  end

  defp schedule_work do
    # Every 60 seconds by default
    gc_time =
      Application.get_env(:phoenix_react_server, Phoenix.React)
      |> Keyword.get(:gc_time, 60_000)

    Process.send_after(self(), :gc, gc_time)
  end

  @doc """
  Get a cached value
  """
  @spec get(Phoenix.React.component(), Phoenix.React.props(), :static_markup | :string) ::
          binary() | nil
  def get(component, props, mod) do
    lookup(component, props, mod)
  end

  @doc """
  Set a cached value
  """
  @spec put(Phoenix.React.component(), Phoenix.React.props(), :static_markup | :string, binary(),
          ttl: integer()
        ) :: true
  def put(component, props, mod, result, opt \\ []) do
    ttl = Keyword.get(opt, :ttl, @default_ttl)
    expiration = :os.system_time(:seconds) + ttl
    record = {[component, props, mod], result, expiration}
    :ets.insert(@ets_table_name, record)
  end

  @doc """
  Remove cached value
  """
  @spec delete_cache(Phoenix.React.component(), Phoenix.React.props(), :static_markup | :string) ::
          true
  def delete_cache(component, props, mod) do
    :ets.delete(@ets_table_name, [component, props, mod])
  end

  defp lookup(component, props, mod) do
    case :ets.lookup(@ets_table_name, [component, props, mod]) do
      [result | _] -> check_freshness(result)
      [] -> nil
    end
  end

  defp check_freshness({[component, props, mod], result, expiration}) do
    cond do
      expiration > :os.system_time(:seconds) ->
        result

      :else ->
        delete_cache(component, props, mod)
        nil
    end
  end

  defp ensure_started() do
    case :ets.whereis(@ets_table_name) do
      :undefined -> :ets.new(@ets_table_name, [:set, :public, :named_table])
      ref -> ref
    end
  end
end
