defmodule Microwaveprop.Propagation.NotifyListener do
  @moduledoc """
  Listens for `NOTIFY propagation_ready '<iso valid_time>'` from the Rust
  `prop-grid-rs` worker. When a notification arrives, warms
  `ScoreCache` for every band at that `valid_time` (reading from the
  shared NFS `/data/scores/<band>/<iso>.prop` tree that Rust just
  wrote) and fans out the standard `"propagation:updated"` PubSub so
  LiveView clients refresh without polling.

  No-op under `testing: :inline` Oban / when the Postgrex.Notifications
  supervisor isn't started — the listener is additive; shadow mode and
  unit tests don't need it running.
  """

  use GenServer

  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.ScoreCache
  alias Microwaveprop.Repo
  alias Microwaveprop.Weather

  require Logger

  @channel "propagation_ready"
  @pubsub Microwaveprop.PubSub
  @topic "propagation:updated"

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

  @impl true
  def init(_opts) do
    send(self(), :subscribe)
    {:ok, %{ref: nil, pid: nil}}
  end

  @impl true
  def handle_info(:subscribe, state) do
    case start_notifications_conn() do
      {:ok, pid, ref} ->
        {:noreply, %{state | pid: pid, ref: ref}}

      {:error, reason} ->
        Logger.warning("NotifyListener: failed to subscribe to #{@channel}: #{inspect(reason)}")
        # Retry in 30s. An unreachable DB at boot shouldn't wedge the
        # supervisor tree.
        Process.send_after(self(), :subscribe, 30_000)
        {:noreply, state}
    end
  end

  @impl true
  def handle_info({:notification, _pid, _ref, @channel, payload}, state) do
    case DateTime.from_iso8601(payload) do
      {:ok, valid_time, _} ->
        handle_propagation_ready(valid_time)

      _ ->
        Logger.warning("NotifyListener: malformed #{@channel} payload: #{inspect(payload)}")
    end

    {:noreply, state}
  end

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

  defp start_notifications_conn do
    config = Application.fetch_env!(:microwaveprop, Repo)

    case Postgrex.Notifications.start_link(config) do
      {:ok, pid} ->
        case Postgrex.Notifications.listen(pid, @channel) do
          {:ok, ref} ->
            {:ok, pid, ref}

          other ->
            {:error, other}
        end

      other ->
        other
    end
  end

  @doc """
  Prune ScoreCache entries outside the active forecast window, then
  broadcast `propagation_updated` to PubSub so LiveView clients refresh.

  Note: this no longer eagerly materialises every band's 92k-cell grid
  into ETS. Each entry weighed ~1.94 MiB and 23 bands × 19 forecast
  hours kept hot pods around 850 MiB of redundant cache (data already
  on NFS as `.prop` files), tripping the 6 GiB OOM. LiveView callers
  now lazy-fill via `Propagation.scores_at/3` on first access; the
  ScoreCache cap (`ScoreCache.max_entries/0`) keeps the working set
  bounded thereafter.

  Public so tests can exercise the path without standing up the
  Postgrex.Notifications subscriber.
  """
  @spec handle_propagation_ready(DateTime.t()) :: :ok
  def handle_propagation_ready(valid_time) do
    {past, future} = Propagation.hot_cache_window()
    ScoreCache.prune_outside_window(past, future)

    kickoff_scalar_materialization(valid_time)

    _ = Phoenix.PubSub.broadcast(@pubsub, @topic, {:propagation_updated, [valid_time]})

    Logger.info("NotifyListener: broadcast propagation_updated for valid_time=#{valid_time}")

    :ok
  end

  # Kick off `Weather.materialize_scalar_file/1` in a detached Task so the
  # NotifyListener GenServer never blocks on NFS I/O. The materializer is
  # itself idempotent and self-rescuing, so failures only land in logs.
  defp kickoff_scalar_materialization(valid_time) do
    {:ok, _pid} =
      Task.start(fn ->
        Weather.materialize_scalar_file(valid_time)
      end)

    :ok
  end
end
