defmodule AprsmeWeb.Live.Shared.BoundsUtilsTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias AprsmeWeb.Live.Shared.BoundsUtils

  defp world_bounds, do: %{north: 85.0, south: -85.0, east: 180.0, west: -180.0}
  defp usa_bounds, do: %{north: 49.0, south: 25.0, east: -67.0, west: -125.0}
  # crosses antimeridian
  defp pacific_bounds, do: %{north: 60.0, south: -60.0, east: -170.0, west: 170.0}

  describe "valid_bounds?/1" do
    test "accepts valid bounds" do
      assert BoundsUtils.valid_bounds?(world_bounds())
      assert BoundsUtils.valid_bounds?(usa_bounds())
    end

    test "rejects bounds with north <= south" do
      refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 20.0, east: 0.0, west: -10.0})
      refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 10.0, east: 0.0, west: -10.0})
    end

    test "rejects bounds with out-of-range latitude" do
      refute BoundsUtils.valid_bounds?(%{north: 91.0, south: 0.0, east: 10.0, west: -10.0})
      refute BoundsUtils.valid_bounds?(%{north: 10.0, south: -91.0, east: 10.0, west: -10.0})
    end

    test "rejects non-map input" do
      refute BoundsUtils.valid_bounds?(nil)
      refute BoundsUtils.valid_bounds?(%{})
      refute BoundsUtils.valid_bounds?("bounds")
    end

    test "rejects bounds with non-numeric values" do
      refute BoundsUtils.valid_bounds?(%{north: "10", south: 0.0, east: 0.0, west: 0.0})
    end
  end

  describe "compare_bounds/2" do
    test "two nils are equal" do
      assert BoundsUtils.compare_bounds(nil, nil)
    end

    test "nil vs map is not equal" do
      refute BoundsUtils.compare_bounds(nil, usa_bounds())
      refute BoundsUtils.compare_bounds(usa_bounds(), nil)
    end

    test "identical bounds compare equal" do
      assert BoundsUtils.compare_bounds(usa_bounds(), usa_bounds())
    end

    test "bounds equal within rounding to 4 places" do
      a = %{north: 10.00001, south: 0.0, east: 0.0, west: -10.0}
      b = %{north: 10.00002, south: 0.0, east: 0.0, west: -10.0}
      assert BoundsUtils.compare_bounds(a, b)
    end

    test "bounds different beyond 4 places compare unequal" do
      a = %{north: 10.0001, south: 0.0, east: 0.0, west: -10.0}
      b = %{north: 10.0003, south: 0.0, east: 0.0, west: -10.0}
      refute BoundsUtils.compare_bounds(a, b)
    end

    test "integer vs equivalent float compares equal" do
      a = %{north: 10, south: 0, east: 5, west: -5}
      b = %{north: 10.0, south: 0.0, east: 5.0, west: -5.0}
      assert BoundsUtils.compare_bounds(a, b)
    end
  end

  describe "within_bounds?/2" do
    test "coordinates inside USA bounds match" do
      assert BoundsUtils.within_bounds?({40.0, -100.0}, usa_bounds())
      assert BoundsUtils.within_bounds?(%{lat: 40.0, lon: -100.0}, usa_bounds())
    end

    test "coordinates outside USA bounds don't match" do
      refute BoundsUtils.within_bounds?({0.0, 0.0}, usa_bounds())
      refute BoundsUtils.within_bounds?(%{lat: 60.0, lon: -100.0}, usa_bounds())
    end

    test "coordinates on antimeridian-crossing bounds" do
      # Pacific bounds go from west=170 to east=-170, wrapping across 180.
      assert BoundsUtils.within_bounds?({0.0, 175.0}, pacific_bounds())
      assert BoundsUtils.within_bounds?({0.0, -175.0}, pacific_bounds())
      refute BoundsUtils.within_bounds?({0.0, 0.0}, pacific_bounds())
    end

    test "nil coordinates are never within bounds" do
      refute BoundsUtils.within_bounds?(%{lat: nil, lon: nil}, usa_bounds())
      refute BoundsUtils.within_bounds?(%{}, usa_bounds())
    end
  end

  describe "filter_packets_by_bounds/2" do
    test "filters a list of packets" do
      packets = [
        %{lat: 40.0, lon: -100.0, id: "in"},
        %{lat: 0.0, lon: 0.0, id: "out"}
      ]

      result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds())
      assert length(result) == 1
      assert hd(result).id == "in"
    end

    test "filters a map of packets" do
      packets = %{
        "a" => %{lat: 40.0, lon: -100.0},
        "b" => %{lat: 0.0, lon: 0.0}
      }

      result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds())
      assert Map.has_key?(result, "a")
      refute Map.has_key?(result, "b")
    end

    test "returns empty on empty input" do
      assert BoundsUtils.filter_packets_by_bounds([], usa_bounds()) == []
      assert BoundsUtils.filter_packets_by_bounds(%{}, usa_bounds()) == %{}
    end
  end

  describe "reject_packets_by_bounds/2" do
    test "returns keys for packets outside bounds" do
      packets = %{
        "in" => %{lat: 40.0, lon: -100.0},
        "out" => %{lat: 0.0, lon: 0.0}
      }

      assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == ["out"]
    end

    test "returns empty list when everything is in bounds" do
      packets = %{"a" => %{lat: 40.0, lon: -100.0}}
      assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == []
    end
  end

  describe "normalize_bounds/1" do
    test "converts string keys to atoms" do
      result =
        BoundsUtils.normalize_bounds(%{
          "north" => 49.0,
          "south" => 25.0,
          "east" => -67.0,
          "west" => -125.0
        })

      assert result == usa_bounds()
    end

    test "converts string values to floats" do
      result =
        BoundsUtils.normalize_bounds(%{
          "north" => "49.0",
          "south" => "25.0",
          "east" => "-67.0",
          "west" => "-125.0"
        })

      assert result.north == 49.0
      assert result.south == 25.0
    end

    test "passes through already-atom bounds" do
      assert BoundsUtils.normalize_bounds(usa_bounds()) == usa_bounds()
    end

    test "returns nil for unsupported input" do
      assert BoundsUtils.normalize_bounds(nil) == nil
      assert BoundsUtils.normalize_bounds(%{foo: "bar"}) == nil
      assert BoundsUtils.normalize_bounds("bounds") == nil
    end
  end

  describe "calculate_bounds_from_center_and_zoom/2" do
    test "centered bounds contain the center" do
      center = %{lat: 40.0, lng: -100.0}
      bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 5)
      assert bounds.south <= center.lat and bounds.north >= center.lat
      assert bounds.west <= center.lng and bounds.east >= center.lng
    end

    test "higher zoom produces tighter bounds" do
      center = %{lat: 40.0, lng: -100.0}
      b_low = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 3)
      b_high = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 15)
      low_width = b_low.east - b_low.west
      high_width = b_high.east - b_high.west
      assert high_width < low_width
    end

    test "center-symmetric about the origin produces symmetric bounds" do
      bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(%{lat: 0.0, lng: 0.0}, 5)
      assert_in_delta bounds.north, -bounds.south, 0.0001
      assert_in_delta bounds.east, -bounds.west, 0.0001
    end
  end

  describe "property: filter + reject partition the input" do
    property "every packet key ends up in exactly one of filter/reject" do
      packet_gen =
        fixed_map(%{
          lat: float(min: -89.0, max: 89.0),
          lon: float(min: -179.0, max: 179.0)
        })

      check all(packets <- map_of(string(:alphanumeric, min_length: 1, max_length: 5), packet_gen)) do
        bounds = usa_bounds()
        kept = packets |> BoundsUtils.filter_packets_by_bounds(bounds) |> Map.keys()
        dropped = BoundsUtils.reject_packets_by_bounds(packets, bounds)

        assert Enum.sort(kept ++ dropped) == packets |> Map.keys() |> Enum.sort()
        assert kept -- dropped == kept
      end
    end
  end

  describe "property: within_bounds? matches manual range check (non-wrapping bounds)" do
    property "matches manual check for any coordinate against USA bounds" do
      usa = usa_bounds()

      check all(
              lat <- float(min: -90.0, max: 90.0),
              lon <- float(min: -180.0, max: 180.0)
            ) do
        expected =
          lat >= usa.south and lat <= usa.north and
            lon >= usa.west and lon <= usa.east

        assert BoundsUtils.within_bounds?({lat, lon}, usa) == expected
      end
    end
  end
end
