defmodule Aprsme.Packets.PreparedQueriesTest do
  use Aprsme.DataCase, async: false

  alias Aprsme.Packet
  alias Aprsme.Packets.PreparedQueries
  alias Aprsme.Repo

  defp create_positioned_packet(attrs) do
    defaults = %{
      sender: "TEST-1",
      base_callsign: "TEST",
      ssid: "1",
      destination: "APRS",
      data_type: "position",
      lat: Decimal.new("33.0000"),
      lon: Decimal.new("-96.0000"),
      received_at: DateTime.truncate(DateTime.utc_now(), :second)
    }

    merged = Map.merge(defaults, attrs)

    %Packet{}
    |> Packet.changeset(Map.from_struct(Map.merge(%Packet{}, merged)))
    |> Repo.insert()
  end

  describe "get_latest_packets_for_callsigns/1" do
    test "returns full packet structs for multiple callsigns" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000"),
          comment: "Digi TX"
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "N5TXZ-10",
          base_callsign: "N5TXZ",
          ssid: "10",
          lat: Decimal.new("33.2000"),
          lon: Decimal.new("-96.5000"),
          comment: "iGate"
        })

      result = PreparedQueries.get_latest_packets_for_callsigns(["K5GVL-10", "N5TXZ-10"])

      assert length(result) == 2
      assert Enum.all?(result, &is_struct(&1, Packet))

      senders = Enum.map(result, & &1.sender)
      assert "K5GVL-10" in senders
      assert "N5TXZ-10" in senders

      k5gvl = Enum.find(result, &(&1.sender == "K5GVL-10"))
      assert_in_delta k5gvl.lat, 33.1, 0.01
      assert_in_delta k5gvl.lon, -96.6, 0.01
    end

    test "returns empty list for empty input" do
      assert PreparedQueries.get_latest_packets_for_callsigns([]) == []
    end

    test "returns empty list for nonexistent callsigns" do
      result = PreparedQueries.get_latest_packets_for_callsigns(["NONEXIST-1", "FAKE-2"])
      assert result == []
    end

    test "returns latest packet when multiple exist for a callsign" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000"),
          received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("34.0000"),
          lon: Decimal.new("-97.0000"),
          received_at: DateTime.truncate(DateTime.utc_now(), :second)
        })

      result = PreparedQueries.get_latest_packets_for_callsigns(["K5GVL-10"])

      assert length(result) == 1
      packet = hd(result)
      assert is_struct(packet, Packet)
      assert_in_delta packet.lat, 34.0, 0.01
      assert_in_delta packet.lon, -97.0, 0.01
    end
  end

  describe "get_latest_packet_for_callsign/1" do
    test "prefers the latest positioned packet over a newer non-position packet" do
      positioned_received_at = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second)
      status_received_at = DateTime.truncate(DateTime.utc_now(), :second)

      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000"),
          received_at: positioned_received_at
        })

      {:ok, _} =
        %Packet{}
        |> Packet.changeset(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          destination: "APRS",
          data_type: "status",
          received_at: status_received_at,
          has_position: false,
          raw_packet: "K5GVL-10>APRS:>status update"
        })
        |> Repo.insert()

      packet = PreparedQueries.get_latest_packet_for_callsign("K5GVL-10")

      assert packet.has_position == true
      assert_in_delta packet.lat, 33.1, 0.01
      assert_in_delta packet.lon, -96.6, 0.01
    end

    test "falls back to the latest packet when no positioned packet exists" do
      received_at = DateTime.truncate(DateTime.utc_now(), :second)

      {:ok, _} =
        %Packet{}
        |> Packet.changeset(%{
          sender: "STATUS-ONLY",
          base_callsign: "STATUS",
          ssid: "0",
          destination: "APRS",
          data_type: "status",
          received_at: received_at,
          has_position: false,
          raw_packet: "STATUS-ONLY>APRS:>status update"
        })
        |> Repo.insert()

      packet = PreparedQueries.get_latest_packet_for_callsign("STATUS-ONLY")

      assert packet.sender == "STATUS-ONLY"
      assert packet.has_position == false
    end

    test "ignores packets older than the retention window" do
      # Insert a packet whose received_at is way beyond any reasonable retention
      # window — older than the configured retention_days plus a margin. The
      # query should not return it, so partition pruning can skip those
      # partitions on the live table.
      retention_days = Application.get_env(:aprsme, :packet_retention_days, 7)
      ancient = DateTime.add(DateTime.utc_now(), -(retention_days + 30) * 86_400, :second)

      {:ok, _} =
        create_positioned_packet(%{
          sender: "ANCIENT-1",
          base_callsign: "ANCIENT",
          ssid: "1",
          received_at: DateTime.truncate(ancient, :second)
        })

      assert is_nil(PreparedQueries.get_latest_packet_for_callsign("ANCIENT-1"))
    end
  end

  describe "get_nearby_stations_knn/4" do
    test "returns stations sorted by distance from the point" do
      # Close to center
      {:ok, _} =
        create_positioned_packet(%{
          sender: "NEAR-1",
          base_callsign: "NEAR",
          ssid: "1",
          lat: Decimal.new("33.0100"),
          lon: Decimal.new("-96.0100")
        })

      # Further
      {:ok, _} =
        create_positioned_packet(%{
          sender: "FAR-1",
          base_callsign: "FAR",
          ssid: "1",
          lat: Decimal.new("33.5000"),
          lon: Decimal.new("-96.5000")
        })

      result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, nil, %{limit: 5})

      callsigns = Enum.map(result, & &1.callsign)
      assert "NEAR-1" in callsigns
      assert "FAR-1" in callsigns
      # NEAR-1 must come before FAR-1 (sorted by distance)
      assert Enum.find_index(result, &(&1.callsign == "NEAR-1")) <
               Enum.find_index(result, &(&1.callsign == "FAR-1"))
    end

    test "excludes the specified callsign" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "ME-1",
          base_callsign: "ME",
          ssid: "1",
          lat: Decimal.new("33.0100"),
          lon: Decimal.new("-96.0100")
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "OTHER-1",
          base_callsign: "OTHER",
          ssid: "1",
          lat: Decimal.new("33.0200"),
          lon: Decimal.new("-96.0200")
        })

      result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, "ME-1", %{limit: 5})

      callsigns = Enum.map(result, & &1.callsign)
      refute "ME-1" in callsigns
      assert "OTHER-1" in callsigns
    end

    test "honors :max_radius_km option" do
      # Within 10km of (33.0, -96.0): ~1km offset
      {:ok, _} =
        create_positioned_packet(%{
          sender: "INSIDE-1",
          base_callsign: "INSIDE",
          ssid: "1",
          lat: Decimal.new("33.0100"),
          lon: Decimal.new("-96.0100")
        })

      # Outside 10km: ~50km away
      {:ok, _} =
        create_positioned_packet(%{
          sender: "OUTSIDE-1",
          base_callsign: "OUTSIDE",
          ssid: "1",
          lat: Decimal.new("33.5000"),
          lon: Decimal.new("-96.5000")
        })

      result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, nil, %{limit: 10, max_radius_km: 10})

      callsigns = Enum.map(result, & &1.callsign)
      assert "INSIDE-1" in callsigns
      refute "OUTSIDE-1" in callsigns
    end
  end

  describe "get_latest_positions_for_callsigns/1" do
    test "returns positions for multiple callsigns" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000")
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "N5TXZ-10",
          base_callsign: "N5TXZ",
          ssid: "10",
          lat: Decimal.new("33.2000"),
          lon: Decimal.new("-96.5000")
        })

      result = PreparedQueries.get_latest_positions_for_callsigns(["K5GVL-10", "N5TXZ-10"])

      assert length(result) == 2
      callsigns = Enum.map(result, & &1.callsign)
      assert "K5GVL-10" in callsigns
      assert "N5TXZ-10" in callsigns

      k5gvl = Enum.find(result, &(&1.callsign == "K5GVL-10"))
      assert_in_delta k5gvl.lat, 33.1, 0.01
      assert_in_delta k5gvl.lng, -96.6, 0.01
    end

    test "returns empty list for empty input" do
      assert PreparedQueries.get_latest_positions_for_callsigns([]) == []
    end

    test "returns empty list for nonexistent callsigns" do
      result = PreparedQueries.get_latest_positions_for_callsigns(["NONEXIST-1", "FAKE-2"])
      assert result == []
    end

    test "is case-insensitive" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000")
        })

      result = PreparedQueries.get_latest_positions_for_callsigns(["k5gvl-10"])

      assert length(result) == 1
      assert hd(result).callsign == "K5GVL-10"
    end

    test "returns latest position when multiple packets exist for a callsign" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("33.1000"),
          lon: Decimal.new("-96.6000"),
          received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "K5GVL-10",
          base_callsign: "K5GVL",
          ssid: "10",
          lat: Decimal.new("34.0000"),
          lon: Decimal.new("-97.0000"),
          received_at: DateTime.truncate(DateTime.utc_now(), :second)
        })

      result = PreparedQueries.get_latest_positions_for_callsigns(["K5GVL-10"])

      assert length(result) == 1
      position = hd(result)
      assert_in_delta position.lat, 34.0, 0.01
      assert_in_delta position.lng, -97.0, 0.01
    end
  end

  describe "get_nearby_weather_stations/4" do
    setup do
      # San Francisco center: 37.7749, -122.4194
      center_lat = 37.7749
      center_lon = -122.4194

      # Station A: ~0.5 miles north, weather 1h ago (should be included)
      {:ok, station_a} =
        create_positioned_packet(%{
          sender: "WX-A",
          base_callsign: "WX-A",
          ssid: "0",
          lat: Decimal.new("37.7822"),
          lon: Decimal.new("-122.4194"),
          temperature: 72.5,
          humidity: 65.0,
          pressure: 1013.25,
          wind_speed: 5.0,
          wind_direction: 180,
          symbol_table_id: "/",
          symbol_code: "_",
          comment: "Weather Station A",
          received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        })

      # Station B: ~10 miles east, weather 2h ago (should be included)
      {:ok, station_b} =
        create_positioned_packet(%{
          sender: "WX-B-1",
          base_callsign: "WX-B",
          ssid: "1",
          lat: Decimal.new("37.7749"),
          lon: Decimal.new("-122.2700"),
          temperature: 68.0,
          humidity: 70.0,
          wind_gust: 12.0,
          symbol_table_id: "/",
          symbol_code: "_",
          comment: "Weather Station B",
          received_at: DateTime.add(DateTime.utc_now(), -7200, :second)
        })

      # Station C: ~50 miles south, weather 1h ago (should be excluded - outside radius)
      {:ok, _station_c} =
        create_positioned_packet(%{
          sender: "WX-C",
          base_callsign: "WX-C",
          ssid: "0",
          lat: Decimal.new("37.0500"),
          lon: Decimal.new("-122.4194"),
          temperature: 75.0,
          humidity: 60.0,
          symbol_table_id: "/",
          symbol_code: "_",
          comment: "Weather Station C",
          received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        })

      # Station D: ~5 miles west, weather 8h ago (should be excluded - outside time window)
      {:ok, _station_d} =
        create_positioned_packet(%{
          sender: "WX-D",
          base_callsign: "WX-D",
          ssid: "0",
          lat: Decimal.new("37.7749"),
          lon: Decimal.new("-122.4900"),
          temperature: 70.0,
          humidity: 68.0,
          symbol_table_id: "/",
          symbol_code: "_",
          comment: "Weather Station D",
          received_at: DateTime.add(DateTime.utc_now(), -28_800, :second)
        })

      # Station E: ~5 miles southeast, no weather data (should be excluded)
      {:ok, _station_e} =
        create_positioned_packet(%{
          sender: "WX-E",
          base_callsign: "WX-E",
          ssid: "0",
          lat: Decimal.new("37.7400"),
          lon: Decimal.new("-122.3800"),
          symbol_table_id: "/",
          symbol_code: "-",
          comment: "Regular Station",
          received_at: DateTime.add(DateTime.utc_now(), -3600, :second)
        })

      # Duplicate SSID for Station B (should be deduplicated)
      {:ok, _station_b2} =
        create_positioned_packet(%{
          sender: "WX-B-2",
          base_callsign: "WX-B",
          ssid: "2",
          lat: Decimal.new("37.7749"),
          lon: Decimal.new("-122.2700"),
          temperature: 69.0,
          humidity: 72.0,
          symbol_table_id: "/",
          symbol_code: "_",
          comment: "Weather Station B SSID 2",
          received_at: DateTime.add(DateTime.utc_now(), -1800, :second)
        })

      %{
        center_lat: center_lat,
        center_lon: center_lon,
        station_a: station_a,
        station_b: station_b
      }
    end

    test "returns nearby weather stations within radius and time window", %{
      center_lat: lat,
      center_lon: lon
    } do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)

      assert length(result) == 2
      callsigns = Enum.map(result, & &1.callsign)
      assert "WX-A" in callsigns
      assert "WX-B-2" in callsigns
    end

    test "returns results ordered by distance (closest first)", %{center_lat: lat, center_lon: lon} do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)

      assert length(result) == 2
      # Station A (~0.5 miles) should be first
      assert hd(result).callsign == "WX-A"
      assert hd(result).distance_miles < 1.0

      # Station B (~8-10 miles) should be second
      second = Enum.at(result, 1)
      assert second.callsign == "WX-B-2"
      assert second.distance_miles > 7.0
      assert second.distance_miles < 10.0
    end

    test "excludes stations outside radius", %{center_lat: lat, center_lon: lon} do
      # Use 5 mile radius - should only get Station A
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 5.0)

      assert length(result) == 1
      assert hd(result).callsign == "WX-A"
    end

    test "excludes stations outside time window", %{center_lat: lat, center_lon: lon} do
      # Use 1 hour window - should get both A (1h ago) and B-2 (30min ago)
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 1)

      assert length(result) == 2
      callsigns = Enum.map(result, & &1.callsign)
      assert "WX-A" in callsigns
      assert "WX-B-2" in callsigns
    end

    test "excludes stations without weather data", %{center_lat: lat, center_lon: lon} do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)

      callsigns = Enum.map(result, & &1.callsign)
      refute "WX-E" in callsigns
    end

    test "deduplicates by base_callsign and returns most recent", %{
      center_lat: lat,
      center_lon: lon
    } do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)

      base_callsigns = Enum.map(result, & &1.base_callsign)
      assert length(base_callsigns) == length(Enum.uniq(base_callsigns))

      # Should get WX-B-2 (most recent) not WX-B-1
      wx_b = Enum.find(result, &(&1.base_callsign == "WX-B"))
      assert wx_b.callsign == "WX-B-2"
    end

    test "respects limit option", %{center_lat: lat, center_lon: lon} do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, limit: 1)

      assert length(result) == 1
      assert hd(result).callsign == "WX-A"
    end

    test "returns all required fields", %{center_lat: lat, center_lon: lon} do
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0)

      station = hd(result)
      assert byte_size(station.callsign) > 0
      assert byte_size(station.base_callsign) > 0
      assert is_float(station.lat)
      assert is_float(station.lon)
      assert is_float(station.distance_miles)
      assert byte_size(station.symbol_table_id) > 0
      assert byte_size(station.symbol_code) > 0
      assert byte_size(station.comment) > 0
      assert %DateTime{} = station.received_at

      # Weather fields (may be nil)
      case station.temperature do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.humidity do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.pressure do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.wind_speed do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.wind_direction do
        nil -> :ok
        value when is_integer(value) -> :ok
      end

      case station.wind_gust do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.rain_1h do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.rain_24h do
        nil -> :ok
        value when is_float(value) -> :ok
      end

      case station.rain_since_midnight do
        nil -> :ok
        value when is_float(value) -> :ok
      end
    end

    test "returns empty list when no stations in radius", %{center_lat: _lat, center_lon: _lon} do
      # Use coordinates far from any station
      result = PreparedQueries.get_nearby_weather_stations(40.0, -100.0, 5.0)

      assert result == []
    end

    test "handles custom hours option", %{center_lat: lat, center_lon: lon} do
      # Use 3 hour window - should get both A and B
      result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 3)

      assert length(result) == 2
    end
  end

  describe "get_recent_packets_in_bounds/2" do
    test "returns packets within the bounding box" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "INBOUNDS-1",
          base_callsign: "INBOUNDS",
          lat: Decimal.new("33.5"),
          lon: Decimal.new("-96.5")
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "OUTOFBOUNDS-1",
          base_callsign: "OUTOFBOUNDS",
          lat: Decimal.new("50.0"),
          lon: Decimal.new("-120.0")
        })

      # bounds = [north, south, east, west]
      result = PreparedQueries.get_recent_packets_in_bounds([34.0, 33.0, -96.0, -97.0], 24)

      senders = Enum.map(result, & &1.sender)
      assert "INBOUNDS-1" in senders
      refute "OUTOFBOUNDS-1" in senders
    end
  end

  describe "get_packet_count_in_area/2" do
    test "counts only packets inside the bounding box" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "COUNT-IN-1",
          base_callsign: "COUNT",
          lat: Decimal.new("33.25"),
          lon: Decimal.new("-96.25")
        })

      {:ok, _} =
        create_positioned_packet(%{
          sender: "COUNT-OUT",
          base_callsign: "COUNT",
          lat: Decimal.new("55.0"),
          lon: Decimal.new("-30.0")
        })

      # bounds = [north, south, east, west]
      count = PreparedQueries.get_packet_count_in_area([34.0, 33.0, -96.0, -97.0], 24)
      assert is_integer(count)
      assert count >= 1
    end

    test "returns 0 when area has no packets" do
      assert PreparedQueries.get_packet_count_in_area([0.0, -1.0, 180.0, 179.0], 1) == 0
    end
  end

  describe "has_weather_packets?/1" do
    test "returns true when callsign has weather packets" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "HASWX-1",
          base_callsign: "HASWX",
          temperature: 72.0,
          humidity: 50.0
        })

      assert PreparedQueries.has_weather_packets?("HASWX-1")
    end

    test "returns false when callsign has no weather packets" do
      refute PreparedQueries.has_weather_packets?("NOWX-NEVER")
    end
  end

  describe "weather_callsigns/1" do
    test "returns a MapSet containing callsigns with weather data" do
      {:ok, _} =
        create_positioned_packet(%{
          sender: "WXSET-1",
          base_callsign: "WXSET",
          has_weather: true,
          temperature: 65.0
        })

      result = PreparedQueries.weather_callsigns(["WXSET-1", "NOWX-1"])
      assert %MapSet{} = result
      assert MapSet.member?(result, "WXSET-1")
      refute MapSet.member?(result, "NOWX-1")
    end

    test "returns an empty MapSet for empty input" do
      assert PreparedQueries.weather_callsigns([]) == MapSet.new()
    end
  end
end
