defmodule MicrowavepropWeb.PathLive do
  @moduledoc false
  use MicrowavepropWeb, :live_view

  import MicrowavepropWeb.Components.SkewTChart

  alias Microwaveprop.Ionosphere
  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.Scorer
  alias Microwaveprop.Propagation.SporadicE
  alias Microwaveprop.Radio.CallsignClient
  alias Microwaveprop.Radio.Maidenhead
  alias Microwaveprop.Repo
  alias Microwaveprop.Terrain.ElevationClient
  alias Microwaveprop.Terrain.TerrainAnalysis
  alias Microwaveprop.Weather
  alias Microwaveprop.Weather.Station

  @band_options BandConfig.band_options()

  @url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
  @defaults %{
    "source" => "",
    "destination" => "",
    "band" => "10000",
    "src_height_ft" => "30",
    "dst_height_ft" => "30",
    "tx_power_dbm" => "20",
    "src_gain_dbi" => "30",
    "dst_gain_dbi" => "30"
  }

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
    end

    {:ok,
     assign(socket,
       page_title: "Path Calculator",
       band_options: @band_options,
       result: nil,
       error: nil,
       computing: false,
       source: "",
       destination: "",
       band: "10000",
       src_height_ft: "30",
       dst_height_ft: "30",
       tx_power_dbm: "20",
       src_gain_dbi: "30",
       dst_gain_dbi: "30",
       source_is_gps: false
     )}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    p = Map.merge(@defaults, Map.take(params, @url_params))

    is_gps = p["source"] == "gps"

    socket =
      assign(socket,
        source: if(is_gps, do: socket.assigns.source, else: p["source"]),
        destination: p["destination"],
        band: p["band"],
        src_height_ft: p["src_height_ft"],
        dst_height_ft: p["dst_height_ft"],
        tx_power_dbm: p["tx_power_dbm"],
        src_gain_dbi: p["src_gain_dbi"],
        dst_gain_dbi: p["dst_gain_dbi"],
        source_is_gps: is_gps
      )

    # If source=gps and we already have coords from a previous fix, just recalculate.
    # Only request GPS from the device on the initial page load (no coords yet).
    if p["source"] == "gps" and connected?(socket) do
      if socket.assigns.source != "" and socket.assigns.source != nil do
        auto_calculate(socket, Map.put(p, "source", socket.assigns.source))
      else
        {:noreply, push_event(socket, "request_gps", %{})}
      end
    else
      auto_calculate(socket, p)
    end
  end

  defp auto_calculate(socket, p) do
    if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
         not socket.assigns.computing and is_nil(socket.assigns.result) do
      src_ht = parse_float(p["src_height_ft"], 30.0)
      dst_ht = parse_float(p["dst_height_ft"], 30.0)
      tx_dbm = parse_float(p["tx_power_dbm"], 20.0)

      station_params = %{
        src_height_ft: src_ht,
        dst_height_ft: dst_ht,
        src_height_m: src_ht * 0.3048,
        dst_height_m: dst_ht * 0.3048,
        tx_power_dbm: tx_dbm,
        tx_power_mw: :math.pow(10, tx_dbm / 10),
        src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0),
        dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
      }

      send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
      {:noreply, assign(socket, computing: true)}
    else
      {:noreply, socket}
    end
  end

  @impl true
  def handle_event("update_form", params, socket) do
    {:noreply,
     assign(socket,
       source: params["source"] || "",
       destination: params["destination"] || "",
       band: params["band"] || "10000",
       src_height_ft: params["src_height_ft"] || "30",
       dst_height_ft: params["dst_height_ft"] || "30",
       tx_power_dbm: params["tx_power_dbm"] || "20",
       src_gain_dbi: params["src_gain_dbi"] || "30",
       dst_gain_dbi: params["dst_gain_dbi"] || "30"
     )}
  end

  def handle_event("calculate", params, socket) do
    src_height_ft = parse_float(params["src_height_ft"], 30.0)
    dst_height_ft = parse_float(params["dst_height_ft"], 30.0)

    tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0)
    tx_power_mw = :math.pow(10, tx_power_dbm / 10)

    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,
      tx_power_dbm: tx_power_dbm,
      tx_power_mw: tx_power_mw,
      src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0),
      dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0)
    }

    socket =
      assign(socket,
        source: params["source"],
        destination: params["destination"],
        band: params["band"],
        src_height_ft: params["src_height_ft"],
        dst_height_ft: params["dst_height_ft"],
        tx_power_dbm: params["tx_power_dbm"],
        src_gain_dbi: params["src_gain_dbi"],
        dst_gain_dbi: params["dst_gain_dbi"],
        error: nil,
        computing: true,
        result: nil
      )

    send(
      self(),
      {:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
    )

    url_params =
      params
      |> Map.take(@url_params)
      |> then(fn p -> if socket.assigns.source_is_gps, do: Map.put(p, "source", "gps"), else: p end)
      |> Enum.reject(fn {_k, v} -> v == "" end)
      |> Map.new()

    {:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
  end

  def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
    coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"

    # Show coords in the input but keep source=gps in the URL
    socket = assign(socket, source: coords, source_is_gps: true, result: nil)

    url_params =
      %{
        "source" => "gps",
        "destination" => socket.assigns.destination,
        "band" => socket.assigns.band,
        "src_height_ft" => socket.assigns.src_height_ft,
        "dst_height_ft" => socket.assigns.dst_height_ft,
        "tx_power_dbm" => socket.assigns.tx_power_dbm,
        "src_gain_dbi" => socket.assigns.src_gain_dbi,
        "dst_gain_dbi" => socket.assigns.dst_gain_dbi
      }
      |> Enum.reject(fn {_k, v} -> v == "" end)
      |> Map.new()

    {:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
  end

  @impl true
  def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do
    case compute_path(source, dest, band_mhz, station_params) do
      {:ok, result} ->
        {:noreply, assign(socket, result: result, computing: false)}

      {:error, reason} ->
        {:noreply, assign(socket, error: reason, computing: false)}
    end
  end

  def handle_info({:propagation_updated, _valid_times}, socket) do
    case socket.assigns.result do
      %{band_mhz: band_mhz, source: src, destination: dst} = result ->
        midlat = (src.lat + dst.lat) / 2
        midlon = (src.lon + dst.lon) / 2
        forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
        {:noreply, assign(socket, result: %{result | forecast: forecast})}

      _ ->
        {:noreply, socket}
    end
  end

  defp compute_path(source, dest, band_mhz, station_params) do
    with {:ok, src} <- resolve_location(source),
         {:ok, dst} <- resolve_location(dest) do
      band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
      freq_ghz = band_mhz / 1000

      dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
      bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)

      # Terrain profile with antenna heights
      terrain_result =
        case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
          {:ok, profile} ->
            analysis =
              TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
                ant_ht_a: station_params.src_height_m,
                ant_ht_b: station_params.dst_height_m
              )

            %{profile: profile, analysis: analysis}

          {:error, _} ->
            nil
        end

      # HRRR profiles along path — 9 evenly-spaced samples so mid-path
      # ducts that endpoint-only queries miss show up in the duct count.
      now = DateTime.utc_now()
      midlat = (src.lat + dst.lat) / 2
      midlon = (src.lon + dst.lon) / 2

      hrrr_points =
        src
        |> path_sample_points(dst, 9)
        |> Task.async_stream(&label_hrrr_point(&1, now), max_concurrency: 9, timeout: 10_000, on_timeout: :kill_task)
        |> Enum.flat_map(fn
          {:ok, nil} -> []
          {:ok, point} -> [point]
          {:exit, _} -> []
        end)

      hrrr_profiles = Enum.map(hrrr_points, & &1.profile)

      # Independent duct signal: the nearest RAOB within 300 km / ±3 h of
      # the midpoint. HRRR pressure levels systematically under-read thin
      # surface ducts that sounding data resolves.
      sounding = build_sounding_readout(midlat, midlon, now)

      # Native-resolution HRRR duct band + Bulk Richardson near the
      # midpoint. Resolves thin trapping layers HRRR's 13 pressure levels
      # miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on
      # Richardson so turbulent duct readings don't inflate the score.
      native_duct =
        case Weather.nearest_native_duct_info(midlat, midlon, now) do
          {:ok, info} -> info
          {:error, _} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
        end

      # Build conditions and score
      {conditions, scoring} =
        build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)

      # Loss and power budgets
      loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
      power_budget = compute_power_budget(station_params, loss_budget)

      # 18-hour forecast from propagation grid (midpoint of path)
      forecast = Propagation.point_forecast(band_mhz, midlat, midlon)

      # Ionosphere readout at the midpoint (sporadic-E potential).
      # Only relevant for VHF where Es propagation is physically possible.
      ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)

      {:ok,
       %{
         source: src,
         destination: dst,
         station_params: station_params,
         band_config: band_config,
         band_mhz: band_mhz,
         freq_ghz: freq_ghz,
         dist_km: dist_km,
         bearing: bearing,
         terrain: terrain_result,
         conditions: conditions,
         scoring: scoring,
         loss_budget: loss_budget,
         power_budget: power_budget,
         forecast: forecast,
         hrrr_count: length(hrrr_profiles),
         hrrr_points: hrrr_points,
         ionosphere: ionosphere,
         sounding: sounding
       }}
    end
  end

  # Returns nil when there's no usable readout, or a map describing the
  # nearest ionosonde's current foEs + the computed Es score for
  # (band, distance). Only VHF bands get the readout.
  defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
    case Ionosphere.nearest_foes(midlat, midlon) do
      {:ok, obs} ->
        es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
        muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)

        %{
          station_code: obs.station_code,
          valid_time: obs.valid_time,
          fo_es_mhz: obs.fo_es_mhz,
          fo_f2_mhz: obs.fo_f2_mhz,
          mufd_mhz: obs.mufd_mhz,
          es_score: es_score,
          es_muf_mhz: muf,
          es_in_range?: dist_km >= 500 and dist_km <= 2500
        }

      {:error, _reason} ->
        nil
    end
  end

  defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil

  defp resolve_location(input) do
    input = String.trim(input)

    cond do
      input == "" ->
        {:error, "Location is required"}

      coordinate_pair?(input) ->
        [lat_s, lon_s] = String.split(input, ",", parts: 2)
        {lat, _} = Float.parse(String.trim(lat_s))
        {lon, _} = Float.parse(String.trim(lon_s))
        {:ok, %{lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", type: :coordinates}}

      maidenhead?(input) ->
        case Maidenhead.to_latlon(input) do
          {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}}
          :error -> {:error, "Invalid grid square: #{input}"}
        end

      true ->
        case CallsignClient.locate(input) do
          {:ok, info} ->
            {:ok,
             %{lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, type: :callsign}}

          {:error, reason} ->
            {:error, "Could not find #{input}: #{reason}"}
        end
    end
  end

  defp coordinate_pair?(input) do
    case String.split(input, ",", parts: 2) do
      [a, b] ->
        match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b)))

      _ ->
        false
    end
  end

  defp maidenhead?(input) do
    String.length(input) >= 4 and String.length(input) <= 10 and
      Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
  end

  defp label_hrrr_point({label, lat, lon}, now) do
    case Weather.find_nearest_hrrr(lat, lon, now) do
      nil -> nil
      profile -> %{label: label, profile: profile}
    end
  end

  # Linear interpolation along the great-circle-approximate path. Good
  # enough at the path-calculator's typical range (<2000 km); for longer
  # paths the path deviates from a rhumb line but we're picking
  # HRRR grid cells, not doing precise bearing.
  defp path_sample_points(src, dst, count) when count >= 2 do
    Enum.map(1..count, fn i ->
      t = (i - 1) / (count - 1)
      lat = src.lat + (dst.lat - src.lat) * t
      lon = src.lon + (dst.lon - src.lon) * t

      label =
        cond do
          i == 1 -> "Source"
          i == count -> "Destination"
          i * 2 == count + 1 -> "Midpoint"
          true -> "#{round(t * 100)}%"
        end

      {label, lat, lon}
    end)
  end

  defp build_sounding_readout(midlat, midlon, now) do
    case Weather.nearest_sounding_to(midlat, midlon, now) do
      {:ok, sounding} ->
        station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
        station = station || Repo.get(Station, sounding.station_id)

        distance_km =
          if station, do: haversine_km(midlat, midlon, station.lat, station.lon)

        %{
          station_code: station && station.station_code,
          station_name: station && station.name,
          observed_at: sounding.observed_at,
          ducting_detected: sounding.ducting_detected,
          min_refractivity_gradient: sounding.min_refractivity_gradient,
          boundary_layer_depth_m: sounding.boundary_layer_depth_m,
          precipitable_water_mm: sounding.precipitable_water_mm,
          distance_km: distance_km
        }

      {:error, :not_found} ->
        nil
    end
  end

  defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}

  defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
    temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
    dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)

    if temps == [] or dewpoints == [] do
      {nil, nil}
    else
      avg_temp_c = Enum.sum(temps) / length(temps)
      avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)

      pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
      gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
      bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
      pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)

      conditions = %{
        abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
        temp_f: Scorer.c_to_f(avg_temp_c),
        dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
        temp_c: avg_temp_c,
        dewpoint_c: avg_dewpoint_c,
        wind_speed_kts: nil,
        sky_cover_pct: nil,
        utc_hour: now.hour,
        utc_minute: now.minute,
        month: now.month,
        latitude: (src.lat + dst.lat) / 2,
        longitude: src.lon,
        pressure_mb: if(pressures != [], do: Enum.min(pressures)),
        prev_pressure_mb: nil,
        rain_rate_mmhr: 0.0,
        min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
        bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
        pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
        best_duct_band_ghz: native_duct[:best_duct_band_ghz],
        bulk_richardson: native_duct[:bulk_richardson]
      }

      scoring = Scorer.composite_score(conditions, band_config)
      {conditions, scoring}
    end
  end

  defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
    freq_mhz = freq_ghz * 1000
    fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44

    o2_loss = band_config.o2_db_km * dist_km
    h2o_coeff = band_config.h2o_coeff

    abs_humidity =
      if conditions do
        conditions.abs_humidity
      else
        7.5
      end

    h2o_loss = h2o_coeff * abs_humidity * dist_km

    rain_loss =
      if conditions && conditions.rain_rate_mmhr > 0 do
        gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
        gamma * dist_km
      else
        0.0
      end

    diffraction_loss =
      if terrain_result do
        # TerrainAnalysis.deygout_diffraction returns integer 0 for clear
        # paths; coerce to float so Float.round/2 below is happy.
        terrain_result.analysis.diffraction_db * 1.0
      else
        0.0
      end

    total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss

    %{
      fspl: Float.round(fspl, 1),
      o2: Float.round(o2_loss, 2),
      h2o: Float.round(h2o_loss, 2),
      rain: Float.round(rain_loss, 2),
      diffraction: Float.round(diffraction_loss, 1),
      total: Float.round(total, 1)
    }
  end

  defp compute_power_budget(station_params, loss_budget) do
    tx_power_dbm = station_params.tx_power_dbm
    eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
    rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi

    # Typical receiver sensitivities by mode (dBm)
    # CW ~-140, SSB ~-130, FM ~-120
    rx_sensitivity_cw = -140.0
    rx_sensitivity_ssb = -130.0
    margin_cw = rx_power_dbm - rx_sensitivity_cw
    margin_ssb = rx_power_dbm - rx_sensitivity_ssb

    %{
      tx_power_dbm: Float.round(tx_power_dbm, 1),
      eirp_dbm: Float.round(eirp_dbm, 1),
      rx_power_dbm: Float.round(rx_power_dbm, 1),
      margin_cw: Float.round(margin_cw, 1),
      margin_ssb: Float.round(margin_ssb, 1)
    }
  end

  defp parse_float(nil, default), do: default
  defp parse_float("", default), do: default

  defp parse_float(str, default) do
    case Float.parse(str) do
      {val, _} -> val
      :error -> default
    end
  end

  defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
  defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo

  # ── Score tier helpers ──

  defp tier_label(score) when score >= 80, do: "EXCELLENT"
  defp tier_label(score) when score >= 65, do: "GOOD"
  defp tier_label(score) when score >= 50, do: "MARGINAL"
  defp tier_label(score) when score >= 33, do: "POOR"
  defp tier_label(_), do: "NEGLIGIBLE"

  defp tier_color(score) when score >= 80, do: "#059669"
  defp tier_color(score) when score >= 65, do: "#0d9488"
  defp tier_color(score) when score >= 50, do: "#ca8a04"
  defp tier_color(score) when score >= 33, do: "#ea580c"
  defp tier_color(_), do: "#dc2626"

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

  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_km_mi(km), do: Microwaveprop.Format.distance_km(km)

  defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
  defp format_utc_hour(_), do: "?"

  defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W"
  defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
  defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"

  # ── Render ──

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
      <.header>
        Path Calculator
        <:subtitle>Analyze microwave propagation between two points</:subtitle>
      </.header>

      <form phx-submit="calculate" phx-change="update_form" class="bg-base-200 rounded-box p-4 mb-6">
        <div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
          <div class="fieldset">
            <label class="label text-sm font-medium">Source</label>
            <div class="flex gap-1">
              <input
                type="text"
                name="source"
                id="source-input"
                value={@source}
                placeholder="W5ISP, EM13sf, or lat,lon"
                class="input input-bordered flex-1"
              />
              <button
                type="button"
                id="locate-me"
                phx-hook="LocateMe"
                class="btn btn-sm btn-outline gap-1"
                title="Use device GPS"
              >
                <.icon name="hero-map-pin" class="size-3.5" /> My Location
              </button>
            </div>
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">
              Destination (<span
                class="tooltip tooltip-top underline decoration-dotted cursor-help"
                data-tip="Callsign lookup queries QRZ for the licensee's mailing address, then geocodes that address — so the position may be off from where the station actually operates."
              >callsign</span> or grid)
            </label>
            <input
              type="text"
              name="destination"
              value={@destination}
              placeholder="K5TR or EM00cd"
              class="input input-bordered w-full"
              required
            />
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">Band</label>
            <select name="band" class="select select-bordered w-full">
              <%= for {label, value} <- @band_options do %>
                <option value={value} selected={value == @band}>{label}</option>
              <% end %>
            </select>
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">TX Power (dBm)</label>
            <input
              type="number"
              name="tx_power_dbm"
              value={@tx_power_dbm}
              step="any"
              class="input input-bordered w-full"
            />
          </div>
        </div>
        <div class="grid grid-cols-2 md:grid-cols-4 gap-4 items-end mt-3">
          <div class="fieldset">
            <label class="label text-sm font-medium">TX Height (ft)</label>
            <input
              type="number"
              name="src_height_ft"
              value={@src_height_ft}
              min="0"
              step="any"
              class="input input-bordered w-full"
            />
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">TX Gain (dBi)</label>
            <input
              type="number"
              name="src_gain_dbi"
              value={@src_gain_dbi}
              step="any"
              class="input input-bordered w-full"
            />
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">RX Height (ft)</label>
            <input
              type="number"
              name="dst_height_ft"
              value={@dst_height_ft}
              min="0"
              step="any"
              class="input input-bordered w-full"
            />
          </div>
          <div class="fieldset">
            <label class="label text-sm font-medium">RX Gain (dBi)</label>
            <input
              type="number"
              name="dst_gain_dbi"
              value={@dst_gain_dbi}
              step="any"
              class="input input-bordered w-full"
            />
          </div>
        </div>
        <div class="mt-4">
          <button type="submit" class="btn btn-primary" disabled={@computing}>
            <%= if @computing do %>
              <span class="loading loading-spinner loading-sm"></span> Computing...
            <% else %>
              Calculate Path
            <% end %>
          </button>
        </div>
      </form>

      <%= if @error do %>
        <div class="alert alert-error mb-6">{@error}</div>
      <% end %>

      <%= if @result do %>
        <.path_results result={@result} />
      <% end %>
    </Layouts.app>
    """
  end

  defp path_results(assigns) do
    ~H"""
    <%!-- Map --%>
    <div
      id="path-map"
      phx-hook="ContactMap"
      phx-update="ignore"
      data-pos1={Jason.encode!(%{lat: @result.source.lat, lon: @result.source.lon})}
      data-pos2={Jason.encode!(%{lat: @result.destination.lat, lon: @result.destination.lon})}
      data-station1={@result.source.label}
      data-station2={@result.destination.label}
      class="w-full h-64 md:h-80 rounded-box overflow-hidden mb-4"
    >
    </div>

    <%!-- Terrain profile --%>
    <%= if @result.terrain do %>
      <div
        id="path-elevation"
        phx-hook="ElevationProfile"
        phx-update="ignore"
        data-profile={Jason.encode!(elevation_data(@result))}
        class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200 p-2"
      >
        <canvas></canvas>
      </div>
    <% end %>

    <%!-- Link Summary --%>
    <div class="bg-base-200 rounded-box p-4 mb-4">
      <div class="text-xs opacity-60 mb-2">
        Link Summary &middot; {@result.band_config.label}
      </div>
      <div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
        <div>
          <span class="opacity-60">Distance</span>
          <div class="font-mono text-lg">{format_km_mi(@result.dist_km)}</div>
        </div>
        <div>
          <span class="opacity-60">Bearing</span>
          <div class="font-mono text-lg">{format_number(@result.bearing)}&deg;</div>
        </div>
        <%= if @result.terrain do %>
          <div>
            <span class="opacity-60">Terrain</span>
            <div>
              <span class={terrain_verdict_class(@result.terrain.analysis.verdict)}>
                {@result.terrain.analysis.verdict}
              </span>
            </div>
          </div>
        <% end %>
        <%= if @result.scoring do %>
          <div>
            <span class="opacity-60">Propagation</span>
            <div class="font-mono text-lg" style={"color: #{tier_color(@result.scoring.score)}"}>
              {@result.scoring.score}/100
            </div>
            <div class="text-xs" style={"color: #{tier_color(@result.scoring.score)}"}>
              {tier_label(@result.scoring.score)}
            </div>
          </div>
        <% end %>
        <div>
          <span class="opacity-60">HRRR Data</span>
          <div class="font-mono">{@result.hrrr_count} / 9 points</div>
        </div>
      </div>
    </div>

    <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
      <%!-- Loss Budget --%>
      <div class="bg-base-200 rounded-box p-4">
        <div class="text-xs opacity-60 mb-2">
          Loss Budget &middot; {@result.band_config.label}
        </div>
        <div class="space-y-1 text-sm">
          <.budget_row label="FSPL (free-space)" value={@result.loss_budget.fspl} unit="dB" />
          <.budget_row
            label="Gaseous (O₂+H₂O)"
            value={Float.round(@result.loss_budget.o2 + @result.loss_budget.h2o, 2)}
            unit="dB"
          />
          <%= if @result.loss_budget.diffraction > 0 do %>
            <.budget_row
              label="Diffraction loss"
              value={@result.loss_budget.diffraction}
              unit="dB"
              highlight
            />
          <% end %>
          <%= if @result.loss_budget.rain > 0 do %>
            <.budget_row label="Rain attenuation" value={@result.loss_budget.rain} unit="dB" />
          <% end %>
          <div class="divider my-1"></div>
          <.budget_row
            label="TOTAL PATH LOSS"
            value={@result.loss_budget.total}
            unit="dB"
            bold
          />
        </div>
        <div class="mt-3 text-xs opacity-50 space-y-0.5">
          <div>
            Gas rate: {format_number(
              Float.round(
                (@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001),
                4
              )
            )} dB/km
          </div>
          <div>
            H&#8322;O: {format_number(@result.band_config.h2o_coeff)} dB/km/g &middot;
            O&#8322;: {format_number(@result.band_config.o2_db_km)} dB/km
          </div>
          <%= if @result.conditions do %>
            <div>Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m&sup3;</div>
          <% end %>
        </div>
      </div>

      <%!-- Power Budget --%>
      <div class="bg-base-200 rounded-box p-4">
        <div class="text-xs opacity-60 mb-2">Power Budget</div>
        <div class="space-y-1 text-sm">
          <.budget_row
            label="TX Power"
            value={@result.power_budget.tx_power_dbm}
            unit="dBm"
            note={"(#{format_mw(@result.station_params.tx_power_mw)})"}
          />
          <.budget_row
            label="TX Ant Gain"
            value={@result.station_params.src_gain_dbi}
            unit="dBi"
            positive
          />
          <.budget_row label="Feed Loss (×2)" value={-2.0} unit="dB" />
          <div class="divider my-1"></div>
          <.budget_row label="EIRP" value={@result.power_budget.eirp_dbm - 2.0} unit="dBm" bold />
          <.budget_row
            label="Path Loss (w/FF)"
            value={-@result.loss_budget.total}
            unit="dB"
            highlight
          />
          <.budget_row
            label="RX Ant Gain"
            value={@result.station_params.dst_gain_dbi}
            unit="dBi"
            positive
          />
          <div class="divider my-1"></div>
          <.budget_row
            label="RX Power"
            value={@result.power_budget.rx_power_dbm - 2.0}
            unit="dBm"
            bold
          />
          <.budget_row label="Sensitivity (CW)" value={-141.0} unit="dBm" />
          <div class="divider my-1"></div>
          <% margin = @result.power_budget.margin_cw - 2.0 %>
          <.budget_row
            label="MARGIN"
            value={Float.round(margin, 1)}
            unit="dB"
            bold
            color={if margin > 0, do: "text-success", else: "text-error"}
          />
        </div>
      </div>
    </div>

    <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
      <%!-- Path Weather --%>
      <%= if @result.conditions do %>
        <div class="bg-base-200 rounded-box p-4">
          <div class="text-xs opacity-60 mb-3">Path Weather Avg</div>
          <div class="grid grid-cols-2 gap-x-6 gap-y-3">
            <div>
              <span class="text-xs opacity-60">Temperature</span>
              <div class="font-mono text-xl">{format_number(@result.conditions.temp_f)}&deg;F</div>
              <div class="font-mono text-xs opacity-50">
                {format_number(@result.conditions.temp_c)}&deg;C
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">Dewpoint</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.dewpoint_f)}&deg;F
              </div>
              <div class="font-mono text-xs opacity-50">
                {format_number(@result.conditions.dewpoint_c)}&deg;C
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">T/Td Gap</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}&deg;F
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">Abs Humidity</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.abs_humidity)} g/m&sup3;
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">Pressure</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.pressure_mb || 0)} mb
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">PWAT</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.pwat_mm || 0)} mm
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">BL Depth</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.bl_depth_m || 0)} m
              </div>
            </div>
            <div>
              <span class="text-xs opacity-60">Refrac Gradient</span>
              <div class="font-mono text-xl">
                {format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
              </div>
            </div>
          </div>
        </div>
      <% end %>

      <%!-- Propagation Factors --%>
      <%= if @result.scoring do %>
        <div class="bg-base-200 rounded-box p-4">
          <div class="text-xs opacity-60 mb-3">
            Propagation Factors &middot; {@result.band_config.label}
          </div>
          <.factor_bars scoring={@result.scoring} />
        </div>
      <% end %>
    </div>

    <%!-- Atmospheric Profile (HRRR) --%>
    <%= if @result.hrrr_points != [] do %>
      <.atmospheric_profile hrrr_points={@result.hrrr_points} sounding={@result.sounding} />
    <% end %>

    <%!-- Ionosphere (GIRO sporadic-E readout) --%>
    <%= if @result.ionosphere do %>
      <.ionosphere_readout readout={@result.ionosphere} band_mhz={@result.band_mhz} />
    <% end %>

    <%!-- 18-Hour Forecast --%>
    <%= if @result.forecast != [] do %>
      <.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
    <% end %>

    <%!-- Share link --%>
    <div class="mb-6">
      <button
        type="button"
        phx-hook="CopyLink"
        id="copy-link"
        class="btn btn-sm btn-outline gap-1"
      >
        <.icon name="hero-link" class="size-3.5" /> Copy Link
      </button>
    </div>
    """
  end

  attr :hrrr_points, :list, required: true
  attr :sounding, :any, default: nil

  defp atmospheric_profile(assigns) do
    mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points)
    valid_time = mid.profile.valid_time
    run_time = Map.get(mid.profile, :run_time)
    duct_count = Enum.count(assigns.hrrr_points, & &1.profile.ducting_detected)
    total_points = length(assigns.hrrr_points)

    assigns =
      assigns
      |> assign(:mid, mid)
      |> assign(:valid_time, valid_time)
      |> assign(:run_time, run_time)
      |> assign(:duct_count, duct_count)
      |> assign(:total_points, total_points)

    ~H"""
    <div class="bg-base-200 rounded-box p-4 mb-6">
      <div class="flex flex-wrap items-center gap-x-4 gap-y-2 mb-3">
        <h3 class="text-base font-semibold">Atmospheric Profile</h3>
        <span class="badge badge-outline badge-sm whitespace-nowrap">HRRR (3 km)</span>
        <span class="text-xs opacity-60 whitespace-nowrap">
          Valid {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
        </span>
        <%= if @run_time do %>
          <span class="text-xs opacity-60 whitespace-nowrap">
            Run {Calendar.strftime(@run_time, "%Y-%m-%d %H:%M UTC")}
          </span>
        <% end %>
        <span class="text-xs opacity-60 whitespace-nowrap">
          {@total_points} path samples
        </span>
        <%= if @duct_count > 0 do %>
          <span class="badge badge-warning badge-sm whitespace-nowrap">
            Duct at {@duct_count} / {@total_points} HRRR points
          </span>
        <% end %>
        <%= if @sounding && @sounding.ducting_detected do %>
          <span class="badge badge-warning badge-sm whitespace-nowrap">
            RAOB {@sounding.station_code} confirms duct
          </span>
        <% end %>
      </div>

      <%= if @sounding do %>
        <div class="text-xs opacity-70 mb-3">
          Nearest sounding: <span class="font-mono">{@sounding.station_code}</span>
          <%= if @sounding.station_name do %>
            ({@sounding.station_name})
          <% end %>
          <%= if @sounding.distance_km do %>
            · <span class="font-mono">{format_km_mi(@sounding.distance_km)}</span> from midpoint
          <% end %>
          · obs
          <span class="font-mono">
            {Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")}
          </span>
          <%= if @sounding.min_refractivity_gradient do %>
            · dN/dh
            <span class="font-mono">
              {format_number(@sounding.min_refractivity_gradient)} N/km
            </span>
          <% end %>
          <%= if @sounding.precipitable_water_mm do %>
            · PWAT <span class="font-mono">{format_number(@sounding.precipitable_water_mm)} mm</span>
          <% end %>
        </div>
      <% end %>

      <div class="overflow-x-auto">
        <table class="table table-xs table-zebra">
          <thead>
            <tr>
              <th>Point</th>
              <th>Lat, Lon</th>
              <th>Temp</th>
              <th>Dewpt</th>
              <th>Press</th>
              <th>HPBL</th>
              <th>PWAT</th>
              <th>N<sub>s</sub></th>
              <th>dN/dh</th>
            </tr>
          </thead>
          <tbody>
            <%= for pt <- @hrrr_points do %>
              <tr>
                <td class="font-semibold">{pt.label}</td>
                <td class="font-mono opacity-70">
                  {format_number(pt.profile.lat)}, {format_number(pt.profile.lon)}
                </td>
                <td>{format_number(pt.profile.surface_temp_c)}&deg;C</td>
                <td>{format_number(pt.profile.surface_dewpoint_c)}&deg;C</td>
                <td>{format_number(pt.profile.surface_pressure_mb)} mb</td>
                <td>{format_number(pt.profile.hpbl_m)} m</td>
                <td>{format_number(pt.profile.pwat_mm)} mm</td>
                <td>{format_number(pt.profile.surface_refractivity)}</td>
                <td>{format_number(pt.profile.min_refractivity_gradient)}</td>
              </tr>
            <% end %>
          </tbody>
        </table>
      </div>

      <%= if @mid.profile.profile && @mid.profile.profile != [] do %>
        <div class="text-xs opacity-60 mt-4">
          Midpoint vertical profile
        </div>
        <.skew_t_chart profile={@mid.profile.profile} />
      <% end %>
    </div>
    """
  end

  attr :readout, :map, required: true
  attr :band_mhz, :integer, required: true

  defp ionosphere_readout(assigns) do
    ~H"""
    <div class="bg-base-200 rounded-box p-4 mb-6">
      <div class="flex items-center justify-between mb-2">
        <div class="text-xs opacity-60">Ionosphere &middot; Sporadic-E Potential</div>
        <div class="text-[10px] opacity-50">
          {@readout.station_code} · {Calendar.strftime(@readout.valid_time, "%Y-%m-%d %H:%M UTC")}
        </div>
      </div>

      <div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
        <div>
          <div class="text-[10px] uppercase tracking-wider opacity-60">foEs</div>
          <div class="font-bold text-lg">
            {format_foes(@readout.fo_es_mhz)}
            <span class="text-xs font-normal opacity-60">MHz</span>
          </div>
        </div>
        <div>
          <div class="text-[10px] uppercase tracking-wider opacity-60">foF2</div>
          <div class="font-bold text-lg">
            {format_foes(@readout.fo_f2_mhz)}
            <span class="text-xs font-normal opacity-60">MHz</span>
          </div>
        </div>
        <div>
          <div class="text-[10px] uppercase tracking-wider opacity-60">Es MUF (path)</div>
          <div class="font-bold text-lg">
            {format_foes(@readout.es_muf_mhz)}
            <span class="text-xs font-normal opacity-60">MHz</span>
          </div>
        </div>
        <div>
          <div class="text-[10px] uppercase tracking-wider opacity-60">
            Es Score ({@band_mhz} MHz)
          </div>
          <div class="font-bold text-lg" style={"color: #{tier_color(@readout.es_score)}"}>
            {@readout.es_score}
          </div>
        </div>
      </div>

      <div class="text-[11px] opacity-70 mt-3 leading-snug">
        <%= cond do %>
          <% not @readout.es_in_range? -> %>
            Path length is outside the single-hop Es window (500–2500 km) — tropo only for this distance.
          <% @readout.es_score == 0 -> %>
            No sporadic-E propagation predicted: the layer is not ionised enough at {@readout.station_code} to reflect {@band_mhz} MHz at this path length.
          <% @readout.es_score >= 80 -> %>
            Strong sporadic-E opening predicted — the Es MUF exceeds {@band_mhz} MHz for this path length.
          <% @readout.es_score >= 50 -> %>
            Marginal sporadic-E opening: MUF is near the band threshold, worth monitoring.
          <% true -> %>
            Weak sporadic-E fringe — the layer is ionised but not quite enough to reliably reflect {@band_mhz} MHz.
        <% end %>
      </div>
    </div>
    """
  end

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

  attr :forecast, :list, required: true
  attr :band_config, :map, required: true

  defp forecast_chart(assigns) do
    points = build_forecast_points(assigns.forecast)

    assigns =
      assigns
      |> assign(:points, points)
      |> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end))
      |> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points))
      |> assign(:best_pt, Enum.max_by(points, & &1.score))
      |> assign(:worst_pt, Enum.min_by(points, & &1.score))
      |> assign(:trend, forecast_trend(points))
      |> assign(:x_labels, forecast_x_labels(points))

    ~H"""
    <div class="bg-base-200 rounded-box p-4 mb-6">
      <div class="flex items-center justify-between mb-2">
        <div class="text-xs opacity-60">
          {length(@forecast)}-Hour Propagation Forecast &middot; {@band_config.label}
        </div>
        <div class="text-xs font-bold">
          {Phoenix.HTML.raw(@trend)}
        </div>
      </div>
      <svg viewBox="0 0 1200 240" class="w-full aspect-[5/1]">
        <%= for s <- [0, 25, 50, 75, 100] do %>
          <% y = 20 + (100 - s) / 100 * 185 %>
          <line
            x1="55"
            y1={y}
            x2="1180"
            y2={y}
            stroke="currentColor"
            class="opacity-10"
            stroke-width="1"
          />
          <text
            x="48"
            y={y + 5}
            font-size="14"
            text-anchor="end"
            fill="currentColor"
            class="opacity-40"
          >
            {s}
          </text>
        <% end %>
        <polyline
          points={@polyline}
          fill="none"
          stroke={tier_color(@best_pt.score)}
          stroke-width="3"
          stroke-linejoin="round"
          stroke-linecap="round"
        />
        <%= for pt <- @points do %>
          <circle cx={pt.x} cy={pt.y} r="4" fill={tier_color(pt.score)} />
        <% end %>
        <circle
          cx={@now_pt.x}
          cy={@now_pt.y}
          r="8"
          fill="white"
          stroke={tier_color(@now_pt.score)}
          stroke-width="3"
        />
        <circle
          cx={@best_pt.x}
          cy={@best_pt.y}
          r="8"
          fill={tier_color(@best_pt.score)}
          stroke="white"
          stroke-width="2"
        />
        <text
          x={@best_pt.x}
          y={@best_pt.y - 12}
          font-size="16"
          text-anchor="middle"
          font-weight="700"
          fill={tier_color(@best_pt.score)}
        >
          {@best_pt.score}
        </text>
        <%= for {x, label} <- @x_labels do %>
          <text
            x={x}
            y="230"
            font-size="14"
            text-anchor="middle"
            fill="currentColor"
            class="opacity-40"
          >
            {label}
          </text>
        <% end %>
      </svg>
      <div class="flex justify-between text-xs mt-2">
        <span>
          Best:
          <span class="font-bold" style={"color: #{tier_color(@best_pt.score)}"}>
            {@best_pt.score}
          </span>
          at {@best_pt.label} UTC
        </span>
        <span>
          Worst:
          <span class="font-bold" style={"color: #{tier_color(@worst_pt.score)}"}>
            {@worst_pt.score}
          </span>
          at {@worst_pt.label} UTC
        </span>
      </div>
    </div>
    """
  end

  defp build_forecast_points(forecast) do
    n = length(forecast)
    now = DateTime.utc_now()

    # Map each forecast point to SVG coords (viewBox 0 0 1200 240)
    # plot area: x in [55, 1180], y in [20, 205]
    indexed = Enum.with_index(forecast)

    now_idx =
      indexed
      |> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end)
      |> elem(1)

    Enum.map(indexed, fn {p, i} ->
      x =
        if n <= 1 do
          617
        else
          55 + i / (n - 1) * 1125
        end

      y = 20 + (100 - p.score) / 100 * 185

      %{
        x: Float.round(x * 1.0, 2),
        y: Float.round(y * 1.0, 2),
        score: p.score,
        valid_time: p.valid_time,
        label: format_utc_hour(p.valid_time),
        now?: i == now_idx
      }
    end)
  end

  defp forecast_trend(points) do
    now_idx = Enum.find_index(points, & &1.now?) || 0
    future = Enum.drop(points, now_idx)

    case future do
      [first | _] ->
        last = List.last(future)
        diff = last.score - first.score

        cond do
          diff > 5 -> ~s(<span class="text-success">▲ Improving</span>)
          diff < -5 -> ~s(<span class="text-error">▼ Declining</span>)
          true -> ~s(<span class="text-warning">→ Steady</span>)
        end

      _ ->
        ~s(<span class="opacity-50">→ Steady</span>)
    end
  end

  defp forecast_x_labels(points) do
    n = length(points)

    indices =
      cond do
        n <= 1 -> [0]
        n <= 7 -> Enum.to_list(0..(n - 1))
        true -> label_indices(n, 7)
      end

    Enum.map(indices, fn i ->
      p = Enum.at(points, i)
      {p.x, p.label}
    end)
  end

  defp label_indices(n, target) do
    step = (n - 1) / (target - 1)

    0..(target - 1)
    |> Enum.map(fn k -> round(k * step) end)
    |> Enum.uniq()
  end

  attr :label, :string, required: true
  attr :value, :any, required: true
  attr :unit, :string, required: true
  attr :note, :string, default: nil
  attr :bold, :boolean, default: false
  attr :highlight, :boolean, default: false
  attr :positive, :boolean, default: false
  attr :color, :string, default: nil

  defp budget_row(assigns) do
    ~H"""
    <div class={["flex justify-between", @bold && "font-bold", @color]}>
      <span class={[@highlight && "text-warning"]}>{@label}</span>
      <span class="font-mono">
        <span class={[@positive && "text-success", @highlight && "text-warning"]}>
          {format_value(@value)}
        </span>
        <span class="opacity-60 text-xs">{@unit}</span>
        <%= if @note do %>
          <span class="opacity-50 text-xs ml-1">{@note}</span>
        <% end %>
      </span>
    </div>
    """
  end

  defp format_value(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
  defp format_value(v), do: to_string(v)

  defp factor_bars(assigns) do
    weights = BandConfig.weights()

    rows =
      assigns.scoring.factors
      |> Enum.map(fn {key, score} ->
        %{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)}
      end)
      |> Enum.sort_by(& &1.score, :desc)

    assigns = assign(assigns, :rows, rows)

    ~H"""
    <div class="space-y-2">
      <%= for row <- @rows do %>
        <div>
          <div class="flex justify-between text-xs mb-0.5">
            <span class="opacity-70">{row.name}</span>
            <span class="font-mono font-bold">{row.score}</span>
          </div>
          <div class="w-full bg-base-300 rounded-full h-2">
            <div
              class="h-2 rounded-full"
              style={"width: #{row.score}%; background-color: #{score_bar_color(row.score)}"}
            >
            </div>
          </div>
        </div>
      <% end %>
    </div>
    """
  end

  defp score_bar_color(s) when s >= 80, do: "#059669"
  defp score_bar_color(s) when s >= 65, do: "#0d9488"
  defp score_bar_color(s) when s >= 50, do: "#ca8a04"
  defp score_bar_color(s) when s >= 33, do: "#ea580c"
  defp score_bar_color(_), do: "#dc2626"

  defp factor_name(:humidity), do: "Humidity"
  defp factor_name(:time_of_day), do: "Time of Day"
  defp factor_name(:td_depression), do: "T-Td Depression"
  defp factor_name(:refractivity), do: "Refractivity"
  defp factor_name(:sky), do: "Sky Cover"
  defp factor_name(:season), do: "Season"
  defp factor_name(:wind), do: "Wind"
  defp factor_name(:rain), do: "Rain"
  defp factor_name(:pwat), do: "PWAT"
  defp factor_name(:pressure), do: "Pressure"

  defp elevation_data(result) do
    # ElevationProfile hook expects: points with dist_km, elev, beam, r1
    points =
      Enum.map(result.terrain.analysis.points, fn p ->
        %{
          dist_km: p.dist_km,
          elev: p.elev,
          beam: p[:beam] || 0,
          r1: p[:r1] || 0
        }
      end)

    %{
      points: points,
      freq_mhz: result.band_mhz,
      dist_km: result.dist_km,
      verdict: result.terrain.analysis.verdict,
      ducts: []
    }
  end
end
