defmodule Microwaveprop.Propagation.ProfilesFile do
  @moduledoc """
  On-disk store for the raw, enriched HRRR grid_data that
  `PropagationGridWorker` produces for the f00 analysis hour. One
  compressed ETF file per `valid_time` lands at
  `{base_dir}/profiles/{iso}.etf.gz`. Used by `Propagation.point_detail/4`
  to rebuild the factor breakdown for a clicked cell without a database
  round trip.

  Only f00 is persisted — forecast hours intentionally skip the factor
  breakdown on the map to keep the scoring+upsert phase under a minute
  (see `Propagation.compute_scores/3`).

  ## Atomic writes

  Files are written through `rename(2)`: `path.tmp.<uniq>` → rename to
  the final path. On the shared NFS mount the rename is atomic, so a
  concurrent reader sees either the old file, the new file, or nothing —
  never a partial write.
  """

  alias Microwaveprop.Propagation.Grid

  require Logger

  @doc "Base directory the profile store lives under."
  @spec base_dir() :: String.t()
  def base_dir do
    Path.join(
      Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores"),
      "profiles"
    )
  end

  @doc "Absolute path for the Elixir-written ETF profile file covering `valid_time`."
  @spec path_for(DateTime.t()) :: String.t()
  def path_for(%DateTime{} = valid_time) do
    Path.join(base_dir(), "#{iso_key(valid_time)}.etf.gz")
  end

  @doc """
  Absolute path for the Rust-written MessagePack profile file covering
  `valid_time`. Rust's `prop_grid_rs::profiles_file::write_atomic`
  lands here. Reader prefers `.mp.gz` when both formats coexist during
  Phase 3 Stream A cutover.
  """
  @spec mp_path_for(DateTime.t()) :: String.t()
  def mp_path_for(%DateTime{} = valid_time) do
    Path.join(base_dir(), "#{iso_key(valid_time)}.mp.gz")
  end

  defp iso_key(%DateTime{} = valid_time) do
    valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
  end

  # Keys Rust writes as strings; Elixir callers read them as atoms.
  # Anything outside this whitelist stays a string and is ignored by
  # downstream consumers (weather.ex uses the atom form throughout).
  @mp_atom_keys MapSet.new([
                  "native_min_gradient",
                  "best_duct_freq_ghz",
                  "max_duct_thickness_m",
                  "duct_count",
                  "ducts",
                  "base_m",
                  "top_m",
                  "thickness_m",
                  "m_deficit",
                  "min_freq_ghz",
                  "surface_temp_c",
                  "surface_dewpoint_c",
                  "surface_pressure_mb",
                  "surface_refractivity",
                  "hpbl_m",
                  "pwat_mm",
                  "min_refractivity_gradient",
                  "ducting_detected",
                  "duct_characteristics",
                  "nexrad_max_reflectivity_dbz",
                  "commercial_link_degradation",
                  "degradation_db",
                  "baseline_dbm",
                  "current_dbm",
                  "n_links",
                  "profile",
                  "pres_mb",
                  "hght_m",
                  "tmpc",
                  "dwpc"
                ])

  @doc """
  Persist the enriched grid_data (`%{{lat, lon} => profile}`) for
  `valid_time`. Writes compressed ETF; lat/lon keys are rounded to the
  grid step so later point lookups can snap user-provided coordinates.
  """
  @spec write!(DateTime.t(), %{{float(), float()} => map()}) :: :ok
  def write!(%DateTime{} = valid_time, grid_data) when is_map(grid_data) do
    path = path_for(valid_time)
    File.mkdir_p!(Path.dirname(path))

    snapped =
      Map.new(grid_data, fn {{lat, lon}, profile} ->
        {{Float.round(lat, 3), Float.round(lon, 3)}, profile}
      end)

    binary = :erlang.term_to_binary(snapped, [:compressed])

    tmp = path <> ".tmp." <> unique_suffix()
    File.write!(tmp, binary, [:binary])
    File.rename!(tmp, path)
    invalidate_caches_for(valid_time)
    :ok
  end

  @doc """
  Read the full persisted grid_data for `valid_time`. Returns
  `{:ok, %{{lat, lon} => profile}}` or `{:error, :enoent}` if the file
  doesn't exist. Used by `Microwaveprop.Weather` to warm `GridCache`
  on pod startup without touching the database.

  Does *not* pass `[:safe]` to `binary_to_term/1`. The file was written
  by `write!/2` in the same code base, so we trust it. The previous
  `[:safe]` flag was rejecting our own files at app-startup warm time
  because some atoms in the persisted profile (`:base_m`, `:top_m`,
  `:thickness_m`, `:min_freq_ghz`, `:native_min_gradient`, etc.) live
  in modules that aren't eagerly loaded during boot, so they weren't
  in the runtime atom table when `warm_grid_cache_from_latest_profile`
  ran. The fix is not "load more modules eagerly" — it's "trust files
  we wrote ourselves."
  """
  @spec read(DateTime.t()) :: {:ok, %{{float(), float()} => map()}} | {:error, :enoent}
  def read(%DateTime{} = valid_time) do
    # Decoded profile maps are ~10 MB each (92k cells × atmospheric
    # profile). A map click fires `point_detail` → `factors_for` which
    # calls this, and the user scrubbing the forecast timeline hits
    # the same valid_time repeatedly within seconds. Caching the full
    # decoded map for 5s turns scrub clicks into ETS lookups.
    Microwaveprop.Cache.fetch_or_store({__MODULE__, :read, base_dir(), valid_time}, 5_000, fn ->
      do_read(valid_time)
    end)
  end

  defp do_read(valid_time) do
    # Rust-written MessagePack wins over Elixir-written ETF if both
    # exist. After Phase 3 Stream A cutover only `.mp.gz` is produced;
    # the ETF branch remains so pods picking up an older in-flight run
    # during the rollout window still render correctly.
    cond do
      File.exists?(mp_path_for(valid_time)) -> read_mp(valid_time)
      File.exists?(path_for(valid_time)) -> read_etf(valid_time)
      true -> {:error, :enoent}
    end
  end

  defp read_etf(valid_time) do
    case File.read(path_for(valid_time)) do
      {:ok, binary} -> {:ok, :erlang.binary_to_term(binary)}
      {:error, _} -> {:error, :enoent}
    end
  end

  defp read_mp(valid_time) do
    with {:ok, gz} <- File.read(mp_path_for(valid_time)),
         binary = :zlib.gunzip(gz),
         {:ok, body} <- Msgpax.unpack(binary) do
      {:ok, decode_mp_body(body)}
    else
      {:error, _} -> {:error, :enoent}
    end
  end

  defp decode_mp_body(%{"cells" => cells}) when is_list(cells) do
    Map.new(cells, fn cell ->
      lat = cell |> Map.get("lat") |> to_float()
      lon = cell |> Map.get("lon") |> to_float()
      profile = cell |> Map.get("profile", %{}) |> normalize_profile()
      {{lat, lon}, profile}
    end)
  end

  defp decode_mp_body(_), do: %{}

  defp normalize_profile(value) when is_map(value) do
    Map.new(value, fn {k, v} ->
      key = if is_binary(k) and MapSet.member?(@mp_atom_keys, k), do: String.to_atom(k), else: k
      {key, normalize_profile(v)}
    end)
  end

  defp normalize_profile(value) when is_list(value) do
    Enum.map(value, &normalize_profile/1)
  end

  defp normalize_profile(value), do: value

  defp to_float(v) when is_float(v), do: v
  defp to_float(v) when is_integer(v), do: v * 1.0
  defp to_float(_), do: 0.0

  @doc """
  Read a single profile for `(valid_time, lat, lon)`. Returns `nil` if
  the file is missing or the point has no profile. Input lat/lon are
  snapped to the nearest grid cell before lookup.
  """
  @spec read_point(DateTime.t(), float(), float()) :: map() | nil
  def read_point(%DateTime{} = valid_time, lat, lon) do
    case read(valid_time) do
      {:ok, grid_data} ->
        {snapped_lat, snapped_lon} = snap(lat, lon)
        Map.get(grid_data, {snapped_lat, snapped_lon})

      {:error, _} ->
        nil
    end
  end

  @doc """
  Delete profile files whose valid_time is strictly before `cutoff`.
  Returns the number of files removed.
  """
  @spec prune_older_than(DateTime.t()) :: non_neg_integer()
  def prune_older_than(%DateTime{} = cutoff) do
    cutoff_unix = DateTime.to_unix(cutoff)

    deleted =
      base_dir()
      |> list_profile_files()
      |> Enum.reduce(0, fn {path, valid_time_unix}, acc ->
        if valid_time_unix < cutoff_unix do
          _ = File.rm(path)
          acc + 1
        else
          acc
        end
      end)

    if deleted > 0, do: invalidate_all_caches()
    deleted
  end

  @doc """
  Keep only profile files whose valid_time is inside the closed window
  `[run_time, run_time + max_forecast_hour * 3600]`. Mirrors
  `ScoresFile.retain_window/2` and is called at the end of a
  `PropagationGridWorker` chain.
  """
  @spec retain_window(DateTime.t(), non_neg_integer()) :: non_neg_integer()
  def retain_window(%DateTime{} = run_time, max_forecast_hour) when max_forecast_hour >= 0 do
    lo = DateTime.to_unix(run_time)
    hi = lo + max_forecast_hour * 3600

    deleted =
      base_dir()
      |> list_profile_files()
      |> Enum.reduce(0, fn {path, valid_time_unix}, acc ->
        if valid_time_unix < lo or valid_time_unix > hi do
          _ = File.rm(path)
          acc + 1
        else
          acc
        end
      end)

    if deleted > 0, do: invalidate_all_caches()
    deleted
  end

  @doc """
  Latest valid_time of any persisted profile, or nil if the store is
  empty.
  """
  @spec latest_valid_time() :: DateTime.t() | nil
  def latest_valid_time do
    case list_profile_files(base_dir()) do
      [] ->
        nil

      files ->
        files
        |> Enum.map(fn {_path, unix} -> unix end)
        |> Enum.max()
        |> DateTime.from_unix!()
    end
  end

  @doc """
  Every persisted valid_time, sorted ascending. Used to build the
  /weather forecast timeline from the on-disk f00..f18 profile files.
  """
  @spec list_valid_times() :: [DateTime.t()]
  def list_valid_times do
    # Hit on every point_detail click when the clicked valid_time
    # lacks its own profile and fallback scan searches for the
    # nearest past analysis. NFS dir-listing on every click was a
    # visible chunk of click latency.
    Microwaveprop.Cache.fetch_or_store({__MODULE__, :list_valid_times, base_dir()}, 5_000, fn ->
      base_dir()
      |> list_profile_files()
      |> Enum.map(fn {_path, unix} -> unix end)
      |> Enum.uniq()
      |> Enum.map(&DateTime.from_unix!/1)
      |> Enum.sort(DateTime)
    end)
  end

  defp invalidate_caches_for(valid_time) do
    dir = base_dir()
    Microwaveprop.Cache.invalidate({__MODULE__, :read, dir, valid_time})
    Microwaveprop.Cache.invalidate({__MODULE__, :list_valid_times, dir})
    :ok
  end

  defp invalidate_all_caches do
    # Match-delete every cached entry for this module without clobbering
    # keys owned by other modules.
    :ets.match_delete(:microwaveprop_cache, {{__MODULE__, :read, :_, :_}, :_, :_})
    Microwaveprop.Cache.invalidate({__MODULE__, :list_valid_times, base_dir()})
    :ok
  end

  defp snap(lat, lon) do
    step = Grid.step()
    snapped_lat = Float.round(Float.round(lat / step) * step, 3)
    snapped_lon = Float.round(Float.round(lon / step) * step, 3)
    {snapped_lat, snapped_lon}
  end

  defp list_profile_files(dir) do
    case File.ls(dir) do
      {:ok, entries} ->
        for entry <- entries,
            valid_time_unix = parse_valid_time(entry),
            valid_time_unix != nil do
          {Path.join(dir, entry), valid_time_unix}
        end

      _ ->
        []
    end
  end

  defp parse_valid_time(filename) do
    # Matches both `.etf.gz` (Elixir legacy) and `.mp.gz` (Rust Phase 3
    # Stream A). If both exist for the same valid_time the directory
    # listing yields two entries with the same timestamp; the pipeline
    # downstream of list_valid_times/0 uniq-sorts, and read/1 prefers
    # the mp.gz variant via mp_path_for/1.
    with [_, iso, _fmt] <- Regex.run(~r/^(.+)\.(etf|mp)\.gz$/, filename),
         {:ok, dt, _} <- DateTime.from_iso8601(iso) do
      DateTime.to_unix(dt)
    else
      _ -> nil
    end
  end

  defp unique_suffix do
    "#{System.system_time(:nanosecond)}.#{:erlang.unique_integer([:positive])}"
  end
end
