defmodule AprsmeWeb.MapLive.PacketProcessorTest do
  use ExUnit.Case, async: true

  alias AprsmeWeb.MapLive.PacketProcessor

  defp build_socket(assigns \\ %{}) do
    defaults = %{
      map_bounds: %{north: 34.0, south: 32.0, east: -95.0, west: -97.0},
      visible_packets: %{},
      map_zoom: 10,
      station_popup_open: false,
      locale: "en"
    }

    merged = Map.merge(defaults, assigns)

    socket = %Phoenix.LiveView.Socket{
      assigns: Map.put(merged, :__changed__, %{})
    }

    socket
  end

  defp build_packet(overrides \\ %{}) do
    Map.merge(
      %{
        id: "test-packet-1",
        sender: "K5GVL-10",
        base_callsign: "K5GVL",
        ssid: "10",
        lat: 33.1,
        lon: -96.5,
        symbol_table_id: "/",
        symbol_code: "-",
        comment: "Test station",
        received_at: DateTime.utc_now()
      },
      overrides
    )
  end

  describe "process_packet_for_marker_data/2" do
    test "returns marker data for in-bounds packet at marker zoom level" do
      socket = build_socket()
      packet = build_packet()

      {updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert %{"id" => _} = marker_data
      # Socket state should be updated
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert map_size(visible) == 1
    end

    test "returns nil marker data for out-of-bounds packet" do
      socket = build_socket()
      # Packet outside the bounds (north: 34, south: 32, east: -95, west: -97)
      packet = build_packet(%{lat: 40.0, lon: -80.0})

      {updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert marker_data == nil
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert visible == %{}
    end

    test "returns nil marker data for packet with nil coordinates" do
      socket = build_socket()
      packet = build_packet(%{lat: nil, lon: nil})

      {updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert marker_data == nil
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert visible == %{}
    end

    test "returns nil marker data in heat map mode (zoom <= 8)" do
      socket = build_socket(%{map_zoom: 7})
      packet = build_packet()

      {_updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert marker_data == nil
    end

    test "does not push any events to socket" do
      socket = build_socket()
      packet = build_packet()

      {updated_socket, _marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      # Socket should not have any push_events queued
      # In LiveView, push_event adds to socket.private[:push_events]
      push_events = get_in(updated_socket.private, [:push_events]) || []
      assert push_events == []
    end

    test "returns convert_from when replacing an existing visible packet" do
      first_packet = build_packet(%{id: "packet-1", lat: 33.1, lon: -96.5})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => first_packet}})

      # Second packet for same callsign has a different packet ID but should still
      # replace the existing marker for that station.
      second_packet = build_packet(%{id: "packet-2", lat: 33.2, lon: -96.4})

      {_updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(second_packet, socket)

      assert %{"convert_from" => "K5GVL-10"} = marker_data
    end

    test "returns nil convert_from for first packet from a callsign" do
      socket = build_socket()
      packet = build_packet()

      {_updated_socket, marker_data, _removed} = PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert %{"id" => _} = marker_data
      refute Map.has_key?(marker_data, "convert_from")
    end

    test "returns removed callsign key when out-of-bounds packet had existing marker" do
      # Set up socket with an existing visible packet
      existing_packet = build_packet(%{id: "packet-1", lat: 33.1, lon: -96.5})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => existing_packet}})

      # New packet from same callsign is out of bounds
      out_of_bounds_packet = build_packet(%{id: "packet-2", lat: 40.0, lon: -80.0})

      {updated_socket, marker_data, removed_id} =
        PacketProcessor.process_packet_for_marker_data(out_of_bounds_packet, socket)

      assert marker_data == nil
      assert removed_id == "K5GVL-10"
      # The marker should be removed from visible_packets
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      refute Map.has_key?(visible, "K5GVL-10")
    end

    test "returns nil removed_id for normal in-bounds packets" do
      socket = build_socket()
      packet = build_packet()

      {_updated_socket, _marker_data, removed_id} =
        PacketProcessor.process_packet_for_marker_data(packet, socket)

      assert removed_id == nil
    end

    test "keeps one visible packet per station even when packet IDs change" do
      first_packet = build_packet(%{id: "packet-1", lat: 33.1, lon: -96.5})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => first_packet}})
      second_packet = build_packet(%{id: "packet-2", lat: 33.2, lon: -96.4})

      {updated_socket, marker_data, _removed_id} =
        PacketProcessor.process_packet_for_marker_data(second_packet, socket)

      assert marker_data["convert_from"] == "K5GVL-10"
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert Map.keys(visible) == ["K5GVL-10"]
      assert visible["K5GVL-10"].id == "packet-2"
    end

    test "skips marker update when movement is below significance threshold" do
      # ~1m move — well below the 50m threshold used by GeoUtils.significant_movement?/5.
      existing = build_packet(%{id: "packet-1", lat: 33.10000, lon: -96.50000})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => existing}})
      tiny_move = build_packet(%{id: "packet-2", lat: 33.10001, lon: -96.50000})

      {updated_socket, marker_data, removed_id} =
        PacketProcessor.process_packet_for_marker_data(tiny_move, socket)

      assert marker_data == nil
      assert removed_id == nil
      # State is still updated silently so the newest packet is stored.
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert visible["K5GVL-10"].id == "packet-2"
    end

    test "emits a marker when movement exceeds significance threshold" do
      existing = build_packet(%{id: "packet-1", lat: 33.10, lon: -96.50})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => existing}})
      # ~0.03° ≈ 3 km — far above 50m.
      big_move = build_packet(%{id: "packet-2", lat: 33.13, lon: -96.53})

      {_, marker_data, _} = PacketProcessor.process_packet_for_marker_data(big_move, socket)
      assert %{"id" => _} = marker_data
    end

    test "treats invalid existing coordinates as 'not moved' — no marker emitted" do
      existing_bad = build_packet(%{id: "packet-1", lat: nil, lon: nil})
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => existing_bad}})
      incoming = build_packet(%{id: "packet-2", lat: 33.11, lon: -96.51})

      {updated_socket, marker_data, _} =
        PacketProcessor.process_packet_for_marker_data(incoming, socket)

      # No significant-move decision possible → replace silently (no marker emitted).
      assert marker_data == nil
      assert %{assigns: %{visible_packets: visible}} = updated_socket
      assert visible["K5GVL-10"].id == "packet-2"
    end
  end

  describe "handle_packet_visibility/5" do
    test "returns socket unchanged when coordinates are nil" do
      socket = build_socket()
      packet = build_packet()
      result = PacketProcessor.handle_packet_visibility(packet, nil, nil, "K5GVL-10", socket)
      assert result == socket
    end

    test "removes an existing marker when packet goes out of bounds" do
      existing = build_packet()
      socket = build_socket(%{visible_packets: %{"K5GVL-10" => existing}})
      # Out-of-bounds coords.
      result = PacketProcessor.handle_packet_visibility(existing, 40.0, -80.0, "K5GVL-10", socket)
      assert %{assigns: %{visible_packets: visible}} = result
      refute Map.has_key?(visible, "K5GVL-10")
    end
  end

  describe "remove_marker_from_map/2" do
    test "removes the callsign key from visible_packets" do
      existing = build_packet()

      socket =
        build_socket(%{visible_packets: %{"K5GVL-10" => existing, "OTHER-1" => existing}})

      result = PacketProcessor.remove_marker_from_map("K5GVL-10", socket)
      assert %{assigns: %{visible_packets: visible}} = result
      refute Map.has_key?(visible, "K5GVL-10")
      assert Map.has_key?(visible, "OTHER-1")
    end
  end

  describe "process_packet_for_display/2" do
    test "updates last_update_at and returns {:noreply, socket}" do
      socket = build_socket()
      packet = build_packet()

      before = DateTime.utc_now()
      {:noreply, new_socket} = PacketProcessor.process_packet_for_display(packet, socket)

      assert %{assigns: %{last_update_at: last_update, visible_packets: visible}} = new_socket
      assert DateTime.compare(last_update, before) in [:gt, :eq]
      # Marker should have been added to visible_packets.
      assert Map.has_key?(visible, "K5GVL-10")
    end

    test "leaves state mostly intact for out-of-bounds packets" do
      socket = build_socket()
      packet = build_packet(%{lat: 50.0, lon: -50.0})

      {:noreply, new_socket} = PacketProcessor.process_packet_for_display(packet, socket)

      assert %{assigns: %{visible_packets: visible, last_update_at: last_update}} = new_socket
      refute Map.has_key?(visible, "K5GVL-10")
      assert %DateTime{} = last_update
    end
  end

  describe "handle_valid_postgres_packet/4 at marker zoom" do
    test "adds marker to visible_packets and returns a socket" do
      socket = build_socket(%{map_zoom: 12})
      packet = build_packet()

      new_socket =
        PacketProcessor.handle_valid_postgres_packet(packet, packet.lat, packet.lon, socket)

      assert %{assigns: %{visible_packets: visible}} = new_socket
      assert Map.has_key?(visible, "K5GVL-10")
    end
  end

  describe "process_packet_for_display/2 with existing marker (movement detection)" do
    test "moving an existing in-bounds marker by < 50m keeps the existing visible packet" do
      existing_packet = build_packet()
      visible = %{"K5GVL-10" => existing_packet}
      socket = build_socket(%{visible_packets: visible})

      # New packet at almost the same coordinates as the existing one — well
      # under the 50m significance threshold.
      tiny_move = build_packet(%{lat: 33.10001, lon: -96.50001})

      {:noreply, new_socket} = PacketProcessor.process_packet_for_display(tiny_move, socket)

      # State updates with the new packet but no marker push happens.
      assert %{assigns: %{visible_packets: visible}} = new_socket
      assert Map.has_key?(visible, "K5GVL-10")
    end

    test "moving an existing in-bounds marker by > 50m triggers full handling" do
      existing_packet = build_packet()
      visible = %{"K5GVL-10" => existing_packet}
      socket = build_socket(%{visible_packets: visible})

      # New packet ~5km north — clearly significant movement.
      big_move = build_packet(%{lat: 33.15, lon: -96.5})

      {:noreply, new_socket} = PacketProcessor.process_packet_for_display(big_move, socket)
      assert %{assigns: %{visible_packets: visible}} = new_socket
      assert Map.has_key?(visible, "K5GVL-10")
    end
  end
end
