defmodule Tarakan.AnalyticsCacheTest do
  # Not async: this case enables the process-global cache, which every other
  # case relies on being bypassed.
  use ExUnit.Case, async: false

  alias Tarakan.AnalyticsCache

  setup do
    previous = Application.get_env(:tarakan, AnalyticsCache)
    on_exit(fn -> Application.put_env(:tarakan, AnalyticsCache, previous) end)
    :ok
  end

  defp with_ttl(ttl_ms, fun) do
    Application.put_env(:tarakan, AnalyticsCache, ttl_ms: ttl_ms)
    fun.()
  end

  defp unique_key, do: {:analytics_cache_test, System.unique_integer([:positive])}

  test "a zero TTL bypasses the cache entirely" do
    with_ttl(0, fn ->
      counter = :counters.new(1, [])
      key = unique_key()

      compute = fn ->
        :counters.add(counter, 1, 1)
        :counters.get(counter, 1)
      end

      assert AnalyticsCache.fetch(key, compute) == 1
      assert AnalyticsCache.fetch(key, compute) == 2
    end)
  end

  test "a positive TTL computes once and serves the cached value" do
    with_ttl(60_000, fn ->
      counter = :counters.new(1, [])
      key = unique_key()

      compute = fn ->
        :counters.add(counter, 1, 1)
        :counters.get(counter, 1)
      end

      assert AnalyticsCache.fetch(key, compute) == 1
      assert AnalyticsCache.fetch(key, compute) == 1
      assert :counters.get(counter, 1) == 1
    end)
  end

  test "concurrent misses on a cold key run the query once" do
    with_ttl(60_000, fn ->
      counter = :counters.new(1, [])
      key = unique_key()

      compute = fn ->
        :counters.add(counter, 1, 1)
        # Long enough that every task is waiting rather than arriving later.
        Process.sleep(50)
        :computed
      end

      results =
        1..20
        |> Task.async_stream(fn _index -> AnalyticsCache.fetch(key, compute) end,
          max_concurrency: 20,
          timeout: 5_000
        )
        |> Enum.map(fn {:ok, value} -> value end)

      assert Enum.all?(results, &(&1 == :computed))

      # This is the property that matters under load: 20 simultaneous callers
      # cost one query, not twenty.
      assert :counters.get(counter, 1) == 1
    end)
  end

  test "a failing query degrades to the fallback instead of crashing the caller" do
    with_ttl(60_000, fn ->
      assert AnalyticsCache.fetch(unique_key(), fn -> raise "boom" end, on_unavailable: []) == []

      assert AnalyticsCache.fetch(unique_key(), fn -> raise "boom" end,
               on_unavailable: :unavailable
             ) == :unavailable
    end)
  end

  test "a failure is not cached, so the next caller retries" do
    with_ttl(60_000, fn ->
      counter = :counters.new(1, [])
      key = unique_key()

      compute = fn ->
        case :counters.get(counter, 1) do
          0 ->
            :counters.add(counter, 1, 1)
            raise "transient"

          _recovered ->
            :ok
        end
      end

      assert AnalyticsCache.fetch(key, compute, on_unavailable: :unavailable) == :unavailable
      assert AnalyticsCache.fetch(key, compute, on_unavailable: :unavailable) == :ok
    end)
  end

  test "the configured TTL is read from application config" do
    with_ttl(1234, fn -> assert AnalyticsCache.configured_ttl() == 1234 end)
  end
end
