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

  alias Aprs.Types.MicE
  alias AprsmeWeb.Live.Shared.CoordinateUtils

  describe "get_coordinates/1" do
    test "extracts lat/lon from atom-keyed packet" do
      packet = %{lat: 30.5, lon: -95.0}
      assert CoordinateUtils.get_coordinates(packet) == {30.5, -95.0, nil}
    end

    test "extracts lat/lon from string-keyed packet" do
      packet = %{"lat" => 30.5, "lon" => -95.0}
      assert CoordinateUtils.get_coordinates(packet) == {30.5, -95.0, nil}
    end

    test "returns nil coordinates when missing" do
      assert CoordinateUtils.get_coordinates(%{}) == {nil, nil, nil}
    end

    test "extracts coordinates from data_extended.latitude/longitude" do
      packet = %{data_extended: %{latitude: 10.0, longitude: 20.0, extra: :keep}}
      {lat, lon, ext} = CoordinateUtils.get_coordinates(packet)
      assert lat == 10.0
      assert lon == 20.0
      assert ext.extra == :keep
    end

    test "extracts coordinates from a MicE data_extended" do
      mic_e = %MicE{
        lat_degrees: 30,
        lat_minutes: 30,
        lat_fractional: 0,
        lat_direction: :north,
        lon_degrees: 95,
        lon_minutes: 0,
        lon_fractional: 0,
        lon_direction: :west
      }

      {lat, lon, ext} = CoordinateUtils.get_coordinates(%{data_extended: mic_e})
      assert_in_delta lat, 30.5, 0.001
      assert_in_delta lon, -95.0, 0.001
      assert ext == mic_e
    end

    test "returns data_extended map when no latitude/longitude keys" do
      packet = %{lat: 1.0, lon: 2.0, data_extended: %{other: :value}}
      {lat, lon, ext} = CoordinateUtils.get_coordinates(packet)
      assert lat == 1.0
      assert lon == 2.0
      assert ext == %{other: :value}
    end
  end

  describe "get_coordinates_from_mic_e/1" do
    test "combines degrees, minutes, and fractional minutes" do
      # lat = deg + min/60 + frac/6000 = 30 + 30/60 + 3000/6000 = 31.0
      mic_e = %MicE{
        lat_degrees: 30,
        lat_minutes: 30,
        lat_fractional: 3000,
        lat_direction: :north,
        lon_degrees: 95,
        lon_minutes: 15,
        lon_fractional: 0,
        lon_direction: :east
      }

      {lat, lon} = CoordinateUtils.get_coordinates_from_mic_e(mic_e)
      assert_in_delta lat, 31.0, 0.0001
      assert_in_delta lon, 95.25, 0.0001
    end

    test "negates for southern/western hemispheres" do
      mic_e = %MicE{
        lat_degrees: 30,
        lat_minutes: 0,
        lat_fractional: 0,
        lat_direction: :south,
        lon_degrees: 95,
        lon_minutes: 0,
        lon_fractional: 0,
        lon_direction: :west
      }

      {lat, lon} = CoordinateUtils.get_coordinates_from_mic_e(mic_e)
      assert lat == -30.0
      assert lon == -95.0
    end

    test "returns {nil, nil} for out-of-range coordinates" do
      mic_e = %MicE{
        lat_degrees: 200,
        lat_minutes: 0,
        lat_fractional: 0,
        lat_direction: :north,
        lon_degrees: 0,
        lon_minutes: 0,
        lon_fractional: 0,
        lon_direction: :east
      }

      assert CoordinateUtils.get_coordinates_from_mic_e(mic_e) == {nil, nil}
    end
  end

  describe "has_position_data?/1" do
    test "returns true for direct coordinates" do
      assert CoordinateUtils.has_position_data?(%{lat: 1.0, lon: 2.0})
      assert CoordinateUtils.has_position_data?(%{"lat" => 1.0, "lon" => 2.0})
    end

    test "returns true when data_extended has latitude and longitude" do
      assert CoordinateUtils.has_position_data?(%{data_extended: %{latitude: 1.0, longitude: 2.0}})
    end

    test "returns true for MicE data_extended" do
      assert CoordinateUtils.has_position_data?(%{data_extended: %MicE{}})
    end

    test "returns false for empty maps" do
      refute CoordinateUtils.has_position_data?(%{})
    end

    test "returns false when coordinates are nil" do
      refute CoordinateUtils.has_position_data?(%{lat: nil, lon: nil})
    end

    test "returns false when data_extended has nil lat/lon" do
      refute CoordinateUtils.has_position_data?(%{data_extended: %{latitude: nil, longitude: nil}})
    end
  end

  describe "valid_coordinates?/2" do
    test "accepts valid integer and float coordinates" do
      assert CoordinateUtils.valid_coordinates?(0, 0)
      assert CoordinateUtils.valid_coordinates?(30.5, -95.0)
      assert CoordinateUtils.valid_coordinates?(90, 180)
      assert CoordinateUtils.valid_coordinates?(-90, -180)
    end

    test "rejects out-of-range values" do
      refute CoordinateUtils.valid_coordinates?(91, 0)
      refute CoordinateUtils.valid_coordinates?(-91, 0)
      refute CoordinateUtils.valid_coordinates?(0, 181)
      refute CoordinateUtils.valid_coordinates?(0, -181)
    end

    test "rejects non-numbers" do
      refute CoordinateUtils.valid_coordinates?(nil, 0)
      refute CoordinateUtils.valid_coordinates?(0, nil)
      refute CoordinateUtils.valid_coordinates?("30", "-95")
    end
  end

  describe "valid_coordinates_any_type?/2" do
    test "accepts Decimal coordinates" do
      assert CoordinateUtils.valid_coordinates_any_type?(Decimal.new("30.5"), Decimal.new("-95.0"))
    end

    test "accepts mixed numeric types" do
      assert CoordinateUtils.valid_coordinates_any_type?(30, -95.0)
    end

    test "rejects Decimal out of range" do
      refute CoordinateUtils.valid_coordinates_any_type?(Decimal.new("200"), Decimal.new("0"))
    end

    test "rejects non-numeric inputs" do
      refute CoordinateUtils.valid_coordinates_any_type?(nil, nil)
      refute CoordinateUtils.valid_coordinates_any_type?("abc", "xyz")
    end
  end

  describe "normalize_coordinate/1" do
    test "converts Decimal to float" do
      assert CoordinateUtils.normalize_coordinate(Decimal.new("30.5")) == 30.5
    end

    test "passes through non-Decimal" do
      assert CoordinateUtils.normalize_coordinate(42) == 42
      assert CoordinateUtils.normalize_coordinate(30.5) == 30.5
      assert CoordinateUtils.normalize_coordinate(nil) == nil
    end
  end

  describe "normalize_longitude/1" do
    test "leaves in-range longitude alone" do
      assert CoordinateUtils.normalize_longitude(0) == 0
      assert CoordinateUtils.normalize_longitude(100.5) == 100.5
      assert CoordinateUtils.normalize_longitude(-170) == -170
    end

    test "wraps positive longitude > 180" do
      assert CoordinateUtils.normalize_longitude(190) == -170
    end

    test "wraps negative longitude < -180" do
      assert CoordinateUtils.normalize_longitude(-190) == 170
    end

    test "handles multiple wraps" do
      assert CoordinateUtils.normalize_longitude(540) == 180
      assert CoordinateUtils.normalize_longitude(-540) == -180
    end

    test "passes through non-numbers" do
      assert CoordinateUtils.normalize_longitude(nil) == nil
      assert CoordinateUtils.normalize_longitude("170") == "170"
    end
  end

  describe "normalize_latitude/1" do
    test "leaves in-range latitude alone" do
      assert CoordinateUtils.normalize_latitude(0) == 0
      assert CoordinateUtils.normalize_latitude(45.5) == 45.5
    end

    test "clamps above 90" do
      assert CoordinateUtils.normalize_latitude(91) == 90.0
      assert CoordinateUtils.normalize_latitude(100.0) == 90.0
    end

    test "clamps below -90" do
      assert CoordinateUtils.normalize_latitude(-91) == -90.0
    end

    test "passes through non-numbers" do
      assert CoordinateUtils.normalize_latitude(nil) == nil
    end
  end

  describe "calculate_distance_meters/4" do
    test "returns 0 for identical points" do
      assert CoordinateUtils.calculate_distance_meters(0.0, 0.0, 0.0, 0.0) == 0.0
    end

    test "computes symmetric distances" do
      d1 = CoordinateUtils.calculate_distance_meters(0.0, 0.0, 10.0, 10.0)
      d2 = CoordinateUtils.calculate_distance_meters(10.0, 10.0, 0.0, 0.0)
      assert_in_delta d1, d2, 0.01
    end

    test "matches a known distance (NYC to LA, ~3936 km)" do
      d = CoordinateUtils.calculate_distance_meters(40.7128, -74.0060, 34.0522, -118.2437)
      # Accept a fairly loose tolerance — haversine vs the "true" number.
      assert_in_delta d / 1000, 3936, 30
    end

    test "gives positive distance for distinct points" do
      d = CoordinateUtils.calculate_distance_meters(0.0, 0.0, 0.01, 0.01)
      assert d > 0
    end
  end

  describe "property: normalize_longitude wraps into [-180, 180]" do
    property "result is always within [-180, 180]" do
      check all(lon <- float(min: -1080.0, max: 1080.0)) do
        result = CoordinateUtils.normalize_longitude(lon)
        assert result >= -180 and result <= 180
      end
    end
  end

  describe "property: normalize_latitude clamps into [-90, 90]" do
    property "result is always within [-90, 90]" do
      check all(lat <- float(min: -500.0, max: 500.0)) do
        result = CoordinateUtils.normalize_latitude(lat)
        assert result >= -90 and result <= 90
      end
    end
  end

  describe "property: valid_coordinates? agrees with range checks" do
    property "result equals in-range predicate for numbers" do
      check all(
              lat <- float(min: -200.0, max: 200.0),
              lon <- float(min: -400.0, max: 400.0)
            ) do
        expected = lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180
        assert CoordinateUtils.valid_coordinates?(lat, lon) == expected
      end
    end
  end

  describe "property: distance is symmetric and non-negative" do
    property "calculate_distance_meters is symmetric" do
      check all(
              lat1 <- float(min: -89.0, max: 89.0),
              lon1 <- float(min: -179.0, max: 179.0),
              lat2 <- float(min: -89.0, max: 89.0),
              lon2 <- float(min: -179.0, max: 179.0)
            ) do
        d1 = CoordinateUtils.calculate_distance_meters(lat1, lon1, lat2, lon2)
        d2 = CoordinateUtils.calculate_distance_meters(lat2, lon2, lat1, lon1)
        assert d1 >= 0
        assert_in_delta d1, d2, 0.01
      end
    end
  end
end
