defmodule AprsmeWeb.MapLive.Index do
  @moduledoc """
  LiveView for displaying real-time APRS packets on a map
  """
  use AprsmeWeb, :live_view

  import AprsmeWeb.MapLive.Components
  import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]

  import Phoenix.LiveView,
    only: [push_event: 3, put_flash: 3]

  alias Aprsme.Packets
  alias AprsmeWeb.Live.Shared.BoundsUtils
  alias AprsmeWeb.Live.Shared.CoordinateUtils
  alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
  alias AprsmeWeb.MapLive.BoundsUpdater
  alias AprsmeWeb.MapLive.DataBuilder
  alias AprsmeWeb.MapLive.DisplayManager
  alias AprsmeWeb.MapLive.Events
  alias AprsmeWeb.MapLive.HistoricalLoader
  alias AprsmeWeb.MapLive.Navigation
  alias AprsmeWeb.MapLive.PacketBatcher
  alias AprsmeWeb.MapLive.PacketProcessor
  alias AprsmeWeb.MapLive.State
  alias AprsmeWeb.MapLive.Subscriptions
  alias AprsmeWeb.MapLive.UrlParams
  alias AprsmeWeb.TimeUtils
  alias Phoenix.LiveView.JS

  # Import the new components module
  alias Phoenix.Socket.Broadcast

  require Logger

  @impl true
  def mount(params, session, socket) do
    # Basic setup
    deployed_at = Aprsme.Release.deployed_at()
    one_day_ago = TimeUtils.hours_ago(24)

    # Parse and determine map location
    {map_center, map_zoom, should_skip_initial_url_update} = Navigation.determine_map_location(params, session)

    # Parse trail duration and historical hours from URL
    trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string()
    historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string()

    # Calculate packet age threshold based on trail duration
    hours = String.to_integer(trail_duration)
    packet_age_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)

    # Setup defaults with parsed values
    socket = State.default(socket, packet_age_threshold)

    socket =
      assign(socket,
        initial_historical_completed: false,
        trail_duration: trail_duration,
        historical_hours: historical_hours
      )

    # Handle callsign tracking - check path params first, then query params
    tracked_callsign =
      case Map.get(params, "callsign") || Map.get(params, "call") do
        callsign when is_binary(callsign) and callsign != "" ->
          callsign |> String.trim() |> String.upcase()

        _ ->
          ""
      end

    {final_map_center, final_map_zoom} =
      Navigation.handle_callsign_tracking(
        tracked_callsign,
        map_center,
        map_zoom,
        UrlParams.has_explicit_url_params?(params)
      )

    # Calculate initial bounds
    initial_bounds = State.initial_bounds(final_map_center, final_map_zoom)
    socket = Subscriptions.setup(socket, initial_bounds)

    # Store client IP for rate-limiting in event handlers
    socket = Events.store_client_ip(socket)

    # Final socket assignment
    {:ok,
     State.finalize(socket, %{
       initial_bounds: initial_bounds,
       final_map_center: final_map_center,
       final_map_zoom: final_map_zoom,
       should_skip_initial_url_update: should_skip_initial_url_update,
       tracked_callsign: tracked_callsign,
       deployed_at: deployed_at,
       one_day_ago: one_day_ago
     })}
  end

  # Handle both bounds_changed and update_bounds events
  @impl true
  def handle_event(event, %{"bounds" => bounds}, socket) when event in ["bounds_changed", "update_bounds"] do
    Events.handle_bounds_changed(bounds, socket)
  end

  @impl true
  def handle_event("locate_me", _params, socket), do: Events.handle_locate_me(socket)

  @impl true
  def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket),
    do: Events.handle_set_location(lat, lng, socket)

  @impl true
  def handle_event("clear_and_reload_markers", _params, socket), do: Events.handle_clear_and_reload_markers(socket)

  @impl true
  def handle_event("map_ready", _params, socket), do: Events.handle_map_ready(socket)

  @impl true
  def handle_event("marker_clicked", _params, socket), do: Events.handle_marker_clicked(socket)

  @impl true
  def handle_event("marker_hover_start", %{"id" => id, "path" => path, "lat" => lat, "lng" => lng}, socket),
    do: Events.handle_marker_hover_start(id, path, lat, lng, socket)

  @impl true
  def handle_event("marker_hover_end", _params, socket), do: Events.handle_marker_hover_end(socket)

  @impl true
  def handle_event("update_callsign", %{"callsign" => callsign}, socket),
    do: Events.handle_update_callsign(callsign, socket)

  @impl true
  def handle_event("track_callsign", %{"callsign" => callsign}, socket),
    do: Events.handle_track_callsign(callsign, socket)

  @impl true
  def handle_event("clear_tracking", _params, socket), do: Events.handle_clear_tracking(socket)

  @impl true
  def handle_event("update_trail_duration", %{"trail_duration" => duration}, socket),
    do: Events.handle_update_trail_duration(duration, socket)

  @impl true
  def handle_event("update_historical_hours", %{"historical_hours" => hours}, socket),
    do: Events.handle_update_historical_hours(hours, socket)

  @impl true
  def handle_event("search_callsign", %{"callsign" => callsign}, socket),
    do: Events.handle_search_callsign(callsign, socket)

  @impl true
  def handle_event("toggle_slideover", _params, socket), do: Events.handle_toggle_slideover(socket)

  @impl true
  def handle_event("set_slideover_state", %{"open" => open}, socket),
    do: Events.handle_set_slideover_state(open, socket)

  @impl true
  def handle_event("geolocation_error", %{"error" => error}, socket), do: Events.handle_geolocation_error(error, socket)

  @impl true
  def handle_event("request_geolocation", _params, socket), do: Events.handle_request_geolocation(socket)

  @impl true
  def handle_event("popup_closed", _params, socket), do: Events.handle_popup_closed(socket)

  @impl true
  def handle_event("get_assigns", _params, socket), do: Events.handle_get_assigns(socket)

  @impl true
  def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket),
    do: Events.handle_update_map_state(center, zoom, params, socket)

  @impl true
  def handle_event(
        "error_boundary_triggered",
        %{"message" => message, "stack" => stack, "component_id" => component_id},
        socket
      ), do: Events.handle_error_boundary_triggered(message, stack, component_id, socket)

  # --- Private helpers kept in Index (used by handle_info / mount / handle_params) ---

  @impl true
  def handle_params(params, _url, socket) do
    # Check if we should skip this update (e.g., when using IP geolocation on initial load)
    if Map.get(socket.assigns, :should_skip_initial_url_update, false) and
         not Map.get(socket.assigns, :map_ready, false) do
      # Skip the URL parameter update to preserve IP geolocation
      socket = assign(socket, should_skip_initial_url_update: false)
      {:noreply, socket}
    else
      # Parse new map state from URL parameters
      {map_center, map_zoom} = UrlParams.parse_map_params(params)

      # Parse trail duration and historical hours from URL
      trail_duration = params |> Map.get("trail", "1") |> parse_trail_duration() |> to_string()
      historical_hours = params |> Map.get("hist", "1") |> parse_historical_hours() |> to_string()

      # Update socket state
      socket =
        socket
        |> assign(map_center: map_center, map_zoom: map_zoom)
        |> assign(trail_duration: trail_duration, historical_hours: historical_hours)

      # Update packet age threshold based on trail duration
      hours = String.to_integer(trail_duration)
      new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
      socket = assign(socket, packet_age_threshold: new_threshold)

      # If map is ready, update the client-side map and trail duration
      socket =
        if socket.assigns.map_ready do
          socket
          |> push_event("zoom_to_location", %{
            lat: map_center.lat,
            lng: map_center.lng,
            zoom: map_zoom
          })
          |> push_event("update_trail_duration", %{duration_hours: hours})
        else
          socket
        end

      # Trigger cleanup and reload if settings changed
      if socket.assigns[:map_ready] do
        send(self(), :cleanup_old_packets)
        send(self(), :reload_historical_packets)
      end

      {:noreply, socket}
    end
  end

  # Parse trail duration with validation and bounds checking
  defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration)

  # Parse historical hours with validation
  defp parse_historical_hours(hours), do: SharedPacketUtils.parse_historical_hours(hours)

  @impl true
  def handle_info({:process_bounds_update, map_bounds}, socket),
    do: handle_info_process_bounds_update(map_bounds, socket)

  def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket)

  def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket)

  def handle_info(:reload_historical_packets, socket), do: handle_reload_historical_packets(socket)

  def handle_info({:spatial_packet, packet}, socket) do
    # Add packet to batcher instead of processing immediately
    _ =
      if socket.assigns[:batcher_pid] do
        PacketBatcher.add_packet(socket.assigns.batcher_pid, packet)
      else
        handle_info_postgres_packet(packet, socket)
      end

    {:noreply, socket}
  end

  # Retained for targeted callsign topics used by non-map LiveViews and for
  # rolling cluster upgrades. MapLive no longer subscribes to the global topic.
  def handle_info({:postgres_packet, packet}, socket) do
    handle_info({:spatial_packet, packet}, socket)
  end

  def handle_info({:packet_batch, packets}, socket) do
    {socket, marker_data_list, removal_ids} = process_packet_batch(packets, socket)

    socket =
      socket
      |> remove_markers_if_needed(removal_ids)
      |> assign(:last_update_at, DateTime.utc_now())
      |> update_display_for_batch(marker_data_list)

    {:noreply, socket}
  end

  def handle_info({:DOWN, _ref, :process, pid, _reason}, socket) when pid == socket.assigns.batcher_pid do
    # PacketBatcher crashed — restart it
    {:ok, new_pid} = PacketBatcher.start_link(self())
    Process.monitor(new_pid)
    {:noreply, assign(socket, :batcher_pid, new_pid)}
  end

  def handle_info(:clear_rf_path, socket) do
    # Clear the RF path lines with debouncing
    {:noreply, push_event(socket, "clear_rf_path", %{})}
  end

  def handle_info(
        %Broadcast{topic: "deployment_events", payload: {:new_deployment, %{deployed_at: deployed_at}}},
        socket
      ) do
    {:noreply, assign(socket, :deployed_at, deployed_at)}
  end

  # Phoenix.PubSub.broadcast delivers the raw payload (no %Broadcast{} wrapper)
  # to plain subscribers. DeploymentNotifier broadcasts via that path.
  def handle_info({:new_deployment, %{deployed_at: deployed_at}}, socket) do
    {:noreply, assign(socket, :deployed_at, deployed_at)}
  end

  def handle_info({:drain_connections, to_drain}, socket) do
    if :rand.uniform(100) <= to_drain * 10 do
      {:noreply,
       socket
       |> put_flash(:info, "Server load balancing in progress. Reconnecting...")
       |> push_event("reconnect", %{delay: :rand.uniform(5000)})}
    else
      {:noreply, socket}
    end
  end

  def handle_info({:load_rf_path_station_packets, stations}, socket) do
    # Load the most recent packet for each RF path station in a single batch query
    station_packets = Packets.get_latest_packets_for_callsigns(stations)

    socket =
      if Enum.any?(station_packets) do
        # Build packet data for the RF path stations
        packet_data_list = DataBuilder.build_packet_data_list(station_packets)

        # Send these packets to the frontend
        DisplayManager.add_markers_if_any(socket, packet_data_list)
      else
        socket
      end

    {:noreply, socket}
  end

  def handle_info({:process_pending_bounds}, socket) do
    if socket.assigns.pending_bounds && !socket.assigns.historical_loading do
      # Process the pending bounds update
      bounds = socket.assigns.pending_bounds
      socket = assign(socket, pending_bounds: nil)
      BoundsUpdater.handle_bounds_update(bounds, socket)
    else
      {:noreply, socket}
    end
  end

  def handle_info({:load_historical_batch, batch_offset}, socket) do
    # For backward compatibility with old messages
    socket = HistoricalLoader.load_historical_batch(socket, batch_offset, socket.assigns.loading_generation)
    {:noreply, socket}
  end

  def handle_info({:load_historical_batch, batch_offset, generation}, socket) do
    # Only process if generation matches current loading generation
    if generation == socket.assigns.loading_generation do
      socket = HistoricalLoader.load_historical_batch(socket, batch_offset, generation)
      {:noreply, socket}
    else
      # Stale request, ignore it
      {:noreply, socket}
    end
  end

  def handle_info({:historical_loading_timeout, generation}, socket) do
    # Only process if generation matches current loading generation and we're still loading
    if generation == socket.assigns.loading_generation && socket.assigns.historical_loading do
      Logger.warning("Historical loading timeout reached, forcing completion")

      socket =
        socket
        |> assign(:historical_loading, false)
        |> assign(:loading_batch, socket.assigns.total_batches || 1)

      {:noreply, socket}
    else
      # Stale timeout or already completed, ignore it
      {:noreply, socket}
    end
  end

  def handle_info({:show_error, message}, socket) do
    socket = put_flash(socket, :error, message)
    {:noreply, socket}
  end

  # Private handler functions for each message type

  defp prepare_markers_for_push(markers, popup_open?) do
    Enum.map(markers, fn data ->
      data = Map.delete(data, "convert_from")
      if popup_open?, do: Map.put(data, :openPopup, false), else: data
    end)
  end

  defp handle_info_process_bounds_update(map_bounds, socket) do
    # Check if this is a stale update (newer bounds have been scheduled)
    if socket.assigns[:pending_bounds] && socket.assigns.pending_bounds != map_bounds do
      Logger.debug("Skipping stale bounds update, newer bounds pending")
      {:noreply, socket}
    else
      # Clear the timer reference since we're processing this update
      socket = assign(socket, bounds_update_timer: nil, pending_bounds: nil)

      # Check if we need to process this bounds update
      should_process =
        !socket.assigns[:initial_bounds_loaded] or
          socket.assigns[:needs_initial_historical_load] or
          not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds)

      Logger.debug(
        "handle_info_process_bounds_update - should_process: #{should_process}, initial_bounds_loaded: #{socket.assigns[:initial_bounds_loaded]}, needs_initial_historical_load: #{socket.assigns[:needs_initial_historical_load]}"
      )

      if should_process do
        Logger.debug("Processing bounds update: #{inspect(map_bounds)}")
        socket = BoundsUpdater.process_bounds_update(map_bounds, socket)
        socket = assign(socket, initial_bounds_loaded: true)
        {:noreply, socket}
      else
        Logger.debug("Skipping bounds update - no change detected")
        {:noreply, socket}
      end
    end
  end

  defp handle_info_initialize_replay(socket) do
    if not socket.assigns.historical_loaded and socket.assigns.map_ready do
      # Only proceed if we have actual map bounds - don't use world bounds
      if socket.assigns.map_bounds do
        # Use progressive loading for better performance
        socket = HistoricalLoader.start_progressive_historical_loading(socket)
        socket = assign(socket, historical_loaded: true)
        {:noreply, socket}
      else
        # Wait a bit longer for map bounds to be available
        # Increase delay to give client more time to send real bounds
        Process.send_after(self(), :initialize_replay, 500)
        {:noreply, socket}
      end
    else
      {:noreply, socket}
    end
  end

  defp handle_info_postgres_packet(packet, socket) do
    # Check if we're tracking a specific callsign
    # Only process if this packet is from the tracked callsign
    if socket.assigns.tracked_callsign == "" do
      # No tracking - show all packets
      process_packet_for_display(packet, socket)
    else
      packet_sender = Map.get(packet, :sender, Map.get(packet, "sender", ""))

      if String.upcase(packet_sender) == String.upcase(socket.assigns.tracked_callsign) do
        # Update the tracked callsign's latest packet
        socket =
          assign(
            socket,
            :tracked_callsign_latest_packet,
            preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet)
          )

        process_packet_for_display(packet, socket)
      else
        {:noreply, socket}
      end
    end
  end

  defp process_packet_for_display(packet, socket) do
    PacketProcessor.process_packet_for_display(packet, socket)
  end

  defp process_packet_for_batch(packet, socket) do
    if socket.assigns.tracked_callsign == "" do
      PacketProcessor.process_packet_for_marker_data(packet, socket)
    else
      packet_sender = Map.get(packet, :sender, Map.get(packet, "sender", ""))

      if String.upcase(packet_sender) == String.upcase(socket.assigns.tracked_callsign) do
        socket =
          assign(
            socket,
            :tracked_callsign_latest_packet,
            preferred_tracked_packet(socket.assigns.tracked_callsign_latest_packet, packet)
          )

        PacketProcessor.process_packet_for_marker_data(packet, socket)
      else
        {socket, nil, nil}
      end
    end
  end

  defp process_packet_batch(packets, socket) do
    Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} ->
      {new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket)
      markers = if marker_data, do: [marker_data | markers], else: markers
      removals = if removed_id, do: [removed_id | removals], else: removals
      {new_socket, markers, removals}
    end)
  end

  defp remove_markers_if_needed(socket, []), do: socket

  defp remove_markers_if_needed(socket, removal_ids) do
    DisplayManager.remove_markers_batch(socket, removal_ids)
  end

  defp update_display_for_batch(socket, []), do: socket

  # PacketProcessor.build_marker_data returns nil when zoom <= 8, so by the time
  # marker_data_list is non-empty here, zoom is always > 8. Heat-map / trail-line
  # rendering for low-zoom packet batches happens inside the per-packet handler.
  defp update_display_for_batch(socket, marker_data_list), do: send_marker_batch(socket, marker_data_list)

  defp send_marker_batch(socket, marker_data_list) do
    reversed = Enum.reverse(marker_data_list)

    # Deduplicate by callsign_group — when multiple senders track the same
    # object (e.g. two stations reporting the same radiosonde), keep only
    # the most recent packet per group to avoid duplicate "current" markers.
    deduped =
      reversed
      |> Enum.reduce(%{}, fn marker, acc ->
        group = marker["callsign_group"] || marker["callsign"]
        Map.put(acc, group, marker)
      end)
      |> Map.values()

    # Send callsign_groups so the frontend can look up existing markers
    # via its callsignIndex (keyed by callsign_group, not by packet UUID)
    convert_to_historical =
      deduped
      |> Enum.map(fn m -> m["callsign_group"] || m["callsign"] end)
      |> Enum.filter(& &1)
      |> Enum.uniq()

    markers = prepare_markers_for_push(deduped, socket.assigns.station_popup_open)

    push_event(socket, "new_packets", %{
      packets: markers,
      convert_to_historical: convert_to_historical
    })
  end

  # Handle replaying the next historical packet

  @impl true
  def render(assigns) do
    ~H"""
    <.map_styles />

    <.map_container
      slideover_open={@slideover_open}
      map_center={@map_center}
      map_zoom={@map_zoom}
    />

    <.locate_button />

    <.bottom_controls {assigns} />
    """
  end

  # Additional component functions that weren't extracted yet
  defp locate_button(assigns) do
    ~H"""
    <button class="locate-button" phx-click="locate_me" title="Locate me">
      <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
        <path
          fill-rule="evenodd"
          d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
          clip-rule="evenodd"
        />
      </svg>
    </button>
    """
  end

  defp toggle_slideover_js do
    "toggle_slideover"
    |> JS.push()
    |> JS.toggle_class("slideover-open", to: "#aprs-map")
    |> JS.toggle_class("slideover-closed", to: "#aprs-map")
    |> JS.dispatch("phx:map-resize", to: "#aprs-map")
  end

  defp bottom_controls(assigns) do
    ~H"""
    <%!-- Existing bottom controls code will go here --%>
    <style>
      .locate-button {
        position: absolute;
        left: 12px;
        top: 100px;
        z-index: 1000;
        background: white;
        border: 2px solid rgba(0,0,0,0.2);
        border-radius: 4px;
        padding: 5px;
        cursor: pointer;
        color: #333;
      }

      .locate-button:hover {
        background: #f4f4f4;
      }

      /* Mobile: larger touch targets push zoom controls taller */
      @media (max-width: 1023px) {
        .locate-button {
          top: 110px;
          width: 44px;
          height: 44px;
          display: flex;
          align-items: center;
          justify-content: center;
        }
      }

      .aprs-marker {
        background: transparent !important;
        border: none !important;
      }

      .historical-marker {
        opacity: 0.7;
      }

      .aprs-popup {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        font-size: 12px;
        line-height: 1.4;
        max-width: 200px;
      }

      .aprs-callsign {
        font-size: 14px;
        font-weight: bold;
        color: #1e40af;
        margin-bottom: 4px;
      }

      .aprs-info-link {
        font-size: 11px;
        color: #007cba;
        text-decoration: none;
        font-weight: normal;
        margin-left: 8px;
        padding: 2px 4px;
        border-radius: 3px;
        transition: background-color 0.2s;
      }

      .aprs-info-link:hover {
        background-color: rgba(0, 124, 186, 0.1);
        text-decoration: none;
      }

      .aprs-comment {
        color: #374151;
        margin-bottom: 4px;
        word-wrap: break-word;
      }

      .aprs-coords {
        color: #6b7280;
        font-size: 11px;
        font-family: monospace;
      }

      .aprs-timestamp {
        color: #6b7280;
        font-size: 11px;
        font-family: monospace;
        padding-top: 4px;
      }

      /* Leaflet popup improvements for APRS data */
      .leaflet-popup-content-wrapper {
        border-radius: 8px;
      }

      .leaflet-popup-content {
        margin: 8px 12px;
      }

      /* Slideover panel styles */
      .slideover-panel {
        position: fixed;
        top: 0;
        right: 0;
        height: 100vh;
        background: white;
        box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
        z-index: 1000;
        display: flex;
        flex-direction: column;
        transition: transform 0.3s ease-in-out;
        overflow: hidden;
        box-sizing: border-box;
      }

      @media (prefers-color-scheme: dark) {
        .slideover-panel {
          background: rgb(15 23 42); /* slate-900 */
          box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
        }
      }

      /* Ensure proper box-sizing for all children */
      .slideover-panel * {
        box-sizing: border-box;
      }

      /* Desktop styles */
      @media (min-width: 1024px) {
        .slideover-panel {
          width: 352px;
          transform: translateX(0);
        }

        .slideover-panel.slideover-closed {
          transform: translateX(100%);
        }
      }

      /* Mobile styles - override the desktop styles */
      @media (max-width: 1023px) {
        .slideover-panel {
          width: 90vw !important;
          max-width: 400px;
        }

        .slideover-panel.slideover-closed {
          transform: translateX(100%);
        }

        .slideover-panel.slideover-open {
          transform: translateX(0);
        }
      }

      /* Slideover toggle button */
      .slideover-toggle {
        position: fixed;
        top: 50%;
        transform: translateY(-50%);
        z-index: 999;
        background: white;
        color: #374151;
        border: 2px solid rgba(0, 0, 0, 0.1);
        border-radius: 8px 0 0 8px;
        padding: 12px 8px;
        cursor: pointer;
        transition: all 0.3s ease-in-out;
        box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
      }

      .slideover-toggle.slideover-open {
        right: 352px;
      }

      .slideover-toggle.slideover-closed {
        right: 0;
        border-right: none;
      }

      @media (max-width: 1023px) {
        .slideover-toggle.slideover-open {
          display: none;
        }
      }

      .slideover-toggle:hover {
        background: #f3f4f6;
      }
    </style>

    <!-- Slideover Toggle Button -->
    <button
      class={["slideover-toggle", if(@slideover_open, do: "slideover-open", else: "slideover-closed")]}
      phx-click={toggle_slideover_js()}
      title={
        if @slideover_open,
          do: Gettext.gettext(AprsmeWeb.Gettext, "Hide controls"),
          else: Gettext.gettext(AprsmeWeb.Gettext, "Show controls")
      }
    >
      <%= if @slideover_open do %>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <path d="m9 18 6-6-6-6" />
        </svg>
      <% else %>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
        >
          <path d="m15 18-6-6 6-6" />
        </svg>
      <% end %>
    </button>

    <!-- Loading Indicator -->
    <%= if @historical_loading do %>
      <div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-[1000]
                  bg-white rounded-lg shadow-lg px-4 py-2 flex items-center space-x-2">
        <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
        <span class="text-sm text-gray-600">
          {gettext("Loading historical data...")}
          <%= if @loading_batch && @total_batches do %>
            ({@loading_batch}/{@total_batches})
          <% end %>
        </span>
      </div>
    <% end %>

    <!-- Mobile Backdrop -->
    <%= if @slideover_open do %>
      <div class="fixed inset-0 bg-black bg-opacity-50 z-[999] lg:hidden" phx-click={toggle_slideover_js()}></div>
    <% end %>

    <!-- Control Panel Overlay -->
    <div
      id="slideover-panel"
      class={[
        "slideover-panel",
        if(@slideover_open, do: "slideover-open", else: "slideover-closed")
      ]}
    >
      <!-- Header -->
      <div class="bg-indigo-700 px-5 py-5 dark:bg-indigo-800">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2.5">
            <svg class="h-5 w-5 text-indigo-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
              />
            </svg>
            <h2 class="text-base font-semibold text-white">APRS.me</h2>
          </div>
          <button
            class="relative rounded-md text-indigo-200 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white lg:hidden"
            phx-click={toggle_slideover_js()}
            title={Gettext.gettext(AprsmeWeb.Gettext, "Close controls")}
          >
            <span class="absolute -inset-2.5"></span>
            <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
              <path d="M6 18 18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
        </div>
        <p class="mt-1 text-sm text-indigo-300 dark:text-indigo-200">
          {gettext("Real-time APRS packet tracking")}
        </p>
      </div>

      <!-- Content -->
      <div class="relative flex-1 overflow-y-auto bg-white px-5 py-5 dark:bg-gray-800 dark:after:absolute dark:after:inset-y-0 dark:after:left-0 dark:after:w-px dark:after:bg-white/10">
        <div class="space-y-5">
          <!-- Callsign Search -->
          <div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
            <label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              <svg class="h-3.5 w-3.5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
                />
              </svg>
              {gettext("Search Callsign")}
            </label>
            <form phx-submit="track_callsign" phx-change="track_callsign_form" id="track-callsign-form" class="space-y-2">
              <div class="flex gap-2">
                <input
                  type="text"
                  name="callsign"
                  value={@tracked_callsign || @overlay_callsign}
                  phx-change="update_callsign"
                  placeholder={gettext("Enter callsign...")}
                  class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 uppercase dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
                />
                <button
                  type="submit"
                  class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 whitespace-nowrap flex-shrink-0 dark:bg-indigo-500 dark:hover:bg-indigo-400"
                >
                  {gettext("Track")}
                </button>
              </div>
              <%= if @tracked_callsign != "" do %>
                <div class="flex items-center justify-between rounded-md bg-indigo-50 px-3 py-2 dark:bg-indigo-500/10">
                  <span class="text-sm text-indigo-700 font-medium dark:text-indigo-300">
                    {gettext("Tracking:")} <span class="font-bold font-mono">{@tracked_callsign}</span>
                  </span>
                  <button
                    type="button"
                    phx-click="clear_tracking"
                    class="rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20"
                  >
                    {gettext("Clear")}
                  </button>
                </div>
              <% end %>
            </form>
          </div>

          <!-- Other SSIDs -->
          <%= if @tracked_callsign != "" and @other_ssids != [] do %>
            <div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
              <label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
                <svg class="h-3.5 w-3.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    stroke-width="2"
                    d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
                  />
                </svg>
                {gettext("Other SSIDs")}
              </label>
              <div class="space-y-1">
                <%= for ssid_info <- @other_ssids do %>
                  <div class="group flex items-center justify-between rounded-md bg-white px-3 py-2 text-sm ring-1 ring-gray-950/5 hover:ring-indigo-300 dark:bg-gray-800 dark:ring-white/10 dark:hover:ring-indigo-500/50">
                    <button
                      phx-click="track_callsign"
                      phx-value-callsign={ssid_info.callsign}
                      class="font-semibold font-mono text-indigo-600 group-hover:text-indigo-500 cursor-pointer dark:text-indigo-400 dark:group-hover:text-indigo-300"
                    >
                      {ssid_info.callsign}
                    </button>
                    <div class="flex items-center gap-2">
                      <%= if ssid_info.received_at do %>
                        <span class="text-xs text-gray-500 dark:text-gray-400">
                          {time_ago_in_words(ssid_info.received_at)}
                        </span>
                      <% end %>
                      <.link
                        navigate={~p"/info/#{ssid_info.callsign}"}
                        class="text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
                        title={gettext("Station info")}
                      >
                        <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                          <path
                            stroke-linecap="round"
                            stroke-linejoin="round"
                            stroke-width="2"
                            d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                          />
                        </svg>
                      </.link>
                    </div>
                  </div>
                <% end %>
              </div>
            </div>
          <% end %>

          <!-- Trail Duration -->
          <div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
            <label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              <svg class="h-3.5 w-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
                />
              </svg>
              {gettext("Trail Duration")}
            </label>
            <form id="trail-duration-form" phx-change="update_trail_duration">
              <select
                name="trail_duration"
                class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
              >
                <option value="1" selected={@trail_duration == "1"}>
                  {ngettext("1 Hour", "%{count} Hours", 1)}
                </option>
                <option value="6" selected={@trail_duration == "6"}>
                  {ngettext("1 Hour", "%{count} Hours", 6)}
                </option>
                <option value="12" selected={@trail_duration == "12"}>
                  {ngettext("1 Hour", "%{count} Hours", 12)}
                </option>
                <option value="24" selected={@trail_duration == "24"}>
                  {ngettext("1 Hour", "%{count} Hours", 24)}
                </option>
                <option value="48" selected={@trail_duration == "48"}>
                  {ngettext("1 Hour", "%{count} Hours", 48)}
                </option>
                <option value="168" selected={@trail_duration == "168"}>
                  {gettext("1 Week")}
                </option>
              </select>
            </form>
          </div>

          <!-- Historical Data -->
          <div class="rounded-lg bg-gray-50 p-4 ring-1 ring-gray-950/5 dark:bg-gray-900/50 dark:ring-white/10">
            <label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              <svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
                />
              </svg>
              {gettext("Historical Data")}
            </label>
            <form id="historical-hours-form" phx-change="update_historical_hours">
              <select
                name="historical_hours"
                class="block w-full rounded-md bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:text-white dark:outline-white/10 dark:focus:outline-indigo-500"
              >
                <option value="1" selected={@historical_hours == "1"}>
                  {ngettext("1 Hour", "%{count} Hours", 1)}
                </option>
                <option value="3" selected={@historical_hours == "3"}>
                  {ngettext("1 Hour", "%{count} Hours", 3)}
                </option>
                <option value="6" selected={@historical_hours == "6"}>
                  {ngettext("1 Hour", "%{count} Hours", 6)}
                </option>
                <option value="12" selected={@historical_hours == "12"}>
                  {ngettext("1 Hour", "%{count} Hours", 12)}
                </option>
                <option value="24" selected={@historical_hours == "24"}>
                  {ngettext("1 Hour", "%{count} Hours", 24)}
                </option>
              </select>
            </form>
          </div>

          <!-- Navigation -->
          <div class="border-t border-gray-200 pt-4 dark:border-white/10">
            <label class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
              <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
                />
              </svg>
              {gettext("Navigation")}
            </label>
            <nav class="space-y-1">
              <.navigation
                variant={:sidebar}
                current_user={@current_user}
                map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}}
                tracked_callsign={@tracked_callsign}
              />
            </nav>
          </div>

          <!-- Status Footer -->
          <div class="border-t border-gray-200 pt-4 dark:border-white/10">
            <div class="grid grid-cols-2 gap-4">
              <div>
                <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
                  {gettext("Last Update")}
                </div>
                <div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
                  <%= if @last_update_at do %>
                    <div class="font-mono font-medium">{time_ago_in_words(@last_update_at)}</div>
                    <div class="font-mono text-gray-400 dark:text-gray-500">
                      {Calendar.strftime(@last_update_at, "%H:%M UTC")}
                    </div>
                  <% else %>
                    <div class="font-mono text-gray-400">--</div>
                  <% end %>
                </div>
              </div>
              <div>
                <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
                  {gettext("Last Deploy")}
                </div>
                <div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
                  <div class="font-mono font-medium">{time_ago_in_words(@deployed_at)}</div>
                  <div class="font-mono text-gray-400 dark:text-gray-500">
                    {Calendar.strftime(@deployed_at, "%H:%M UTC")}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
    """
  end

  # Clean up expired markers from visible_packets and client, but do not re-query the DB
  defp handle_cleanup_old_packets(socket) do
    # Schedule next cleanup
    Process.send_after(self(), :cleanup_old_packets, 60_000)

    threshold = socket.assigns.packet_age_threshold

    # Partition visible_packets into kept and expired
    {kept, expired_keys} =
      Enum.reduce(socket.assigns.visible_packets, {%{}, []}, fn {key, packet}, {kept_acc, expired_acc} ->
        if SharedPacketUtils.packet_within_time_threshold?(packet, threshold) do
          {Map.put(kept_acc, key, packet), expired_acc}
        else
          {kept_acc, [key | expired_acc]}
        end
      end)

    socket = DisplayManager.remove_markers_batch(socket, expired_keys)

    {:noreply, assign(socket, :visible_packets, kept)}
  end

  defp handle_reload_historical_packets(socket) do
    Logger.debug(
      "handle_reload_historical_packets called - map_ready: #{socket.assigns.map_ready}, map_bounds: #{inspect(socket.assigns.map_bounds)}"
    )

    if socket.assigns.map_ready and socket.assigns.map_bounds do
      # Clear existing historical packets
      socket = push_event(socket, "clear_historical_packets", %{})

      # Start progressive loading using LiveView's efficient batching
      socket = HistoricalLoader.start_progressive_historical_loading(socket)

      {:noreply, socket}
    else
      Logger.debug("Skipping historical reload - conditions not met")
      {:noreply, socket}
    end
  end

  # Helper functions

  # Helper functions for marker operations

  defp preferred_tracked_packet(nil, packet), do: packet

  defp preferred_tracked_packet(current_packet, incoming_packet) do
    cond do
      CoordinateUtils.has_position_data?(incoming_packet) ->
        incoming_packet

      CoordinateUtils.has_position_data?(current_packet) ->
        current_packet

      newer_packet?(incoming_packet, current_packet) ->
        incoming_packet

      true ->
        current_packet
    end
  end

  defp newer_packet?(left, right) do
    case {packet_received_at(left), packet_received_at(right)} do
      {%DateTime{} = left_dt, %DateTime{} = right_dt} ->
        DateTime.after?(left_dt, right_dt)

      {%NaiveDateTime{} = left_dt, %NaiveDateTime{} = right_dt} ->
        NaiveDateTime.after?(left_dt, right_dt)

      _ ->
        false
    end
  end

  defp packet_received_at(packet) do
    Map.get(packet, :received_at) || Map.get(packet, "received_at")
  end

  @impl true
  def terminate(_reason, socket) do
    Subscriptions.teardown(socket)
    :ok
  end
end
