defmodule MicrowavepropWeb.MapLive do
  @moduledoc false
  use MicrowavepropWeb, :live_view
  use LiveStash

  alias Microwaveprop.Propagation
  alias Microwaveprop.Propagation.BandConfig
  alias Microwaveprop.Propagation.PipelineStatus
  alias Microwaveprop.Terrain.Viewshed

  require Logger

  @default_band 10_000
  # Default map center when no visitor geolocation is available — DFW (EM12).
  @default_center %{lat: 32.897, lon: -97.038}
  @default_zoom 7

  # Continental-US bounding box (excludes Alaska, Hawaii, Puerto Rico). Used
  # to decide whether to show the "data is CONUS-only" banner to visitors
  # whose Cloudflare geolocation falls outside the dataset's coverage.
  @conus_lat_min 24.5
  @conus_lat_max 49.5
  @conus_lon_min -125.0
  @conus_lon_max -66.5

  # How often to re-query oban_jobs for pipeline state while the map is
  # open. Short enough that the "Updating…" chip shows up within a page-
  # visit for an in-progress hourly run, long enough to be cheap.
  @pipeline_status_refresh_ms 15_000

  # How often to check whether the wall clock has ticked into a new
  # forecast hour. 60s is frequent enough to advance within a minute
  # of top-of-hour and cheap enough to run for every connected map.
  @advance_now_cursor_ms 60_000

  @impl true
  def mount(params, session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
      Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline")
      Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)
      Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms)
    end

    socket =
      socket
      |> assign(:initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
      |> assign(:pipeline_status, PipelineStatus.current())
      |> assign(:pipeline_progress, nil)

    # LiveStash only persists the keys passed to `stash_assigns/2`
    # (selected_band + selected_time). On reconnect the recovered socket has
    # just those — the rest of the mount-time assigns (bands, bounds, the
    # initial score JSON payload, toggles, etc.) have to be rebuilt or the
    # first render crashes with KeyError on :initial_scores_json.
    {recovered_band, recovered_time, socket} =
      case LiveStash.recover_state(socket) do
        {:recovered, recovered} ->
          {recovered.assigns[:selected_band], recovered.assigns[:selected_time], recovered}

        _ ->
          {nil, nil, socket}
      end

    bands = BandConfig.all_bands()

    %{band: selected_band, time: selected_time, center: center, zoom: zoom, valid_times: valid_times} =
      resolve_view(params, session, recovered_band, recovered_time)

    visitor = visitor_location(session)
    bounds = bounds_around(center)
    initial_scores = Propagation.scores_at(selected_band, selected_time, bounds)

    # preload_forecast runs on the first map_bounds event instead of
    # here — mount only knows a hardcoded fallback bounding box, so
    # preloading now populates the forecast cache with scores for the
    # wrong viewport and forecast-hour clicks show a small square of
    # coverage instead of the full map.

    build_ts = Microwaveprop.Application.build_timestamp()

    {:ok,
     assign(socket,
       page_title: "Propagation Prediction Map",
       bands: bands,
       selected_band: selected_band,
       initial_scores_json: Jason.encode!(initial_scores),
       valid_times: valid_times,
       selected_time: selected_time,
       tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
       bounds: bounds,
       initial_center: center,
       initial_zoom: zoom,
       current_center: center,
       current_zoom: zoom,
       outside_conus: outside_conus?(visitor),
       grid_visible: false,
       radar_visible: false,
       deploy_iso: Calendar.strftime(build_ts, "%Y-%m-%d %H:%M UTC"),
       deploy_ago: format_deploy_ago(build_ts),
       preload_forecast_timer: nil
     )}
  end

  # Compact relative-time rendering of the build timestamp. Matches the
  # format used by Layouts.deploy_stamp/1 so the nav and the map
  # sidebar stay consistent.
  defp format_deploy_ago(%DateTime{} = dt) do
    diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)

    cond do
      diff < 60 -> "just now"
      diff < 3600 -> "#{div(diff, 60)}m ago"
      diff < 86_400 -> "#{div(diff, 3600)}h ago"
      true -> "#{div(diff, 86_400)}d ago"
    end
  end

  # Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
  defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
    %{lat: lat, lon: lon}
  end

  defp initial_center(_session), do: @default_center

  # Return the visitor's Cloudflare geolocation as a %{lat:, lon:} map, or
  # nil when the CF headers weren't set (local dev, direct connect, tests
  # without a stubbed session).
  defp visitor_location(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
    %{lat: lat, lon: lon}
  end

  defp visitor_location(_session), do: nil

  # True when the visitor's location is known and falls outside the CONUS
  # bounding box. Missing geolocation returns false so we don't flash a
  # banner at anyone we can't actually place.
  defp outside_conus?(nil), do: false

  defp outside_conus?(%{lat: lat, lon: lon}) do
    lat < @conus_lat_min or lat > @conus_lat_max or
      lon < @conus_lon_min or lon > @conus_lon_max
  end

  # Resolve the initial band/time/center/zoom from URL params, falling
  # back to LiveStash recovery, then session geolocation and defaults.
  # Parsers return nil for anything malformed so `||` walks to the next
  # sensible source; ISO8601 times that sit outside the current HRRR
  # window get dropped too so a stale shared link doesn't land the map
  # on a hour with no scores.
  defp resolve_view(params, session, recovered_band, recovered_time) do
    band = parse_band_param(params["band"]) || recovered_band || @default_band
    valid_times = Propagation.available_valid_times(band)
    time = resolve_time(params["t"], recovered_time, valid_times)
    center = parse_center_param(params["lat"], params["lon"]) || initial_center(session)
    zoom = parse_zoom_param(params["zoom"]) || @default_zoom

    %{band: band, time: time, center: center, zoom: zoom, valid_times: valid_times}
  end

  defp resolve_time(param, recovered_time, valid_times) do
    case parse_time_param(param) do
      %DateTime{} = t ->
        if time_in_window?(t, valid_times), do: t, else: closest_to_now(valid_times)

      nil ->
        recovered_time || closest_to_now(valid_times)
    end
  end

  defp time_in_window?(_, []), do: false

  defp time_in_window?(%DateTime{} = t, valid_times) do
    earliest = Enum.min(valid_times, DateTime)
    latest = Enum.max(valid_times, DateTime)
    DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt
  end

  # Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
  # around a given center so the initial HRRR score query still returns a
  # useful tile set for the visible area.
  defp bounds_around(%{lat: lat, lon: lon}) do
    %{
      "south" => lat - 3.4,
      "north" => lat + 3.4,
      "west" => lon - 4.5,
      "east" => lon + 4.5
    }
  end

  @impl true
  def handle_params(_params, _url, socket) do
    # URL params are applied in mount/3; subsequent push_patch calls
    # hit this callback with the same params that our own push_patch
    # just wrote, so there's nothing further to reconcile here.
    {:noreply, socket}
  end

  @impl true
  def handle_event("select_band", %{"value" => band}, socket) do
    band = if is_binary(band), do: String.to_integer(band), else: band
    valid_times = Propagation.available_valid_times(band)
    selected_time = closest_to_now(valid_times)
    scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)

    socket = schedule_preload_forecast(socket)

    socket =
      socket
      |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
      |> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
      |> LiveStash.stash_assigns([:selected_band, :selected_time])
      |> push_event("update_scores", %{scores: scores})
      |> push_event("update_band_info", %{band_info: band_info(band)})
      |> push_timeline()
      |> patch_map_url()

    {:noreply, socket}
  end

  def handle_event("select_time", %{"time" => time_str}, socket) do
    case DateTime.from_iso8601(time_str) do
      {:ok, time, _} ->
        scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)

        socket =
          socket
          |> assign(:selected_time, time)
          |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
          |> push_event("update_scores", %{scores: scores})
          |> patch_map_url()

        {:noreply, socket}

      _ ->
        {:noreply, socket}
    end
  end

  # Fast path: the client already has the preloaded hour's scores in its
  # local cache and only needs the server to remember which time is selected
  # (used later for point_detail and forecast state on reconnect).
  def handle_event("set_selected_time", %{"time" => time_str}, socket) do
    case DateTime.from_iso8601(time_str) do
      {:ok, time, _} ->
        socket =
          socket
          |> assign(:selected_time, time)
          |> assign(:tracking_now?, tracking_now?(time, socket.assigns.valid_times, DateTime.utc_now()))
          |> patch_map_url()

        {:noreply, socket}

      _ ->
        {:noreply, socket}
    end
  end

  def handle_event("toggle_grid", _params, socket) do
    visible = !socket.assigns.grid_visible

    socket =
      socket
      |> assign(:grid_visible, visible)
      |> push_event("toggle_grid", %{visible: visible})

    {:noreply, socket}
  end

  def handle_event("toggle_radar", _params, socket) do
    visible = !socket.assigns.radar_visible

    socket =
      socket
      |> assign(:radar_visible, visible)
      |> push_event("toggle_radar", %{visible: visible})

    {:noreply, socket}
  end

  def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
    band = socket.assigns.selected_band
    detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time)
    forecast = Propagation.point_forecast(band, lat, lon)

    forecast_data =
      Enum.map(forecast, fn f ->
        %{time: DateTime.to_iso8601(f.valid_time), score: f.score}
      end)

    # Rain-scatter (NEXRAD cell overlay + scatter-dB readings in the detail
    # panel) is disabled for now — the live radar WMS toggle covers the
    # "is there rain?" question and the scatter-propagation feature needs
    # more work before it's useful. To re-enable: restore the :rain_scatter
    # payload key and the start_async/handle_async pair below.
    payload =
      if detail,
        do: Map.put(detail, :forecast, forecast_data),
        else: %{}

    {:noreply, push_event(socket, "point_detail", payload)}
  end

  def handle_event("map_bounds", bounds, socket) do
    scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)

    socket =
      socket
      |> assign(:bounds, bounds)
      |> maybe_assign_center_zoom(bounds)
      |> push_event("update_scores", %{scores: scores})
      |> schedule_preload_forecast()
      |> patch_map_url(replace: true)

    {:noreply, socket}
  end

  def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
    band_mhz = socket.assigns.selected_band
    band_config = BandConfig.get(band_mhz)
    freq_ghz = band_mhz / 1_000

    score =
      case Propagation.point_detail(band_mhz, lat, lon) do
        %{score: s} -> s
        _ -> 50
      end

    max_range_km = score_range_km(score, band_config)

    socket =
      start_async(socket, :viewshed, fn ->
        Viewshed.compute(lat, lon,
          freq_ghz: freq_ghz,
          ant_height_m: 10.0,
          max_range_km: max_range_km,
          score: score
        )
      end)

    {:noreply, socket}
  end

  @impl true
  def handle_info(:preload_forecast, socket) do
    band = socket.assigns.selected_band
    bounds = socket.assigns.bounds
    selected = socket.assigns.selected_time

    hours =
      socket.assigns.valid_times
      |> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq))
      |> Enum.map(fn t ->
        %{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
      end)

    {:noreply,
     socket
     |> assign(:preload_forecast_timer, nil)
     |> push_event("preload_forecast", %{hours: hours})}
  end

  def handle_info({:propagation_updated, _valid_times}, socket) do
    band = socket.assigns.selected_band
    valid_times = Propagation.available_valid_times(band)
    # Stay on the same selected time if still available, else pick earliest
    selected_time =
      if socket.assigns.selected_time in valid_times do
        socket.assigns.selected_time
      else
        closest_to_now(valid_times)
      end

    # scores_at_fresh bypasses the cache and re-reads the .ntms file so
    # the map reflects the newly written forecast hour even when the
    # ScoreCache still holds the previous chain's bytes. The propagation
    # update arrives via `propagation:updated` while the cache refresh
    # rides `propagation:cache`; the two topics deliver independently
    # so reading through the cache here can race against stale data.
    scores =
      if selected_time,
        do: Propagation.scores_at_fresh(band, selected_time, socket.assigns.bounds),
        else: []

    socket = schedule_preload_forecast(socket)

    socket =
      socket
      |> assign(valid_times: valid_times, selected_time: selected_time)
      |> assign(:pipeline_status, PipelineStatus.current())
      |> push_event("update_scores", %{scores: scores})
      |> push_timeline()

    {:noreply, socket}
  end

  def handle_info(:advance_now_cursor, socket) do
    Process.send_after(self(), :advance_now_cursor, @advance_now_cursor_ms)

    if socket.assigns.tracking_now? do
      new_time = closest_to_now(socket.assigns.valid_times)

      if new_time && socket.assigns.selected_time &&
           DateTime.after?(new_time, socket.assigns.selected_time) do
        scores = Propagation.scores_at(socket.assigns.selected_band, new_time, socket.assigns.bounds)

        socket =
          socket
          |> assign(:selected_time, new_time)
          |> push_event("update_scores", %{scores: scores})
          |> push_timeline()
          |> patch_map_url(replace: true)

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

  def handle_info(:refresh_pipeline_status, socket) do
    Process.send_after(self(), :refresh_pipeline_status, @pipeline_status_refresh_ms)

    new_status = PipelineStatus.current()

    progress =
      if new_status.state == :running do
        socket.assigns[:pipeline_progress]
      end

    socket =
      socket
      |> assign(:pipeline_status, new_status)
      |> assign(:pipeline_progress, progress)

    {:noreply, socket}
  end

  def handle_info({:propagation_pipeline_progress, progress}, socket) do
    {:noreply, assign(socket, :pipeline_progress, progress)}
  end

  @impl true
  def handle_async(:viewshed, {:ok, result}, socket) do
    points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
    Logger.info("Viewshed complete: #{length(points)} boundary points")

    socket =
      push_event(socket, "viewshed_result", %{
        origin: result.origin,
        points: points
      })

    {:noreply, socket}
  end

  def handle_async(:viewshed, {:exit, reason}, socket) do
    Logger.warning("Viewshed computation failed: #{inspect(reason)}")
    {:noreply, socket}
  end

  defp push_timeline(socket) do
    times =
      Enum.map(socket.assigns.valid_times, fn t ->
        %{time: DateTime.to_iso8601(t), label: format_time_label(t)}
      end)

    selected =
      if socket.assigns.selected_time,
        do: DateTime.to_iso8601(socket.assigns.selected_time)

    push_event(socket, "update_timeline", %{times: times, selected: selected})
  end

  defp format_time_label(dt) do
    # Show as "HH:MM" in UTC
    Calendar.strftime(dt, "%H:%M")
  end

  @doc false
  # Render the small "Data from HH:MM UTC · Nh ago" indicator that sits
  # above the pipeline status chip. Tells the user at a glance which
  # valid_time the map is currently showing when the bottom timeline
  # isn't visible.
  @spec format_data_timestamp(DateTime.t() | nil) :: String.t()
  def format_data_timestamp(nil), do: "No data"

  def format_data_timestamp(%DateTime{} = dt) do
    diff_minutes = DateTime.diff(dt, DateTime.utc_now(), :minute)
    relative = format_relative_minutes(diff_minutes)
    "Data from #{Calendar.strftime(dt, "%H:%M")} UTC · #{relative}"
  end

  defp format_relative_minutes(diff) when diff >= -30 and diff <= 30, do: "now"

  defp format_relative_minutes(diff) when diff > 30 do
    # Future
    hours = div(diff + 30, 60)
    "+#{hours}h"
  end

  defp format_relative_minutes(diff) do
    # Past
    hours = div(-diff + 30, 60)
    "#{hours}h ago"
  end

  # --- URL param plumbing -----------------------------------------------

  # The JS hook piggybacks map center + zoom onto the bounds event
  # so the URL can carry a shareable view. Old payloads (no center)
  # should still work — fall back to keeping whatever we had.
  defp maybe_assign_center_zoom(socket, %{"center_lat" => lat, "center_lon" => lon, "zoom" => zoom})
       when is_number(lat) and is_number(lon) and is_number(zoom) do
    assign(socket, current_center: %{lat: lat, lon: lon}, current_zoom: trunc(zoom))
  end

  defp maybe_assign_center_zoom(socket, _), do: socket

  # Leaflet fires `moveend` on every pan + zoom, so bounds events arrive
  # in bursts during interactive map use. Each :preload_forecast delivers
  # 18 forecast-hour score lists filtered to the current viewport (up to
  # ~90k cells per hour at full CONUS view), so firing it per moveend
  # produced an 18× read + large websocket payload per pan. Debounce to
  # the trailing edge: the user has stopped moving for 750 ms before we
  # pay the preload cost.
  @preload_debounce_ms 750

  defp schedule_preload_forecast(%{assigns: %{preload_forecast_timer: ref}} = socket) when is_reference(ref) do
    _ = Process.cancel_timer(ref)
    do_schedule_preload_forecast(socket)
  end

  defp schedule_preload_forecast(socket), do: do_schedule_preload_forecast(socket)

  defp do_schedule_preload_forecast(socket) do
    ref = Process.send_after(self(), :preload_forecast, @preload_debounce_ms)
    assign(socket, :preload_forecast_timer, ref)
  end

  defp patch_map_url(socket, opts \\ []) do
    push_patch(socket, to: map_url(socket.assigns), replace: Keyword.get(opts, :replace, false))
  end

  defp map_url(assigns) do
    params =
      %{}
      |> Map.put("band", assigns.selected_band)
      |> put_if(assigns.selected_time, "t", fn dt -> DateTime.to_iso8601(dt) end)
      |> put_if(assigns[:current_center], "lat", fn c -> Float.round(c.lat, 3) end)
      |> put_if(assigns[:current_center], "lon", fn c -> Float.round(c.lon, 3) end)
      |> put_if(assigns[:current_zoom], "zoom", fn z -> trunc(z) end)

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

  defp put_if(map, nil, _key, _fun), do: map
  defp put_if(map, value, key, fun), do: Map.put(map, key, fun.(value))

  @doc false
  def parse_band_param(nil), do: nil

  def parse_band_param(str) when is_binary(str) do
    case Integer.parse(str) do
      {n, ""} -> if BandConfig.get(n), do: n
      _ -> nil
    end
  end

  @doc false
  def parse_time_param(nil), do: nil

  def parse_time_param(str) when is_binary(str) do
    case DateTime.from_iso8601(str) do
      {:ok, dt, _} -> DateTime.truncate(dt, :second)
      _ -> nil
    end
  end

  @doc false
  def parse_center_param(lat_s, lon_s) when is_binary(lat_s) and is_binary(lon_s) do
    with {lat, ""} <- Float.parse(lat_s),
         {lon, ""} <- Float.parse(lon_s),
         true <- lat >= -90.0 and lat <= 90.0,
         true <- lon >= -180.0 and lon <= 180.0 do
      %{lat: lat, lon: lon}
    else
      _ -> nil
    end
  end

  def parse_center_param(_, _), do: nil

  @doc false
  def parse_zoom_param(nil), do: nil

  def parse_zoom_param(str) when is_binary(str) do
    case Integer.parse(str) do
      {n, ""} when n >= 1 and n <= 18 -> n
      _ -> nil
    end
  end

  # --- end URL param plumbing -------------------------------------------

  defp closest_to_now(times), do: cursor_for_now(times, DateTime.utc_now())

  @doc false
  # Picks the forecast valid_time the map should display as "Now".
  # The rule is "latest valid_time at or before `now`" — a nearest-by-
  # absolute-distance pick can land on a future hour (e.g. at 18:35 UTC,
  # 19:00 is 25 min away vs. 18:00 at 35 min, so nearest would label
  # 19:00 "Now"). Falls back to the earliest time only if every slot is
  # in the future, which HRRR analysis + forecast lists shouldn't produce.
  @spec cursor_for_now([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
  def cursor_for_now([], _now), do: nil

  def cursor_for_now(times, %DateTime{} = now) do
    past_or_now = Enum.filter(times, &(DateTime.compare(&1, now) != :gt))

    case past_or_now do
      [] -> Enum.min_by(times, &DateTime.to_unix/1)
      past -> Enum.max_by(past, &DateTime.to_unix/1)
    end
  end

  @doc false
  # True when the map's current selected_time is still the "Now" slot,
  # i.e. the user hasn't scrubbed to a specific past/future hour. Used
  # by :advance_now_cursor to decide whether to auto-follow the wall
  # clock when it ticks into the next hour.
  @spec tracking_now?(DateTime.t() | nil, [DateTime.t()], DateTime.t()) :: boolean()
  def tracking_now?(nil, _times, _now), do: false
  def tracking_now?(_selected, [], _now), do: false

  def tracking_now?(%DateTime{} = selected, times, %DateTime{} = now) do
    case cursor_for_now(times, now) do
      nil -> false
      cursor -> DateTime.compare(selected, cursor) == :eq
    end
  end

  defp score_range_km(score, config) do
    cond do
      score >= 80 -> config.exceptional_range_km
      score >= 65 -> config.extended_range_km
      score >= 50 -> config.typical_range_km
      score >= 33 -> round(config.typical_range_km * 0.6)
      true -> round(config.typical_range_km * 0.3)
    end
  end

  defp band_info(band_mhz) do
    config = BandConfig.get(band_mhz)

    %{
      band_label: config.label,
      typical_range_km: config.typical_range_km,
      extended_range_km: config.extended_range_km,
      exceptional_range_km: config.exceptional_range_km,
      humidity_effect: to_string(config.humidity_effect),
      freq_mhz: config.freq_mhz
    }
  end

  defp selected_label(bands, selected_band) do
    case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
      nil -> "Select Band"
      band -> band.label
    end
  end

  attr :id, :string, required: true
  attr :status, :map, required: true
  attr :progress, :any, default: nil

  defp pipeline_status_chip(assigns) do
    details = chip_detail_labels(assigns.status, assigns.progress)

    assigns =
      assigns
      |> assign(:display_main, assigns.status.label)
      |> assign(:display_details, details)
      |> assign(:display_title, chip_title(assigns.status.label, details))

    ~H"""
    <div
      id={@id}
      data-pipeline-state={@status.state}
      class="flex items-start gap-1.5 text-xs px-1 py-1 leading-tight"
      title={@display_title}
    >
      <%= case @status.state do %>
        <% :running -> %>
          <.icon
            name="hero-arrow-path"
            class="size-3.5 shrink-0 mt-0.5 text-info motion-safe:animate-spin"
          />
          <span class="opacity-90 break-words min-w-0">
            {@display_main}
            <%= for d <- @display_details do %>
              <span class="block text-[11px] opacity-70">{d}</span>
            <% end %>
          </span>
        <% :idle -> %>
          <span class="size-2 rounded-full bg-success inline-block shrink-0 mt-[5px]"></span>
          <span class="opacity-70 break-words min-w-0">{@display_main}</span>
        <% :stale -> %>
          <span class="size-2 rounded-full bg-warning inline-block shrink-0 mt-[5px]"></span>
          <span class="opacity-80 break-words min-w-0">{@display_main}</span>
        <% :unknown -> %>
          <span class="size-2 rounded-full bg-base-content/30 inline-block shrink-0 mt-[5px]"></span>
          <span class="opacity-60 break-words min-w-0">{@display_main}</span>
      <% end %>
    </div>
    """
  end

  # Map the PipelineStatus details list to a plain list of sub-line
  # strings. For the grid worker specifically, the forecast-hour
  # progress payload overrides the base label so the chip can show
  # "+3h" / "now" instead of the generic "HRRR run".
  defp chip_detail_labels(%{state: :running, details: details}, progress) do
    override = PipelineStatus.running_detail(progress)

    Enum.map(details, fn
      %{worker: :grid} -> override || "HRRR run"
      %{label: label} -> label
    end)
  end

  defp chip_detail_labels(_status, _progress), do: []

  defp chip_title(main, []), do: main
  defp chip_title(main, details), do: "#{main} — #{Enum.join(details, " · ")}"

  @impl true
  def render(assigns) do
    ~H"""
    <div class="flex w-screen h-screen overflow-hidden">
      <%!-- Map container --%>
      <div class="relative flex-1 min-w-0">
        <%!-- CONUS-only data banner (shown only when visitor geolocation is
             outside the continental US) --%>
        <div
          :if={@outside_conus}
          id="conus-banner"
          role="status"
          class="alert alert-info absolute top-2 left-1/2 -translate-x-1/2 z-[1002] max-w-[calc(100vw-1rem)] md:max-w-xl shadow-lg text-sm"
        >
          <.icon name="hero-information-circle" class="size-5 shrink-0" />
          <span>
            This data set currently covers the continental United States only.
            You're viewing the map centered on your location, but propagation
            data won't be available there yet.
          </span>
        </div>

        <%!-- Full-page map --%>
        <div
          id="propagation-map"
          phx-hook="PropagationMap"
          phx-update="ignore"
          data-scores={@initial_scores_json}
          data-band-info={Jason.encode!(band_info(@selected_band))}
          data-valid-times={
            Jason.encode!(
              Enum.map(@valid_times, fn t ->
                %{time: DateTime.to_iso8601(t), label: format_time_label(t)}
              end)
            )
          }
          data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
          data-selected-band={@selected_band}
          data-center-lat={@initial_center.lat}
          data-center-lon={@initial_center.lon}
          data-zoom={@initial_zoom}
          class="absolute inset-0 z-0"
        >
        </div>

        <%!-- Mobile-only floating controls --%>
        <div
          id="map-controls"
          class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
        >
          <div
            data-theme="dark"
            class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
          >
            <div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
              <div class="min-w-0">
                <span>NTMS</span>
                <div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
              </div>
              <div class="flex items-center gap-1.5 shrink-0">
                <div
                  id="utc-clock"
                  phx-hook="UtcClock"
                  phx-update="ignore"
                  class="font-mono text-xs opacity-70 tabular-nums"
                >
                  {@initial_utc_clock}
                </div>
                <button
                  id="mobile-panel-toggle"
                  class="btn btn-xs btn-square btn-ghost"
                  onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
                >
                  <.icon name="hero-bars-3" class="size-4" />
                </button>
              </div>
            </div>

            <div
              id="data-timestamp-mobile"
              class="text-[11px] opacity-70 px-1 leading-tight"
            >
              {format_data_timestamp(@selected_time)}
            </div>

            <.pipeline_status_chip
              id="pipeline-status-mobile"
              status={@pipeline_status}
              progress={@pipeline_progress}
            />

            <div class="dropdown">
              <div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
                {selected_label(@bands, @selected_band)}
                <.icon name="hero-chevron-down" class="size-3" />
              </div>
              <ul
                tabindex="0"
                class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
              >
                <li :for={band <- @bands}>
                  <button
                    onclick="document.activeElement.blur()"
                    phx-click={
                      JS.dispatch("show-loading", to: "#propagation-map")
                      |> JS.push("select_band", value: %{value: band.freq_mhz})
                    }
                    class={[if(@selected_band == band.freq_mhz, do: "active")]}
                  >
                    {band.label}
                  </button>
                </li>
              </ul>
            </div>

            <div id="panel-extras" class="hidden flex-col gap-2">
              <label class="flex items-center gap-2 cursor-pointer px-1">
                <input
                  type="checkbox"
                  class="toggle toggle-sm toggle-primary"
                  checked={@grid_visible}
                  phx-click="toggle_grid"
                />
                <span class="text-sm">Grid squares</span>
              </label>

              <label class="flex items-center gap-2 cursor-pointer px-1">
                <input
                  type="checkbox"
                  class="toggle toggle-sm toggle-primary"
                  checked={@radar_visible}
                  phx-click="toggle_radar"
                />
                <span class="text-sm">Weather radar</span>
              </label>

              <div class="flex flex-col gap-1 border-t border-base-300 pt-2">
                <.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
                  Path Calculator
                </.link>
                <.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
                  Beacons
                </.link>
                <.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
                  Weather Map
                </.link>
                <.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
                  Submit a Contact
                </.link>
                <.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
                  Contact Training Data
                </.link>
                <.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
                  Contact Map
                </.link>
                <.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
                  Scoring Algorithm
                </.link>
                <.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
                  About
                </.link>
              </div>
            </div>
          </div>
        </div>

        <%!-- Point detail floating panel --%>
        <div
          id="detail-panel"
          phx-update="ignore"
          class={[
            "bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
            "fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
            "md:absolute md:top-auto md:right-auto md:bottom-24 md:left-3 md:max-h-[calc(100vh-8rem)] md:w-80 md:rounded-box"
          ]}
          style="display:none;"
        >
        </div>

        <%!-- Bottom timeline bar --%>
        <div
          id="forecast-timeline"
          phx-update="ignore"
          class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
        >
        </div>

        <%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
        <button
          id="sidebar-expand"
          class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
          onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
        >
          <.icon name="hero-bars-3" class="size-4" />
        </button>
      </div>

      <%!-- Desktop right sidebar --%>
      <div
        id="sidebar"
        data-theme="dark"
        class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
      >
        <%!-- Sidebar controls --%>
        <div class="p-3 flex flex-col gap-2 shrink-0">
          <div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
            <div class="min-w-0">
              <span>NTMS</span>
              <div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
            </div>
            <div class="flex items-center gap-1.5 shrink-0">
              <div
                id="utc-clock-desktop"
                phx-hook="UtcClock"
                phx-update="ignore"
                class="font-mono text-xs opacity-70 tabular-nums"
              >
                {@initial_utc_clock}
              </div>
              <button
                id="sidebar-collapse"
                class="btn btn-xs btn-square btn-ghost"
                title="Collapse sidebar"
                onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
              >
                <.icon name="hero-chevron-right" class="size-4" />
              </button>
            </div>
          </div>

          <%!-- Band selector --%>
          <div class="dropdown">
            <div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
              {selected_label(@bands, @selected_band)}
              <.icon name="hero-chevron-down" class="size-3" />
            </div>
            <ul
              tabindex="0"
              class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
            >
              <li :for={band <- @bands}>
                <button
                  onclick="document.activeElement.blur()"
                  phx-click={
                    JS.dispatch("show-loading", to: "#propagation-map")
                    |> JS.push("select_band", value: %{value: band.freq_mhz})
                  }
                  class={[if(@selected_band == band.freq_mhz, do: "active")]}
                >
                  {band.label}
                </button>
              </li>
            </ul>
          </div>

          <%!-- Grid toggle --%>
          <label class="flex items-center gap-2 cursor-pointer px-1">
            <input
              type="checkbox"
              class="toggle toggle-sm toggle-primary"
              checked={@grid_visible}
              phx-click="toggle_grid"
            />
            <span class="text-sm">Grid squares</span>
          </label>

          <%!-- Weather radar toggle --%>
          <label class="flex items-center gap-2 cursor-pointer px-1">
            <input
              type="checkbox"
              class="toggle toggle-sm toggle-primary"
              checked={@radar_visible}
              phx-click="toggle_radar"
            />
            <span class="text-sm">Weather radar</span>
          </label>

          <%!-- Navigation --%>
          <ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
            <li>
              <.link navigate="/path">Path Calculator</.link>
            </li>
            <li>
              <.link navigate="/beacons">Beacons</.link>
            </li>
            <li>
              <.link navigate="/weather">Weather Map</.link>
            </li>
            <li>
              <.link navigate="/submit">Submit a Contact</.link>
            </li>
            <li>
              <.link navigate="/contacts">Contact Training Data</.link>
            </li>
            <li>
              <.link navigate="/contacts/map">Contact Map</.link>
            </li>
            <li>
              <.link navigate="/algo">Scoring Algorithm</.link>
            </li>
            <li>
              <.link navigate="/about">About</.link>
            </li>
          </ul>

          <%!-- Auth --%>
          <ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
            <%= if @current_scope && @current_scope.user do %>
              <li>
                <.link navigate={~p"/u/#{@current_scope.user.callsign}"} class="font-semibold">
                  {@current_scope.user.callsign}
                </.link>
              </li>
              <li>
                <.link href={~p"/users/settings"}>Settings</.link>
              </li>
              <li>
                <.link href={~p"/users/log-out"} method="delete">Log out</.link>
              </li>
            <% else %>
              <li>
                <.link href={~p"/users/register"}>Register</.link>
              </li>
              <li>
                <.link href={~p"/users/log-in"}>Log in</.link>
              </li>
            <% end %>
          </ul>

          <div class="border-t border-base-300 pt-2">
            <div
              id="data-timestamp-desktop"
              class="text-[11px] opacity-70 px-1 pb-1 leading-tight"
            >
              {format_data_timestamp(@selected_time)}
            </div>

            <.pipeline_status_chip
              id="pipeline-status-desktop"
              status={@pipeline_status}
              progress={@pipeline_progress}
            />

            <div
              class="text-[11px] opacity-60 px-1 pt-2 leading-tight"
              title={"Last deployed #{@deploy_iso}"}
            >
              Deployed {@deploy_ago}
            </div>
          </div>
        </div>
      </div>
    </div>

    <Layouts.flash_group flash={@flash} />
    """
  end
end
