defmodule AprsmeWeb.MapLive.Events do
  @moduledoc """
  Handles all LiveView client events (handle_event/3) for the map view.
  Extracted from Index to reduce module size.

  For bounds-related events, delegates back to the message loop in Index;
  the bounds pipeline will be extracted to BoundsUpdater in Phase 4.
  """

  import AprsmeWeb.Live.Shared.PacketUtils, only: [get_callsign_key: 1]
  import Phoenix.Component, only: [assign: 2, assign: 3]
  import Phoenix.LiveView, only: [push_event: 3, push_patch: 2]

  alias Aprsme.Packets
  alias Aprsme.Packets.Clustering
  alias AprsmeWeb.Live.Shared.BoundsUtils
  alias AprsmeWeb.Live.Shared.CoordinateUtils
  alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils
  alias AprsmeWeb.Live.Shared.ParamUtils
  alias AprsmeWeb.MapLive.DataBuilder
  alias AprsmeWeb.MapLive.DisplayManager
  alias AprsmeWeb.MapLive.Navigation
  alias AprsmeWeb.MapLive.RfPath
  alias AprsmeWeb.MapLive.UrlParams
  alias AprsmeWeb.Plugs.RateLimiter, as: RateLimiterPlug
  alias Phoenix.LiveView.Socket

  require Logger

  @event_rate_scale 60_000

  # ---------------------------------------------------------------------------
  # Public event handlers — each corresponds to a handle_event/3 clause in Index
  # ---------------------------------------------------------------------------

  @doc """
  Handle bounds_changed or update_bounds events.
  Delegates to the bounds update pipeline which lives in Index until Phase 4.
  """
  @spec handle_bounds_changed(map(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_bounds_changed(bounds, socket) do
    if socket.assigns.historical_loading do
      {:noreply, assign(socket, pending_bounds: bounds)}
    else
      map_bounds = BoundsUtils.normalize_bounds(bounds)

      if valid_bounds?(map_bounds) do
        send(self(), {:process_bounds_update, map_bounds})
      end

      {:noreply, socket}
    end
  end

  defp valid_bounds?(map_bounds) do
    map_bounds.north <= 90 and map_bounds.south >= -90 and map_bounds.north > map_bounds.south
  end

  @spec handle_locate_me(Socket.t()) :: {:noreply, Socket.t()}
  def handle_locate_me(socket) do
    {:noreply, push_event(socket, "request_geolocation", %{})}
  end

  @spec handle_set_location(number() | String.t(), number() | String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_set_location(lat, lng, socket) do
    lat_float = to_float(lat)
    lng_float = to_float(lng)

    if UrlParams.valid_coordinates?(lat_float, lng_float) do
      socket = Navigation.update_and_zoom_to_location(socket, lat_float, lng_float, 12)
      {:noreply, socket}
    else
      {:noreply, socket}
    end
  end

  @spec handle_clear_and_reload_markers(Socket.t()) :: {:noreply, Socket.t()}
  def handle_clear_and_reload_markers(socket) do
    filtered_packets =
      filter_packets_by_time_and_bounds_with_tracked(
        socket.assigns.visible_packets,
        socket.assigns.map_bounds,
        socket.assigns.packet_age_threshold,
        socket.assigns.tracked_callsign,
        socket.assigns.tracked_callsign_latest_packet
      )

    visible_packets_list = DataBuilder.build_packet_data_list_from_map(filtered_packets, false, socket)

    socket =
      if socket.assigns.map_zoom <= 8 do
        send_heat_map_data(socket, filtered_packets)
      else
        add_markers_if_any(socket, visible_packets_list)
      end

    {:noreply, socket}
  end

  @spec handle_map_ready(Socket.t()) :: {:noreply, Socket.t()}
  def handle_map_ready(socket) do
    socket = assign(socket, map_ready: true)

    default_center = UrlParams.default_center()

    socket =
      if socket.assigns.map_center.lat == default_center.lat and
           socket.assigns.map_center.lng == default_center.lng do
        socket
      else
        Navigation.zoom_to_current_location(socket)
      end

    socket =
      if socket.assigns.tracked_callsign != "" and socket.assigns.tracked_callsign_latest_packet do
        packet = socket.assigns.tracked_callsign_latest_packet
        packet_data = DataBuilder.build_packet_data(packet)

        push_event(socket, "add_historical_packets_batch", %{
          packets: [packet_data],
          batch: 0,
          is_final: false
        })
      else
        socket
      end

    socket =
      if socket.assigns.needs_initial_historical_load and socket.assigns.map_bounds do
        send(self(), {:process_bounds_update, socket.assigns.map_bounds})
        socket
      else
        socket
      end

    {:noreply, socket}
  end

  @spec handle_marker_clicked(Socket.t()) :: {:noreply, Socket.t()}
  def handle_marker_clicked(socket) do
    {:noreply, assign(socket, station_popup_open: true)}
  end

  @spec handle_marker_hover_start(String.t(), String.t(), number(), number(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_marker_hover_start(_id, path, lat, lng, socket) do
    socket =
      if socket.assigns[:hover_end_timer] do
        _ = Process.cancel_timer(socket.assigns.hover_end_timer)
        assign(socket, hover_end_timer: nil)
      else
        socket
      end

    lat_float = ParamUtils.safe_parse_coordinate(lat, 0.0, -90.0, 90.0)
    lng_float = ParamUtils.safe_parse_coordinate(lng, 0.0, -180.0, 180.0)
    safe_path = ParamUtils.sanitize_path_string(path)
    path_stations = RfPath.parse_rf_path(safe_path)
    path_station_positions = RfPath.get_path_station_positions(path_stations, socket)

    socket =
      if path_station_positions == [] do
        socket
      else
        push_event(socket, "draw_rf_path", %{
          station_lat: lat_float,
          station_lng: lng_float,
          path_stations: path_station_positions
        })
      end

    {:noreply, socket}
  end

  @spec handle_marker_hover_end(Socket.t()) :: {:noreply, Socket.t()}
  def handle_marker_hover_end(socket) do
    timer = Process.send_after(self(), :clear_rf_path, 100)
    {:noreply, assign(socket, hover_end_timer: timer)}
  end

  @spec handle_update_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_update_callsign(callsign, socket) do
    {:noreply, assign(socket, overlay_callsign: callsign)}
  end

  @spec handle_track_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_track_callsign(callsign, socket) do
    case check_event_rate(socket, "track_callsign", 20) do
      :ok ->
        normalized_callsign = String.upcase(String.trim(callsign))

        socket =
          if normalized_callsign == "" do
            socket
            |> assign(tracked_callsign: "", other_ssids: [])
            |> update_url_with_current_state()
          else
            other_ssids = Packets.get_other_ssids(normalized_callsign)
            latest_packet = Packets.get_latest_packet_for_callsign(normalized_callsign)

            socket =
              assign(socket,
                tracked_callsign: normalized_callsign,
                other_ssids: other_ssids,
                tracked_callsign_latest_packet: latest_packet
              )

            socket = zoom_to_latest_packet(socket, latest_packet)

            push_patch(socket, to: "/#{normalized_callsign}")
          end

        {:noreply, socket}

      {:deny, _retry_after} ->
        {:noreply, socket}
    end
  end

  @spec handle_clear_tracking(Socket.t()) :: {:noreply, Socket.t()}
  def handle_clear_tracking(socket) do
    socket =
      socket
      |> assign(tracked_callsign: "", overlay_callsign: "", other_ssids: [])
      |> push_event("clear_trail_line", %{})
      |> update_url_with_current_state()

    {:noreply, socket}
  end

  @spec handle_update_trail_duration(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_update_trail_duration(duration, socket) do
    case check_event_rate(socket, "update_trail_duration", 20) do
      :ok ->
        hours = SharedPacketUtils.parse_trail_duration(duration)
        new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)

        socket = assign(socket, trail_duration: duration, packet_age_threshold: new_threshold)
        socket = push_event(socket, "update_trail_duration", %{duration_hours: hours})
        socket = update_url_with_current_state(socket)

        send(self(), :cleanup_old_packets)

        socket =
          if socket.assigns.tracked_callsign != "" and socket.assigns.map_zoom <= 8 do
            DisplayManager.send_trail_line_for_tracked_callsign(socket)
          else
            socket
          end

        {:noreply, socket}

      {:deny, _retry_after} ->
        {:noreply, socket}
    end
  end

  @spec handle_update_historical_hours(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_update_historical_hours(hours, socket) do
    case check_event_rate(socket, "update_historical_hours", 20) do
      :ok ->
        validated_hours = SharedPacketUtils.parse_historical_hours(hours)
        socket = assign(socket, historical_hours: to_string(validated_hours))
        socket = update_url_with_current_state(socket)

        if socket.assigns.map_ready do
          send(self(), :reload_historical_packets)
        end

        {:noreply, socket}

      {:deny, _retry_after} ->
        {:noreply, socket}
    end
  end

  @spec handle_search_callsign(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_search_callsign(callsign, socket) do
    case check_event_rate(socket, "search_callsign", 30) do
      :ok ->
        callsign
        |> String.trim()
        |> String.upcase()
        |> Navigation.handle_callsign_search(socket)

      {:deny, _retry_after} ->
        {:noreply, socket}
    end
  end

  @spec handle_toggle_slideover(Socket.t()) :: {:noreply, Socket.t()}
  def handle_toggle_slideover(socket) do
    {:noreply, assign(socket, slideover_open: !socket.assigns.slideover_open)}
  end

  @spec handle_set_slideover_state(boolean(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_set_slideover_state(open, socket) do
    {:noreply, assign(socket, slideover_open: open)}
  end

  @spec handle_geolocation_error(String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_geolocation_error(_error, socket) do
    {:noreply, socket}
  end

  @spec handle_request_geolocation(Socket.t()) :: {:noreply, Socket.t()}
  def handle_request_geolocation(socket) do
    {:noreply, socket}
  end

  @spec handle_popup_closed(Socket.t()) :: {:noreply, Socket.t()}
  def handle_popup_closed(socket) do
    {:noreply, assign(socket, station_popup_open: false)}
  end

  @spec handle_get_assigns(Socket.t()) :: {:noreply, Socket.t()}
  def handle_get_assigns(socket) do
    send(self(), {:test_assigns, socket.assigns})
    {:noreply, socket}
  end

  @spec handle_update_map_state(map(), map(), map(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_update_map_state(center, zoom, params, socket) do
    {lat, lng} = parse_center_coordinates(center, socket)
    zoom = clamp_zoom(zoom)
    map_center = %{lat: lat, lng: lng}

    socket = update_map_state(socket, map_center, zoom)
    socket = handle_url_update(socket, lat, lng, zoom)
    socket = process_bounds_from_params(socket, params)

    {:noreply, socket}
  end

  @spec handle_error_boundary_triggered(String.t(), String.t(), String.t(), Socket.t()) :: {:noreply, Socket.t()}
  def handle_error_boundary_triggered(message, stack, component_id, socket) do
    Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}")
    {:noreply, socket}
  end

  # ---------------------------------------------------------------------------
  # Rate limiting helpers
  # ---------------------------------------------------------------------------

  @doc """
  Store client IP in socket assigns for rate-limiting.
  """
  @spec store_client_ip(Socket.t()) :: Socket.t()
  def store_client_ip(socket) do
    ip = RateLimiterPlug.extract_socket_ip(socket)
    assign(socket, :client_ip, ip)
  end

  @doc """
  Check whether an event handler should be rate-limited.
  """
  @spec check_event_rate(Socket.t(), String.t(), integer()) :: :ok | {:deny, integer()}
  def check_event_rate(socket, event_name, limit) do
    ip = Map.get(socket.assigns, :client_ip, "unknown")
    key = "lv_event:#{event_name}:#{ip}"

    case RateLimiterPlug.check_rate_limit(key, @event_rate_scale, limit) do
      {:allow, _count} ->
        :ok

      {:deny, retry_after} ->
        Logger.warning("LiveView event rate-limited: event=#{event_name} ip=#{ip}")
        {:deny, retry_after}
    end
  end

  # ---------------------------------------------------------------------------
  # URL state helpers
  # ---------------------------------------------------------------------------

  @doc """
  Update URL with current map state, trail duration, and historical hours.
  """
  @spec update_url_with_current_state(Socket.t()) :: Socket.t()
  def update_url_with_current_state(socket) do
    lat = socket.assigns.map_center.lat
    lng = socket.assigns.map_center.lng
    zoom = socket.assigns.map_zoom
    handle_url_update(socket, lat, lng, zoom)
  end

  # ---------------------------------------------------------------------------
  # Private helpers
  # ---------------------------------------------------------------------------

  defp parse_center_coordinates(center, socket) when is_map(center) do
    CoordinateUtils.parse_center_coordinates(center, socket)
  end

  defp parse_center_coordinates(_, socket) do
    CoordinateUtils.parse_center_coordinates(nil, socket)
  end

  defp clamp_zoom(zoom) do
    ParamUtils.clamp_zoom(zoom)
  end

  defp update_map_state(socket, map_center, zoom) do
    old_zoom = socket.assigns.map_zoom
    crossing_threshold = DisplayManager.crossing_zoom_threshold?(old_zoom, zoom)

    socket = assign(socket, map_center: map_center, map_zoom: zoom)

    if crossing_threshold do
      DisplayManager.handle_zoom_threshold_crossing(socket, zoom)
    else
      socket
    end
  end

  defp handle_url_update(socket, lat, lng, zoom) do
    if socket.assigns[:should_skip_initial_url_update] && !socket.assigns[:initial_bounds_loaded] do
      assign(socket, should_skip_initial_url_update: false)
    else
      trail_param =
        if socket.assigns[:trail_duration] && socket.assigns[:trail_duration] != "1",
          do: "&trail=#{socket.assigns[:trail_duration]}",
          else: ""

      hist_param =
        if socket.assigns[:historical_hours] && socket.assigns[:historical_hours] != "1",
          do: "&hist=#{socket.assigns[:historical_hours]}",
          else: ""

      base_path =
        if socket.assigns.tracked_callsign == "" do
          "/"
        else
          "/#{socket.assigns.tracked_callsign}"
        end

      new_path = "#{base_path}?lat=#{lat}&lng=#{lng}&z=#{zoom}#{trail_param}#{hist_param}"
      push_patch(socket, to: new_path, replace: true)
    end
  end

  defp process_bounds_from_params(socket, params) do
    case Map.get(params, "bounds") do
      %{"north" => north, "south" => south, "east" => east, "west" => west} ->
        map_bounds = %{north: north, south: south, east: east, west: west}

        if should_process_bounds?(socket, map_bounds) do
          send(self(), {:process_bounds_update, map_bounds})
        end

        socket

      _ ->
        socket
    end
  end

  defp should_process_bounds?(socket, new_bounds) do
    socket.assigns.map_bounds != new_bounds or
      !socket.assigns[:initial_bounds_loaded] or
      socket.assigns[:needs_initial_historical_load]
  end

  defp to_float(val) when is_float(val), do: val
  defp to_float(val) when is_integer(val), do: val / 1

  defp to_float(val) when is_binary(val) do
    case Float.parse(val) do
      {f, _} -> f
      :error -> nil
    end
  end

  defp to_float(_), do: nil

  defp filter_packets_by_time_and_bounds(packets, bounds, time_threshold) do
    packets
    |> Enum.filter(fn {_callsign, packet} ->
      BoundsUtils.within_bounds?(packet, bounds) &&
        SharedPacketUtils.packet_within_time_threshold?(packet, time_threshold)
    end)
    |> Map.new()
  end

  defp filter_packets_by_time_and_bounds_with_tracked(
         packets,
         bounds,
         time_threshold,
         tracked_callsign,
         tracked_latest_packet
       ) do
    filtered = filter_packets_by_time_and_bounds(packets, bounds, time_threshold)

    if tracked_callsign != "" and tracked_latest_packet do
      key = get_callsign_key(tracked_latest_packet)
      Map.put(filtered, key, tracked_latest_packet)
    else
      filtered
    end
  end

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

  defp add_markers_if_any(socket, markers) do
    push_event(socket, "add_markers", %{markers: markers})
  end

  defp send_heat_map_data(socket, filtered_packets) do
    packet_list = Map.values(filtered_packets)

    case Clustering.cluster_packets(packet_list, socket.assigns.map_zoom) do
      {:heat_map, heat_points} ->
        push_event(socket, "show_heat_map", %{heat_points: heat_points})

      {:raw_packets, _packets} ->
        socket
    end
  end

  defp zoom_to_latest_packet(socket, nil), do: socket

  defp zoom_to_latest_packet(socket, latest_packet) do
    if latest_packet.lat && latest_packet.lon do
      lat = Aprsme.EncodingUtils.to_float(latest_packet.lat) || 0.0
      lon = Aprsme.EncodingUtils.to_float(latest_packet.lon) || 0.0

      packet_data = DataBuilder.build_packet_data(latest_packet)

      socket
      |> push_event("zoom_to_location", %{lat: lat, lng: lon, zoom: 12})
      |> push_event("add_historical_packets_batch", %{
        packets: [packet_data],
        batch: 0,
        is_final: false
      })
    else
      socket
    end
  end
end
