"""
Map API tests — geofence CRUD, proximity, asset coordinate persistence.
Comprehensive coverage: happy paths, edge cases, error handling.
"""
import os
import sys
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

TEST_DB = Path(__file__).parent / "test_map_api.db"
os.environ["CANTEEN_DB_PATH"] = str(TEST_DB)
os.environ["CANTEEN_SKIP_AUTH"] = "1"


def _clean_db():
    for suffix in ("", "-shm", "-wal", "-journal"):
        p = TEST_DB.with_suffix(TEST_DB.suffix + suffix)
        if p.exists():
            p.unlink()


@pytest.fixture(autouse=True)
def clean_db():
    _clean_db()
    yield
    _clean_db()


@pytest.fixture
def client():
    # Ensure isolated DB path (module-level env may be overwritten by other tests)
    os.environ["CANTEEN_DB_PATH"] = str(TEST_DB)
    _clean_db()
    import importlib
    importlib.invalidate_caches()
    for mod in list(sys.modules.keys()):
        if mod == "server" or mod.startswith("server."):
            del sys.modules[mod]
    import server
    with TestClient(server.app) as tc:
        yield tc


# ─── helpers ────────────────────────────────────────────────────────────────

def _square_points(lat=0, lng=0, size=1):
    """Return a square polygon centered at (lat, lng)."""
    return [
        {"lat": lat - size, "lng": lng - size},
        {"lat": lat - size, "lng": lng + size},
        {"lat": lat + size, "lng": lng + size},
        {"lat": lat + size, "lng": lng - size},
    ]


def _create_geofence(client, name, lat=0, lng=0, size=1, color="#3388ff"):
    return client.post("/api/geofences", json={
        "name": name,
        "points": _square_points(lat, lng, size),
        "color": color,
    })


# ═══════════════════════════════════════════════════════════════════════════
# 1. Geofence CRUD — happy paths
# ═══════════════════════════════════════════════════════════════════════════

class TestGeofenceCRUD:
    """Full geofence lifecycle: create, list, get, update, delete."""

    def test_create_geofence(self, client):
        """Create a geofence with polygon points."""
        r = client.post("/api/geofences", json={
            "name": "Test Zone",
            "points": [{"lat": 40.0, "lng": -74.0}, {"lat": 40.1, "lng": -74.0},
                       {"lat": 40.1, "lng": -73.9}, {"lat": 40.0, "lng": -73.9}],
            "color": "#ff0000",
        })
        assert r.status_code == 201
        data = r.json()
        assert data["name"] == "Test Zone"
        assert data["color"] == "#ff0000"
        assert "points" in data
        assert "id" in data

    def test_create_geofence_default_color(self, client):
        """Create without color — uses default #3388ff."""
        r = client.post("/api/geofences", json={
            "name": "Default Color",
            "points": _square_points(),
        })
        assert r.status_code == 201
        data = r.json()
        assert data["color"] == "#3388ff"

    def test_list_geofences(self, client):
        """List all geofences."""
        _create_geofence(client, "A")
        r = client.get("/api/geofences")
        assert r.status_code == 200
        assert len(r.json()) == 1

    def test_list_geofences_empty(self, client):
        """No geofences — empty list."""
        r = client.get("/api/geofences")
        assert r.status_code == 200
        assert r.json() == []

    def test_list_geofences_sorted_by_name(self, client):
        """Geofences are returned sorted by name."""
        _create_geofence(client, "Zulu")
        _create_geofence(client, "Alpha")
        _create_geofence(client, "Mike")
        r = client.get("/api/geofences")
        names = [g["name"] for g in r.json()]
        assert names == sorted(names)

    def test_update_geofence_name_and_color(self, client):
        """Update geofence name and color."""
        gf = _create_geofence(client, "Old Name", color="#ff0000").json()
        gid = gf["id"]

        r = client.put(f"/api/geofences/{gid}", json={
            "name": "New Name", "color": "#00ff00",
        })
        assert r.status_code == 200
        assert r.json()["name"] == "New Name"
        assert r.json()["color"] == "#00ff00"

    def test_update_geofence_points(self, client):
        """Update geofence polygon points."""
        gf = _create_geofence(client, "Original Points", size=1).json()
        gid = gf["id"]

        new_points = _square_points(lat=10, lng=20, size=2)
        r = client.put(f"/api/geofences/{gid}", json={"points": new_points})
        assert r.status_code == 200
        returned = r.json()["points"]
        if isinstance(returned, str):
            import json
            returned = json.loads(returned)
        assert len(returned) == 4

    def test_update_geofence_partial(self, client):
        """Partial update — only change name, color unchanged."""
        gf = _create_geofence(client, "Old", color="#abc123").json()
        gid = gf["id"]

        r = client.put(f"/api/geofences/{gid}", json={"name": "New"})
        assert r.status_code == 200
        assert r.json()["name"] == "New"
        assert r.json()["color"] == "#abc123"

    def test_delete_geofence(self, client):
        """Delete a geofence."""
        gf = _create_geofence(client, "Delete Me").json()
        r = client.delete(f"/api/geofences/{gf['id']}")
        assert r.status_code == 204
        # Verify removed
        r = client.get("/api/geofences")
        assert len(r.json()) == 0

    def test_geofence_points_persist(self, client):
        """Points are stored and returned correctly."""
        points = [{"lat": 10.0, "lng": 20.0}, {"lat": 10.5, "lng": 20.5},
                  {"lat": 11.0, "lng": 20.0}]
        gf = client.post("/api/geofences", json={
            "name": "Triangle", "points": points, "color": "#0000ff"
        }).json()
        returned = gf["points"]
        if isinstance(returned, str):
            import json
            returned = json.loads(returned)
        assert len(returned) == 3
        assert returned[0]["lat"] == 10.0

    def test_duplicate_name_allowed(self, client):
        """Server allows duplicate geofence names (no uniqueness constraint)."""
        _create_geofence(client, "Same Name")
        r = _create_geofence(client, "Same Name")
        assert r.status_code == 201
        # Both should appear in list
        lst = client.get("/api/geofences").json()
        names = [g["name"] for g in lst]
        assert names.count("Same Name") == 2


# ═══════════════════════════════════════════════════════════════════════════
# 2. Geofence CRUD — error handling
# ═══════════════════════════════════════════════════════════════════════════

class TestGeofenceErrors:
    """Edge cases: 404s, 422s, invalid inputs."""

    def test_create_missing_name(self, client):
        """Create without required 'name' field → 422."""
        r = client.post("/api/geofences", json={
            "points": _square_points(),
        })
        assert r.status_code == 422

    def test_create_missing_points(self, client):
        """Create without required 'points' field → 422."""
        r = client.post("/api/geofences", json={
            "name": "No Points",
        })
        assert r.status_code == 422

    def test_create_empty_name(self, client):
        """Create with empty name string — should still work (no server-side validation)."""
        r = client.post("/api/geofences", json={
            "name": "",
            "points": _square_points(),
        })
        # Server doesn't validate empty name — it just stores it
        assert r.status_code == 201

    def test_create_invalid_points_type(self, client):
        """Create with points as string instead of list → 422."""
        r = client.post("/api/geofences", json={
            "name": "Bad Points",
            "points": "not-a-list",
        })
        assert r.status_code == 422

    def test_update_nonexistent_geofence(self, client):
        """Update a geofence that doesn't exist → 404."""
        r = client.put("/api/geofences/99999", json={"name": "Ghost"})
        assert r.status_code == 404

    def test_delete_nonexistent_geofence(self, client):
        """Delete a geofence that doesn't exist → 404."""
        r = client.delete("/api/geofences/99999")
        assert r.status_code == 404

    def test_delete_then_verify_gone(self, client):
        """After delete, ensure geofence cannot be updated."""
        gf = _create_geofence(client, "Ephemeral").json()
        client.delete(f"/api/geofences/{gf['id']}")
        r = client.put(f"/api/geofences/{gf['id']}", json={"name": "Revived?"})
        assert r.status_code == 404


# ═══════════════════════════════════════════════════════════════════════════
# 3. Geofence point-in-polygon check
# ═══════════════════════════════════════════════════════════════════════════

class TestGeofenceCheck:
    """POST /api/geofences/check — point-in-polygon."""

    def test_point_inside_single_geofence(self, client):
        """Returns the geofence containing the point."""
        _create_geofence(client, "Central", lat=40, lng=-74, size=1)
        r = client.post("/api/geofences/check", json={"lat": 40, "lng": -74})
        assert r.status_code == 200
        names = [g["name"] for g in r.json()]
        assert "Central" in names

    def test_point_outside_all_geofences(self, client):
        """Returns empty list when point is outside all geofences."""
        _create_geofence(client, "Local", lat=0, lng=0, size=1)
        r = client.post("/api/geofences/check", json={"lat": 50, "lng": 50})
        assert r.status_code == 200
        assert r.json() == []

    def test_point_inside_multiple_geofences(self, client):
        """Returns all geofences containing the point."""
        _create_geofence(client, "Big", lat=0, lng=0, size=10)
        _create_geofence(client, "Small", lat=0, lng=0, size=1)
        r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
        assert r.status_code == 200
        assert len(r.json()) == 2

    def test_check_no_geofences(self, client):
        """No geofences exist — empty array."""
        r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
        assert r.status_code == 200
        assert r.json() == []

    def test_check_missing_lat(self, client):
        """Missing 'lat' field → 422."""
        r = client.post("/api/geofences/check", json={"lng": 0})
        assert r.status_code == 422

    def test_check_missing_lng(self, client):
        """Missing 'lng' field → 422."""
        r = client.post("/api/geofences/check", json={"lat": 0})
        assert r.status_code == 422

    def test_check_empty_body(self, client):
        """Empty request body → 422."""
        r = client.post("/api/geofences/check", json={})
        assert r.status_code == 422

    def test_point_on_polygon_boundary(self, client):
        """Point exactly on the edge of a polygon — ray-casting may or may not include."""
        _create_geofence(client, "Square", lat=0, lng=0, size=1)
        # Test a point on one of the edges
        r = client.post("/api/geofences/check", json={"lat": -1.0, "lng": 0})
        assert r.status_code == 200
        # Boundary behavior is implementation-defined; just verify no crash
        assert isinstance(r.json(), list)

    def test_self_intersecting_polygon(self, client):
        """Self-intersecting bow-tie polygon — should not crash."""
        # Bow-tie shape: (0,0) → (1,1) → (0,1) → (1,0)
        r = client.post("/api/geofences", json={
            "name": "Bowtie",
            "points": [
                {"lat": 0, "lng": 0},
                {"lat": 1, "lng": 1},
                {"lat": 0, "lng": 1},
                {"lat": 1, "lng": 0},
            ],
            "color": "#ff0000",
        })
        assert r.status_code == 201
        # Point-in-polygon check should not crash
        r2 = client.post("/api/geofences/check", json={"lat": 0.5, "lng": 0.5})
        assert r2.status_code == 200
        assert isinstance(r2.json(), list)


# ═══════════════════════════════════════════════════════════════════════════
# 4. Proximity search
# ═══════════════════════════════════════════════════════════════════════════

class TestProximitySearch:
    """GET /api/proximity — assets near a GPS point."""

    def test_proximity_within_radius(self, client):
        """Asset inside radius appears in results."""
        client.post("/api/assets", json={
            "machine_id": "PROX-A",
            "name": "Nearby",
            "latitude": 40.7128, "longitude": -74.006,
        })
        # Query ~100m away, radius 500m → should be included
        r = client.get("/api/proximity?lat=40.713&lng=-74.007&radius_meters=500")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-A" in mids

    def test_proximity_outside_radius(self, client):
        """Asset far from query point excluded."""
        client.post("/api/assets", json={
            "machine_id": "PROX-B",
            "name": "Far",
            "latitude": 40.7128, "longitude": -74.006,
        })
        # NYC vs Tokyo — half the planet apart
        r = client.get("/api/proximity?lat=35.676&lng=139.650&radius_meters=1000")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-B" not in mids

    def test_proximity_no_coords(self, client):
        """Asset without lat/lng excluded."""
        client.post("/api/assets", json={
            "machine_id": "PROX-NOCOORD",
            "name": "No GPS",
        })
        r = client.get("/api/proximity?lat=0&lng=0&radius_meters=50000")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-NOCOORD" not in mids

    def test_proximity_empty_db(self, client):
        """No assets — empty results."""
        r = client.get("/api/proximity?lat=0&lng=0&radius_meters=1000")
        assert r.status_code == 200
        assert r.json() == []

    def test_proximity_default_radius(self, client):
        """Default radius is 200m when not specified."""
        client.post("/api/assets", json={
            "machine_id": "PROX-DEF",
            "name": "Default Radius",
            "latitude": 40.7128, "longitude": -74.006,
        })
        # ~20m away — well within default 200m
        r = client.get("/api/proximity?lat=40.7129&lng=-74.0061")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-DEF" in mids

    def test_proximity_missing_lat(self, client):
        """Missing required 'lat' param → 422."""
        r = client.get("/api/proximity?lng=0")
        assert r.status_code == 422

    def test_proximity_missing_lng(self, client):
        """Missing required 'lng' param → 422."""
        r = client.get("/api/proximity?lat=0")
        assert r.status_code == 422

    def test_proximity_no_params(self, client):
        """No query params → 422."""
        r = client.get("/api/proximity")
        assert r.status_code == 422

    def test_proximity_custom_radius(self, client):
        """Custom radius_meters value respected."""
        client.post("/api/assets", json={
            "machine_id": "PROX-RAD",
            "name": "Radius Test",
            "latitude": 40.7128, "longitude": -74.006,
        })
        # ~1.5km away, radius 500m → should NOT be included
        r = client.get("/api/proximity?lat=40.725&lng=-74.006&radius_meters=500")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-RAD" not in mids

    def test_proximity_max_radius(self, client):
        """Maximum radius of 50000m (~50km) should work."""
        client.post("/api/assets", json={
            "machine_id": "PROX-MAX",
            "name": "Max Radius",
            "latitude": 40.8, "longitude": -74.0,
        })
        r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=50000")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        assert "PROX-MAX" in mids

    def test_proximity_radius_below_min(self, client):
        """radius_meters below minimum 1 → 422."""
        r = client.get("/api/proximity?lat=0&lng=0&radius_meters=0")
        assert r.status_code == 422

    def test_proximity_radius_above_max(self, client):
        """radius_meters above maximum 50000 → 422."""
        r = client.get("/api/proximity?lat=0&lng=0&radius_meters=50001")
        assert r.status_code == 422

    def test_proximity_results_sorted_by_distance(self, client):
        """Results are sorted nearest-first."""
        client.post("/api/assets", json={
            "machine_id": "PROX-FAR2",
            "name": "Farther",
            "latitude": 40.73, "longitude": -74.0,
        })
        client.post("/api/assets", json={
            "machine_id": "PROX-NEAR2",
            "name": "Nearer",
            "latitude": 40.713, "longitude": -74.006,
        })
        r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=5000")
        assert r.status_code == 200
        mids = [a["machine_id"] for a in r.json()]
        # Nearer should come first
        if len(mids) >= 2:
            assert mids[0] == "PROX-NEAR2"

    def test_proximity_limit_50(self, client):
        """Max 50 results returned."""
        # Create 55 assets within range
        for i in range(55):
            client.post("/api/assets", json={
                "machine_id": f"PROX-{i:03d}",
                "name": f"Asset {i}",
                "latitude": 40.7128 + (i * 0.0001),
                "longitude": -74.006,
            })
        r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=50000")
        assert r.status_code == 200
        assert len(r.json()) <= 50


# ═══════════════════════════════════════════════════════════════════════════
# 5. Asset coordinate persistence
# ═══════════════════════════════════════════════════════════════════════════

class TestAssetCoordinates:
    """Assets store and retrieve lat/lng properly."""

    def test_create_asset_with_coords(self, client):
        """Create asset with latitude/longitude."""
        r = client.post("/api/assets", json={
            "machine_id": "COORD-001",
            "name": "Coordinated Asset",
            "latitude": 40.7128, "longitude": -74.006,
        })
        assert r.status_code == 201
        assert r.json()["latitude"] == 40.7128
        assert r.json()["longitude"] == -74.006

    def test_create_asset_with_only_latitude(self, client):
        """Asset with latitude but no longitude — should store both."""
        r = client.post("/api/assets", json={
            "machine_id": "COORD-LAT",
            "name": "Lat Only",
            "latitude": 40.0,
        })
        assert r.status_code == 201
        data = r.json()
        assert data["latitude"] == 40.0
        assert data["longitude"] is None

    def test_create_asset_with_only_longitude(self, client):
        """Asset with longitude but no latitude."""
        r = client.post("/api/assets", json={
            "machine_id": "COORD-LNG",
            "name": "Lng Only",
            "longitude": -74.0,
        })
        assert r.status_code == 201
        data = r.json()
        assert data["longitude"] == -74.0
        assert data["latitude"] is None

    def test_update_asset_coords(self, client):
        """Update asset coordinates."""
        aid = client.post("/api/assets", json={
            "machine_id": "COORD-002", "name": "Move Me",
        }).json()["id"]
        r = client.put(f"/api/assets/{aid}", json={
            "latitude": 41.0, "longitude": -73.0,
        })
        assert r.status_code == 200
        assert r.json()["latitude"] == 41.0
        assert r.json()["longitude"] == -73.0

    def test_update_asset_preserves_coords(self, client):
        """Updating asset name preserves existing coordinates."""
        aid = client.post("/api/assets", json={
            "machine_id": "COORD-PRES",
            "name": "Keep Coords",
            "latitude": 40.0, "longitude": -74.0,
        }).json()["id"]
        r = client.put(f"/api/assets/{aid}", json={"name": "Renamed"})
        assert r.status_code == 200
        data = r.json()
        assert data["name"] == "Renamed"
        assert data["latitude"] == 40.0
        assert data["longitude"] == -74.0

    def test_bulk_assets_include_coords(self, client):
        """GET /api/assets?limit=1000 returns coordinates for pin loading."""
        client.post("/api/assets", json={
            "machine_id": "BULK-001", "name": "Bulk A",
            "latitude": 40.0, "longitude": -74.0,
        })
        client.post("/api/assets", json={
            "machine_id": "BULK-002", "name": "Bulk B",
            "latitude": 41.0, "longitude": -73.0,
        })
        r = client.get("/api/assets?limit=1000")
        assert r.status_code == 200
        with_coords = [a for a in r.json() if a["latitude"] is not None]
        assert len(with_coords) == 2

    def test_asset_with_null_coords_excluded(self, client):
        """Asset with null coords is valid but won't get a pin."""
        r = client.post("/api/assets", json={
            "machine_id": "NOCOORD", "name": "No Coord",
        })
        assert r.status_code == 201
        assert r.json()["latitude"] is None

    def test_null_coords_preserve_existing(self, client):
        """Sending null for lat/lng preserves existing values (PATCH semantics)."""
        aid = client.post("/api/assets", json={
            "machine_id": "NULL-PRES", "name": "Preserve Me",
            "latitude": 40.0, "longitude": -74.0,
        }).json()["id"]
        r = client.put(f"/api/assets/{aid}", json={"latitude": None, "longitude": None})
        assert r.status_code == 200
        # None means "don't update" — existing values are preserved
        assert r.json()["latitude"] == 40.0
        assert r.json()["longitude"] == -74.0

    def test_asset_coords_in_list(self, client):
        """All assets in list endpoint include lat/lng fields."""
        client.post("/api/assets", json={
            "machine_id": "LIST-COORD",
            "name": "List Coord",
            "latitude": 35.0, "longitude": 139.0,
        })
        r = client.get("/api/assets")
        assert r.status_code == 200
        for asset in r.json():
            assert "latitude" in asset
            assert "longitude" in asset


# ═══════════════════════════════════════════════════════════════════════════
# 5. Geofence User Assignment (service areas)
# ═══════════════════════════════════════════════════════════════════════════

class TestGeofenceUserAssignment:
    """User-to-geofence assignments for service areas."""

    def _create_user(self, client, username="tech", role="technician"):
        return client.post("/api/users", json={
            "username": username, "password": "pass123", "role": role,
        }).json()

    def _create_geofence(self, client, name="Zone A", user_ids=None):
        return client.post("/api/geofences", json={
            "name": name,
            "points": [{"lat": 40, "lng": -74}, {"lat": 40.1, "lng": -74},
                       {"lat": 40.1, "lng": -73.9}, {"lat": 40, "lng": -73.9}],
            "color": "#ff0000",
            "user_ids": user_ids or [],
        }).json()

    def test_create_with_single_user(self, client):
        """Create geofence with one assigned user."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[user["id"]])
        assert len(gf["assigned_users"]) == 1
        assert gf["assigned_users"][0]["username"] == "tech"

    def test_create_with_multiple_users(self, client):
        """Create geofence with multiple assigned users."""
        u1 = self._create_user(client, "tech1")
        u2 = self._create_user(client, "tech2")
        gf = self._create_geofence(client, user_ids=[u1["id"], u2["id"]])
        assert len(gf["assigned_users"]) == 2

    def test_create_without_users(self, client):
        """Create geofence without user assignment."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[])
        assert gf.get("assigned_users") == []

    def test_list_includes_assigned_users(self, client):
        """GET /api/geofences includes assigned_users on each geofence."""
        user = self._create_user(client)
        self._create_geofence(client, "Zone A", user_ids=[user["id"]])
        self._create_geofence(client, "Zone B", user_ids=[])
        r = client.get("/api/geofences")
        assert r.status_code == 200
        data = r.json()
        zone_a = next(g for g in data if g["name"] == "Zone A")
        zone_b = next(g for g in data if g["name"] == "Zone B")
        assert len(zone_a["assigned_users"]) == 1
        assert zone_b["assigned_users"] == []

    def test_update_add_user(self, client):
        """Add user assignment to existing geofence."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[])
        r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [user["id"]]})
        assert r.status_code == 200
        assert len(r.json()["assigned_users"]) == 1

    def test_update_remove_all_users(self, client):
        """Remove all user assignments from geofence."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[user["id"]])
        r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": []})
        assert r.status_code == 200
        assert r.json()["assigned_users"] == []

    def test_update_replace_users(self, client):
        """Replace one assigned user with another."""
        u1 = self._create_user(client, "tech1")
        u2 = self._create_user(client, "tech2")
        gf = self._create_geofence(client, user_ids=[u1["id"]])
        r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [u2["id"]]})
        assert r.status_code == 200
        users = r.json()["assigned_users"]
        assert len(users) == 1
        assert users[0]["username"] == "tech2"

    def test_user_geofences_list(self, client):
        """GET /api/users/:id/geofences returns user's service areas."""
        user = self._create_user(client)
        gf1 = self._create_geofence(client, "Zone A", user_ids=[user["id"]])
        gf2 = self._create_geofence(client, "Zone B", user_ids=[user["id"]])
        r = client.get(f"/api/users/{user['id']}/geofences")
        assert r.status_code == 200
        names = [g["name"] for g in r.json()]
        assert len(names) == 2
        assert "Zone A" in names
        assert "Zone B" in names

    def test_user_geofences_empty(self, client):
        """User with no assignments gets empty list."""
        user = self._create_user(client)
        r = client.get(f"/api/users/{user['id']}/geofences")
        assert r.status_code == 200
        assert r.json() == []

    def test_user_geofences_not_found(self, client):
        """Non-existent user returns 404."""
        r = client.get("/api/users/99999/geofences")
        assert r.status_code == 404

    def test_geofence_delete_cascades_assignments(self, client):
        """Deleting a geofence removes its user assignments."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[user["id"]])
        r = client.delete(f"/api/geofences/{gf['id']}")
        assert r.status_code in (200, 204)
        r = client.get(f"/api/users/{user['id']}/geofences")
        assert r.status_code == 200
        assert r.json() == []

    def test_user_delete_cascades_assignments(self, client):
        """Deleting a user removes them from geofence assignments."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[user["id"]])
        r = client.delete(f"/api/users/{user['id']}")
        assert r.status_code in (200, 204)
        # Geofence should still exist but with no assigned users
        r = client.get("/api/geofences")
        assert r.status_code == 200
        geofence = next((g for g in r.json() if g["id"] == gf["id"]), None)
        assert geofence is not None, "Geofence should still exist after user delete"
        assert geofence.get("assigned_users", []) == []

    def test_create_geofence_invalid_user_id(self, client):
        """Creating with non-existent user ID returns 422."""
        r = client.post("/api/geofences", json={
            "name": "Bad Zone",
            "points": [{"lat": 0, "lng": 0}, {"lat": 0, "lng": 1},
                       {"lat": 1, "lng": 1}, {"lat": 1, "lng": 0}],
            "user_ids": [99999],
        })
        assert r.status_code == 422

    def test_update_geofence_invalid_user_id(self, client):
        """Updating with non-existent user ID returns 422."""
        gf = self._create_geofence(client)
        r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [99999]})
        assert r.status_code == 422

    def test_create_without_user_ids_field(self, client):
        """Create geofence omitting user_ids field — no assignments."""
        r = client.post("/api/geofences", json={
            "name": "No Users Field",
            "points": [{"lat": 0, "lng": 0}, {"lat": 0, "lng": 1},
                       {"lat": 1, "lng": 1}, {"lat": 1, "lng": 0}],
            "color": "#ff0000",
        })
        assert r.status_code == 201
        assert r.json().get("assigned_users") == []

    def test_update_without_user_ids_field(self, client):
        """Update geofence omitting user_ids — existing assignments unchanged."""
        user = self._create_user(client)
        gf = self._create_geofence(client, user_ids=[user["id"]])
        # Update only name, don't touch user_ids
        r = client.put(f"/api/geofences/{gf['id']}", json={"name": "Renamed"})
        assert r.status_code == 200
        assert r.json()["name"] == "Renamed"
        assert len(r.json()["assigned_users"]) == 1
        assert r.json()["assigned_users"][0]["username"] == "tech"
