defmodule Microwaveprop.Cache do
  @moduledoc """
  Tiny ETS-backed TTL cache for values that are expensive to compute but
  tolerate short staleness. Used for things like `Repo.aggregate` counts that
  would otherwise run on every page load.

  Not a replacement for `Microwaveprop.Propagation.ScoreCache` or
  `Microwaveprop.Weather.NexradCache` — those have bespoke invalidation logic
  driven by PubSub. This module is for generic time-boxed memoization.
  """
  use GenServer

  @table :microwaveprop_cache

  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Return the cached value for `key` if it's still fresh, otherwise invoke
  `fun`, store the result with `ttl_ms` lifetime, and return it.
  """
  @spec fetch_or_store(term(), non_neg_integer(), (-> value)) :: value when value: term()
  def fetch_or_store(key, ttl_ms, fun) when is_function(fun, 0) do
    now = System.monotonic_time(:millisecond)

    case :ets.lookup(@table, key) do
      [{_, value, expires_at}] when expires_at > now ->
        value

      _ ->
        value = fun.()
        :ets.insert(@table, {key, value, now + ttl_ms})
        value
    end
  end

  @doc "Insert `value` directly, overwriting any existing entry for `key`."
  @spec put(term(), term(), integer()) :: :ok
  def put(key, value, ttl_ms) do
    :ets.insert(@table, {key, value, System.monotonic_time(:millisecond) + ttl_ms})
    :ok
  end

  @doc "Remove `key` from the cache, forcing the next fetch to recompute."
  @spec invalidate(term()) :: :ok
  def invalidate(key) do
    :ets.delete(@table, key)
    :ok
  end

  @spec clear() :: :ok
  def clear do
    :ets.delete_all_objects(@table)
    :ok
  end

  @impl true
  def init(_opts) do
    :ets.new(@table, [
      :set,
      :named_table,
      :public,
      read_concurrency: true,
      write_concurrency: true
    ])

    {:ok, %{}}
  end
end
