defmodule Microwaveprop.Weather.GridCache do
  @moduledoc """
  Cross-pod cache of derived HRRR grid rows keyed by `valid_time`. When
  `Microwaveprop.Valkey` is configured, the chunked map for each
  valid_time is held in Valkey (one key per 5°×5° spatial chunk plus a
  ZSET index of cached valid_times). Otherwise the cache stays
  pod-local in ETS — preserved so dev / test work without Valkey.

  Each valid_time worth of CONUS HRRR is ~92k cells × 22 fields ≈
  32 MiB compressed. Holding that in pod-local ETS meant N replicas
  paid the full memory cost per pod. With the Valkey backend a single
  cluster-wide copy serves every reader.

  ## Storage layout (Valkey)

    * `prop:wg:vts` — ZSET of cached valid_time iso8601 strings, score
      = unix epoch seconds. `latest_valid_time/0` does ZREVRANGE 0 0.
    * `prop:wg:<vt_iso>:<lat_band>:<lon_band>` — string key per chunk,
      value is `:erlang.term_to_binary(%{{lat, lon} => row, ...})`.
      `fetch_bounds/2` MGETs only the chunks intersecting the viewport.

  Per-key TTL is `@ttl_seconds`, so cache eviction is automatic — no
  prune worker needed when running on Valkey. The ETS path keeps the
  legacy `prune_keep_latest`/`prune_older_than` helpers since they're
  still called by `Microwaveprop.Weather.fill_grid_cache_from_scalar/1`
  on every fill.

  ## Fill coordination

  Cache fills run `ScalarFile.read_bounds/2` which gunzips and decodes
  per-chunk files (~15 s for a full CONUS valid_time). The
  `:weather_grid_fill_locks` ETS table (kept regardless of backend)
  stops N concurrent /weather mounts on a single pod from each firing
  the slow read. Multi-pod duplicate fills are tolerated: the second
  pod's writes overwrite identical bytes in Valkey, no data corruption.
  """
  use GenServer

  alias Microwaveprop.Valkey
  alias Phoenix.PubSub

  require Logger

  @table :weather_grid_cache
  @lock_table :weather_grid_fill_locks
  @topic "weather:cache"
  @pubsub Microwaveprop.PubSub
  @chunk_step 5

  # Per-chunk TTL in Valkey. 8 hours covers analysis + 7 forecast hours
  # (matches the ETS valid_time cap). Beyond 8 h, fall back to disk.
  @ttl_seconds 8 * 3600

  @valkey_index_key "prop:wg:vts"
  @valkey_chunk_prefix "prop:wg:"

  @type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => any()}
  @type bounds :: %{optional(String.t()) => float()}

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

  # ---------- Public API ----------

  @spec fetch(DateTime.t()) :: {:ok, [row()]} | :miss
  def fetch(valid_time) do
    if valkey_backend?() do
      valkey_fetch(valid_time)
    else
      ets_fetch(valid_time)
    end
  end

  @spec fetch_bounds(DateTime.t(), bounds() | nil) :: {:ok, [row()]} | :miss
  def fetch_bounds(valid_time, bounds) do
    if valkey_backend?() do
      valkey_fetch_bounds(valid_time, bounds)
    else
      ets_fetch_bounds(valid_time, bounds)
    end
  end

  @spec fetch_point(DateTime.t(), float(), float()) :: {:ok, row()} | :miss
  def fetch_point(valid_time, lat, lon) do
    if valkey_backend?() do
      valkey_fetch_point(valid_time, lat, lon)
    else
      ets_fetch_point(valid_time, lat, lon)
    end
  end

  @spec put(DateTime.t(), [row()]) :: :ok
  def put(valid_time, rows) do
    chunked = list_to_chunks(rows)

    if valkey_backend?() do
      valkey_put(valid_time, chunked)
    else
      :ets.insert(@table, {valid_time, chunked})
      :ok
    end
  end

  @doc """
  Insert (Valkey path) or insert + broadcast (ETS path). Kept as a single
  public name so legacy callers don't have to branch.
  """
  @spec broadcast_put(DateTime.t(), [row()]) :: :ok
  def broadcast_put(valid_time, rows) do
    if valkey_backend?() do
      # Valkey is the single source of truth — peers will read the same
      # data on their next fetch. Broadcasting becomes redundant.
      put(valid_time, rows)
    else
      _ = PubSub.broadcast(@pubsub, @topic, {:weather_cache_refresh, valid_time, rows})
      :ok
    end
  end

  @spec latest_valid_time() :: DateTime.t() | nil
  def latest_valid_time do
    if valkey_backend?() do
      valkey_latest_valid_time()
    else
      ets_latest_valid_time()
    end
  end

  @spec prune_older_than(DateTime.t()) :: non_neg_integer()
  def prune_older_than(cutoff) do
    if valkey_backend?() do
      valkey_prune_older_than(cutoff)
    else
      match_spec = [{{:"$1", :_}, [{:<, :"$1", {:const, cutoff}}], [true]}]
      :ets.select_delete(@table, match_spec)
    end
  end

  @doc """
  Keep only the `keep` most-recent valid_times, dropping the rest. On
  Valkey this is a no-op because per-key TTL handles eviction; the ETS
  path is unchanged.
  """
  @spec prune_keep_latest(non_neg_integer()) :: non_neg_integer()
  def prune_keep_latest(keep) when is_integer(keep) and keep >= 0 do
    if valkey_backend?() do
      0
    else
      ets_prune_keep_latest(keep)
    end
  end

  @spec clear() :: :ok
  def clear, do: GenServer.call(__MODULE__, :clear)

  @spec claim_fill(term()) :: boolean()
  def claim_fill(valid_time) do
    GenServer.call(__MODULE__, {:claim_fill, valid_time, self()})
  end

  @spec release_fill(term()) :: :ok
  def release_fill(valid_time) do
    GenServer.call(__MODULE__, {:release_fill, valid_time})
  end

  @spec sync() :: :ok
  def sync, do: GenServer.call(__MODULE__, :sync)

  # ---------- GenServer ----------

  @impl true
  def init(_opts) do
    # ETS table is created unconditionally so the legacy backend (and
    # tests that bypass Valkey) work without separate setup. The lock
    # table is also unconditional — fill coordination still happens
    # per-pod regardless of where the data lives.
    _ = :ets.new(@table, [:set, :named_table, :public, :compressed, read_concurrency: true])
    _ = :ets.new(@lock_table, [:set, :named_table, :protected])
    :ok = PubSub.subscribe(@pubsub, @topic)
    {:ok, %{monitors: %{}}}
  end

  @impl true
  def handle_call(:sync, _from, state), do: {:reply, :ok, state}

  def handle_call(:clear, _from, state) do
    if valkey_backend?(), do: valkey_clear(), else: :ets.delete_all_objects(@table)

    for {ref, _vt} <- state.monitors, do: Process.demonitor(ref, [:flush])
    :ets.delete_all_objects(@lock_table)

    {:reply, :ok, %{state | monitors: %{}}}
  end

  def handle_call({:claim_fill, valid_time, caller}, _from, state) do
    if :ets.insert_new(@lock_table, {valid_time, caller}) do
      ref = Process.monitor(caller)
      {:reply, true, %{state | monitors: Map.put(state.monitors, ref, valid_time)}}
    else
      {:reply, false, state}
    end
  end

  def handle_call({:release_fill, valid_time}, _from, state) do
    {monitors, _matched} = pop_monitor_for(state.monitors, valid_time)
    :ets.delete(@lock_table, valid_time)
    {:reply, :ok, %{state | monitors: monitors}}
  end

  @impl true
  def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
    case Map.pop(state.monitors, ref) do
      {nil, _} ->
        {:noreply, state}

      {valid_time, monitors} ->
        :ets.delete(@lock_table, valid_time)
        {:noreply, %{state | monitors: monitors}}
    end
  end

  def handle_info({:weather_cache_refresh, valid_time, rows}, state) do
    # Only meaningful on the ETS backend. With Valkey, broadcasting was
    # disabled at the source and this handler will simply not fire.
    if !valkey_backend?(), do: put(valid_time, rows)
    {:noreply, state}
  end

  def handle_info(_msg, state), do: {:noreply, state}

  defp pop_monitor_for(monitors, valid_time) do
    case Enum.find(monitors, fn {_ref, vt} -> vt == valid_time end) do
      nil ->
        {monitors, nil}

      {ref, ^valid_time} ->
        Process.demonitor(ref, [:flush])
        {Map.delete(monitors, ref), ref}
    end
  end

  # ---------- Backend selector ----------

  defp valkey_backend?, do: Valkey.configured?()

  # ---------- ETS backend ----------

  defp ets_fetch(valid_time) do
    case :ets.lookup(@table, valid_time) do
      [{_, chunked}] ->
        emit_lookup(true)
        {:ok, chunks_to_list(chunked)}

      [] ->
        emit_lookup(false)
        :miss
    end
  end

  defp ets_fetch_bounds(valid_time, bounds) do
    case :ets.lookup(@table, valid_time) do
      [{_, chunked}] ->
        emit_lookup(true)
        {:ok, chunks_filtered_to_list(chunked, bounds)}

      [] ->
        emit_lookup(false)
        :miss
    end
  end

  defp ets_fetch_point(valid_time, lat, lon) do
    case :ets.lookup(@table, valid_time) do
      [{_, chunked}] ->
        chunk_key = chunk_key_for(lat, lon)

        case chunked |> Map.get(chunk_key, %{}) |> Map.get({lat, lon}) do
          nil ->
            emit_lookup(false)
            :miss

          row ->
            emit_lookup(true)
            {:ok, row}
        end

      [] ->
        emit_lookup(false)
        :miss
    end
  end

  defp ets_latest_valid_time do
    match_spec = [{{:"$1", :_}, [], [:"$1"]}]

    case :ets.select(@table, match_spec) do
      [] -> nil
      times -> Enum.max(times, DateTime)
    end
  end

  defp ets_prune_keep_latest(keep) do
    case :ets.select(@table, [{{:"$1", :_}, [], [:"$1"]}]) do
      [] ->
        0

      times ->
        sorted = Enum.sort(times, {:desc, DateTime})
        to_drop = Enum.drop(sorted, keep)
        Enum.each(to_drop, &:ets.delete(@table, &1))
        length(to_drop)
    end
  end

  # ---------- Valkey backend ----------

  defp valkey_fetch(valid_time) do
    case Valkey.scan_match(chunk_key_pattern(valid_time)) do
      {:ok, []} ->
        emit_lookup(false)
        :miss

      {:ok, keys} ->
        merge_fetched_chunks(keys)

      {:error, reason} ->
        log_valkey_error(:scan, reason)
        :miss
    end
  end

  defp merge_fetched_chunks(keys) do
    case Valkey.mget(keys) do
      {:ok, vals} ->
        chunks =
          vals
          |> Enum.reject(&is_nil/1)
          |> Enum.reduce(%{}, &Map.merge(&2, decode_chunk_value(&1)))

        if map_size(chunks) == 0 do
          emit_lookup(false)
          :miss
        else
          emit_lookup(true)
          {:ok, chunks_to_list(chunks)}
        end

      {:error, reason} ->
        log_valkey_error(:fetch, reason)
        :miss
    end
  end

  defp valkey_fetch_bounds(valid_time, nil), do: valkey_fetch(valid_time)

  defp valkey_fetch_bounds(valid_time, %{"south" => _, "north" => _, "west" => _, "east" => _} = bounds) do
    keys = chunk_keys_in_bounds(valid_time, bounds)

    case Valkey.mget(keys) do
      {:ok, vals} ->
        rows = collect_rows_in_bounds(vals, bounds)

        if rows == [] do
          emit_lookup(false)
          :miss
        else
          emit_lookup(true)
          {:ok, rows}
        end

      {:error, reason} ->
        log_valkey_error(:mget, reason)
        :miss
    end
  end

  defp collect_rows_in_bounds(vals, bounds) do
    vals
    |> Enum.reject(&is_nil/1)
    |> Enum.flat_map(fn cells ->
      cells |> decode_chunk_value() |> Enum.flat_map(&cells_for_chunk_in_bounds(&1, bounds))
    end)
  end

  defp cells_for_chunk_in_bounds({_chunk_key, cell_map}, bounds) do
    cells_in_bounds(cell_map, bounds)
  end

  defp valkey_fetch_point(valid_time, lat, lon) do
    chunk_key = chunk_key_for(lat, lon)
    redis_key = chunk_redis_key(valid_time, chunk_key)

    case Valkey.get(redis_key) do
      {:ok, %{} = chunk_map} ->
        case Map.get(chunk_map, {lat, lon}) do
          nil ->
            emit_lookup(false)
            :miss

          row ->
            emit_lookup(true)
            {:ok, row}
        end

      :miss ->
        emit_lookup(false)
        :miss

      {:error, reason} ->
        log_valkey_error(:get, reason)
        :miss
    end
  end

  defp valkey_put(valid_time, chunked) do
    pairs =
      Enum.map(chunked, fn {chunk_key, cells} ->
        {chunk_redis_key(valid_time, chunk_key), %{chunk_key => cells}}
      end)

    case Valkey.mset_with_ttl(pairs, @ttl_seconds) do
      :ok ->
        _ = Valkey.zadd(@valkey_index_key, DateTime.to_unix(valid_time), DateTime.to_iso8601(valid_time))
        :ok

      {:error, reason} ->
        log_valkey_error(:mset, reason)
        :ok
    end
  end

  defp valkey_latest_valid_time do
    case Valkey.zrevrange(@valkey_index_key, 0, 0) do
      {:ok, [iso | _]} ->
        case DateTime.from_iso8601(iso) do
          {:ok, dt, _} -> dt
          _ -> nil
        end

      _ ->
        nil
    end
  end

  defp valkey_prune_older_than(cutoff) do
    cutoff_score = DateTime.to_unix(cutoff)

    case Valkey.zrangebyscore(@valkey_index_key, "-inf", "(#{cutoff_score}") do
      {:ok, []} ->
        0

      {:ok, members} ->
        keys = Enum.flat_map(members, &chunk_keys_for_iso/1)
        _ = Valkey.del(keys)
        _ = Valkey.zrem(@valkey_index_key, members)
        length(members)

      _ ->
        0
    end
  end

  defp chunk_keys_for_iso(iso) do
    case DateTime.from_iso8601(iso) do
      {:ok, dt, _} ->
        case Valkey.scan_match(chunk_key_pattern(dt)) do
          {:ok, ks} -> ks
          _ -> []
        end

      _ ->
        []
    end
  end

  defp valkey_clear do
    case Valkey.scan_match("#{@valkey_chunk_prefix}*") do
      {:ok, keys} -> Valkey.del(keys)
      _ -> :ok
    end

    Valkey.del([@valkey_index_key])
  end

  defp chunk_key_pattern(valid_time) do
    "#{@valkey_chunk_prefix}#{DateTime.to_iso8601(valid_time)}:*"
  end

  defp chunk_redis_key(valid_time, {lat_band, lon_band}) do
    "#{@valkey_chunk_prefix}#{DateTime.to_iso8601(valid_time)}:#{lat_band}:#{lon_band}"
  end

  defp chunk_keys_in_bounds(valid_time, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
    s_band = chunk_band(s * 1.0)
    n_band = chunk_band(n * 1.0)
    w_band = chunk_band(w * 1.0)
    e_band = chunk_band(e * 1.0)

    for lat <- s_band..n_band, lon <- w_band..e_band do
      chunk_redis_key(valid_time, {lat, lon})
    end
  end

  defp cells_in_bounds(cell_map, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
    for {{lat, lon}, row} <- cell_map, lat >= s, lat <= n, lon >= w, lon <= e, do: row
  end

  # The chunk value persisted to Valkey is `%{chunk_key => cells_map}`
  # so the decoder can return the cells to a uniform shape regardless of
  # whether we read 1 chunk (fetch_point/fetch_bounds) or many (fetch).
  defp decode_chunk_value(%{} = single_chunk_map), do: single_chunk_map
  defp decode_chunk_value(_), do: %{}

  defp log_valkey_error(op, reason) do
    Logger.warning("GridCache Valkey #{op} failed (#{inspect(reason)}); falling back to disk.")
  end

  defp emit_lookup(hit) do
    :telemetry.execute([:microwaveprop, :weather, :grid_cache, :lookup], %{}, %{hit: hit})
  end

  # ---------- Chunk helpers (shared) ----------

  defp list_to_chunks(rows) do
    Enum.reduce(rows, %{}, fn %{lat: lat, lon: lon} = row, acc ->
      key = chunk_key_for(lat, lon)
      chunk = Map.get(acc, key, %{})
      Map.put(acc, key, Map.put(chunk, {lat, lon}, row))
    end)
  end

  defp chunks_to_list(chunked) do
    Enum.flat_map(chunked, fn {_chunk_key, cells} ->
      Enum.map(cells, fn {_, row} -> row end)
    end)
  end

  defp chunks_filtered_to_list(chunked, nil), do: chunks_to_list(chunked)

  defp chunks_filtered_to_list(chunked, %{"south" => s, "north" => n, "west" => w, "east" => e} = bounds) do
    chunked
    |> Enum.filter(fn {chunk_key, _} -> chunk_intersects_bounds?(chunk_key, bounds) end)
    |> Enum.flat_map(fn {_, cells} ->
      for {{lat, lon}, row} <- cells, lat >= s, lat <= n, lon >= w, lon <= e, do: row
    end)
  end

  defp chunk_key_for(lat, lon) do
    {chunk_band(lat * 1.0), chunk_band(lon * 1.0)}
  end

  defp chunk_band(value) when is_float(value) do
    (value / @chunk_step) |> Float.floor() |> trunc()
  end

  defp chunk_intersects_bounds?({lat_band, lon_band}, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
    chunk_south = lat_band * @chunk_step
    chunk_north = chunk_south + @chunk_step
    chunk_west = lon_band * @chunk_step
    chunk_east = chunk_west + @chunk_step

    chunk_north >= s and chunk_south <= n and chunk_east >= w and chunk_west <= e
  end
end
