defmodule MicrowavepropWeb.RoverLive do
  @moduledoc """
  Rover-planning page: enter the fixed stations you want to work, pick a
  band and forecast hour, and the map paints a smoothed propagation
  quality heatmap with the top 5 ranked drive-to candidates pinned in the
  bottom strip.
  """

  use MicrowavepropWeb, :live_view

  import MicrowavepropWeb.LiveHelpers, only: [parse_int: 2]

  alias Microwaveprop.Accounts.User
  alias Microwaveprop.Buildings.Index, as: BuildingsIndex
  alias Microwaveprop.Geocoder
  alias Microwaveprop.Propagation
  alias Microwaveprop.Radio.CallsignClient
  alias Microwaveprop.Radio.Maidenhead
  alias Microwaveprop.Repo
  alias Microwaveprop.Rover
  alias Microwaveprop.Rover.CandidateDetail
  alias Microwaveprop.Rover.Compute
  alias Microwaveprop.Rover.DriveTime
  alias Microwaveprop.Rover.LinkMargin
  alias Microwaveprop.Terrain.Srtm

  require Logger

  @bands [
    %{label: "10 GHz", value: 10_000},
    %{label: "24 GHz", value: 24_000},
    %{label: "47 GHz", value: 47_000}
  ]

  @default_band 10_000
  @default_mode :ssb
  @default_forecast_hour 0
  @default_max_distance_mi 50
  @km_per_mi 1.609344
  @avg_speed_kmh 65.0

  # NTMS-area anonymous home: EM13 centroid.
  @anonymous_home %{label: "EM13", grid: "EM13", lat: 33.5, lon: -97.0, elev_m: nil}

  @impl true
  def mount(_params, _session, socket) do
    {fixed_stations, persisted?} = load_stations(socket)
    home = home_for(socket)
    valid_times = Propagation.available_valid_times(@default_band)
    current_valid_time = Propagation.latest_valid_time(@default_band)

    {:ok,
     assign(socket,
       page_title: "Rover Planner",
       bands: @bands,
       band: @default_band,
       mode: @default_mode,
       forecast_hour: @default_forecast_hour,
       max_distance_mi: @default_max_distance_mi,
       drive_radius_km: drive_radius_km(@default_max_distance_mi),
       fixed_stations: fixed_stations,
       persisted?: persisted?,
       home: home,
       valid_times: valid_times,
       current_valid_time: current_valid_time,
       top_candidates: [],
       selected_candidate: nil,
       station_form_error: nil,
       home_form_error: nil,
       scoring_loading: false,
       scoring_step: nil,
       only_good_locations: false,
       url_loaded?: false
     )}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    if socket.assigns.url_loaded? do
      {:noreply, socket}
    else
      socket =
        socket
        |> apply_url_stations(params)
        |> assign(url_loaded?: true)

      {:noreply, socket}
    end
  end

  defp apply_url_stations(%{assigns: %{persisted?: true}} = socket, _params), do: socket

  defp apply_url_stations(socket, %{"stations" => stations_str}) when is_binary(stations_str) do
    stations = parse_url_stations(stations_str)
    if stations == [], do: socket, else: assign(socket, fixed_stations: stations)
  end

  defp apply_url_stations(socket, _params), do: socket

  defp parse_url_stations(str) do
    str
    |> String.split(",", trim: true)
    |> Enum.with_index()
    |> Enum.flat_map(fn {entry, idx} -> entry |> parse_url_station_entry(idx) |> List.wrap() end)
  end

  defp parse_url_station_entry("-" <> rest, idx), do: parse_url_station_pair(rest, idx, false)
  defp parse_url_station_entry(entry, idx), do: parse_url_station_pair(entry, idx, true)

  defp parse_url_station_pair(entry, idx, selected?) do
    with [call, grid] <- String.split(entry, ":", parts: 2),
         call = String.upcase(String.trim(call)),
         grid = String.trim(grid),
         true <- call != "",
         {:ok, {lat, lon}} <- Maidenhead.to_latlon(grid) do
      %{
        id: "url-#{idx}",
        callsign: call,
        grid: grid,
        lat: lat,
        lon: lon,
        elevation_m: nil,
        selected: selected?,
        position: idx
      }
    else
      _ -> nil
    end
  end

  # ── Lifecycle helpers ────────────────────────────────────────────────

  defp load_stations(socket) do
    case current_user(socket) do
      %User{} = user ->
        case Rover.list_stations(user) do
          [] -> {seed_default_stations(user), true}
          stations -> {stations, true}
        end

      _ ->
        {Rover.default_stations(), false}
    end
  end

  defp seed_default_stations(user) do
    Enum.each(Rover.default_stations(), fn attrs ->
      Rover.create_station(user, Map.delete(attrs, :position))
    end)

    Rover.list_stations(user)
  end

  defp home_for(socket) do
    case current_user(socket) do
      %User{home_lat: lat, home_lon: lon, home_grid: grid, home_elevation_m: elev}
      when is_float(lat) and is_float(lon) ->
        %{label: grid || home_label(lat, lon), grid: grid, lat: lat, lon: lon, elev_m: elev}

      _ ->
        @anonymous_home
    end
  end

  defp current_user(socket) do
    case socket.assigns[:current_scope] do
      %{user: %User{} = user} -> user
      _ -> nil
    end
  end

  defp home_label(lat, lon), do: Maidenhead.from_latlon(lat, lon, 10)

  defp drive_radius_km(max_distance_mi), do: max_distance_mi * @km_per_mi

  # ── Events ────────────────────────────────────────────────────────────

  @impl true
  def handle_event("select_band", %{"band" => band_str}, socket) do
    band = parse_int(band_str, @default_band)
    {:noreply, assign(socket, band: band)}
  end

  def handle_event("set_forecast_hour", %{"value" => v}, socket) do
    hour = v |> parse_int(@default_forecast_hour) |> clamp(0, 18)
    {:noreply, assign(socket, forecast_hour: hour)}
  end

  def handle_event("set_max_distance", %{"value" => v}, socket) do
    miles = v |> parse_int(@default_max_distance_mi) |> clamp(25, 250)
    radius_km = drive_radius_km(miles)

    {:noreply,
     socket
     |> assign(max_distance_mi: miles, drive_radius_km: radius_km)
     |> push_event("update_drive_radius", %{km: radius_km})}
  end

  def handle_event("toggle_only_good", _params, socket) do
    {:noreply, assign(socket, only_good_locations: not socket.assigns.only_good_locations)}
  end

  def handle_event("toggle_station", %{"id" => id}, socket) do
    handle_station_mutation(socket, id, &toggle_station/2)
  end

  def handle_event("delete_station", %{"id" => id}, socket) do
    handle_station_mutation(socket, id, &delete_station/2)
  end

  def handle_event("add_station", params, socket) do
    callsign = params |> Map.get("callsign", "") |> String.trim()
    grid = params |> Map.get("grid", "") |> String.trim()

    case resolve_station_grid(callsign, grid) do
      {:ok, resolved_grid} ->
        attrs = build_station_attrs(callsign, resolved_grid)

        case create_station(socket, attrs) do
          {:ok, stations} ->
            {:noreply,
             socket
             |> assign_stations(stations)
             |> assign(station_form_error: nil)
             |> sync_url()}

          {:error, msg} ->
            {:noreply, assign(socket, station_form_error: msg)}
        end

      {:error, msg} ->
        {:noreply, assign(socket, station_form_error: msg)}
    end
  end

  def handle_event("update_station_grid", %{"station_id" => id, "grid" => new_grid}, socket) do
    new_grid = String.trim(new_grid)

    cond do
      new_grid == "" ->
        {:noreply, socket}

      not Maidenhead.valid?(new_grid) ->
        {:noreply, assign(socket, station_form_error: "invalid grid: #{new_grid}")}

      true ->
        case mutate_station_grid(socket, id, new_grid) do
          {:ok, stations} ->
            {:noreply,
             socket
             |> assign_stations(stations)
             |> assign(station_form_error: nil)
             |> sync_url()}

          {:error, msg} ->
            {:noreply, assign(socket, station_form_error: msg)}
        end
    end
  end

  def handle_event("set_home_qth", params, socket) do
    input = params |> Map.get("home_grid", "") |> String.trim()

    case resolve_home_input(input) do
      {:ok, %{lat: lat, lon: lon, grid: grid}} ->
        case update_home(socket, %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}) do
          {:ok, home} ->
            {:noreply,
             socket
             |> assign(home: home, home_form_error: nil)
             |> push_event("update_home", %{lat: home.lat, lon: home.lon})}

          {:error, msg} ->
            {:noreply, assign(socket, home_form_error: msg)}
        end

      {:error, msg} ->
        {:noreply, assign(socket, home_form_error: msg)}
    end
  end

  def handle_event("calculate", _params, socket) do
    {:noreply, start_scoring(socket)}
  end

  def handle_event("select_candidate", %{"grid" => grid}, socket) do
    case find_candidate(socket.assigns.top_candidates, grid) do
      nil ->
        case Maidenhead.to_latlon(grid) do
          {:ok, {lat, lon}} ->
            {:noreply, push_event(socket, "focus_cell", %{lat: lat, lon: lon, zoom: 11})}

          :error ->
            {:noreply, socket}
        end

      candidate ->
        detail = build_candidate_detail(candidate, socket)
        paths = candidate_path_payload(detail, socket.assigns.fixed_stations)

        {:noreply,
         socket
         |> assign(selected_candidate: detail)
         |> push_event("focus_cell", %{lat: candidate.lat, lon: candidate.lon, zoom: 11})
         |> push_event("candidate_paths", %{
           candidate: %{lat: candidate.lat, lon: candidate.lon},
           paths: paths
         })
         |> push_event("candidate_buildings", %{
           buildings: buildings_along_paths(candidate, paths)
         })}
    end
  end

  def handle_event("close_candidate_detail", _params, socket) do
    {:noreply,
     socket
     |> assign(selected_candidate: nil)
     |> push_event("candidate_paths", %{candidate: nil, paths: []})
     |> push_event("candidate_buildings", %{buildings: []})}
  end

  def handle_event("rover_cell_detail", %{"lat" => lat, "lon" => lon}, socket) do
    {:noreply, push_cell_popup(socket, lat, lon)}
  end

  # ── Async ────────────────────────────────────────────────────────────

  @impl true
  def handle_async(:scoring, {:ok, %{cells: cells, top_candidates: cands} = result}, socket) do
    warnings = Map.get(result, :warnings, [])

    {:noreply,
     socket
     |> assign(
       top_candidates: cands,
       selected_candidate: nil,
       scoring_loading: false,
       scoring_step: nil
     )
     |> apply_scoring_warnings(warnings)
     |> push_event("candidate_paths", %{candidate: nil, paths: []})
     |> push_event("rover_results", %{
       cells: cells,
       drive_radius_km: socket.assigns.drive_radius_km
     })}
  end

  def handle_async(:scoring, {:exit, reason}, socket) do
    Logger.error("rover scoring crashed: #{inspect(reason)}")

    {:noreply,
     socket
     |> assign(top_candidates: [], scoring_loading: false, scoring_step: nil)
     |> put_flash(:error, "Calculate failed: #{inspect(reason)}")}
  end

  @impl true
  def handle_info({:rover_progress, label, n, total}, socket) do
    {:noreply, assign(socket, scoring_step: "#{n}/#{total} · #{label}")}
  end

  defp apply_scoring_warnings(socket, []), do: clear_flash(socket)

  defp apply_scoring_warnings(socket, warnings) do
    put_flash(socket, :info, Enum.join(warnings, " "))
  end

  # When the user supplies just a callsign, look it up via QRZ (with the
  # Google Maps geocoder fallback already wired into CallsignClient) and
  # use the returned 10-char grid. Explicit user-entered grids win over
  # the lookup so an operator can override an old QRZ entry.
  defp resolve_station_grid(_call, grid) when is_binary(grid) and grid != "", do: {:ok, grid}

  defp resolve_station_grid("", _grid), do: {:error, "callsign required"}

  defp resolve_station_grid(call, _grid) do
    case CallsignClient.locate(call) do
      {:ok, %{gridsquare: g}} when is_binary(g) and byte_size(g) >= 4 ->
        {:ok, g}

      {:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) ->
        {:ok, Maidenhead.from_latlon(lat, lon, 10)}

      {:ok, _} ->
        {:error, "QRZ lookup for #{call} returned no grid or coords"}

      {:error, reason} ->
        {:error, "couldn't locate #{call}: #{inspect(reason)}"}
    end
  end

  # ── Mutation helpers ────────────────────────────────────────────────

  defp handle_station_mutation(socket, id, fun) do
    case fun.(socket, id) do
      {:ok, stations} ->
        {:noreply, socket |> assign_stations(stations) |> sync_url()}

      {:error, reason} ->
        Logger.error("RoverLive station mutation failed: id=#{inspect(id)} reason=#{inspect(reason)}")

        {:noreply, socket}
    end
  end

  defp assign_stations(socket, stations) do
    socket
    |> assign(fixed_stations: stations)
    |> push_event("stations_updated", %{stations: stations_for_compute(stations)})
  end

  defp sync_url(socket) do
    push_patch(socket, to: rover_path(socket), replace: true)
  end

  defp rover_path(socket) do
    case build_url_stations(socket.assigns.fixed_stations) do
      "" -> ~p"/rover"
      str -> ~p"/rover?#{[stations: str]}"
    end
  end

  defp build_url_stations(stations) do
    stations
    |> Enum.map(&encode_url_station/1)
    |> Enum.reject(&is_nil/1)
    |> Enum.join(",")
  end

  defp encode_url_station(%{callsign: call, grid: grid, selected: sel}) when is_binary(grid) and grid != "" do
    prefix = if sel, do: "", else: "-"
    "#{prefix}#{call}:#{grid}"
  end

  defp encode_url_station(_), do: nil

  defp toggle_station(%{assigns: %{persisted?: true}} = socket, id) do
    user = current_user(socket)

    with {:ok, _} <- Rover.toggle_selected(user, id) do
      {:ok, Rover.list_stations(user)}
    end
  end

  defp toggle_station(socket, id) do
    stations =
      Enum.map(socket.assigns.fixed_stations, fn s ->
        if station_id(s) == id, do: Map.put(s, :selected, !s.selected), else: s
      end)

    {:ok, stations}
  end

  defp delete_station(%{assigns: %{persisted?: true}} = socket, id) do
    user = current_user(socket)

    with {:ok, _} <- Rover.delete_station(user, id) do
      {:ok, Rover.list_stations(user)}
    end
  end

  defp delete_station(socket, id) do
    stations = Enum.reject(socket.assigns.fixed_stations, &(station_id(&1) == id))
    {:ok, stations}
  end

  defp mutate_station_grid(%{assigns: %{persisted?: true}} = socket, id, new_grid) do
    user = current_user(socket)

    case Rover.update_station(user, id, %{"grid" => new_grid, "lat" => nil, "lon" => nil}) do
      {:ok, _} -> {:ok, Rover.list_stations(user)}
      {:error, :not_found} -> {:error, "station not found"}
      {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)}
    end
  end

  defp mutate_station_grid(socket, id, new_grid) do
    stations =
      Enum.map(socket.assigns.fixed_stations, fn s ->
        if station_id(s) == id, do: replace_anonymous_station_grid(s, new_grid), else: s
      end)

    {:ok, stations}
  end

  defp replace_anonymous_station_grid(station, new_grid) do
    case Maidenhead.to_latlon(new_grid) do
      {:ok, {lat, lon}} -> %{station | grid: new_grid, lat: lat, lon: lon}
      :error -> station
    end
  end

  defp create_station(%{assigns: %{persisted?: true}} = socket, attrs) do
    user = current_user(socket)

    case Rover.create_station(user, attrs) do
      {:ok, _station} -> {:ok, Rover.list_stations(user)}
      {:error, %Ecto.Changeset{} = cs} -> {:error, changeset_first_error(cs)}
    end
  end

  defp create_station(socket, attrs) do
    case build_anonymous_station(attrs, length(socket.assigns.fixed_stations)) do
      {:ok, station} -> {:ok, socket.assigns.fixed_stations ++ [station]}
      {:error, msg} -> {:error, msg}
    end
  end

  defp build_anonymous_station(%{"callsign" => call} = attrs, position) when is_binary(call) and call != "" do
    grid = attrs["grid"]

    with {:ok, {lat, lon}} <- resolve_anonymous_latlon(grid, attrs["lat"], attrs["lon"]) do
      {:ok,
       %{
         id: "anon-#{:erlang.unique_integer([:positive])}",
         callsign: call |> String.trim() |> String.upcase(),
         grid: if(grid && grid != "", do: grid),
         lat: lat,
         lon: lon,
         elevation_m: nil,
         selected: true,
         position: position
       }}
    end
  end

  defp build_anonymous_station(_attrs, _position), do: {:error, "callsign required"}

  defp resolve_anonymous_latlon(grid, _lat, _lon) when is_binary(grid) and grid != "" do
    case Maidenhead.to_latlon(grid) do
      {:ok, {lat, lon}} -> {:ok, {lat, lon}}
      :error -> {:error, "invalid grid"}
    end
  end

  defp resolve_anonymous_latlon(_grid, lat, lon) when is_number(lat) and is_number(lon) do
    {:ok, {lat * 1.0, lon * 1.0}}
  end

  defp resolve_anonymous_latlon(_grid, _lat, _lon), do: {:error, "grid or lat/lon required"}

  defp build_station_attrs(callsign, grid) do
    %{}
    |> put_if_present("callsign", callsign)
    |> put_if_present("grid", grid)
  end

  defp put_if_present(map, _key, ""), do: map
  defp put_if_present(map, key, val), do: Map.put(map, key, val)

  defp update_home(%{assigns: %{persisted?: true}} = socket, attrs) do
    user = current_user(socket)

    case user |> User.change_home_qth(attrs) |> Repo.update() do
      {:ok, %User{} = updated} ->
        {:ok,
         %{
           label: updated.home_grid || home_label(updated.home_lat, updated.home_lon),
           grid: updated.home_grid,
           lat: updated.home_lat,
           lon: updated.home_lon,
           elev_m: updated.home_elevation_m
         }}

      {:error, %Ecto.Changeset{} = cs} ->
        {:error, changeset_first_error(cs)}
    end
  end

  defp update_home(_socket, %{"home_lat" => lat, "home_lon" => lon} = attrs) when is_number(lat) and is_number(lon) do
    grid = Map.get(attrs, "home_grid") || home_label(lat * 1.0, lon * 1.0)
    {:ok, %{label: grid, grid: grid, lat: lat * 1.0, lon: lon * 1.0, elev_m: nil}}
  end

  defp update_home(_socket, %{"home_grid" => grid}) when is_binary(grid) and grid != "" do
    case Maidenhead.to_latlon(grid) do
      {:ok, {lat, lon}} ->
        {:ok, %{label: grid, grid: grid, lat: lat, lon: lon, elev_m: nil}}

      :error ->
        {:error, "invalid grid"}
    end
  end

  defp update_home(_socket, _attrs), do: {:error, "grid required"}

  # Accepts grid (EM13qc), callsign (W5ABC), or address ("100 Main St, Dallas TX").
  # Returns {:ok, %{lat, lon, grid}} or {:error, "..."}.
  defp resolve_home_input(""), do: {:error, "enter a grid, callsign, or address"}

  defp resolve_home_input(input) do
    cond do
      Maidenhead.valid?(input) -> resolve_home_grid(input)
      callsign?(input) -> resolve_home_callsign(input)
      true -> resolve_home_address(input)
    end
  end

  defp resolve_home_grid(grid) do
    case Maidenhead.to_latlon(grid) do
      {:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, grid: grid}}
      :error -> {:error, "invalid grid"}
    end
  end

  defp resolve_home_callsign(call) do
    case CallsignClient.locate(call) do
      {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}}
      {:error, msg} -> {:error, "callsign: #{msg}"}
    end
  end

  defp resolve_home_address(addr) do
    case Geocoder.geocode(addr) do
      {:ok, %{lat: lat, lon: lon}} -> {:ok, %{lat: lat, lon: lon, grid: home_label(lat, lon)}}
      {:error, msg} -> {:error, "geocode: #{msg}"}
    end
  end

  defp callsign?(input) when is_binary(input) do
    String.match?(input, ~r/^[A-Za-z0-9\/]{3,12}$/) and not Maidenhead.valid?(input)
  end

  defp changeset_first_error(%Ecto.Changeset{errors: [{field, {msg, _}} | _]}) do
    "#{field}: #{msg}"
  end

  defp changeset_first_error(_), do: "could not save"

  # ── Cell popup ───────────────────────────────────────────────────────

  defp push_cell_popup(socket, lat, lon) do
    %{
      band: band,
      mode: mode,
      current_valid_time: valid_time,
      fixed_stations: stations,
      home: home
    } = socket.assigns

    grid = Maidenhead.from_latlon(lat, lon, 10)
    elev_m = lookup_elev(lat, lon)
    dist_km = haversine_km(home, %{lat: lat, lon: lon})

    drive_min = dist_km / @avg_speed_kmh * 60.0

    rows =
      stations
      |> Enum.filter(& &1.selected)
      |> Enum.map(fn s -> link_row(s, band, valid_time, lat, lon, mode) end)

    html = render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows)

    push_event(socket, "show_cell_popup", %{lat: lat, lon: lon, html: html})
  end

  defp link_row(station, band, valid_time, lat, lon, mode) do
    case LinkMargin.link_margin_db(
           band,
           valid_time,
           {lat, lon},
           {station.lat, station.lon},
           mode
         ) do
      nil -> %{call: station.callsign, predicted_db: nil, margin: nil}
      margin -> %{call: station.callsign, predicted_db: margin, margin: margin}
    end
  end

  defp render_cell_popup_html(grid, elev_m, drive_min, dist_km, rows) do
    rows_html =
      Enum.map_join(rows, "", fn r ->
        margin_str =
          case r.margin do
            nil -> "—"
            m -> :erlang.float_to_binary(m, decimals: 1) <> " dB"
          end

        ~s(<tr><td style="padding-right:8px">#{r.call}</td><td style="text-align:right">#{margin_str}</td></tr>)
      end)

    elev_str = if is_integer(elev_m), do: "#{elev_m} m", else: "—"

    """
    <div style="font-size:12px;min-width:200px">
      <div style="font-weight:600;font-size:13px">#{grid}</div>
      <div style="opacity:0.7">#{elev_str} · #{Float.round(drive_min, 0)} min · #{Float.round(dist_km, 0)} km</div>
      <table style="margin-top:6px;border-collapse:collapse;width:100%">#{rows_html}</table>
      <div style="margin-top:6px;display:flex;gap:8px">
        <a href="https://maps.apple.com/?q=#{grid}&ll=#{Float.round(:erlang.element(1, grid |> Maidenhead.to_latlon() |> elem(1)), 4)}" target="_blank" rel="noopener" style="color:#3b82f6">Open in Maps</a>
      </div>
    </div>
    """
  rescue
    _ -> "<div style=\"font-size:12px\">Cell detail unavailable</div>"
  end

  defp lookup_elev(lat, lon) do
    tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm")

    case Srtm.lookup(lat, lon, tiles_dir) do
      {:ok, e} -> e
      _ -> nil
    end
  end

  defp haversine_km(%{lat: lat1, lon: lon1}, %{lat: lat2, lon: lon2}) do
    DriveTime.haversine_km({lat1, lon1}, {lat2, lon2})
  end

  # ── Calculate ───────────────────────────────────────────────────────

  defp start_scoring(socket) do
    args = scoring_args(socket)
    pid = self()
    progress = fn label, n, total -> send(pid, {:rover_progress, label, n, total}) end

    socket
    |> assign(scoring_loading: true, scoring_step: "Starting…")
    |> start_async(:scoring, fn -> Compute.run(args, progress: progress) end)
  end

  defp scoring_args(socket) do
    base = %{
      home: socket.assigns.home,
      stations: stations_for_compute(socket.assigns.fixed_stations),
      band_mhz: socket.assigns.band,
      valid_time: socket.assigns.current_valid_time || DateTime.utc_now(),
      mode: socket.assigns.mode,
      max_distance_km: socket.assigns.drive_radius_km,
      min_elev_gain: 0
    }

    if socket.assigns.only_good_locations do
      goods =
        Rover.list_locations()
        |> Enum.filter(&(&1.status == :good))
        |> Enum.map(fn l -> %{lat: l.lat, lon: l.lon} end)

      Map.put(base, :good_locations, goods)
    else
      base
    end
  end

  defp find_candidate(candidates, grid) do
    Enum.find(candidates, fn c -> c.grid == grid end)
  end

  defp build_candidate_detail(candidate, socket) do
    selected = Enum.filter(socket.assigns.fixed_stations, & &1.selected)

    summary =
      CandidateDetail.summarize(
        candidate,
        selected,
        socket.assigns.band,
        socket.assigns.current_valid_time || DateTime.utc_now(),
        socket.assigns.mode
      )

    Map.merge(summary, %{
      candidate: candidate,
      links: Enum.map(summary.links, &decorate_link_profile/1)
    })
  end

  # Pre-render the elevation profile as an SVG path so the template
  # doesn't have to do float math inline.
  defp decorate_link_profile(link) do
    Map.put(link, :profile_svg, profile_to_svg(link.profile))
  end

  defp buildings_along_paths(candidate, paths) do
    paths
    |> Enum.flat_map(fn p ->
      sample_along({candidate.lat, candidate.lon}, {p.station_lat, p.station_lon})
    end)
    |> Enum.flat_map(fn {lat, lon} ->
      BuildingsIndex.records_near(lat, lon, 150)
    end)
    |> Enum.uniq_by(fn r -> {r.centroid_lat, r.centroid_lon} end)
    |> Enum.map(fn r ->
      %{
        lat: r.centroid_lat,
        lon: r.centroid_lon,
        radius_m: r.max_radius_m,
        height_m: r.height_m
      }
    end)
  end

  defp sample_along({lat1, lon1}, {lat2, lon2}) do
    n = 24

    for i <- 1..n do
      f = i / (n + 1)
      {lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)}
    end
  end

  defp candidate_path_payload(detail, fixed_stations) do
    by_callsign = Map.new(fixed_stations, fn s -> {s.callsign, s} end)

    Enum.flat_map(detail.links, fn link ->
      case Map.get(by_callsign, link.callsign) do
        nil ->
          []

        station ->
          [
            %{
              callsign: link.callsign,
              station_lat: station.lat,
              station_lon: station.lon,
              margin_db: link.margin_db
            }
          ]
      end
    end)
  end

  defp profile_to_svg([]), do: nil

  defp profile_to_svg(points) do
    tops =
      Enum.map(points, fn pt ->
        pt.elev + round(max(Map.get(pt, :building_m, 0.0), Map.get(pt, :canopy_m, 0)))
      end)

    elevs = Enum.map(points, & &1.elev)
    min_e = Enum.min(elevs)
    max_e = max(Enum.max(elevs), Enum.max(tops))
    span = max(max_e - min_e, 1)
    first = List.first(points)
    last = List.last(points)
    total_km = if last && last.dist_km > 0, do: last.dist_km, else: 1.0

    line =
      Enum.map_join(points, " ", fn pt ->
        x = pt.dist_km / total_km * 240.0
        y = 60.0 - (pt.elev - min_e) / span * 50.0
        "#{Float.round(x, 2)},#{Float.round(y, 2)}"
      end)

    fill = "0,60 " <> line <> " 240,60"

    has_buildings? = Enum.any?(points, fn pt -> Map.get(pt, :building_m, 0.0) > 0.5 end)
    has_canopy? = Enum.any?(points, fn pt -> Map.get(pt, :canopy_m, 0) > 1 end)

    terrain_back =
      points
      |> Enum.reverse()
      |> Enum.map_join(" ", fn pt ->
        x = pt.dist_km / total_km * 240.0
        y = 60.0 - (pt.elev - min_e) / span * 50.0
        "#{Float.round(x, 2)},#{Float.round(y, 2)}"
      end)

    obstacle_fill = fn obstacle_key ->
      obstacle_line =
        Enum.map_join(points, " ", fn pt ->
          x = pt.dist_km / total_km * 240.0
          top = pt.elev + round(Map.get(pt, obstacle_key, 0))
          y = 60.0 - (top - min_e) / span * 50.0
          "#{Float.round(x, 2)},#{Float.round(y, 2)}"
        end)

      obstacle_line <> " " <> terrain_back
    end

    canopy_fill = if has_canopy?, do: obstacle_fill.(:canopy_m)
    building_fill = if has_buildings?, do: obstacle_fill.(:building_m)

    los_y_start = Float.round(60.0 - (first.elev - min_e) / span * 50.0, 2)
    los_y_end = Float.round(60.0 - (last.elev - min_e) / span * 50.0, 2)

    %{
      line: line,
      fill: fill,
      canopy_fill: canopy_fill,
      building_fill: building_fill,
      start_elev_m: first.elev,
      end_elev_m: last.elev,
      total_km: total_km,
      los_y_start: los_y_start,
      los_y_end: los_y_end
    }
  end

  defp stations_for_compute(stations) do
    Enum.map(stations, fn s ->
      %{
        callsign: s.callsign,
        lat: s.lat,
        lon: s.lon,
        selected: !!s.selected
      }
    end)
  end

  # ── Helpers ─────────────────────────────────────────────────────────

  defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi)

  defp band_label(band) do
    Enum.find_value(@bands, "#{band} MHz", fn b -> if b.value == band, do: b.label end)
  end

  defp station_id(%{id: id}) when not is_nil(id), do: to_string(id)
  defp station_id(%{callsign: call}) when is_binary(call), do: "call:" <> call
  defp station_id(_), do: ""

  defp km_to_mi(km) when is_number(km), do: round(km * 0.621371)
  defp km_to_mi(_), do: 0

  defp elev_ft(nil), do: "—"
  defp elev_ft(m) when is_number(m), do: "#{round(m * 3.28084)} ft"

  defp format_margin(nil), do: "— dB"
  defp format_margin(db) when is_number(db), do: :erlang.float_to_binary(db * 1.0, decimals: 1) <> " dB"

  defp clearance_label(nil), do: "—"

  defp clearance_label(m) when is_number(m) do
    sign = if m >= 0, do: "+", else: ""
    "#{sign}#{round(m * 3.28084)} ft"
  end

  # ── Render ───────────────────────────────────────────────────────────

  @impl true
  def render(assigns) do
    ~H"""
    <div class="flex flex-col w-screen h-screen overflow-hidden">
      <header class="flex items-center justify-between h-11 px-4 bg-base-100 border-b border-base-300 shrink-0">
        <div class="flex flex-col leading-tight">
          <span class="font-semibold text-sm">Rover planning</span>
          <span class="text-xs text-base-content/60">
            {band_label(@band)} · Forecast +{@forecast_hour}h
          </span>
        </div>
        <div class="flex items-center gap-3">
          <span
            :if={@scoring_loading and @scoring_step}
            class="text-xs text-base-content/60 font-mono"
          >
            {@scoring_step}
          </span>
          <button
            type="button"
            phx-click="calculate"
            class="btn btn-primary btn-sm"
            disabled={@scoring_loading}
          >
            <span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
            {if @scoring_loading, do: "Calculating…", else: "Calculate"}
          </button>
        </div>
      </header>

      <div class="flex flex-1 min-h-0">
        <div class="relative flex-1 min-w-0">
          <div
            id="rover-map"
            phx-hook="RoverMap"
            phx-update="ignore"
            data-home-lat={@home.lat}
            data-home-lon={@home.lon}
            data-drive-radius-km={@drive_radius_km}
            data-stations={Jason.encode!(stations_for_compute(@fixed_stations))}
            class={[
              "absolute inset-0 z-0 transition-opacity",
              @scoring_loading && "opacity-60"
            ]}
          >
          </div>

          <.candidate_detail_panel :if={@selected_candidate} detail={@selected_candidate} />

          <div
            :if={@top_candidates != []}
            class="absolute bottom-0 inset-x-0 bg-base-100/90 backdrop-blur border-t border-base-300 z-10"
          >
            <div class="px-3 pt-1 text-[10px] uppercase tracking-wider text-base-content/60 font-semibold">
              Good locations
            </div>
            <div
              id="rover-candidates-strip"
              phx-hook="HorizontalWheelScroll"
              class="flex flex-row flex-nowrap gap-3 px-3 pb-3 pt-1 h-[110px] overflow-x-auto overflow-y-hidden overscroll-x-contain"
            >
              <div
                :for={c <- @top_candidates}
                class="shrink-0 w-[280px] card bg-base-200 shadow-sm cursor-pointer"
                phx-click="select_candidate"
                phx-value-grid={c.grid}
              >
                <div class="card-body p-3">
                  <div class="flex items-center justify-between">
                    <span class="font-mono font-semibold">{c.grid}</span>
                    <span
                      class="badge badge-sm font-mono"
                      style={"background-color: #{c.tier_color}; color: #000"}
                    >
                      {Float.round(c.score, 1)} dB
                    </span>
                  </div>
                  <div class="text-xs opacity-70">
                    {km_to_mi(c.distance_km)} mi {c.bearing_compass} of home
                  </div>
                  <div class="text-xs opacity-70">
                    {elev_ft(c.elev_m)}
                  </div>
                </div>
              </div>
            </div>
          </div>

          <div class="absolute bottom-[140px] left-4 z-20 bg-base-100/90 rounded-box shadow p-2 text-xs flex flex-col gap-1">
            <div class="flex items-center gap-2">
              <span class="w-3 h-3 rounded-sm" style="background-color: #16a34a; opacity: 0.5"></span>
              <span>Excellent ≥10 dB</span>
            </div>
            <div class="flex items-center gap-2">
              <span class="w-3 h-3 rounded-sm" style="background-color: #eab308; opacity: 0.5"></span>
              <span>Good 3..10 dB</span>
            </div>
            <div class="flex items-center gap-2">
              <span class="w-3 h-3 rounded-sm" style="background-color: #f97316; opacity: 0.5"></span>
              <span>Marginal 0..3 dB</span>
            </div>
            <div class="flex items-center gap-2 pt-1 border-t border-base-300/50">
              <span class="w-4 border-t-2 border-dashed border-sky-500"></span>
              <span>Drive radius</span>
            </div>
          </div>
        </div>

        <aside
          id="rover-sidebar"
          data-theme="dark"
          class="hidden md:flex flex-col w-72 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-y-auto"
        >
          <.sidebar_section title="HOME">
            <.home_form home={@home} error={@home_form_error} persisted?={@persisted?} />
          </.sidebar_section>

          <.sidebar_section title="FIXED STATIONS">
            <div class="flex flex-col gap-0.5">
              <div :for={s <- @fixed_stations} class="flex items-center gap-2 py-1">
                <label class="cursor-pointer flex items-center gap-2 min-w-0 shrink-0">
                  <input
                    type="checkbox"
                    class="checkbox checkbox-xs"
                    checked={s.selected}
                    phx-click="toggle_station"
                    phx-value-id={station_id(s)}
                  />
                  <span class="font-mono text-xs truncate">{s.callsign}</span>
                </label>
                <form
                  phx-submit="update_station_grid"
                  class="flex-1 min-w-0"
                  id={"station-grid-form-" <> station_id(s)}
                >
                  <input type="hidden" name="station_id" value={station_id(s)} />
                  <input
                    type="text"
                    name="grid"
                    value={s.grid || ""}
                    placeholder="grid"
                    autocomplete="off"
                    spellcheck="false"
                    phx-debounce="blur"
                    title="Click to edit; press Enter or click away to save"
                    class="font-mono text-[11px] w-full bg-transparent border-0 border-b border-transparent focus:border-base-content/30 focus:bg-base-100/30 focus:outline-none px-1 py-0 opacity-60 focus:opacity-100"
                  />
                </form>
                <button
                  type="button"
                  class="btn btn-ghost btn-xs btn-square opacity-50 hover:opacity-100 shrink-0"
                  phx-click="delete_station"
                  phx-value-id={station_id(s)}
                  title="Remove"
                >
                  ×
                </button>
              </div>
            </div>
            <form phx-submit="add_station" class="flex gap-1 mt-2">
              <input
                type="text"
                name="callsign"
                placeholder="Call (grid auto-looked-up)"
                class="input input-bordered input-xs flex-1"
                autocomplete="off"
              />
              <input
                type="text"
                name="grid"
                placeholder="Grid"
                class="input input-bordered input-xs w-20"
                autocomplete="off"
              />
              <button type="submit" class="btn btn-xs btn-primary">+</button>
            </form>
            <p :if={@station_form_error} class="text-error text-[11px] mt-1">
              {@station_form_error}
            </p>
            <p :if={!@persisted?} class="text-[11px] opacity-70 mt-2">
              <.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link>
              to save your station list.
            </p>
          </.sidebar_section>

          <.sidebar_section title="FORECAST HOUR">
            <input
              type="range"
              min="0"
              max="18"
              step="1"
              value={@forecast_hour}
              phx-change="set_forecast_hour"
              phx-debounce="200"
              phx-hook="RoverSlider"
              phx-update="ignore"
              id="rover-forecast-slider"
              data-label-id="rover-forecast-label"
              data-format="forecast"
              name="value"
              class="range range-xs range-primary"
            />
            <div class="text-xs opacity-70 mt-1">
              <span id="rover-forecast-label">+{@forecast_hour}h</span>
            </div>
          </.sidebar_section>

          <.sidebar_section title="BAND">
            <div class="join w-full">
              <button
                :for={b <- @bands}
                type="button"
                phx-click="select_band"
                phx-value-band={b.value}
                class={[
                  "btn btn-sm join-item flex-1",
                  @band == b.value && "btn-primary"
                ]}
              >
                {b.label |> String.replace(" GHz", "G")}
              </button>
            </div>
          </.sidebar_section>

          <.sidebar_section title="MAX DISTANCE">
            <input
              type="range"
              min="25"
              max="250"
              step="25"
              value={@max_distance_mi}
              phx-change="set_max_distance"
              phx-debounce="200"
              phx-hook="RoverSlider"
              phx-update="ignore"
              id="rover-distance-slider"
              data-label-id="rover-distance-label"
              data-format="distance"
              name="value"
              class="range range-xs range-primary"
            />
            <div class="text-xs opacity-70 mt-1">
              <span id="rover-distance-label">{@max_distance_mi} mi</span> from {@home.label}
            </div>
          </.sidebar_section>

          <.sidebar_section title="CANDIDATES">
            <label class="flex items-start gap-2 cursor-pointer">
              <input
                type="checkbox"
                checked={@only_good_locations}
                phx-click="toggle_only_good"
                class="checkbox checkbox-sm checkbox-primary mt-0.5"
              />
              <span class="text-xs leading-tight">
                Only use known good locations
                <span class="block text-[10px] opacity-60 mt-0.5">
                  Restricts suggestions to spots tagged "good" in <a
                    href={~p"/rover-locations"}
                    class="link"
                    target="_blank"
                  >/rover-locations</a>.
                </span>
              </span>
            </label>
          </.sidebar_section>

          <div class="mt-auto p-3 text-[11px] text-neutral-content/50 border-t border-base-300/50">
            Tip: click any cell to see per-station predicted SNR.
          </div>
        </aside>
      </div>
    </div>
    """
  end

  attr :title, :string, required: true
  slot :inner_block, required: true

  defp sidebar_section(assigns) do
    ~H"""
    <section class="px-3 py-3 border-t border-base-300/50 first:border-t-0">
      <h3 class="text-[10px] uppercase tracking-wider text-neutral-content/60 mb-2">{@title}</h3>
      {render_slot(@inner_block)}
    </section>
    """
  end

  attr :home, :map, required: true
  attr :error, :string, default: nil
  attr :persisted?, :boolean, required: true

  defp home_form(assigns) do
    ~H"""
    <form phx-submit="set_home_qth" class="flex gap-1">
      <input
        type="text"
        name="home_grid"
        value={@home.grid || @home.label}
        placeholder="Home grid"
        class="input input-bordered input-xs flex-1 font-mono"
        autocomplete="off"
      />
      <button type="submit" class="btn btn-xs">Set</button>
    </form>
    <p :if={@error} class="text-error text-[11px] mt-1">{@error}</p>
    <p :if={!@persisted?} class="text-[11px] opacity-70 mt-1">
      <.link href={~p"/users/log-in"} class="link link-primary">Sign in</.link> to save.
    </p>
    """
  end

  attr :detail, :map, required: true

  defp candidate_detail_panel(assigns) do
    ~H"""
    <div class="absolute right-4 bottom-[140px] z-20 w-[340px] max-h-[60vh] bg-base-100/95 backdrop-blur rounded-box shadow-lg border border-base-300 overflow-hidden flex flex-col">
      <div class="flex items-center justify-between px-3 py-2 border-b border-base-300">
        <div class="flex flex-col leading-tight min-w-0">
          <span class="font-mono font-semibold text-sm truncate">{@detail.grid}</span>
          <span class="text-[11px] opacity-60">
            {km_to_mi(@detail.candidate.distance_km)} mi {@detail.candidate.bearing_compass} of home · {elev_ft(
              @detail.rover_elev_m || @detail.candidate.elev_m
            )}
          </span>
        </div>
        <button
          type="button"
          class="btn btn-ghost btn-xs btn-square"
          phx-click="close_candidate_detail"
        >
          ×
        </button>
      </div>

      <div class="flex flex-col divide-y divide-base-300/60 overflow-y-auto">
        <div :if={@detail.links == []} class="px-3 py-3 text-xs opacity-70">
          No selected stations to summarize.
        </div>
        <div :for={link <- @detail.links} class="px-3 py-2">
          <div class="flex items-center justify-between gap-2">
            <span class="font-mono text-sm font-semibold">{link.callsign}</span>
            <span class="font-mono text-xs">
              {format_margin(link.margin_db)} · {km_to_mi(link.distance_km)} mi
            </span>
          </div>
          <div class="text-[11px] opacity-80 mt-0.5">
            Aim <span class="font-mono">{round(link.bearing_deg)}°</span>
            ({link.bearing}) · clearance {clearance_label(link.clearance_m)}
          </div>
          <div class="text-[11px] opacity-60">
            rover {elev_ft(link.rover_elev_m)} · max obstacle {elev_ft(link.max_obstacle_m)}
          </div>
          <.profile_svg :if={link.profile_svg} svg={link.profile_svg} callsign={link.callsign} />
        </div>
      </div>
    </div>
    """
  end

  attr :svg, :map, required: true
  attr :callsign, :string, required: true

  defp profile_svg(assigns) do
    ~H"""
    <svg viewBox="0 0 240 64" class="w-full h-12 mt-1" preserveAspectRatio="none">
      <polygon points={@svg.fill} fill="rgb(14 165 233 / 0.25)" />
      <polygon
        :if={@svg.canopy_fill}
        points={@svg.canopy_fill}
        fill="rgb(34 197 94 / 0.45)"
        stroke="#16a34a"
        stroke-width="0.4"
      />
      <polygon
        :if={@svg.building_fill}
        points={@svg.building_fill}
        fill="rgb(220 38 38 / 0.55)"
        stroke="#dc2626"
        stroke-width="0.6"
      />
      <polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" />
      <line
        x1="0"
        y1={@svg.los_y_start}
        x2="240"
        y2={@svg.los_y_end}
        stroke="#f59e0b"
        stroke-width="1"
        stroke-dasharray="3 2"
        opacity="0.85"
      />
    </svg>
    <div class="flex justify-between text-[10px] opacity-60 font-mono gap-2">
      <span>rover {elev_ft(@svg.start_elev_m)}</span>
      <span class="opacity-70">{km_to_mi(@svg.total_km)} mi</span>
      <span>{@callsign} {elev_ft(@svg.end_elev_m)}</span>
    </div>
    """
  end
end
