defmodule Microwaveprop.Workers.RoverPathProfileWorker do
  @moduledoc """
  Pre-computes one rover-planning path so the live `/path` page can
  render instantly when the operator clicks through. Calls the same
  `Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path`
  uses (terrain analysis, 9 HRRR profile samples, sounding +
  ionosphere readouts, scoring, link + power budgets, forecast) and
  stores the full output on the matching `RoverPlanning.Path`.

  The map is persisted in two parallel forms:
    * Flat string-keyed fields (`distance_km`, `verdict`,
      `propagation_score`, `total_baseline_loss_db`, `path_points`,
      …) — what the rover-planning show table reads at-a-glance.
    * `term` — the entire atom-keyed compute output, encoded with
      `:erlang.term_to_binary/1` + Base64. PathLive decodes this when
      handed `?rover_path_id=UUID` and renders without recomputing.
  """
  use Oban.Worker,
    queue: :rover_path,
    max_attempts: 5,
    unique: [
      period: 300,
      keys: [:mission_id, :rover_location_id, :station_id, :band_mhz],
      states: [:available, :scheduled, :executing, :retryable]
    ]

  import Ecto.Query

  alias Microwaveprop.Propagation.PathCompute
  alias Microwaveprop.Propagation.ScoresFile
  alias Microwaveprop.Repo
  alias Microwaveprop.RoverPlanning.Path

  require Logger

  @impl Oban.Worker
  def backoff(%Oban.Job{attempt: attempt}) do
    min(60 * Integer.pow(2, attempt - 1), 3600)
  end

  @impl Oban.Worker
  def perform(%Oban.Job{args: args}) do
    case load_path(args) do
      nil ->
        :ok

      %Path{status: :complete} ->
        :ok

      %Path{} = path ->
        run(path)
    end
  end

  defp load_path(%{
         "mission_id" => mission_id,
         "rover_location_id" => rover_location_id,
         "station_id" => station_id,
         "band_mhz" => band_mhz
       })
       when is_integer(band_mhz) do
    Path
    |> where([p], p.mission_id == ^mission_id)
    |> where([p], p.rover_location_id == ^rover_location_id)
    |> where([p], p.station_id == ^station_id)
    |> where([p], p.band_mhz == ^band_mhz)
    |> preload([:rover_location, :station, :mission])
    |> Repo.one()
  end

  defp load_path(args) do
    Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}")

    nil
  end

  defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
    mark_status(path, :computing)

    band_mhz = path.band_mhz || mission.band_mhz
    src_height_ft = mission.rover_height_ft || 8.0
    dst_height_ft = mission.station_height_ft || 30.0

    station_params = %{
      src_height_ft: src_height_ft,
      dst_height_ft: dst_height_ft,
      src_height_m: src_height_ft * 0.3048,
      dst_height_m: dst_height_ft * 0.3048,
      # Defaults match PathLive's form fallback (10 W TX, 30 dBi gains
      # at each end). Operators can override on the live `/path` page;
      # the cached snapshot uses these so power-budget numbers are
      # representative for a typical microwave home station.
      tx_power_dbm: 20.0,
      tx_power_mw: :math.pow(10, 20.0 / 10),
      src_gain_dbi: 30.0,
      dst_gain_dbi: 30.0
    }

    src = "#{rover.lat},#{rover.lon}"
    dst = "#{station.lat},#{station.lon}"

    case PathCompute.compute(src, dst, band_mhz, station_params) do
      {:ok, result} ->
        store_complete(path, result, band_mhz, rover, station)

      {:error, reason} ->
        store_failed(path, "compute failed: #{inspect(reason)}")
    end
  rescue
    e ->
      Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
      store_failed(path, "exception: #{Exception.message(e)}")
      reraise e, __STACKTRACE__
  end

  defp store_complete(path, result, band_mhz, rover, station) do
    flat = flat_summary(result, band_mhz, rover, station)

    full_term = result |> :erlang.term_to_binary() |> Base.encode64()

    path
    |> Path.changeset(%{
      status: :complete,
      result: Map.put(flat, "term", full_term),
      error: nil,
      computed_at: DateTime.truncate(DateTime.utc_now(), :second)
    })
    |> Repo.update()
    |> case do
      {:ok, updated} ->
        broadcast(updated)
        :ok

      {:error, cs} ->
        {:error, "store update failed: #{inspect(cs.errors)}"}
    end
  end

  # The handful of fields the rover-planning show table renders inline
  # (distance, verdict, propagation score, total baseline loss). Lifted
  # out of the full compute output so the table can read them directly
  # without round-tripping the term blob.
  defp flat_summary(result, band_mhz, rover, station) do
    analysis = result.terrain && result.terrain.analysis
    midlat = (rover.lat + station.lat) / 2
    midlon = (rover.lon + station.lon) / 2

    result
    |> geometry_summary(analysis)
    |> Map.merge(loss_summary(result.loss_budget))
    |> Map.put("propagation_score", lookup_propagation_score(band_mhz, midlat, midlon))
    |> Map.put("path_points", path_points(analysis))
  end

  defp geometry_summary(result, nil) do
    %{
      "distance_km" => result.dist_km,
      "bearing_deg" => result.bearing,
      "max_elevation_m" => nil,
      "min_clearance_m" => nil,
      "diffraction_db" => 0.0,
      "fresnel_hit_count" => 0,
      "obstructed_count" => 0,
      "verdict" => nil,
      "sample_count" => nil
    }
  end

  defp geometry_summary(result, analysis) do
    %{
      "distance_km" => result.dist_km,
      "bearing_deg" => result.bearing,
      "max_elevation_m" => analysis.max_elevation_m,
      "min_clearance_m" => analysis.min_clearance_m,
      "diffraction_db" => analysis.diffraction_db,
      "fresnel_hit_count" => analysis.fresnel_hit_count,
      "obstructed_count" => analysis.obstructed_count,
      "verdict" => to_string(analysis.verdict),
      "sample_count" => length(analysis.points)
    }
  end

  defp loss_summary(loss) do
    %{
      "free_space_loss_db" => loss.fspl,
      "oxygen_loss_db" => loss.o2,
      "humidity_loss_db_baseline" => loss.h2o,
      "total_baseline_loss_db" => loss.total
    }
  end

  defp path_points(nil), do: []

  defp path_points(analysis) do
    Enum.map(analysis.points, fn p ->
      %{
        "lat" => p.lat,
        "lon" => p.lon,
        "d" => p.d,
        "dist_km" => p.dist_km,
        "elev" => p.elev,
        "beam" => Map.get(p, :beam, 0.0),
        "r1" => Map.get(p, :r1, 0.0),
        "clearance" => Map.get(p, :clearance, 0.0)
      }
    end)
  end

  defp store_failed(path, message) do
    path
    |> Path.changeset(%{
      status: :failed,
      error: message,
      computed_at: DateTime.truncate(DateTime.utc_now(), :second)
    })
    |> Repo.update()
    |> case do
      {:ok, updated} ->
        broadcast(updated)
        {:error, message}

      {:error, cs} ->
        {:error, "store update failed: #{inspect(cs.errors)}"}
    end
  end

  defp mark_status(path, status) do
    path
    |> Path.changeset(%{status: status})
    |> Repo.update()
    |> case do
      {:ok, _} -> :ok
      _ -> :ok
    end
  end

  defp lookup_propagation_score(band_mhz, lat, lon) do
    case ScoresFile.list_valid_times(band_mhz) do
      [] ->
        nil

      times ->
        now = DateTime.utc_now()
        time = Enum.min_by(times, &abs(DateTime.diff(&1, now)))
        ScoresFile.read_point(band_mhz, time, lat, lon)
    end
  end

  defp broadcast(%Path{mission_id: mission_id} = path) do
    Phoenix.PubSub.broadcast(
      Microwaveprop.PubSub,
      "rover_planning:#{mission_id}",
      {:rover_path_updated, path.id}
    )
  end
end
