defmodule MicrowavepropWeb.ContactLive.Show do
  @moduledoc false
  use MicrowavepropWeb, :live_view

  import MicrowavepropWeb.Components.SkewTChart

  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.Scorer
  alias Microwaveprop.Radio
  alias Microwaveprop.Terrain
  alias Microwaveprop.Terrain.ElevationClient
  alias Microwaveprop.Terrain.TerrainAnalysis
  alias Microwaveprop.Weather
  alias Microwaveprop.Weather.HrrrClient
  alias Microwaveprop.Weather.HrrrNativeProfile
  alias Microwaveprop.Weather.HrrrPointEnqueuer
  alias Microwaveprop.Weather.NarrClient
  alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
  alias Microwaveprop.Workers.NarrFetchWorker
  alias Microwaveprop.Workers.SolarIndexWorker
  alias Microwaveprop.Workers.TerrainProfileWorker

  require Logger

  @earth_radius_m 6_371_000.0
  # T-Mobile CGNAT range — only enqueue enrichment jobs from this subnet
  @enqueue_subnet {172, 56, 0, 0}
  @enqueue_mask 13

  @band_options BandConfig.band_options()
  @mode_options ~w(CW SSB FM FT8 FT4 Q65)

  @impl true
  def mount(%{"id" => id}, session, socket) do
    contact = Radio.get_contact!(id)

    if !Radio.can_view?(contact, socket.assigns[:current_scope]) do
      raise Ecto.NoResultsError, queryable: Microwaveprop.Radio.Contact
    end

    contact = Radio.ensure_positions!(contact)
    can_enqueue = internal_network?(session)

    socket =
      assign(socket,
        page_title: "#{contact.station1} / #{contact.station2}",
        contact: contact,
        can_enqueue: can_enqueue,
        surface_observations: [],
        soundings: [],
        sounding_search_radius_km: nil,
        sounding_search_pending?: false,
        solar: nil,
        hrrr: nil,
        hrrr_path: [],
        native_profile: nil,
        narr: nil,
        narr_path: [],
        iemre: nil,
        radar: nil,
        mechanism: nil,
        terrain: nil,
        elevation_profile: nil,
        propagation_analysis: nil,
        data_sources: nil,
        terrain_expanded: false,
        hrrr_profile_expanded: true,
        obs_sort_by: "station_name",
        obs_sort_order: "asc",
        sounding_sort_by: "station_name",
        sounding_sort_order: "asc",
        queue_counts: fetch_queue_counts(),
        expanded_soundings: MapSet.new(),
        hydration_pending: MapSet.new(),
        editing: false,
        edit_form: nil,
        pending_edit: load_pending_edit(contact, socket),
        band_options: @band_options,
        mode_options: @mode_options
      )

    socket =
      if connected?(socket) do
        Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")

        kickoff_hydration(socket, contact)
      else
        socket
      end

    {:ok, socket}
  end

  # Each enrichment source loads in its own task so users see sections appear
  # independently instead of waiting for the slowest one. handle_async/3
  # clauses below update the matching slot and re-run the derived analysis.
  defp kickoff_hydration(socket, contact) do
    socket
    |> assign(:hydration_pending, pending_slots(contact))
    |> start_async(:weather, fn -> load_weather(contact) end)
    |> start_async(:solar, fn -> load_solar(contact) end)
    |> start_async(:hrrr_path, fn -> Weather.hrrr_profiles_for_path(contact) end)
    |> start_async(:narr_path, fn -> Weather.narr_profiles_for_path(contact) end)
    |> start_async(:native_profile, fn -> load_native_profile(contact) end)
    |> start_async(:terrain, fn -> Terrain.get_terrain_profile(contact.id) end)
    |> start_async(:iemre, fn -> load_iemre(contact) end)
    |> start_async(:radar, fn -> load_radar(contact) end)
  end

  # A slot shows its loading spinner only while the corresponding enrichment
  # column on the contact says the work is genuinely in flight. For contacts
  # whose status is :complete (or :pending/:unavailable/:failed), the async
  # query just returns whatever is cached without any intermediate flash.
  defp pending_slots(contact) do
    in_flight = [:queued, :processing]
    slots = []
    slots = if contact.weather_status in in_flight, do: [:weather | slots], else: slots

    slots =
      if contact.hrrr_status in in_flight,
        do: [:hrrr_path, :narr_path, :native_profile | slots],
        else: slots

    slots = if contact.terrain_status in in_flight, do: [:terrain | slots], else: slots
    slots = if contact.iemre_status in in_flight, do: [:iemre | slots], else: slots
    MapSet.new(slots)
  end

  defp mark_hydrated(socket, slot) do
    assign(socket, :hydration_pending, MapSet.delete(socket.assigns.hydration_pending, slot))
  end

  defp atmospheric_loading?(pending) do
    MapSet.member?(pending, :hrrr_path) or
      MapSet.member?(pending, :narr_path) or
      MapSet.member?(pending, :native_profile)
  end

  defp load_radar(contact) do
    import Ecto.Query

    alias Microwaveprop.Propagation.RainScatterClassifier
    alias Microwaveprop.Radio.ContactCommonVolumeRadar
    alias Microwaveprop.Repo

    radar = Repo.get_by(ContactCommonVolumeRadar, contact_id: contact.id)
    hrrr_path = Weather.hrrr_profiles_for_path(contact)
    duct_either? = Enum.any?(hrrr_path, &(&1 && &1.ducting_detected))

    band_mhz = Decimal.to_integer(Decimal.round(contact.band, 0))

    distance_km =
      case contact.distance_km do
        nil -> 0.0
        d -> Decimal.to_float(d)
      end

    mechanism =
      RainScatterClassifier.classify(%{
        band_mhz: band_mhz,
        distance_km: distance_km,
        radar:
          if(radar,
            do: %{
              max_dbz: radar.max_dbz,
              heavy_rain_pixel_count: radar.heavy_rain_pixel_count,
              coverage_pct: radar.coverage_pct
            }
          ),
        duct_either_endpoint: duct_either?
      })

    %{row: radar, mechanism: mechanism}
  end

  # The native HRRR profile gives the skew-T a full-atmosphere trace (50
  # hybrid levels up to ~19 km) for historical hours we've backfilled.
  # Falls back silently to nil when no native profile is on disk; the
  # chart then uses the pressure-level profile instead.
  defp load_native_profile(%{pos1: %{"lat" => lat, "lon" => lon}, qso_timestamp: ts}) do
    Weather.find_nearest_native_profile(lat, lon, ts)
  end

  defp load_native_profile(_), do: nil

  defp load_pending_edit(contact, socket) do
    case socket.assigns do
      %{current_scope: %{user: %{id: user_id}}} ->
        Radio.pending_edit_for_user(contact.id, user_id)

      _ ->
        nil
    end
  end

  defp current_user(%{current_scope: %{user: %{} = user}}), do: user
  defp current_user(_), do: nil

  defp admin?(%{current_scope: %{user: %{is_admin: true}}}), do: true
  defp admin?(_), do: false

  @impl true
  def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do
    {sort_by, sort_order} =
      toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field)

    sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order)

    {:noreply,
     assign(socket,
       surface_observations: sorted,
       obs_sort_by: sort_by,
       obs_sort_order: sort_order
     )}
  end

  def handle_event("toggle_hrrr_profile", _params, socket) do
    {:noreply, assign(socket, hrrr_profile_expanded: !socket.assigns.hrrr_profile_expanded)}
  end

  def handle_event("toggle_terrain", _params, socket) do
    {:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
  end

  def handle_event("toggle_flag", _params, socket) do
    if admin?(socket.assigns.current_scope) do
      contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
      {:noreply, assign(socket, contact: contact)}
    else
      {:noreply, put_flash(socket, :error, "Admins only.")}
    end
  end

  def handle_event("toggle_profile", %{"id" => id}, socket) do
    expanded =
      if MapSet.member?(socket.assigns.expanded_soundings, id) do
        MapSet.delete(socket.assigns.expanded_soundings, id)
      else
        MapSet.put(socket.assigns.expanded_soundings, id)
      end

    {:noreply, assign(socket, expanded_soundings: expanded)}
  end

  def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do
    {sort_by, sort_order} =
      toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field)

    sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order)

    {:noreply,
     assign(socket,
       soundings: sorted,
       sounding_sort_by: sort_by,
       sounding_sort_order: sort_order
     )}
  end

  def handle_event("toggle_edit", _params, socket) do
    if socket.assigns.editing do
      {:noreply, assign(socket, editing: false, edit_form: nil)}
    else
      contact = socket.assigns.contact

      form_data = %{
        "station1" => contact.station1,
        "station2" => contact.station2,
        "grid1" => contact.grid1,
        "grid2" => contact.grid2,
        "band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""),
        "mode" => contact.mode,
        "qso_timestamp" =>
          if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""),
        "height1_ft" => contact.height1_ft,
        "height2_ft" => contact.height2_ft,
        "private" => contact.private
      }

      {:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
    end
  end

  def handle_event("validate_edit", %{"edit" => _params}, socket) do
    {:noreply, socket}
  end

  def handle_event("submit_edit", %{"edit" => params}, socket) do
    user = current_user(socket.assigns)

    if is_nil(user) do
      {:noreply, put_flash(socket, :error, "You must be logged in to edit.")}
    else
      {:noreply, submit_edit(socket, user, params)}
    end
  end

  defp submit_edit(socket, user, params) do
    contact = socket.assigns.contact

    cond do
      admin?(socket.assigns) ->
        apply_admin_edit(socket, contact, params)

      Radio.owner?(contact, user) ->
        apply_owner_edit(socket, contact, user, params)

      true ->
        submit_user_edit(socket, contact, user, params)
    end
  end

  defp apply_admin_edit(socket, contact, params) do
    proposed = Radio.diff_against_contact(contact, Radio.normalize_proposed(params))

    if proposed == %{} do
      put_flash(socket, :error, "No changes detected.")
    else
      Radio.apply_edit_to_contact(contact, proposed)
      updated = Radio.get_contact!(contact.id)

      socket
      |> assign(contact: updated, editing: false, edit_form: nil)
      |> put_flash(:info, "Contact updated.")
    end
  end

  defp apply_owner_edit(socket, contact, user, params) do
    case Radio.apply_owner_edit(contact, user, params) do
      {:ok, _updated} ->
        updated = Radio.get_contact!(contact.id)

        socket
        |> assign(contact: updated, editing: false, edit_form: nil)
        |> put_flash(:info, "Contact updated.")

      {:error, :no_changes} ->
        put_flash(socket, :error, "No changes detected.")

      {:error, :not_owner} ->
        # Shouldn't happen because we checked owner? before dispatching,
        # but guard anyway so the LiveView doesn't blow up on stale state.
        put_flash(socket, :error, "You can only edit contacts you submitted.")
    end
  end

  defp submit_user_edit(socket, contact, user, params) do
    case Radio.create_contact_edit(contact, user, params) do
      {:ok, _edit} ->
        socket
        |> assign(editing: false, edit_form: nil, pending_edit: Radio.pending_edit_for_user(contact.id, user.id))
        |> put_flash(:info, "Edit submitted for review.")

      {:error, changeset} ->
        messages =
          changeset
          |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
          |> Enum.flat_map(fn {_field, msgs} -> msgs end)
          |> Enum.join(", ")

        put_flash(socket, :error, "Could not submit edit: #{messages}")
    end
  end

  @impl true
  def handle_async(:weather, {:ok, weather}, socket) do
    contact = socket.assigns.contact

    contact =
      if socket.assigns.can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact

    socket =
      socket
      |> assign(
        contact: contact,
        surface_observations: weather.surface_observations,
        soundings: weather.soundings,
        sounding_search_radius_km: Map.get(weather, :sounding_search_radius_km),
        sounding_search_pending?: false
      )
      |> mark_hydrated(:weather)
      |> maybe_kick_off_sounding_fetch(weather)
      |> refresh_computed()

    {:noreply, socket}
  end

  def handle_async(:solar, {:ok, solar}, socket) do
    solar =
      if socket.assigns.can_enqueue and is_nil(solar) do
        enqueue_missing_solar(socket.assigns.contact)
        nil
      else
        solar
      end

    {:noreply, socket |> assign(:solar, solar) |> mark_hydrated(:solar)}
  end

  def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do
    contact = socket.assigns.contact
    hrrr = List.first(hrrr_path)

    {hrrr, contact} =
      if socket.assigns.can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact}

    socket =
      socket
      |> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path)
      |> mark_hydrated(:hrrr_path)
      |> refresh_computed()

    {:noreply, socket}
  end

  def handle_async(:narr_path, {:ok, narr_path}, socket) do
    narr = List.first(narr_path)
    contact = socket.assigns.contact

    contact =
      if socket.assigns.can_enqueue, do: maybe_enqueue_narr(narr, contact), else: contact

    {:noreply,
     socket
     |> assign(contact: contact, narr: narr, narr_path: narr_path)
     |> mark_hydrated(:narr_path)}
  end

  def handle_async(:terrain, {:ok, terrain}, socket) do
    contact = socket.assigns.contact

    contact =
      if socket.assigns.can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact

    socket =
      socket
      |> assign(contact: contact, terrain: terrain)
      |> mark_hydrated(:terrain)
      |> refresh_computed()

    {:noreply, socket}
  end

  def handle_async(:iemre, {:ok, iemre}, socket) do
    {:noreply, socket |> assign(:iemre, iemre) |> mark_hydrated(:iemre)}
  end

  def handle_async(:radar, {:ok, %{row: row, mechanism: mechanism}}, socket) do
    {:noreply, socket |> assign(radar: row, mechanism: mechanism) |> mark_hydrated(:radar)}
  end

  def handle_async(:native_profile, {:ok, native_profile}, socket) do
    {:noreply, socket |> assign(:native_profile, native_profile) |> mark_hydrated(:native_profile)}
  end

  def handle_async(slot, {:exit, reason}, socket)
      when slot in [:weather, :solar, :hrrr_path, :narr_path, :native_profile, :terrain, :iemre, :radar] do
    Logger.warning("contact hydrate task #{slot} exited: #{inspect(reason)}")
    {:noreply, mark_hydrated(socket, slot)}
  end

  @impl true
  def handle_info({:sounding_fetched, %{contact_id: contact_id}}, socket) do
    if socket.assigns.contact.id == contact_id do
      contact = socket.assigns.contact
      weather = load_weather(contact)

      socket =
        socket
        |> assign(
          soundings: weather.soundings,
          sounding_search_radius_km: weather.sounding_search_radius_km,
          sounding_search_pending?: weather.soundings == []
        )
        |> refresh_computed()

      {:noreply, socket}
    else
      {:noreply, socket}
    end
  end

  def handle_info({:terrain_ready, _contact_id}, socket) do
    contact = %{socket.assigns.contact | terrain_status: :complete}
    terrain = Terrain.get_terrain_profile(contact.id)
    soundings = socket.assigns.soundings
    hrrr_path = Weather.hrrr_profiles_for_path(contact)
    elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
    propagation_analysis = build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)

    {:noreply,
     assign(socket,
       contact: contact,
       terrain: terrain,
       elevation_profile: elevation_profile,
       propagation_analysis: propagation_analysis
     )}
  end

  # Soundings are sparse, so even the 1000 km widening search can come up
  # empty for a given contact when no RAOB data has been ingested yet.
  # When that happens, enqueue fetches for stations at the widest radius
  # that has any; the WeatherFetchWorker broadcasts back via
  # `contact_enrichment:<contact_id>` so this LiveView rehydrates when
  # each sounding lands.
  defp maybe_kick_off_sounding_fetch(socket, weather) do
    contact = socket.assigns.contact

    cond do
      not socket.assigns.can_enqueue ->
        socket

      not Map.get(weather, :sounding_search_exhausted?, false) ->
        socket

      is_nil(contact.pos1) ->
        socket

      true ->
        case ContactWeatherEnqueueWorker.enqueue_raob_fetch_with_widening(contact) do
          {:ok, %{jobs_enqueued: jobs, radius_km: radius}} when jobs > 0 ->
            assign(socket,
              sounding_search_pending?: true,
              sounding_search_radius_km: radius
            )

          _ ->
            socket
        end
    end
  end

  defp maybe_enqueue_weather(weather, contact) do
    has_data = weather.surface_observations != [] or weather.soundings != []

    cond do
      has_data ->
        if contact.weather_status != :complete do
          Radio.set_enrichment_status!([contact.id], :weather_status, :complete)
        end

        %{contact | weather_status: :complete}

      contact.pos1 && contact.weather_status != :queued ->
        jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])

        if jobs == [] do
          %{contact | weather_status: :unavailable}
        else
          Oban.insert_all(jobs)
          Radio.set_enrichment_status!([contact.id], :weather_status, :queued)
          %{contact | weather_status: :queued}
        end

      is_nil(contact.pos1) ->
        %{contact | weather_status: :unavailable}

      true ->
        contact
    end
  end

  defp toggle_sort(current_field, current_order, new_field) do
    if current_field == new_field && current_order == "asc",
      do: {new_field, "desc"},
      else: {new_field, "asc"}
  end

  defp sort_observations(observations, sort_by, sort_order) do
    sorter = obs_sort_key(sort_by)
    sorted = Enum.sort_by(observations, sorter)
    if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
  end

  defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end
  defp obs_sort_key("observed_at"), do: & &1.observed_at
  defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0)
  defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0)
  defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0)
  defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0)
  defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end

  defp sort_soundings(soundings, sort_by, sort_order) do
    sorter = sounding_sort_key(sort_by)
    sorted = Enum.sort_by(soundings, sorter)
    if sort_order == "desc", do: Enum.reverse(sorted), else: sorted
  end

  defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end

  defp sounding_sort_key("observed_at"), do: & &1.observed_at
  defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0)
  defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0)
  defp sounding_sort_key("k_index"), do: &(&1.k_index || 0)
  defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
  defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end

  # Rebuild derived analysis from whatever's currently in assigns. Called from
  # each handle_async clause that updates a source slot, so sections that
  # depend on multiple sources (elevation_profile, propagation_analysis,
  # data_sources) refresh as each upstream source arrives.
  defp refresh_computed(socket) do
    %{
      contact: contact,
      hrrr: hrrr,
      hrrr_path: hrrr_path,
      terrain: terrain,
      surface_observations: surface_observations,
      soundings: soundings
    } = socket.assigns

    elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)

    propagation_analysis =
      build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)

    data_sources =
      build_data_sources(
        hrrr,
        hrrr_path,
        terrain,
        %{surface_observations: surface_observations, soundings: soundings},
        elevation_profile
      )

    assign(socket,
      elevation_profile: elevation_profile,
      propagation_analysis: propagation_analysis,
      data_sources: data_sources
    )
  end

  defp load_iemre(contact) do
    case Weather.iemre_for_contact(contact) do
      %{hourly: hourly} = obs when is_list(hourly) and hourly != [] ->
        qso_hour = contact.qso_timestamp.hour
        nearest = Enum.min_by(hourly, fn h -> abs((h["utc_hour"] || 0) - qso_hour) end)
        %{observation: obs, nearest_hour: nearest}

      _ ->
        nil
    end
  end

  defp load_weather(contact) do
    case extract_contact_coords(contact) do
      {:ok, lat1, lon1, lat2, lon2} ->
        fetch_weather_for_path(contact, lat1, lon1, lat2, lon2)

      :error ->
        %{
          surface_observations: [],
          soundings: [],
          sounding_search_exhausted?: false,
          sounding_search_radius_km: nil
        }
    end
  end

  defp extract_contact_coords(contact) do
    lat1 = contact.pos1 && contact.pos1["lat"]
    lon1 = contact.pos1 && contact.pos1["lon"]

    if lat1 && lon1 do
      lat2 = contact.pos2 && contact.pos2["lat"]
      lon2 = contact.pos2 && contact.pos2["lon"]
      {:ok, lat1, lon1, lat2, lon2}
    else
      :error
    end
  end

  defp fetch_weather_for_path(contact, lat1, lon1, lat2, lon2) do
    mid_lat = if lat2, do: (lat1 + lat2) / 2, else: lat1
    mid_lon = if lon2, do: (lon1 + lon2) / 2, else: lon1
    half_dist = if lat2 && lon2, do: haversine_km(lat1, lon1, lat2, lon2) / 2, else: 0
    radius = max(50, half_dist + 50)

    weather =
      Weather.weather_for_contact(
        %{lat: mid_lat, lon: mid_lon, timestamp: contact.qso_timestamp},
        radius_km: radius
      )

    # The surface-observation radius tracks the path geometry, but
    # soundings are sparse (~120/day nationwide) so we widen out to
    # 1000 km before giving up and letting the fetch-on-demand path
    # (handle_async(:soundings_widen)) backfill from IEM.
    sounding_search =
      Weather.soundings_with_widening_radius(%{
        lat: mid_lat,
        lon: mid_lon,
        timestamp: contact.qso_timestamp
      })

    %{
      surface_observations: closest_observations(weather.surface_observations, mid_lat, mid_lon, 5),
      soundings: sounding_search.soundings,
      sounding_search_exhausted?: sounding_search.exhausted,
      sounding_search_radius_km: sounding_search.radius_km
    }
  end

  defp closest_observations(observations, mid_lat, mid_lon, limit) do
    observations
    |> Enum.sort_by(fn obs ->
      abs(obs.station.lat - mid_lat) + abs(obs.station.lon - mid_lon)
    end)
    |> Enum.take(limit)
  end

  defp maybe_enqueue_terrain(terrain, contact) when not is_nil(terrain) do
    if contact.terrain_status != :complete do
      Radio.set_enrichment_status!([contact.id], :terrain_status, :complete)
    end

    %{contact | terrain_status: :complete}
  end

  defp maybe_enqueue_terrain(nil, contact) do
    cond do
      contact.pos1 && contact.pos2 && contact.terrain_status != :queued ->
        case Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id})) do
          {:ok, %{conflict?: false}} ->
            Radio.set_enrichment_status!([contact.id], :terrain_status, :queued)
            %{contact | terrain_status: :queued}

          _ ->
            %{contact | terrain_status: :unavailable}
        end

      is_nil(contact.pos1) or is_nil(contact.pos2) ->
        %{contact | terrain_status: :unavailable}

      true ->
        contact
    end
  end

  defp maybe_enqueue_hrrr(hrrr, contact) when not is_nil(hrrr) do
    if contact.hrrr_status != :complete do
      Radio.set_enrichment_status!([contact.id], :hrrr_status, :complete)
    end

    {hrrr, %{contact | hrrr_status: :complete}}
  end

  defp maybe_enqueue_hrrr(nil, contact) do
    lat = contact.pos1 && contact.pos1["lat"]
    lon = contact.pos1 && contact.pos1["lon"]

    # Always re-enqueue when there's no profile on this page render.
    # The `hrrr_status == :queued` guard used to short-circuit here, but
    # contacts can end up stuck in :queued without a live task row
    # (HrrrPointEnqueuer swallowing an exception, Rust worker marking a
    # task :done with a points array that never contained this contact's
    # point, or a later retention prune removing the row). Re-entering
    # the upsert is idempotent — the ON CONFLICT clause in
    # HrrrPointEnqueuer unions points, resets :done/:failed rows back
    # to :queued, and no-ops if the point + valid_time already match.
    if lat && lon do
      valid_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
      {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)

      # Phase 3 Stream C: route retries through hrrr_fetch_tasks so the
      # Rust hrrr-point-worker picks them up alongside backfill work.
      {:ok, _} = HrrrPointEnqueuer.enqueue(%{valid_time => [{rlat, rlon}]})

      if contact.hrrr_status != :queued do
        Radio.set_enrichment_status!([contact.id], :hrrr_status, :queued)
      end

      {nil, %{contact | hrrr_status: :queued}}
    else
      {nil, contact}
    end
  end

  # NARR is the fallback for contacts where HRRR is unavailable (pre-2014 or
  # missing from the archive). Only enqueue when HRRR is known-unavailable AND
  # we don't already have a NARR profile.
  defp maybe_enqueue_narr(narr, contact) when not is_nil(narr), do: contact

  defp maybe_enqueue_narr(nil, %{hrrr_status: :unavailable} = contact) do
    lat = contact.pos1 && contact.pos1["lat"]
    lon = contact.pos1 && contact.pos1["lon"]
    valid_time = NarrClient.snap_to_analysis_hour(contact.qso_timestamp)

    if lat && lon && NarrClient.in_coverage?(valid_time) do
      %{
        "lat" => lat,
        "lon" => lon,
        "valid_time" => DateTime.to_iso8601(valid_time)
      }
      |> NarrFetchWorker.new()
      |> Oban.insert()
    end

    contact
  end

  defp maybe_enqueue_narr(_nil, contact), do: contact

  defp queue_info(counts, queue) do
    case Map.get(counts, queue, 0) do
      0 -> ""
      1 -> "(processing)"
      n -> "(#{n} jobs ahead)"
    end
  end

  defp fetch_queue_counts do
    Microwaveprop.Cache.fetch_or_store({__MODULE__, :queue_counts}, 5_000, fn ->
      import Ecto.Query

      from(j in Oban.Job,
        where: j.state in ["available", "scheduled", "retryable", "executing"],
        group_by: j.queue,
        select: {j.queue, count(j.id)}
      )
      |> Microwaveprop.Repo.all()
      |> Map.new()
    end)
  end

  defp internal_network?(session) do
    case session["remote_ip"] do
      nil ->
        false

      ip_str ->
        case :inet.parse_address(String.to_charlist(ip_str)) do
          {:ok, {a, b, c, d}} ->
            ip_int = Bitwise.bsl(a, 24) + Bitwise.bsl(b, 16) + Bitwise.bsl(c, 8) + d
            {sa, sb, sc, sd} = @enqueue_subnet
            subnet_int = Bitwise.bsl(sa, 24) + Bitwise.bsl(sb, 16) + Bitwise.bsl(sc, 8) + sd
            mask = Bitwise.bsl(0xFFFFFFFF, 32 - @enqueue_mask)
            Bitwise.band(ip_int, mask) == Bitwise.band(subnet_int, mask)

          _ ->
            false
        end
    end
  end

  defp load_solar(contact) do
    contact.qso_timestamp
    |> DateTime.to_date()
    |> Weather.get_solar_index()
  end

  defp enqueue_missing_solar(contact) do
    date = DateTime.to_date(contact.qso_timestamp)
    Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)}))
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <.header>
        <span class={[@contact.flagged_invalid && "line-through opacity-50"]}>
          {@contact.station1} / {@contact.station2}
        </span>
        <:subtitle>
          {format_band(@contact.band)}
          <%= if @contact.mode do %>
            &middot; {@contact.mode}
          <% end %>
          &middot; {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
          <%= if @contact.flagged_invalid do %>
            <span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
          <% end %>
          <%= if @contact.private do %>
            <span
              class="badge badge-warning badge-sm ml-2"
              title="Only visible to you and administrators"
            >
              <.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private
            </span>
          <% end %>
        </:subtitle>
        <:actions>
          <%= if current_user(assigns) do %>
            <button
              phx-click="toggle_edit"
              class={["btn btn-sm", if(@editing, do: "btn-ghost", else: "btn-outline")]}
            >
              <.icon
                name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
                class="w-4 h-4"
              />
              {cond do
                @editing -> "Cancel"
                admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) -> "Edit"
                true -> "Suggest Edit"
              end}
            </button>
          <% end %>
          <button
            :if={admin?(assigns)}
            phx-click="toggle_flag"
            class={[
              "btn btn-sm",
              if(@contact.flagged_invalid, do: "btn-warning", else: "btn-ghost")
            ]}
          >
            <.icon name="hero-flag" class="w-4 h-4" />
            {if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"}
          </button>
          <.link navigate={path_calc_url(@contact)} class="btn btn-sm btn-outline">
            <.icon name="hero-calculator" class="w-4 h-4" /> Open in Path Calculator
          </.link>
          <.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
            <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Contacts
          </.link>
        </:actions>
      </.header>

      <%= if @pending_edit && !@editing && !admin?(assigns) && !Radio.owner?(@contact, current_user(assigns)) do %>
        <div class="alert alert-info text-sm mb-4">
          <.icon name="hero-clock" class="w-4 h-4 inline" />
          You have a pending edit for this contact awaiting admin review.
        </div>
      <% end %>

      <%= if @editing do %>
        <% direct_edit = admin?(assigns) or Radio.owner?(@contact, current_user(assigns)) %>
        <div class="bg-base-200 rounded-box p-4 mb-4">
          <h3 class="font-semibold mb-3">
            {if direct_edit, do: "Edit Contact", else: "Suggest Edit"}
          </h3>
          <p class="text-sm opacity-70 mb-4">
            {if direct_edit,
              do: "Change any fields below. Changes will be submitted for review.",
              else: "Change any fields below. Only fields you modify will be submitted for review."}
          </p>
          <.form
            for={@edit_form}
            id="edit-form"
            phx-change="validate_edit"
            phx-submit="submit_edit"
            class="space-y-4"
          >
            <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
              <.input field={@edit_form[:station1]} type="text" label="Station 1" />
              <.input field={@edit_form[:grid1]} type="text" label="Grid 1" />
              <.input
                field={@edit_form[:height1_ft]}
                type="number"
                label="Height 1 (ft AGL)"
                min="0"
                max="1000"
                placeholder="Optional"
              />
              <.input field={@edit_form[:station2]} type="text" label="Station 2" />
              <.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
              <.input
                field={@edit_form[:height2_ft]}
                type="number"
                label="Height 2 (ft AGL)"
                min="0"
                max="1000"
                placeholder="Optional"
              />
            </div>
            <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
              <.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} />
              <.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
              <.input
                field={@edit_form[:qso_timestamp]}
                type="text"
                label="Timestamp (UTC, 24h)"
                placeholder="YYYY-MM-DD HH:MM"
                pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
              />
            </div>
            <div class="mt-2">
              <.input
                field={@edit_form[:private]}
                type="checkbox"
                label="Private — only visible to me and administrators"
              />
            </div>
            <div class="flex gap-2">
              <.button type="submit" class="btn btn-primary btn-sm">
                <.icon name="hero-paper-airplane" class="w-4 h-4" />
                {if direct_edit, do: "Save Changes", else: "Submit for Review"}
              </.button>
              <button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
                Cancel
              </button>
            </div>
          </.form>
        </div>
      <% end %>

      <%= if @contact.pos1 && @contact.pos2 do %>
        <p class="text-xs text-base-content/50 italic mb-1">
          Locations approximate, in the center of the grid squares.
          <%= if current_user(assigns) do %>
            Have more detailed position information?
            <button
              type="button"
              phx-click="toggle_edit"
              class="link link-hover not-italic text-base-content/70"
            >
              Suggest an edit
            </button>
          <% else %>
            Have more detailed position information?
            <.link
              navigate={~p"/users/log-in"}
              class="link link-hover not-italic text-base-content/70"
            >
              Log in to suggest an edit
            </.link>
          <% end %>
        </p>
        <div
          id="contact-map"
          phx-hook="ContactMap"
          phx-update="ignore"
          data-pos1={Jason.encode!(@contact.pos1)}
          data-pos2={Jason.encode!(@contact.pos2)}
          data-station1={@contact.station1}
          data-station2={@contact.station2}
          class="w-full h-64 rounded-lg border border-base-300 mb-4"
        />
      <% end %>

      <%= if !@elevation_profile && @contact.terrain_status == :queued do %>
        <div class="flex items-center gap-2 text-sm text-base-content/50 my-4">
          <span class="loading loading-spinner loading-sm"></span>
          Computing elevation profile and terrain analysis {queue_info(@queue_counts, "terrain")}
        </div>
      <% end %>

      <%= if @elevation_profile do %>
        <div class="mb-4">
          <div class="text-sm font-mono mb-2 text-center">
            {format_band(@contact.band)} &middot; {@contact.mode} &middot; {format_dist(
              @elevation_profile.dist_km
            )} &middot; {@contact.grid1 || "—"} &rarr; {@contact.grid2 || "—"}
          </div>
          <table class="text-sm font-mono mb-2 mx-auto">
            <tr>
              <td class="pr-6">{@contact.station1} &rarr; {@contact.station2}</td>
              <td class="pr-6">Az: {format_bearing(@elevation_profile.fwd_az)}</td>
              <td>El: {@elevation_profile.fwd_el}&deg;</td>
            </tr>
            <tr>
              <td class="pr-6">{@contact.station2} &rarr; {@contact.station1}</td>
              <td class="pr-6">Az: {format_bearing(@elevation_profile.rev_az)}</td>
              <td>El: {@elevation_profile.rev_el}&deg;</td>
            </tr>
          </table>

          <%= if @elevation_profile.verdict == "BLOCKED" && @elevation_profile.first_obstruction_km do %>
            <div class="alert alert-error alert-soft py-1 mb-2 text-sm">
              Obstructed at {format_dist(@elevation_profile.first_obstruction_km)}
            </div>
          <% end %>
          <%= if @elevation_profile.verdict == "CLEAR" do %>
            <div class="alert alert-success alert-soft py-1 mb-2 text-sm">
              Line of sight clear
            </div>
          <% end %>

          <div
            id="elevation-profile"
            phx-hook="ElevationProfile"
            phx-update="ignore"
            data-profile={Jason.encode!(@elevation_profile)}
            data-station1={@contact.station1}
            data-station2={@contact.station2}
            class="w-full h-96 border border-base-300 rounded-lg"
          >
            <canvas></canvas>
          </div>
        </div>
      <% end %>

      <%= if @propagation_analysis do %>
        <div class="divider" />

        <h2 class="text-base font-semibold mb-2">Propagation Analysis</h2>
        <div class="text-sm mb-3">
          <p>{@propagation_analysis.summary}</p>
          <%= for detail <- @propagation_analysis.details do %>
            <p class="mt-1 text-base-content/70">{detail}</p>
          <% end %>
        </div>

        <%= if @propagation_analysis.factors do %>
          <div class="overflow-x-auto mb-2">
            <table class="table table-xs table-zebra">
              <thead>
                <tr>
                  <th>Factor</th>
                  <th>Score</th>
                  <th>Weight</th>
                  <th>Contribution</th>
                  <th>Notes</th>
                </tr>
              </thead>
              <tbody>
                <%= for f <- @propagation_analysis.factor_rows do %>
                  <tr>
                    <td class="font-semibold">{f.name}</td>
                    <td>{f.score}/100</td>
                    <td>{f.weight}%</td>
                    <td>{f.contribution}</td>
                    <td class="text-base-content/60">{f.note}</td>
                  </tr>
                <% end %>
              </tbody>
              <tfoot>
                <tr class="font-semibold">
                  <td>Composite</td>
                  <td colspan="2">{@propagation_analysis.composite_score}/100</td>
                  <td colspan="2">
                    <span class={propagation_tier_class(@propagation_analysis.composite_score)}>
                      {propagation_tier_label(@propagation_analysis.composite_score)}
                    </span>
                  </td>
                </tr>
              </tfoot>
            </table>
          </div>
        <% end %>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">Propagation Mechanism</h2>
      <div class="mb-6 border border-base-300 rounded-lg p-4 text-sm">
        <div class="flex items-center gap-3 flex-wrap">
          <span class={["badge badge-sm", mechanism_badge_class(@mechanism)]}>
            {mechanism_label(@mechanism)}
          </span>
          <span class="opacity-70">{mechanism_explainer(@mechanism)}</span>
        </div>
        <%= if @radar do %>
          <div class="mt-3 flex flex-wrap gap-4 text-xs opacity-80">
            <span>
              Common volume: {format_number(@radar.common_volume_km2, 0)} km²
            </span>
            <%= if @radar.max_dbz do %>
              <span>Max reflectivity: {format_number(@radar.max_dbz, 1)} dBZ</span>
            <% end %>
            <span>Heavy-rain pixels: {@radar.heavy_rain_pixel_count}</span>
            <%= if @radar.coverage_pct do %>
              <span>Radar coverage: {format_number(@radar.coverage_pct, 0)}%</span>
            <% end %>
            <span class="opacity-60">
              Frame: {Calendar.strftime(@radar.observed_at, "%Y-%m-%d %H:%M UTC")}
            </span>
          </div>
        <% end %>
      </div>

      <h2 class="text-base font-semibold mb-2" id="terrain-heading">Terrain Profile</h2>
      <%= if @terrain do %>
        <div class="mb-6 border border-base-300 rounded-lg" id="terrain-profile">
          <button
            phx-click="toggle_terrain"
            class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
          >
            <div class="flex items-center gap-4 text-sm flex-wrap">
              <span class={[
                "badge badge-sm",
                terrain_verdict_class(@terrain.verdict)
              ]}>
                {@terrain.verdict}
              </span>
              <span>{@terrain.sample_count} samples</span>
              <span>Max elev: {format_number(@terrain.max_elevation_m)} m</span>
              <span>Min clearance: {format_number(@terrain.min_clearance_m)} m</span>
              <%= if @terrain.diffraction_db && @terrain.diffraction_db > 0 do %>
                <span>Diffraction: {format_number(@terrain.diffraction_db)} dB</span>
              <% end %>
              <%= if @terrain.obstructed_count && @terrain.obstructed_count > 0 do %>
                <span class="text-error">{@terrain.obstructed_count} obstructed</span>
              <% end %>
              <%= if @terrain.fresnel_hit_count && @terrain.fresnel_hit_count > 0 do %>
                <span class="text-warning">{@terrain.fresnel_hit_count} Fresnel hits</span>
              <% end %>
            </div>
            <.icon
              name={if @terrain_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
              class="w-5 h-5"
            />
          </button>

          <%= if @terrain_expanded do %>
            <div class="px-4 pb-4">
              <div class="overflow-x-auto">
                <table class="table table-xs table-zebra">
                  <thead>
                    <tr>
                      <th>#</th>
                      <th>Lat</th>
                      <th>Lon</th>
                      <th>Dist (km)</th>
                      <th>Elev (m)</th>
                    </tr>
                  </thead>
                  <tbody>
                    <%= for {pt, idx} <- Enum.with_index(@terrain.path_points) do %>
                      <tr>
                        <td>{idx}</td>
                        <td>{format_number(pt["lat"])}</td>
                        <td>{format_number(pt["lon"])}</td>
                        <td>{format_number(pt["dist_km"])}</td>
                        <td>{format_number(pt["elev"])}</td>
                      </tr>
                    <% end %>
                  </tbody>
                </table>
              </div>
            </div>
          <% end %>
        </div>
      <% else %>
        <div class="text-sm text-base-content/50 italic">
          <%= cond do %>
            <% MapSet.member?(@hydration_pending, :terrain) -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span> Loading terrain profile…
              </span>
            <% @contact.terrain_status == :queued -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span>
                Computing terrain profile {queue_info(@queue_counts, "terrain")}
              </span>
            <% true -> %>
              No terrain profile available.
          <% end %>
        </div>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">Soundings</h2>
      <%= if @soundings == [] do %>
        <div class="text-sm text-base-content/50 italic">
          <%= cond do %>
            <% MapSet.member?(@hydration_pending, :weather) -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span> Loading soundings…
              </span>
            <% @sounding_search_pending? -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span>
                Fetching soundings from IEM (search radius {@sounding_search_radius_km} km)…
              </span>
            <% @contact.weather_status == :queued -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span>
                Fetching sounding data from nearby stations {queue_info(@queue_counts, "weather")}
              </span>
            <% true -> %>
              No soundings found within 1000 km.
          <% end %>
        </div>
      <% else %>
        <%= for s <- @soundings do %>
          <div class="mb-6 border border-base-300 rounded-lg">
            <button
              phx-click="toggle_profile"
              phx-value-id={s.id}
              class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
            >
              <div class="flex items-center gap-4 text-sm">
                <span class="font-semibold">{s.station.name || s.station.station_code}</span>
                <span>{Calendar.strftime(s.observed_at, "%H:%M UTC")}</span>
                <span>{s.level_count} levels</span>
                <span>Sfc: {format_number(s.surface_temp_c)}°C</span>
                <span>N: {format_number(s.surface_refractivity)}</span>
                <span>K: {format_number(s.k_index)}</span>
                <span>LI: {format_number(s.lifted_index)}</span>
                <%= if s.ducting_detected do %>
                  <span class="badge badge-warning badge-sm">Ducting</span>
                <% end %>
              </div>
              <.icon
                name={
                  if MapSet.member?(@expanded_soundings, s.id),
                    do: "hero-chevron-up",
                    else: "hero-chevron-down"
                }
                class="w-5 h-5"
              />
            </button>

            <%= if MapSet.member?(@expanded_soundings, s.id) do %>
              <div class="px-4 pb-4">
                <div class="overflow-x-auto">
                  <table class="table table-xs table-zebra">
                    <thead>
                      <tr>
                        <th>Pressure (mb)</th>
                        <th>Height (m)</th>
                        <th>Temp (°C)</th>
                        <th>Dewpoint (°C)</th>
                        <th>Wind Dir (°)</th>
                        <th>Wind Spd (kts)</th>
                      </tr>
                    </thead>
                    <tbody>
                      <%= for level <- filter_profile(s.profile) do %>
                        <tr>
                          <td>{format_number(level["pres"])}</td>
                          <td>{format_number(level["hght"])}</td>
                          <td>{format_number(level["tmpc"])}</td>
                          <td>{format_number(level["dwpc"])}</td>
                          <td>{format_number(level["drct"])}</td>
                          <td>{format_number(level["sknt"])}</td>
                        </tr>
                      <% end %>
                    </tbody>
                  </table>
                </div>
              </div>
            <% end %>
          </div>
        <% end %>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
      <%= if @solar do %>
        <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
          <div><span class="font-semibold">SFI:</span> {@solar.sfi || "—"}</div>
          <div><span class="font-semibold">Sunspot #:</span> {@solar.sunspot_number || "—"}</div>
          <div><span class="font-semibold">Ap:</span> {@solar.ap_index || "—"}</div>
          <div><span class="font-semibold">Kp:</span> {format_kp(@solar.kp_values)}</div>
        </div>
      <% else %>
        <div class="text-sm text-base-content/50 italic">
          <span class="flex items-center gap-2">
            <span class="loading loading-spinner loading-sm"></span>
            Fetching solar index data {queue_info(@queue_counts, "solar")}
          </span>
        </div>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">Atmospheric Profile</h2>
      <% profile = @hrrr || @narr %>
      <% profile_source =
        cond do
          @hrrr -> "HRRR (3 km)"
          @narr -> "NARR (32 km)"
          true -> nil
        end %>
      <% skew_t_levels =
        cond do
          @native_profile ->
            HrrrNativeProfile.to_skew_t_profile(@native_profile)

          profile && profile.profile ->
            profile.profile

          true ->
            []
        end %>
      <% skew_t_source =
        cond do
          @native_profile -> "HRRR native (50 hybrid levels)"
          true -> profile_source
        end %>
      <%= if profile do %>
        <div class="mb-6 border border-base-300 rounded-lg">
          <button
            phx-click="toggle_hrrr_profile"
            class="w-full flex items-center justify-between p-4 hover:bg-base-200 transition-colors"
          >
            <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm min-w-0">
              <span class="badge badge-outline badge-sm whitespace-nowrap">{profile_source}</span>
              <span class="font-semibold whitespace-nowrap">
                {format_number(profile.lat)}°, {format_number(profile.lon)}°
              </span>
              <span class="whitespace-nowrap">
                {Calendar.strftime(profile.valid_time, "%H:%M UTC")}
              </span>
              <span class="whitespace-nowrap">HPBL: {format_number(profile.hpbl_m)} m</span>
              <span class="whitespace-nowrap">PWAT: {format_number(profile.pwat_mm)} mm</span>
              <span class="whitespace-nowrap">
                N: {format_number(profile.surface_refractivity)}
              </span>
              <span class="whitespace-nowrap">
                dN/dh: {format_number(profile.min_refractivity_gradient)}
              </span>
              <%= if profile.ducting_detected do %>
                <span class="badge badge-warning badge-sm whitespace-nowrap">Ducting</span>
              <% end %>
            </div>
            <.icon
              name={if @hrrr_profile_expanded, do: "hero-chevron-up", else: "hero-chevron-down"}
              class="w-5 h-5"
            />
          </button>

          <%= if @hrrr_profile_expanded do %>
            <div class="px-4 pb-4">
              <div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm mb-4">
                <div>Surface Temp: {format_number(profile.surface_temp_c)}°C</div>
                <div>Surface Dewpoint: {format_number(profile.surface_dewpoint_c)}°C</div>
                <div>Surface Pressure: {format_number(profile.surface_pressure_mb)} mb</div>
                <div>
                  <%= if run_time = Map.get(profile, :run_time) do %>
                    Run Time: {Calendar.strftime(run_time, "%Y-%m-%d %H:%M UTC")}
                  <% else %>
                    Source: {profile_source} reanalysis
                  <% end %>
                </div>
              </div>
              <%= if skew_t_levels != [] do %>
                <%= if @native_profile do %>
                  <div class="text-xs text-base-content/70 mb-2">
                    Skew-T source: <span class="font-semibold">{skew_t_source}</span>
                  </div>
                <% end %>
                <.skew_t_chart profile={skew_t_levels} />
                <div class="overflow-x-auto mt-4">
                  <table class="table table-xs table-zebra">
                    <thead>
                      <tr>
                        <th>Pressure (mb)</th>
                        <th>Height (m)</th>
                        <th>Temp (°C)</th>
                        <th>Dewpoint (°C)</th>
                      </tr>
                    </thead>
                    <tbody>
                      <%= for level <- skew_t_levels do %>
                        <tr>
                          <td>{format_number(level["pres"])}</td>
                          <td>{format_number(level["hght"])}</td>
                          <td>{format_number(level["tmpc"])}</td>
                          <td>{format_number(level["dwpc"])}</td>
                        </tr>
                      <% end %>
                    </tbody>
                  </table>
                </div>
              <% end %>
            </div>
          <% end %>
        </div>
      <% else %>
        <div class="text-sm text-base-content/50 italic">
          <%= cond do %>
            <% atmospheric_loading?(@hydration_pending) -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span> Loading atmospheric profile…
              </span>
            <% @contact.hrrr_status == :queued -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span>
                Fetching HRRR atmospheric data {queue_info(@queue_counts, "hrrr")}
              </span>
            <% @contact.hrrr_status == :unavailable -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span>
                HRRR unavailable — fetching NARR reanalysis {queue_info(@queue_counts, "narr")}
              </span>
            <% true -> %>
              No atmospheric profile available.
          <% end %>
        </div>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">IEMRE Gridded Reanalysis</h2>
      <%= if @iemre do %>
        <% h = @iemre.nearest_hour %>
        <% obs = @iemre.observation %>
        <div class="text-xs opacity-60 mb-2">
          Grid point: {format_number(obs.lat)}°, {format_number(obs.lon)}° &mdash; {obs.date} hour {h[
            "utc_hour"
          ] || "—"} UTC
        </div>
        <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
          <div><span class="font-semibold">Temp:</span> {format_number(h["air_temp_f"])}°F</div>
          <div><span class="font-semibold">Dewpoint:</span> {format_number(h["dew_point_f"])}°F</div>
          <div><span class="font-semibold">Sky Cover:</span> {format_number(h["skyc_%"])}%</div>
          <div>
            <span class="font-semibold">Wind:</span>
            {format_number(wind_speed_from_components(h["uwnd_mps"], h["vwnd_mps"]))} kts
          </div>
          <div>
            <span class="font-semibold">Precip:</span> {format_number(h["hourly_precip_in"])}"
          </div>
          <div :if={h["soil4t_f"]}>
            <span class="font-semibold">Soil Temp:</span> {format_number(h["soil4t_f"])}°F
          </div>
        </div>
      <% else %>
        <div class="text-sm text-base-content/50 italic">
          <%= case @contact.iemre_status do %>
            <% :queued -> %>
              <span class="flex items-center gap-2">
                <span class="loading loading-spinner loading-sm"></span> Fetching IEMRE data
              </span>
            <% :unavailable -> %>
              IEMRE data unavailable for this contact.
            <% _ -> %>
              No IEMRE data available.
          <% end %>
        </div>
      <% end %>

      <div class="divider" />

      <h2 class="text-base font-semibold mb-2">Surface Observations</h2>
      <%= if @surface_observations == [] do %>
        <div class="text-sm text-base-content/50 italic">
          <%= if @contact.weather_status == :queued do %>
            <span class="flex items-center gap-2">
              <span class="loading loading-spinner loading-sm"></span>
              Fetching surface observations from nearby stations {queue_info(@queue_counts, "weather")}...
            </span>
          <% else %>
            No surface observations found nearby.
          <% end %>
        </div>
      <% else %>
        <div class="overflow-x-auto">
          <table class="table table-xs table-zebra">
            <thead>
              <tr>
                <th>Station</th>
                <th>Time</th>
                <th>Temp</th>
                <th>Dewpt</th>
                <th>RH%</th>
                <th>Wind</th>
                <th>Pres (mb)</th>
                <th>Sky</th>
              </tr>
            </thead>
            <tbody>
              <%= for obs <- @surface_observations do %>
                <tr>
                  <td>{obs.station.name || obs.station.station_code}</td>
                  <td>{Calendar.strftime(obs.observed_at, "%H:%M")}</td>
                  <td>{obs.temp_f || "—"}</td>
                  <td>{obs.dewpoint_f || "—"}</td>
                  <td>{format_number(obs.relative_humidity)}</td>
                  <td>{format_wind(obs)}</td>
                  <td>{obs.sea_level_pressure_mb || "—"}</td>
                  <td>{obs.sky_condition || "—"}</td>
                </tr>
              <% end %>
            </tbody>
          </table>
        </div>
      <% end %>

      <%= if @data_sources do %>
        <div class="divider" />

        <h2 class="text-base font-semibold mb-2">Data Sources</h2>
        <div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-xs">
          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">HRRR Model</div>
              <%= if @data_sources.hrrr do %>
                <div>{@data_sources.hrrr.profile_count} path profiles</div>
                <div>{@data_sources.hrrr.levels} pressure levels each</div>
                <div>Valid: {@data_sources.hrrr.valid_time}</div>
                <div>Grid: {@data_sources.hrrr.lat}, {@data_sources.hrrr.lon}</div>
              <% else %>
                <div class="opacity-50">Not available</div>
              <% end %>
            </div>
          </div>

          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">Elevation Profile</div>
              <%= if @data_sources.elevation do %>
                <div>{@data_sources.elevation.samples} samples along path</div>
                <div>{@data_sources.elevation.dist} path distance</div>
                <div>Source: {@data_sources.elevation.source}</div>
                <div>{@data_sources.elevation.duct_count} duct layers detected</div>
              <% else %>
                <div class="opacity-50">Not available</div>
              <% end %>
            </div>
          </div>

          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">Terrain Analysis</div>
              <%= if @data_sources.terrain do %>
                <div>{@data_sources.terrain.samples} profile samples</div>
                <div>Verdict: {@data_sources.terrain.verdict}</div>
                <div>Max elevation: {@data_sources.terrain.max_elev} m</div>
                <div>Diffraction: {@data_sources.terrain.diffraction} dB</div>
              <% else %>
                <div class="opacity-50">Not available</div>
              <% end %>
            </div>
          </div>

          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">Surface Observations</div>
              <div>{length(@surface_observations)} stations</div>
              <%= if @surface_observations != [] do %>
                <div>
                  {Calendar.strftime(hd(@surface_observations).observed_at, "%H:%M")} – {Calendar.strftime(
                    List.last(@surface_observations).observed_at,
                    "%H:%M"
                  )} UTC
                </div>
              <% end %>
            </div>
          </div>

          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">Soundings (RAOB)</div>
              <div>{length(@soundings)} profiles</div>
              <%= if @soundings != [] do %>
                <div>
                  {Enum.map_join(@soundings, ", ", fn s ->
                    "#{s.station.station_code} (#{s.level_count} levels)"
                  end)}
                </div>
                <div>{Enum.count(@soundings, & &1.ducting_detected)} with ducting</div>
              <% end %>
            </div>
          </div>

          <div class="card card-border card-sm bg-base-200">
            <div class="card-body p-3">
              <div class="font-semibold mb-1">Solar Indices</div>
              <%= if @solar do %>
                <div>SFI: {@solar.sfi || "—"}</div>
                <div>SSN: {@solar.sunspot_number || "—"}</div>
                <div>Ap: {@solar.ap_index || "—"}</div>
              <% else %>
                <div class="opacity-50">Not available</div>
              <% end %>
            </div>
          </div>
        </div>
      <% end %>
    </Layouts.app>
    """
  end

  defp format_number(nil), do: "—"
  defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
  defp format_number(n), do: to_string(n)

  defp format_number(nil, _), do: "—"

  defp format_number(n, decimals) when is_float(n) and is_integer(decimals) do
    :erlang.float_to_binary(n, decimals: decimals)
  end

  defp format_number(n, _), do: to_string(n)

  defp mechanism_label(:likely_rainscatter), do: "Likely Rain Scatter"
  defp mechanism_label(:rainscatter_possible), do: "Rain Scatter Possible"
  defp mechanism_label(:tropo_duct), do: "Tropospheric Duct"
  defp mechanism_label(:troposcatter), do: "Troposcatter"
  defp mechanism_label(:unknown), do: "Unclassified"
  defp mechanism_label(_), do: "Analyzing…"

  defp mechanism_badge_class(:likely_rainscatter), do: "badge-info"
  defp mechanism_badge_class(:rainscatter_possible), do: "badge-info badge-outline"
  defp mechanism_badge_class(:tropo_duct), do: "badge-warning"
  defp mechanism_badge_class(:troposcatter), do: "badge-ghost"
  defp mechanism_badge_class(:unknown), do: "badge-ghost"
  defp mechanism_badge_class(_), do: "badge-ghost"

  defp mechanism_explainer(:likely_rainscatter),
    do:
      "Heavy precipitation detected inside the common volume between the two stations — the most likely carrier at this frequency and distance."

  defp mechanism_explainer(:rainscatter_possible),
    do: "Light-to-moderate precipitation in the common volume, no ducting signature — scatter is plausible but marginal."

  defp mechanism_explainer(:tropo_duct),
    do: "HRRR profile shows a trapping layer (refractivity gradient below −157 N/km) at one of the endpoints."

  defp mechanism_explainer(:troposcatter),
    do: "No duct, no meaningful rain inside the common volume — default tropospheric forward scatter."

  defp mechanism_explainer(:unknown), do: "Not enough radar/profile coverage to distinguish mechanisms."

  defp mechanism_explainer(_), do: ""

  defp wind_speed_from_components(u, v) when is_number(u) and is_number(v) do
    mps = :math.sqrt(u * u + v * v)
    Float.round(mps * 1.944, 1)
  end

  defp wind_speed_from_components(_, _), do: nil

  defp format_dist(km), do: Microwaveprop.Format.distance_km(km)

  defp format_bearing(deg) do
    whole = trunc(deg)
    frac = round((deg - whole) * 10)
    "#{String.pad_leading(to_string(whole), 3, "0")}.#{frac}\u00B0"
  end

  defp format_band(nil), do: "—"

  defp format_band(band) do
    mhz = Decimal.to_float(band)

    if mhz < 10_000 do
      if mhz == Float.round(mhz, 0) do
        "#{trunc(mhz)} MHz"
      else
        "#{:erlang.float_to_binary(mhz, decimals: 1)} MHz"
      end
    else
      ghz = mhz / 1000

      if ghz == Float.round(ghz, 0) do
        "#{trunc(ghz)} GHz"
      else
        "#{:erlang.float_to_binary(ghz, decimals: 1)} GHz"
      end
    end
  end

  defp format_wind(obs) do
    case {obs.wind_direction_deg, obs.wind_speed_kts} do
      {nil, nil} -> "—"
      {dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts"
    end
  end

  defp filter_profile(profile) do
    Enum.filter(profile, fn level ->
      level["tmpc"] != nil || level["dwpc"] != nil
    end)
  end

  defp format_kp(nil), do: "—"
  defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1)

  defp terrain_verdict_class("CLEAR"), do: "badge-success"
  defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge-warning"
  defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge-warning"
  defp terrain_verdict_class("BLOCKED"), do: "badge-error"
  defp terrain_verdict_class(_), do: "badge-ghost"

  defp compute_elevation_profile(contact, hrrr_path, soundings) do
    with %{"lat" => lat1} <- contact.pos1,
         lon1 when is_number(lon1) <- contact.pos1["lon"],
         %{"lat" => lat2} <- contact.pos2,
         lon2 when is_number(lon2) <- contact.pos2["lon"],
         {:ok, profile} <- safe_fetch_elevation(lat1, lon1, lat2, lon2) do
      freq_ghz = band_to_ghz(contact.band)
      dist_km = haversine_km(lat1, lon1, lat2, lon2)
      # Default to 10 ft (3.048 m) AGL when the operator didn't record an
      # antenna height — better than pretending the antenna is on the dirt.
      default_ht_m = 3.048
      ant_ht_a_m = ft_to_m(contact.height1_ft) || default_ht_m
      ant_ht_b_m = ft_to_m(contact.height2_ft) || default_ht_m
      analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m)

      fwd_az = initial_bearing(lat1, lon1, lat2, lon2)
      rev_az = initial_bearing(lat2, lon2, lat1, lon1)

      tx_elev = hd(profile).elev + ant_ht_a_m
      rx_elev = List.last(profile).elev + ant_ht_b_m
      fwd_el = elevation_angle(tx_elev, rx_elev, dist_km * 1000)
      rev_el = elevation_angle(rx_elev, tx_elev, dist_km * 1000)

      first_obs =
        case Enum.find(analysis.points, & &1.obstructed) do
          nil -> nil
          p -> p.dist_km
        end

      ducts =
        hrrr_path
        |> extract_ducts(soundings, tx_elev)
        |> merge_nearby_ducts()
        |> Enum.sort_by(& &1.strength, :desc)
        |> Enum.take(3)
        |> mark_likely_duct(tx_elev, rx_elev)

      %{
        points:
          Enum.map(analysis.points, fn p ->
            %{elev: p.elev, beam: p.beam, r1: p.r1, dist_km: p.dist_km}
          end),
        freq_mhz: freq_ghz * 1000,
        dist_km: dist_km,
        k_factor: analysis.k_factor,
        fwd_az: fwd_az,
        rev_az: rev_az,
        fwd_el: fwd_el,
        rev_el: rev_el,
        verdict: analysis.verdict,
        first_obstruction_km: first_obs,
        ducts: ducts
      }
    else
      _ -> nil
    end
  end

  # Extract duct layers from best available source across all HRRR path profiles:
  # 1. HRRR explicit ducts (M-profile detected) from any path point
  # 2. Sounding explicit ducts (high vertical resolution, most reliable)
  # 3. Inferred from strongest HRRR refractivity gradient along path
  defp extract_ducts(hrrr_path, soundings, surface_elev) do
    hrrr_ducts = extract_hrrr_ducts(hrrr_path, surface_elev)

    cond do
      hrrr_ducts != [] ->
        hrrr_ducts

      (sounding_ducts = extract_sounding_ducts(soundings, surface_elev)) != [] ->
        sounding_ducts

      true ->
        # Use the profile with the strongest (most negative) gradient
        best =
          hrrr_path
          |> Enum.filter(&is_number(&1.min_refractivity_gradient))
          |> Enum.min_by(& &1.min_refractivity_gradient, fn -> nil end)

        infer_duct_from_gradient(best, surface_elev)
    end
  end

  defp extract_hrrr_ducts(hrrr_path, surface_elev) when is_list(hrrr_path) do
    hrrr_path
    |> Enum.flat_map(&duct_characteristics_to_maps(&1.duct_characteristics, surface_elev, "hrrr"))
    |> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
  end

  defp duct_characteristics_to_maps(ducts, surface_elev, source) when is_list(ducts) and ducts != [] do
    Enum.map(ducts, fn d ->
      %{
        base_m_msl: surface_elev + (d["base"] || 0),
        top_m_msl: surface_elev + (d["top"] || 0),
        strength: d["strength"],
        source: source
      }
    end)
  end

  defp duct_characteristics_to_maps(_ducts, _surface_elev, _source), do: []

  defp extract_sounding_ducts(soundings, surface_elev) when is_list(soundings) do
    soundings
    |> Enum.filter(& &1.ducting_detected)
    |> Enum.flat_map(&duct_characteristics_to_maps(&1.duct_characteristics, surface_elev, "sounding"))
    |> Enum.uniq_by(fn d -> {round(d.base_m_msl), round(d.top_m_msl)} end)
  end

  defp extract_sounding_ducts(_, _), do: []

  # HRRR pressure levels are too coarse (~250m) to detect thin ducting layers
  # via the M-profile method. When min_refractivity_gradient indicates enhanced
  # propagation, infer a duct layer within the boundary layer.
  defp infer_duct_from_gradient(nil, _surface_elev), do: []

  defp infer_duct_from_gradient(hrrr, surface_elev) do
    grad = hrrr.min_refractivity_gradient
    hpbl = hrrr.hpbl_m

    if is_number(grad) and grad < -100 and is_number(hpbl) and hpbl > 0 do
      duct_top = min(hpbl * 0.6, 500)
      strength = abs(grad) / 10

      [
        %{
          base_m_msl: surface_elev,
          top_m_msl: surface_elev + duct_top,
          strength: Float.round(strength, 1),
          source: "inferred"
        }
      ]
    else
      []
    end
  end

  # Merge duct layers that overlap or are within 100m of each other
  defp merge_nearby_ducts(ducts) do
    ducts
    |> Enum.sort_by(& &1.base_m_msl)
    |> Enum.reduce([], fn duct, acc ->
      case acc do
        [prev | rest] when duct.base_m_msl <= prev.top_m_msl + 100 ->
          merged = %{
            prev
            | top_m_msl: max(prev.top_m_msl, duct.top_m_msl),
              strength: max(prev.strength, duct.strength)
          }

          [merged | rest]

        _ ->
          [duct | acc]
      end
    end)
    |> Enum.reverse()
  end

  # Mark the duct most likely carrying the signal. The signal enters at
  # surface level from each end, so the duct whose base is closest to
  # the average endpoint elevation is the most probable propagation path.
  defp mark_likely_duct([], _tx_elev, _rx_elev), do: []

  defp mark_likely_duct(ducts, tx_elev, rx_elev) do
    avg_elev = (tx_elev + rx_elev) / 2

    {_, likely_idx} =
      ducts
      |> Enum.with_index()
      |> Enum.min_by(fn {d, _idx} ->
        # Distance from average endpoint elevation to the duct layer
        # Prefer ducts that the endpoints are actually inside of
        if avg_elev >= d.base_m_msl and avg_elev <= d.top_m_msl do
          -d.strength
        else
          min(abs(d.base_m_msl - avg_elev), abs(d.top_m_msl - avg_elev))
        end
      end)

    Enum.with_index(ducts, fn d, i ->
      Map.put(d, :likely, i == likely_idx)
    end)
  end

  defp safe_fetch_elevation(lat1, lon1, lat2, lon2) do
    ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2, 256)
  rescue
    e ->
      Logger.warning("Elevation profile failed: #{Exception.message(e)}")
      {:error, :elevation_unavailable}
  end

  defp band_to_ghz(nil), do: 10.0

  defp band_to_ghz(band) do
    band
    |> Decimal.to_float()
    |> Kernel./(1000)
  end

  defp ft_to_m(nil), do: nil
  defp ft_to_m(ft) when is_integer(ft), do: ft * 0.3048

  defp haversine_km(lat1, lon1, lat2, lon2) do
    dlat = deg_to_rad(lat2 - lat1)
    dlon = deg_to_rad(lon2 - lon1)
    rlat1 = deg_to_rad(lat1)
    rlat2 = deg_to_rad(lat2)

    a =
      :math.sin(dlat / 2) ** 2 +
        :math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2

    2 * 6371.0 * :math.asin(:math.sqrt(a))
  end

  defp initial_bearing(lat1, lon1, lat2, lon2) do
    rlat1 = deg_to_rad(lat1)
    rlat2 = deg_to_rad(lat2)
    dlon = deg_to_rad(lon2 - lon1)

    y = :math.sin(dlon) * :math.cos(rlat2)

    x =
      :math.cos(rlat1) * :math.sin(rlat2) -
        :math.sin(rlat1) * :math.cos(rlat2) * :math.cos(dlon)

    bearing = y |> :math.atan2(x) |> rad_to_deg()
    Float.round(rem_float(bearing + 360, 360), 1)
  end

  defp elevation_angle(h_tx, h_rx, dist_m) do
    k_r = 4 / 3 * @earth_radius_m
    angle = :math.atan2(h_rx - h_tx, dist_m) - dist_m / (2 * k_r)
    Float.round(rad_to_deg(angle), 2)
  end

  defp deg_to_rad(deg), do: deg * :math.pi() / 180
  defp rad_to_deg(rad), do: rad * 180 / :math.pi()
  defp rem_float(a, b), do: a - Float.floor(a / b) * b

  # ── Data sources summary ────────────────────────────────────────

  defp build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile) do
    %{
      hrrr: build_hrrr_source(hrrr, hrrr_path),
      elevation: build_elevation_source(elevation_profile),
      terrain: build_terrain_source(terrain),
      obs_count: length(weather.surface_observations),
      sounding_count: length(weather.soundings)
    }
  end

  defp build_hrrr_source(nil, _), do: nil

  defp build_hrrr_source(hrrr, hrrr_path) do
    levels = if hrrr.profile, do: length(hrrr.profile), else: 0

    %{
      profile_count: length(hrrr_path),
      levels: levels,
      valid_time: Calendar.strftime(hrrr.valid_time, "%Y-%m-%d %H:%M UTC"),
      lat: :erlang.float_to_binary(hrrr.lat / 1, decimals: 2),
      lon: :erlang.float_to_binary(hrrr.lon / 1, decimals: 2)
    }
  end

  defp build_elevation_source(nil), do: nil

  defp build_elevation_source(ep) do
    %{
      samples: length(ep.points),
      dist: format_dist(ep.dist_km),
      source: "SRTM 1-arcsecond",
      duct_count: length(ep.ducts)
    }
  end

  defp build_terrain_source(nil), do: nil

  defp build_terrain_source(terrain) do
    %{
      samples: terrain.sample_count,
      verdict: terrain.verdict,
      max_elev: :erlang.float_to_binary((terrain.max_elevation_m || 0) / 1, decimals: 0),
      diffraction: :erlang.float_to_binary((terrain.diffraction_db || 0) / 1, decimals: 1)
    }
  end

  # ── Propagation analysis ──────────────────────────────────────────

  defp build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings) do
    band_mhz = if contact.band, do: Decimal.to_integer(contact.band), else: 10_000
    band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
    dist_km = contact.distance_km && Decimal.to_float(contact.distance_km)
    hrrr = List.first(hrrr_path)

    {factors, factor_rows, composite} = compute_factors(hrrr_path, contact, band_config)

    summary =
      build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact)

    details = build_details(hrrr, hrrr_path, soundings, elevation_profile, band_mhz, contact)

    %{
      summary: summary,
      details: details,
      factors: factors,
      factor_rows: factor_rows,
      composite_score: composite
    }
  end

  defp compute_factors([], _contact, _band_config), do: {nil, [], nil}
  defp compute_factors(_hrrr_path, %{pos1: nil}, _band_config), do: {nil, [], nil}

  defp compute_factors(hrrr_path, contact, band_config) do
    conditions = Scorer.path_integrated_conditions(hrrr_path, contact)

    if is_nil(conditions) do
      {nil, [], nil}
    else
      result = Scorer.composite_score(conditions, band_config)
      weights = BandConfig.weights(band_config)

      factor_rows =
        [
          {:humidity, "Humidity", humidity_note(conditions.abs_humidity, band_config)},
          {:time_of_day, "Time of Day", time_note(contact.qso_timestamp, conditions.longitude)},
          {:td_depression, "T-Td Depression", td_note(conditions.temp_f, conditions.dewpoint_f)},
          {:refractivity, "Refractivity", refractivity_note(conditions.min_refractivity_gradient)},
          {:sky, "Sky Cover", sky_note(conditions.sky_cover_pct)},
          {:season, "Season", season_note(contact.qso_timestamp.month, band_config)},
          {:wind, "Wind", wind_note(conditions.wind_speed_kts)},
          {:rain, "Rain", rain_note(conditions.rain_rate_mmhr)},
          {:pwat, "PWAT", pwat_note(conditions.pwat_mm, band_config)},
          {:pressure, "Pressure", pressure_note(conditions.pressure_mb)}
        ]
        |> Enum.map(fn {key, name, note} ->
          score = result.factors[key]
          weight = weights[key]

          %{
            name: name,
            score: score,
            weight: round(weight * 100),
            contribution: Float.round(score * weight, 1),
            note: note
          }
        end)
        |> Enum.sort_by(& &1.contribution, :desc)

      {result.factors, factor_rows, result.score}
    end
  end

  defp build_summary(terrain, elevation_profile, hrrr, soundings, dist_km, band_mhz, contact) do
    terrain_status = terrain_summary(terrain, elevation_profile, contact)
    mechanism = propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km)
    band_note = band_summary(band_mhz, hrrr)

    [terrain_status, mechanism, band_note]
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" ")
  end

  defp terrain_summary(nil, nil, _contact), do: "No terrain data available."

  defp terrain_summary(terrain, elevation_profile, contact) do
    verdict = (elevation_profile && elevation_profile.verdict) || (terrain && terrain.verdict)
    describe_verdict(verdict, elevation_profile, contact)
  end

  defp describe_verdict("CLEAR", _, _), do: "Line of sight is clear between stations."
  defp describe_verdict("FRESNEL_MINOR", _, _), do: "Line of sight is clear but with minor Fresnel zone encroachment."
  defp describe_verdict("FRESNEL_PARTIAL", _, _), do: "Line of sight has partial Fresnel zone obstruction."

  defp describe_verdict("BLOCKED", elevation_profile, contact) do
    obs_dist = elevation_profile && elevation_profile.first_obstruction_km
    blocked_message(obs_dist, contact)
  end

  defp describe_verdict(_, _, _), do: nil

  defp blocked_message(nil, _contact), do: "Path is terrain-obstructed."

  defp blocked_message(obs_dist, contact) do
    origin = contact && contact.station1
    origin_label = if origin, do: origin, else: "station 1"

    "Path is terrain-obstructed at #{:erlang.float_to_binary(obs_dist * 0.621371, decimals: 1)} mi (#{:erlang.float_to_binary(obs_dist, decimals: 1)} km) from #{origin_label}."
  end

  defp propagation_mechanism(terrain, elevation_profile, hrrr, soundings, dist_km) do
    blocked? = terrain && terrain.verdict == "BLOCKED"
    ducting? = has_ducting?(hrrr, soundings)
    enhanced_refraction? = enhanced_refraction?(hrrr)
    long_path? = dist_km && dist_km > 100

    ducts = (elevation_profile && elevation_profile.ducts) || []
    likely_duct = Enum.find(ducts, & &1[:likely])

    props = %{
      blocked?: blocked?,
      ducting?: ducting?,
      enhanced_refraction?: enhanced_refraction?,
      long_path?: long_path?,
      likely_duct: likely_duct,
      terrain: terrain,
      hrrr: hrrr
    }

    if blocked? do
      blocked_mechanism(props)
    else
      clear_path_mechanism(props)
    end
  end

  defp enhanced_refraction?(nil), do: false

  defp enhanced_refraction?(hrrr) do
    is_number(hrrr.min_refractivity_gradient) && hrrr.min_refractivity_gradient < -100
  end

  defp blocked_mechanism(%{ducting?: true, likely_duct: duct}) when not is_nil(duct) do
    "Signal likely propagated via atmospheric ducting (#{duct_description(duct)}), bending over the terrain obstruction."
  end

  defp blocked_mechanism(%{ducting?: true}) do
    "Ducting conditions detected — signal likely propagated through a tropospheric duct, bypassing the terrain obstruction."
  end

  defp blocked_mechanism(%{enhanced_refraction?: true, hrrr: hrrr}) do
    grad = round(hrrr.min_refractivity_gradient)

    "Enhanced refraction (dN/dh = #{grad} N-units/km) likely bent the signal over the obstruction. Conditions are super-refractive but below full ducting threshold."
  end

  defp blocked_mechanism(%{terrain: terrain}) do
    diff_db = terrain && terrain.diffraction_db
    blocked_diffraction_message(diff_db)
  end

  defp blocked_diffraction_message(diff_db) when is_number(diff_db) and diff_db > 0 do
    "Path is obstructed with #{:erlang.float_to_binary(diff_db, decimals: 1)} dB diffraction loss. Contact may have been enabled by knife-edge diffraction or tropospheric scatter."
  end

  defp blocked_diffraction_message(_) do
    "Path is obstructed. Contact likely enabled by tropospheric scatter or transient ducting conditions."
  end

  defp clear_path_mechanism(%{ducting?: true, long_path?: true, likely_duct: duct}) when not is_nil(duct) do
    "Ducting conditions present (#{duct_description(duct)}) — likely extending range beyond normal line of sight."
  end

  defp clear_path_mechanism(%{ducting?: true, long_path?: true}) do
    "Ducting conditions present — likely contributing to extended range."
  end

  defp clear_path_mechanism(%{enhanced_refraction?: true, long_path?: true}) do
    "Enhanced refraction present — may have contributed to extended range."
  end

  defp clear_path_mechanism(_), do: nil

  defp duct_description(%{source: source, base_m_msl: base, top_m_msl: top, strength: strength}) do
    base_ft = round(base * 3.28084)
    top_ft = round(top * 3.28084)

    src =
      case source do
        "sounding" -> "sounding-detected"
        "inferred" -> "estimated"
        _ -> "detected"
      end

    "#{src} layer at #{base_ft}-#{top_ft} ft, #{strength} M-units"
  end

  defp duct_description(_), do: "detected layer"

  defp has_ducting?(hrrr, soundings) do
    hrrr_ducting = hrrr && hrrr.ducting_detected
    sounding_ducting = is_list(soundings) && Enum.any?(soundings, & &1.ducting_detected)
    hrrr_ducting || sounding_ducting
  end

  defp band_summary(band_mhz, hrrr) when band_mhz <= 12_000 do
    if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 20 do
      "At 10 GHz, the elevated moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) enhances refractivity and ducting potential."
    end
  end

  defp band_summary(band_mhz, hrrr) when band_mhz >= 24_000 do
    if hrrr && is_number(hrrr.pwat_mm) && hrrr.pwat_mm > 25 do
      "At #{round(band_mhz / 1000)} GHz, high moisture (PWAT #{:erlang.float_to_binary(hrrr.pwat_mm, decimals: 1)} mm) causes significant water vapor absorption."
    end
  end

  defp band_summary(_, _), do: nil

  defp build_details(hrrr, hrrr_path, soundings, elevation_profile, _band_mhz, contact) do
    Enum.reject(
      [
        antenna_heights_detail(contact),
        refractivity_detail(hrrr),
        boundary_layer_detail(hrrr),
        path_duct_count_detail(hrrr_path),
        sounding_ducting_detail(soundings),
        duct_layer_detail(elevation_profile)
      ],
      &is_nil/1
    )
  end

  defp antenna_heights_detail(%{height1_ft: h1, height2_ft: h2} = contact) when is_integer(h1) or is_integer(h2) do
    s1 = contact.station1 || "Station 1"
    s2 = contact.station2 || "Station 2"

    parts =
      Enum.reject(
        [if(is_integer(h1), do: "#{s1} @ #{h1} ft AGL"), if(is_integer(h2), do: "#{s2} @ #{h2} ft AGL")],
        &is_nil/1
      )

    "Antenna heights: #{Enum.join(parts, ", ")}."
  end

  defp antenna_heights_detail(_), do: nil

  defp path_duct_count_detail(hrrr_path) when is_list(hrrr_path) and hrrr_path != [] do
    total = length(hrrr_path)
    ducting = Enum.count(hrrr_path, &(&1 && &1.ducting_detected))

    if ducting > 0 do
      "Tropo duct detected at #{ducting}/#{total} HRRR samples along the path."
    end
  end

  defp path_duct_count_detail(_), do: nil

  defp refractivity_detail(%{min_refractivity_gradient: grad}) when is_number(grad) do
    g = round(grad)
    label = refractivity_label(g)
    "Refractivity gradient: #{g} N-units/km (#{label})."
  end

  defp refractivity_detail(_), do: nil

  defp refractivity_label(g) when g < -157, do: "ducting"
  defp refractivity_label(g) when g < -100, do: "super-refractive"
  defp refractivity_label(g) when g < -79, do: "enhanced"
  defp refractivity_label(_), do: "normal"

  defp boundary_layer_detail(%{hpbl_m: hpbl}) when is_number(hpbl) do
    h = round(hpbl)
    note = if h < 300, do: " — shallow boundary layer favors ducting", else: ""
    "Boundary layer depth: #{h} m#{note}."
  end

  defp boundary_layer_detail(_), do: nil

  defp sounding_ducting_detail(soundings) when is_list(soundings) do
    ducting = Enum.filter(soundings, & &1.ducting_detected)

    if ducting == [] do
      nil
    else
      names = Enum.map_join(ducting, ", ", fn s -> s.station.name || s.station.station_code end)
      "Ducting detected by soundings at: #{names}."
    end
  end

  defp sounding_ducting_detail(_), do: nil

  defp duct_layer_detail(%{ducts: ducts}) when is_list(ducts) do
    likely = Enum.find(ducts, & &1[:likely])
    if likely, do: "Most likely propagation duct: #{duct_description(likely)}."
  end

  defp duct_layer_detail(_), do: nil

  # Factor note helpers
  defp humidity_note(abs_h, band_config) do
    h = Float.round(abs_h, 1)
    effect = if band_config.humidity_effect == :beneficial, do: "beneficial", else: "harmful"
    "#{h} g/m³ (#{effect} at this band)"
  end

  defp time_note(ts, lon) do
    solar_hour = ts.hour + ts.minute / 60 + lon / 15
    solar_hour = rem_float(solar_hour + 24, 24)
    h = round(solar_hour)
    "Solar hour ~#{h} (#{solar_period(h)})"
  end

  defp solar_period(h) when h >= 4 and h < 8, do: "dawn"
  defp solar_period(h) when h >= 8 and h < 12, do: "morning"
  defp solar_period(h) when h >= 12 and h < 17, do: "afternoon"
  defp solar_period(h) when h >= 17 and h < 21, do: "evening"
  defp solar_period(_), do: "night"

  defp td_note(temp_f, dewpoint_f) when is_number(temp_f) and is_number(dewpoint_f) do
    depression = Float.round(temp_f - dewpoint_f, 1)

    label =
      cond do
        depression < 3 -> "near saturation"
        depression < 8 -> "moist"
        depression < 15 -> "moderate"
        true -> "dry"
      end

    "T-Td = #{depression}°F (#{label})"
  end

  defp td_note(_, _), do: "—"

  defp refractivity_note(nil), do: "—"

  defp refractivity_note(grad) do
    g = round(grad)

    label =
      cond do
        g < -157 -> "ducting"
        g < -100 -> "super-refractive"
        g < -79 -> "enhanced"
        true -> "normal"
      end

    "dN/dh = #{g} (#{label})"
  end

  defp sky_note(nil), do: "—"

  defp season_note(month, band_config) do
    month_names = ~w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)
    name = Enum.at(month_names, month - 1)
    effect = if band_config.humidity_effect == :beneficial, do: "summer peak", else: "winter peak"
    "#{name} (#{effect} band)"
  end

  defp wind_note(nil), do: "—"

  defp rain_note(rate) when rate > 0, do: "#{Float.round(rate, 1)} mm/hr"
  defp rain_note(_), do: "None"

  defp pwat_note(nil, _), do: "—"

  defp pwat_note(mm, band_config) do
    effect = if band_config.humidity_effect == :beneficial, do: "refractivity aid", else: "absorption"
    "#{Float.round(mm, 1)} mm (#{effect})"
  end

  defp pressure_note(nil), do: "—"

  defp pressure_note(mb) do
    label =
      cond do
        mb < 1005 -> "low"
        mb < 1015 -> "moderate"
        true -> "high"
      end

    "#{Float.round(mb, 1)} mb (#{label})"
  end

  defp propagation_tier_label(score) do
    cond do
      score >= 80 -> "EXCELLENT"
      score >= 65 -> "GOOD"
      score >= 50 -> "MARGINAL"
      score >= 33 -> "POOR"
      true -> "NEGLIGIBLE"
    end
  end

  defp propagation_tier_class(score) do
    cond do
      score >= 80 -> "badge badge-sm badge-success"
      score >= 65 -> "badge badge-sm badge-info"
      score >= 50 -> "badge badge-sm badge-warning"
      score >= 33 -> "badge badge-sm badge-error"
      true -> "badge badge-sm badge-ghost"
    end
  end

  defp path_calc_url(contact) do
    source = prefer_grid(contact.grid1, contact.station1)
    dest = prefer_grid(contact.grid2, contact.station2)

    params = maybe_put_band(%{"source" => source, "destination" => dest}, contact.band)

    "/path?" <> URI.encode_query(params)
  end

  defp prefer_grid(grid, _fallback) when is_binary(grid) and grid != "", do: grid
  defp prefer_grid(_grid, fallback), do: fallback || ""

  defp maybe_put_band(params, nil), do: params

  defp maybe_put_band(params, %Decimal{} = band) do
    Map.put(params, "band", Decimal.to_string(band, :normal))
  end

  defp maybe_put_band(params, band) when is_integer(band), do: Map.put(params, "band", Integer.to_string(band))
  defp maybe_put_band(params, band) when is_binary(band), do: Map.put(params, "band", band)
  defp maybe_put_band(params, _), do: params
end
