defmodule Raxol.Terminal.ImageCache do
  @moduledoc """
  ETS-backed cache for decoded and encoded terminal images.

  Caches expensive operations:
  - PNG decode results (raw pixel data)
  - Sixel/Kitty encoded output for given parameters

  Keys are `{content_hash, opts_hash}` tuples. Entries expire after a
  configurable TTL (default 5 minutes). The cache is created lazily on
  first access and owned by the calling process (or an explicit owner).

  ## Usage

      ImageCache.start()
      ImageCache.put("img.png", png_bytes, %{max_colors: 64})
      {:ok, cached} = ImageCache.get("img.png", %{max_colors: 64})
      ImageCache.evict("img.png")
  """

  @table :raxol_image_cache
  @default_ttl_ms 5 * 60 * 1000
  @max_entries 256

  @type cache_key :: {binary(), binary()}
  @type cache_entry :: {cache_key(), term(), integer()}

  @doc """
  Creates the ETS table if it doesn't already exist.
  Safe to call multiple times.
  """
  @spec start() :: :ok
  def start do
    case :ets.whereis(@table) do
      :undefined ->
        _ = :ets.new(@table, [:set, :public, :named_table, read_concurrency: true])

      _ref ->
        :ok
    end

    :ok
  end

  @doc """
  Stores a value in the cache keyed by source identifier and options.
  """
  @spec put(binary(), term(), map()) :: :ok
  def put(source_id, value, opts \\ %{}) do
    ensure_table()
    key = cache_key(source_id, opts)
    now = System.monotonic_time(:millisecond)
    :ets.insert(@table, {key, value, now})
    maybe_evict_oldest()
    :ok
  end

  @doc """
  Retrieves a cached value. Returns `{:ok, value}` or `:miss`.
  Expired entries are transparently deleted.
  """
  @spec get(binary(), map()) :: {:ok, term()} | :miss
  def get(source_id, opts \\ %{}) do
    ensure_table()
    key = cache_key(source_id, opts)

    case :ets.lookup(@table, key) do
      [{^key, value, inserted_at}] ->
        if expired?(inserted_at) do
          :ets.delete(@table, key)
          :miss
        else
          {:ok, value}
        end

      [] ->
        :miss
    end
  end

  @doc """
  Fetches from cache or computes and caches the value.

  The `compute_fn` is called only on cache miss and must return
  `{:ok, value}` or `{:error, reason}`.
  """
  @spec fetch(binary(), map(), (-> {:ok, term()} | {:error, term()})) ::
          {:ok, term()} | {:error, term()}
  def fetch(source_id, opts, compute_fn) do
    case get(source_id, opts) do
      {:ok, value} ->
        {:ok, value}

      :miss ->
        case compute_fn.() do
          {:ok, value} = ok ->
            put(source_id, value, opts)
            ok

          {:error, _} = err ->
            err
        end
    end
  end

  @doc """
  Removes all entries matching a source identifier (any opts).
  """
  @spec evict(binary()) :: :ok
  def evict(source_id) do
    ensure_table()
    hash = content_hash(source_id)

    :ets.foldl(
      fn {key, _value, _ts}, acc ->
        case key do
          {^hash, _} -> :ets.delete(@table, key)
          _ -> :ok
        end

        acc
      end,
      :ok,
      @table
    )
  end

  @doc """
  Removes all expired entries from the cache.
  """
  @spec prune() :: non_neg_integer()
  def prune do
    ensure_table()
    now = System.monotonic_time(:millisecond)
    ttl = ttl_ms()

    :ets.foldl(
      fn {key, _value, inserted_at}, count ->
        if now - inserted_at > ttl do
          :ets.delete(@table, key)
          count + 1
        else
          count
        end
      end,
      0,
      @table
    )
  end

  @doc """
  Deletes all entries from the cache.
  """
  @spec clear() :: :ok
  def clear do
    ensure_table()
    :ets.delete_all_objects(@table)
    :ok
  end

  @doc """
  Returns the number of entries currently in the cache.
  """
  @spec size() :: non_neg_integer()
  def size do
    ensure_table()
    :ets.info(@table, :size)
  end

  # -- Private --

  defp cache_key(source_id, opts) do
    {content_hash(source_id), opts_hash(opts)}
  end

  defp content_hash(source_id) when is_binary(source_id) do
    :crypto.hash(:sha256, source_id)
  end

  defp opts_hash(opts) when is_map(opts) do
    opts
    |> :erlang.term_to_binary()
    |> then(&:crypto.hash(:md5, &1))
  end

  defp expired?(inserted_at) do
    System.monotonic_time(:millisecond) - inserted_at > ttl_ms()
  end

  defp ttl_ms do
    Application.get_env(:raxol, :image_cache_ttl_ms, @default_ttl_ms)
  end

  defp ensure_table do
    case :ets.whereis(@table) do
      :undefined -> start()
      _ref -> :ok
    end
  end

  defp maybe_evict_oldest do
    if :ets.info(@table, :size) > @max_entries do
      # Find and delete the oldest entry
      {oldest_key, _} =
        :ets.foldl(
          fn {key, _value, ts}, {_best_key, best_ts} = best ->
            if ts < best_ts, do: {key, ts}, else: best
          end,
          {nil, System.monotonic_time(:millisecond)},
          @table
        )

      if oldest_key, do: :ets.delete(@table, oldest_key)
    end
  end
end
