"""
Tests for Canteen Asset Tracker server — v2 schema.

Covers: DB tables, asset CRUD with machine_id, checkins, stats, CSV export,
v2 schema migration, seed data, and new v2 tables.
"""
import os
import sys
import sqlite3
import tempfile
import pytest
from pathlib import Path
from fastapi.testclient import TestClient

def _get_test_db():
    """Return a worker-isolated test DB path for xdist safety."""
    worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
    return Path(__file__).parent / f"test_assets_{worker_id}.db"


TEST_DB = _get_test_db()
os.environ["CANTEEN_DB_PATH"] = str(TEST_DB)
os.environ["CANTEEN_SKIP_AUTH"] = "1"  # Skip auth enforcement for all tests by default


def pytest_configure():
    """Ensure server module can be imported."""
    import sys
    sys.path.insert(0, str(Path(__file__).parent.parent))


@pytest.fixture(autouse=True)
def clean_db():
    """Remove test DB and WAL/SHM files before each test for isolation."""
    db = _get_test_db()
    os.environ["CANTEEN_DB_PATH"] = str(db)
    for suffix in ("", "-shm", "-wal", "-journal"):
        p = db.with_suffix(db.suffix + suffix)
        if p.exists():
            p.unlink()
    yield
    for suffix in ("", "-shm", "-wal", "-journal"):
        p = db.with_suffix(db.suffix + suffix)
        if p.exists():
            p.unlink()


class _AuthTestClient:
    """Wrap TestClient to auto-inject Authorization header on all requests."""

    def __init__(self, client, auth_headers):
        self._client = client
        self._auth = auth_headers

    def _merge(self, kwargs):
        h = dict(kwargs.pop("headers", {}))
        h.update(self._auth)
        kwargs["headers"] = h
        return kwargs

    def get(self, url, **kwargs):
        return self._client.get(url, **self._merge(kwargs))

    def post(self, url, **kwargs):
        return self._client.post(url, **self._merge(kwargs))

    def put(self, url, **kwargs):
        return self._client.put(url, **self._merge(kwargs))

    def patch(self, url, **kwargs):
        return self._client.patch(url, **self._merge(kwargs))

    def delete(self, url, **kwargs):
        return self._client.delete(url, **self._merge(kwargs))

    def options(self, url, **kwargs):
        return self._client.options(url, **self._merge(kwargs))


@pytest.fixture
def client(clean_db):
    """Import server and return TestClient using context manager (triggers lifespan)."""
    import importlib
    # Always ensure auth is skipped for this test client
    os.environ["CANTEEN_SKIP_AUTH"] = "1"
    # Clear cached imports so clean_db takes effect
    for mod in list(sys.modules.keys()):
        if mod == "server" or mod.startswith("server."):
            del sys.modules[mod]
    import server
    importlib.invalidate_caches()
    # Use context manager to trigger lifespan startup
    with TestClient(server.app) as tc:
        yield tc
    # Lifespan shutdown fires on exit


@pytest.fixture
def auth_client(clean_db):
    """Return authenticated TestClient (auth enforced, no skip)."""
    import importlib
    old = os.environ.pop("CANTEEN_SKIP_AUTH", None)
    for mod in list(sys.modules.keys()):
        if mod == "server" or mod.startswith("server."):
            del sys.modules[mod]
    import server
    importlib.invalidate_caches()
    with TestClient(server.app) as tc:
        # Login to get admin token for auth headers
        tc.get("/health")  # Ensure lifespan triggers
        login_resp = tc.post("/api/auth/login", json={"username": "admin", "password": "changeme"})
        if login_resp.status_code == 200:
            token = login_resp.json()["token"]
            auth_headers = {"Authorization": f"Bearer {token}"}
        else:
            auth_headers = {}
        wrapper = _AuthTestClient(tc, auth_headers)
        yield wrapper
    if old is not None:
        os.environ["CANTEEN_SKIP_AUTH"] = old


@pytest.fixture
def unauth_client(clean_db):
    """Return unauthenticated TestClient (auth enforced, no auto-injected auth)."""
    import importlib
    old = os.environ.pop("CANTEEN_SKIP_AUTH", None)
    for mod in list(sys.modules.keys()):
        if mod == "server" or mod.startswith("server."):
            del sys.modules[mod]
    import server
    importlib.invalidate_caches()
    with TestClient(server.app) as tc:
        yield tc
    if old is not None:
        os.environ["CANTEEN_SKIP_AUTH"] = old


# ─── Task 3: Skeleton & DB ────────────────────────────────────────────────


class TestHealthEndpoint:
    """Task 3 — health check."""

    def test_health_returns_200(self, client):
        response = client.get("/health")
        assert response.status_code == 200

    def test_health_returns_json_with_status(self, client):
        response = client.get("/health")
        data = response.json()
        assert data["status"] == "ok"


class TestDBSetup:
    """Task 3 — database tables created on startup (v2 schema)."""

    def test_assets_table_exists(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='assets'"
        )
        assert cursor.fetchone() is not None
        conn.close()

    def test_checkins_table_exists(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='checkins'"
        )
        assert cursor.fetchone() is not None
        conn.close()

    def test_assets_schema_has_required_columns(self, client):
        """Verify v2 assets columns are present."""
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute("PRAGMA table_info(assets)")
        cols = {row[1] for row in cursor.fetchall()}
        required = {
            "id", "machine_id", "serial_number", "name", "description",
            "category", "status", "make", "model", "address", "building_name",
            "building_number", "floor", "room", "trailer_number",
            "walking_directions", "map_link", "parking_location", "photo_path",
            "customer_id", "location_id", "assigned_to", "created_at", "updated_at",
        }
        assert required.issubset(cols), f"Missing: {required - cols}"
        conn.close()

    def test_checkins_schema_has_required_columns(self, client):
        """Verify v2 checkins columns including user_id."""
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute("PRAGMA table_info(checkins)")
        cols = {row[1] for row in cursor.fetchall()}
        required = {
            "id", "asset_id", "user_id", "latitude", "longitude", "accuracy",
            "photo_path", "notes", "created_at",
        }
        assert required.issubset(cols), f"Missing: {required - cols}"
        conn.close()


class TestCORSAndStatic:
    """Task 3 — CORS middleware and static file mount."""

    def test_cors_headers_present(self, client):
        response = client.options(
            "/health",
            headers={"Origin": "https://example.com", "Access-Control-Request-Method": "GET"},
        )
        assert response.status_code in (200, 405)

    def test_static_index_served(self, client):
        """Ensure static/index.html is reachable at root."""
        response = client.get("/")
        assert response.status_code in (200, 404)


# ─── Task 4: POST /api/assets ─────────────────────────────────────────────


class TestCreateAsset:
    """Task 4 — create asset endpoint (v2: machine_id replaces barcode)."""

    def test_create_asset_returns_201_with_id(self, client):
        payload = {
            "machine_id": "MACH001",
            "name": "Test Asset",
            "description": "A test",
            "category": "Equipment",
        }
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert "id" in data
        assert data["machine_id"] == "MACH001"
        assert data["name"] == "Test Asset"

    def test_create_asset_requires_machine_id(self, client):
        payload = {"name": "No Machine ID"}
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 422

    def test_create_asset_requires_name(self, client):
        payload = {"machine_id": "MACH002"}
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 422

    def test_create_asset_duplicate_machine_id_rejected(self, client):
        """machine_id is UNIQUE — duplicate insertion returns 409."""
        payload = {"machine_id": "MACH003", "name": "First"}
        r1 = client.post("/api/assets", json=payload)
        assert r1.status_code == 201
        r2 = client.post("/api/assets", json=payload)
        assert r2.status_code == 409
        assert "already exists" in r2.json()["detail"]

    def test_create_asset_invalid_category_rejected(self, client):
        payload = {"machine_id": "MACH004", "name": "Bad Cat", "category": "InvalidCategory"}
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 422

    def test_create_asset_defaults_category_to_other(self, client):
        payload = {"machine_id": "MACH005", "name": "No Category"}
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 201
        assert response.json()["category"] == "Other"

    def test_create_asset_defaults_status_to_active(self, client):
        payload = {"machine_id": "MACH006", "name": "Default Status"}
        response = client.post("/api/assets", json=payload)
        assert response.json()["status"] == "active"

    def test_create_asset_with_all_new_fields(self, client):
        """Create asset with v2-specific fields (address, make, model, etc.)."""
        payload = {
            "machine_id": "MACH007",
            "name": "Full Asset",
            "serial_number": "SN-12345",
            "make": "Hobart",
            "model": "HLX-200",
            "address": "123 Main St",
            "building_name": "HQ",
            "building_number": "B1",
            "floor": "2",
            "room": "201A",
            "trailer_number": "T-5",
            "walking_directions": "Through lobby, turn left",
            "map_link": "https://maps.example.com/123",
            "parking_location": "Lot A, Spot 42",
        }
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert data["serial_number"] == "SN-12345"
        assert data["make"] == "Hobart"
        assert data["model"] == "HLX-200"
        assert data["building_name"] == "HQ"
        assert data["floor"] == "2"
        assert data["room"] == "201A"

    def test_create_asset_new_fields_default_empty(self, client):
        payload = {"machine_id": "MACH008", "name": "Minimal"}
        response = client.post("/api/assets", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert data["serial_number"] == ""
        assert data["make"] == ""
        assert data["model"] == ""
        assert data["address"] == ""
        assert data["building_name"] == ""


# ─── Task 5: GET /api/assets and GET /api/assets/{id} ─────────────────────


class TestListAssets:
    """Task 5 — list and detail endpoints (v2)."""

    def test_list_assets_returns_array(self, client):
        response = client.get("/api/assets")
        assert response.status_code == 200
        assert isinstance(response.json(), list)

    def test_list_assets_returns_created_asset(self, client):
        payload = {"machine_id": "LIST001", "name": "Listable"}
        client.post("/api/assets", json=payload)
        response = client.get("/api/assets")
        assets = response.json()
        assert len(assets) == 1
        assert assets[0]["machine_id"] == "LIST001"

    def test_list_assets_filter_by_category(self, client):
        client.post("/api/assets", json={"machine_id": "C1", "name": "Cat1", "category": "Furniture"})
        client.post("/api/assets", json={"machine_id": "C2", "name": "Cat2", "category": "Appliances"})
        response = client.get("/api/assets?category=Furniture")
        assets = response.json()
        assert len(assets) == 1
        assert assets[0]["category"] == "Furniture"

    def test_list_assets_filter_by_status(self, client):
        client.post("/api/assets", json={"machine_id": "S1", "name": "Active", "status": "active"})
        client.post("/api/assets", json={"machine_id": "S2", "name": "Retired", "status": "retired"})
        response = client.get("/api/assets?status=retired")
        assets = response.json()
        assert len(assets) == 1
        assert assets[0]["status"] == "retired"

    def test_list_assets_text_search(self, client):
        client.post("/api/assets", json={"machine_id": "Q1", "name": "Coffee Machine"})
        client.post("/api/assets", json={"machine_id": "Q2", "name": "Toaster Oven"})
        response = client.get("/api/assets?q=coffee")
        assets = response.json()
        assert len(assets) == 1
        assert assets[0]["name"] == "Coffee Machine"

    def test_list_assets_text_search_by_machine_id(self, client):
        """Search by machine_id via text query."""
        client.post("/api/assets", json={"machine_id": "MX-9000", "name": "Mixer"})
        client.post("/api/assets", json={"machine_id": "BL-1000", "name": "Blender"})
        response = client.get("/api/assets?q=MX-9000")
        assets = response.json()
        assert len(assets) == 1
        assert assets[0]["machine_id"] == "MX-9000"

    def test_get_single_asset_returns_200(self, client):
        r = client.post("/api/assets", json={"machine_id": "GET001", "name": "Single"})
        asset_id = r.json()["id"]
        response = client.get(f"/api/assets/{asset_id}")
        assert response.status_code == 200
        assert response.json()["name"] == "Single"

    def test_get_single_asset_not_found_returns_404(self, client):
        response = client.get("/api/assets/99999")
        assert response.status_code == 404


# ─── Task 6: PUT / DELETE /api/assets/{id} ────────────────────────────────


class TestUpdateAsset:
    """Task 6 — update endpoint (v2)."""

    def test_update_asset_name(self, client):
        r = client.post("/api/assets", json={"machine_id": "UP001", "name": "Old Name"})
        asset_id = r.json()["id"]
        response = client.put(f"/api/assets/{asset_id}", json={"name": "New Name"})
        assert response.status_code == 200
        assert response.json()["name"] == "New Name"

    def test_update_asset_machine_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "UP002", "name": "Machine"})
        asset_id = r.json()["id"]
        response = client.put(f"/api/assets/{asset_id}", json={"machine_id": "UP002-NEW"})
        assert response.status_code == 200
        assert response.json()["machine_id"] == "UP002-NEW"

    def test_update_asset_new_fields(self, client):
        """Update v2-specific fields."""
        r = client.post("/api/assets", json={"machine_id": "UP003", "name": "Asset"})
        asset_id = r.json()["id"]
        response = client.put(f"/api/assets/{asset_id}", json={
            "make": "Vollrath",
            "model": "VX-500",
            "building_name": "Warehouse B",
            "floor": "3",
            "walking_directions": "Take elevator to 3rd floor",
        })
        assert response.status_code == 200
        data = response.json()
        assert data["make"] == "Vollrath"
        assert data["model"] == "VX-500"
        assert data["building_name"] == "Warehouse B"
        assert data["floor"] == "3"
        assert data["walking_directions"] == "Take elevator to 3rd floor"

    def test_update_asset_not_found_returns_404(self, client):
        response = client.put("/api/assets/99999", json={"name": "Nope"})
        assert response.status_code == 404


class TestDeleteAsset:
    """Task 6 — delete endpoint."""

    def test_delete_asset_returns_204(self, client):
        r = client.post("/api/assets", json={"machine_id": "DEL001", "name": "Deletable"})
        asset_id = r.json()["id"]
        response = client.delete(f"/api/assets/{asset_id}")
        assert response.status_code == 204

    def test_delete_asset_not_found_returns_404(self, client):
        response = client.delete("/api/assets/99999")
        assert response.status_code == 404


# ─── Task 7: GET /api/assets/search?machine_id= ───────────────────────────


class TestSearchByMachineId:
    """Task 7 — machine_id search endpoint (was barcode in v1)."""

    def test_search_by_machine_id_returns_asset(self, client):
        client.post("/api/assets", json={"machine_id": "SRCH001", "name": "Searchable"})
        response = client.get("/api/assets/search?machine_id=SRCH001")
        assert response.status_code == 200
        assert response.json()["name"] == "Searchable"
        assert response.json()["machine_id"] == "SRCH001"

    def test_search_by_machine_id_not_found_returns_404(self, client):
        response = client.get("/api/assets/search?machine_id=NONEXISTENT")
        assert response.status_code == 404

    def test_search_by_machine_id_multiple_returns_first(self, client):
        """Multiple assets with same machine_id → returns first match."""
        client.post("/api/assets", json={"machine_id": "DUP001", "name": "First"})
        client.post("/api/assets", json={"machine_id": "DUP001", "name": "Second"})
        response = client.get("/api/assets/search?machine_id=DUP001")
        assert response.status_code == 200
        assert response.json()["machine_id"] == "DUP001"


# ─── Task 8: POST /api/checkins ────────────────────────────────────────────


class TestCreateCheckin:
    """Task 8 — create a check-in for an asset."""

    @pytest.fixture
    def asset_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "CHK001", "name": "Checkin Asset"})
        return r.json()["id"]

    def test_create_checkin_returns_201_with_all_fields(self, client, asset_id):
        payload = {
            "asset_id": asset_id,
            "latitude": 40.7128,
            "longitude": -74.0060,
            "accuracy": 10.5,
            "notes": "Found in storage room",
        }
        response = client.post("/api/checkins", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert data["asset_id"] == asset_id
        assert data["latitude"] == 40.7128
        assert data["longitude"] == -74.0060
        assert data["accuracy"] == 10.5
        assert data["notes"] == "Found in storage room"
        assert "id" in data
        assert "created_at" in data

    def test_create_checkin_requires_asset_id(self, client):
        payload = {"latitude": 40.7128, "longitude": -74.0060}
        response = client.post("/api/checkins", json=payload)
        assert response.status_code == 422

    def test_create_checkin_asset_not_found_returns_404(self, client):
        payload = {"asset_id": 99999, "latitude": 40.7128, "longitude": -74.0060}
        response = client.post("/api/checkins", json=payload)
        assert response.status_code == 404

    def test_create_checkin_without_location_allowed(self, client, asset_id):
        payload = {"asset_id": asset_id, "notes": "Just a note, no GPS"}
        response = client.post("/api/checkins", json=payload)
        assert response.status_code == 201
        data = response.json()
        assert data["latitude"] is None
        assert data["longitude"] is None
        assert data["notes"] == "Just a note, no GPS"

    def test_create_checkin_notes_default_empty(self, client, asset_id):
        payload = {"asset_id": asset_id, "latitude": 40.7128, "longitude": -74.0060}
        response = client.post("/api/checkins", json=payload)
        assert response.json()["notes"] == ""


# ─── Task 9: GET /api/checkins ─────────────────────────────────────────────


class TestListCheckins:
    """Task 9 — list and filter check-ins."""

    def test_list_checkins_returns_array(self, client):
        response = client.get("/api/checkins")
        assert response.status_code == 200
        assert isinstance(response.json(), list)

    def test_list_checkins_filter_by_asset_id(self, client):
        r1 = client.post("/api/assets", json={"machine_id": "CHKL1", "name": "Asset 1"})
        r2 = client.post("/api/assets", json={"machine_id": "CHKL2", "name": "Asset 2"})
        a1 = r1.json()["id"]
        client.post("/api/checkins", json={"asset_id": a1, "latitude": 40.0, "longitude": -74.0})
        response = client.get(f"/api/checkins?asset_id={a1}")
        checkins = response.json()
        assert len(checkins) == 1
        assert checkins[0]["asset_id"] == a1

    def test_list_checkins_empty_for_unchecked_asset(self, client):
        r = client.post("/api/assets", json={"machine_id": "CHKL3", "name": "No Checkins"})
        a_id = r.json()["id"]
        response = client.get(f"/api/checkins?asset_id={a_id}")
        assert response.json() == []

    def test_list_checkins_ordered_by_newest_first(self, client):
        r = client.post("/api/assets", json={"machine_id": "CHKL4", "name": "Order Test"})
        a_id = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": a_id, "notes": "First"})
        client.post("/api/checkins", json={"asset_id": a_id, "notes": "Second"})
        checkins = client.get(f"/api/checkins?asset_id={a_id}").json()
        assert checkins[0]["notes"] == "Second"
        assert checkins[1]["notes"] == "First"

    def test_list_checkins_pagination(self, client):
        r = client.post("/api/assets", json={"machine_id": "CHKL5", "name": "Pagination"})
        a_id = r.json()["id"]
        for i in range(5):
            client.post("/api/checkins", json={"asset_id": a_id, "notes": f"Note {i}"})
        response = client.get(f"/api/checkins?asset_id={a_id}&limit=2&offset=1")
        checkins = response.json()
        assert len(checkins) == 2


# ─── Task 10: GET /api/stats ────────────────────────────────────────────────


class TestStats:
    """Task 10 — aggregated statistics."""

    def test_stats_returns_total_assets_and_checkins(self, client):
        response = client.get("/api/stats")
        assert response.status_code == 200
        data = response.json()
        assert "total_assets" in data
        assert "total_checkins" in data

    def test_stats_counts_reflect_data(self, client):
        client.post("/api/assets", json={"machine_id": "ST001", "name": "S1", "category": "Furniture"})
        r = client.post("/api/assets", json={"machine_id": "ST002", "name": "S2", "category": "Equipment"})
        client.post("/api/assets", json={"machine_id": "ST003", "name": "S3", "category": "Furniture",
                                          "status": "retired"})
        a_id = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": a_id, "latitude": 40.0, "longitude": -74.0})
        client.post("/api/checkins", json={"asset_id": a_id, "latitude": 41.0, "longitude": -75.0})

        data = client.get("/api/stats").json()
        assert data["total_assets"] == 3
        assert data["total_checkins"] == 2

    def test_stats_category_breakdown(self, client):
        client.post("/api/assets", json={"machine_id": "STC1", "name": "C1", "category": "Furniture"})
        client.post("/api/assets", json={"machine_id": "STC2", "name": "C2", "category": "Furniture"})
        client.post("/api/assets", json={"machine_id": "STC3", "name": "C3", "category": "Equipment"})

        data = client.get("/api/stats").json()
        cats = data.get("by_category", data.get("categories", {}))
        if cats:
            assert cats.get("Furniture") == 2 or cats.get("Furniture", 0) >= 2
            assert cats.get("Equipment") == 1 or cats.get("Equipment", 0) >= 1

    def test_stats_status_breakdown(self, client):
        client.post("/api/assets", json={"machine_id": "STS1", "name": "S1", "status": "active"})
        client.post("/api/assets", json={"machine_id": "STS2", "name": "S2", "status": "maintenance"})

        data = client.get("/api/stats").json()
        statuses = data.get("by_status", data.get("statuses", {}))
        if statuses:
            assert statuses.get("active") == 1 or statuses.get("active", 0) >= 1
            assert statuses.get("maintenance") == 1 or statuses.get("maintenance", 0) >= 1

    def test_stats_empty_state(self, client):
        data = client.get("/api/stats").json()
        assert data["total_assets"] == 0
        assert data["total_checkins"] == 0


# ─── Task 11: CSV Export ────────────────────────────────────────────────────


class TestCSVExport:
    """Task 11 — CSV export endpoints (v2 columns)."""

    def test_export_assets_csv_returns_csv_content_type(self, client):
        client.post("/api/assets", json={"machine_id": "EXP001", "name": "Exportable"})
        response = client.get("/api/export/assets")
        assert response.status_code == 200
        assert "text/csv" in response.headers["content-type"]

    def test_export_assets_csv_contains_header(self, client):
        client.post("/api/assets", json={"machine_id": "EXP002", "name": "Header Test"})
        response = client.get("/api/export/assets")
        assert response.status_code == 200
        body = response.text
        # V2 header includes machine_id
        assert "machine_id" in body

    def test_export_assets_csv_contains_data(self, client):
        client.post("/api/assets", json={"machine_id": "EXP003", "name": "Data Test"})
        response = client.get("/api/export/assets")
        assert response.status_code == 200
        body = response.text
        assert "EXP003" in body

    def test_export_checkins_csv_returns_csv_content_type(self, client):
        r = client.post("/api/assets", json={"machine_id": "EXPC1", "name": "Checkin CSV"})
        a_id = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": a_id, "latitude": 40.0, "longitude": -74.0})
        response = client.get("/api/export/checkins")
        assert response.status_code == 200
        assert "text/csv" in response.headers["content-type"]

    def test_export_checkins_csv_filter_by_asset_id(self, client):
        r1 = client.post("/api/assets", json={"machine_id": "EXPC2", "name": "CSV Asset 1"})
        r2 = client.post("/api/assets", json={"machine_id": "EXPC3", "name": "CSV Asset 2"})
        a1 = r1.json()["id"]
        a2 = r2.json()["id"]
        client.post("/api/checkins", json={"asset_id": a1, "notes": "A"})
        client.post("/api/checkins", json={"asset_id": a2, "notes": "B"})
        response = client.get(f"/api/export/checkins?asset_id={a1}")
        body = response.text
        assert "A" in body


# ─── v2 Schema: New Tables ───────────────────────────────────────────────


class TestV2Tables:
    """Verify all 17 new v2 tables exist after init_db()."""

    EXPECTED_TABLES = {
        "users", "customers", "customer_contacts", "locations", "rooms",
        "categories", "key_names", "key_types", "badge_types", "makes",
        "models", "asset_keys", "asset_badges", "settings", "activity_log",
        "geofences", "visits",
    }

    def test_all_v2_tables_exist(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
        )
        tables = {row[0] for row in cursor.fetchall()}
        missing = self.EXPECTED_TABLES - tables
        assert not missing, f"Missing tables: {missing}"
        conn.close()

    @pytest.mark.parametrize("table,expected_cols", [
        ("users", {"id", "username", "password_hash", "role", "created_at"}),
        ("customers", {"id", "name", "created_at", "updated_at"}),
        ("customer_contacts", {"id", "customer_id", "name", "phone", "email"}),
        ("locations", {"id", "customer_id", "name", "address", "building_name",
                       "building_number", "floor", "trailer_number", "site_hours",
                       "access_notes", "walking_directions", "map_link",
                       "created_at", "updated_at"}),
        ("rooms", {"id", "location_id", "name", "floor", "created_at", "updated_at"}),
        ("asset_keys", {"id", "asset_id", "key_name", "key_type"}),
        ("asset_badges", {"id", "asset_id", "badge_name"}),
        ("settings", {"id", "key", "value"}),
        ("activity_log", {"id", "user_id", "action", "entity_type", "entity_id",
                          "details", "created_at"}),
        ("geofences", {"id", "name", "points", "color", "created_at", "updated_at"}),
        ("visits", {"id", "user_id", "asset_id", "checkin_time", "checkout_time",
                    "duration_minutes", "created_at"}),
    ])
    def test_table_schema(self, client, table, expected_cols):
        conn = sqlite3.connect(str(TEST_DB))
        cursor = conn.execute(f"PRAGMA table_info({table})")
        cols = {row[1] for row in cursor.fetchall()}
        assert expected_cols.issubset(cols), f"{table}: missing {expected_cols - cols}"
        conn.close()


# ─── v2 Schema: Seed Data ────────────────────────────────────────────────


class TestSeedData:
    """Verify default seed data is populated on fresh install."""

    def test_categories_seeded(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT name FROM categories ORDER BY name").fetchall()
        names = {r["name"] for r in rows}
        assert "Furniture" in names
        assert "Appliances" in names
        assert "Equipment" in names
        conn.close()

    def test_key_names_seeded(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT name FROM key_names").fetchall()
        names = {r["name"] for r in rows}
        assert "MK500" in names
        assert "Green Dot" in names
        conn.close()

    def test_key_types_seeded(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT name FROM key_types").fetchall()
        names = {r["name"] for r in rows}
        assert "Round Short" in names
        assert "Barrel" in names
        conn.close()

    def test_badge_types_seeded(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT name FROM badge_types").fetchall()
        names = {r["name"] for r in rows}
        assert "Disney Contractor Base" in names
        conn.close()

    def test_makes_seeded(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT name FROM makes").fetchall()
        names = {r["name"] for r in rows}
        assert "Canteen" in names
        assert "Hobart" in names
        conn.close()

    def test_default_admin_user_exists(self, client):
        conn = sqlite3.connect(str(TEST_DB))
        conn.row_factory = sqlite3.Row
        row = conn.execute(
            "SELECT username, role FROM users WHERE username = 'admin'"
        ).fetchone()
        assert row is not None
        assert row["username"] == "admin"
        assert row["role"] == "admin"
        conn.close()

    def test_seed_data_idempotent(self, client):
        """Re-initializing should not duplicate seed data."""
        conn = sqlite3.connect(str(TEST_DB))
        # Import server and run init_db again
        import server
        import importlib
        importlib.reload(server)
        server.init_db(conn)
        # Count should still be the same
        cat_count = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0]
        assert cat_count == 5  # original 5, not 10
        conn.close()


# ─── v2 Schema: Migration from v1 ─────────────────────────────────────────


class TestMigration:
    """Verify v1→v2 migration preserves existing data."""

    def test_migration_from_v1_to_v2(self):
        """Create a v1 DB, run migration, verify data preserved and schema upgraded."""
        # Build a v1 database by hand (simulating old state)
        db_path = Path(__file__).parent / "test_migrate.db"
        if db_path.exists():
            db_path.unlink()

        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA foreign_keys=ON")

        # Create v1 schema
        conn.executescript("""
            CREATE TABLE assets (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                barcode     TEXT    UNIQUE NOT NULL,
                name        TEXT    NOT NULL,
                description TEXT    DEFAULT '',
                category    TEXT    NOT NULL DEFAULT 'Other',
                status      TEXT    NOT NULL DEFAULT 'active',
                photo_path  TEXT,
                created_at  TEXT    NOT NULL DEFAULT (datetime('now')),
                updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
            );
            CREATE TABLE checkins (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                asset_id    INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
                latitude    REAL,
                longitude   REAL,
                accuracy    REAL,
                photo_path  TEXT,
                notes       TEXT    DEFAULT '',
                created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
            );
        """)

        # Insert test data
        conn.execute(
            "INSERT INTO assets (barcode, name, description, category, status) VALUES (?, ?, ?, ?, ?)",
            ("OLD001", "Old Asset", "Legacy data", "Equipment", "active"),
        )
        conn.execute(
            "INSERT INTO assets (barcode, name, category) VALUES (?, ?, ?)",
            ("OLD002", "Second Asset", "Furniture"),
        )
        conn.commit()
        conn.close()

        # Now run the server's init_db on it
        import server
        import importlib
        importlib.reload(server)

        # Temporarily point DB_PATH at our test DB
        old_db_path = server.DB_PATH
        # We need to monkey-patch the get_db path. Instead, use init_db directly.
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA foreign_keys=ON")
        conn.row_factory = sqlite3.Row
        server.init_db(conn)
        conn.close()

        # Verify migration results
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row

        # Check old data preserved with machine_id mapping
        rows = conn.execute("SELECT * FROM assets ORDER BY id").fetchall()
        assert len(rows) == 2
        assert rows[0]["machine_id"] == "OLD001"
        assert rows[0]["name"] == "Old Asset"
        assert rows[0]["category"] == "Equipment"
        assert rows[1]["machine_id"] == "OLD002"
        assert rows[1]["name"] == "Second Asset"

        # Check new columns exist with defaults
        assert rows[0]["serial_number"] == ""
        assert rows[0]["make"] == ""
        assert rows[0]["building_name"] == ""

        # Check barcode column is gone
        cursor = conn.execute("PRAGMA table_info(assets)")
        cols = {row[1] for row in cursor.fetchall()}
        assert "barcode" not in cols
        assert "machine_id" in cols

        # Check checkins got user_id column
        cursor = conn.execute("PRAGMA table_info(checkins)")
        checkin_cols = {row[1] for row in cursor.fetchall()}
        assert "user_id" in checkin_cols

        # Check new tables exist
        tables = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        ).fetchall()
        table_names = {r["name"] for r in tables}
        assert "customers" in table_names
        assert "users" in table_names
        assert "categories" in table_names

        conn.close()

        # Cleanup
        if db_path.exists():
            db_path.unlink()

    def test_migration_idempotent(self):
        """Running init_db twice on a v1 DB should not error."""
        db_path = Path(__file__).parent / "test_migrate2.db"
        if db_path.exists():
            db_path.unlink()

        conn = sqlite3.connect(str(db_path))
        conn.executescript("""
            CREATE TABLE assets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                barcode TEXT UNIQUE NOT NULL,
                name TEXT NOT NULL,
                description TEXT DEFAULT '',
                category TEXT NOT NULL DEFAULT 'Other',
                status TEXT NOT NULL DEFAULT 'active',
                photo_path TEXT,
                created_at TEXT NOT NULL DEFAULT (datetime('now')),
                updated_at TEXT NOT NULL DEFAULT (datetime('now'))
            );
            CREATE TABLE checkins (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
                latitude REAL, longitude REAL, accuracy REAL,
                photo_path TEXT, notes TEXT DEFAULT '',
                created_at TEXT NOT NULL DEFAULT (datetime('now'))
            );
            INSERT INTO assets (barcode, name) VALUES ('IDEM001', 'Idempotent');
        """)
        conn.commit()
        conn.close()

        import server
        import importlib
        importlib.reload(server)

        # First migration
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA foreign_keys=ON")
        conn.row_factory = sqlite3.Row
        server.init_db(conn)
        conn.close()

        # Second init (should be a no-op)
        conn = sqlite3.connect(str(db_path))
        conn.execute("PRAGMA foreign_keys=ON")
        conn.row_factory = sqlite3.Row
        server.init_db(conn)
        conn.close()

        # Verify data still intact
        conn = sqlite3.connect(str(db_path))
        conn.row_factory = sqlite3.Row
        rows = conn.execute("SELECT * FROM assets").fetchall()
        assert len(rows) == 1
        assert rows[0]["machine_id"] == "IDEM001"
        conn.close()

        if db_path.exists():
            db_path.unlink()


# ─── Phase B: Customers API ──────────────────────────────────────────────


class TestCreateCustomer:
    """POST /api/customers — create with contacts array."""

    def test_create_customer_returns_201_with_id(self, client):
        payload = {"name": "Acme Corp"}
        r = client.post("/api/customers", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert "id" in data
        assert data["name"] == "Acme Corp"

    def test_create_customer_with_contacts(self, client):
        payload = {
            "name": "Acme Corp",
            "contacts": [
                {"name": "John Doe", "phone": "555-0100", "email": "john@acme.com"},
                {"name": "Jane Smith", "phone": "555-0200", "email": "jane@acme.com"},
            ],
        }
        r = client.post("/api/customers", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert len(data["contacts"]) == 2
        assert data["contacts"][0]["name"] == "John Doe"

    def test_create_customer_requires_name(self, client):
        r = client.post("/api/customers", json={})
        assert r.status_code == 422


class TestListCustomers:
    """GET /api/customers — list all."""

    def test_list_customers_returns_array(self, client):
        r = client.get("/api/customers")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_customers_returns_created(self, client):
        client.post("/api/customers", json={"name": "TestCo"})
        r = client.get("/api/customers")
        data = r.json()
        assert len(data) == 1
        assert data[0]["name"] == "TestCo"


class TestGetCustomer:
    """GET /api/customers/{id} — detail with contacts."""

    def test_get_customer_returns_200(self, client):
        r = client.post("/api/customers", json={
            "name": "DetailCo",
            "contacts": [{"name": "Contact 1", "phone": "555-1111"}],
        })
        cust_id = r.json()["id"]
        r = client.get(f"/api/customers/{cust_id}")
        assert r.status_code == 200
        data = r.json()
        assert data["name"] == "DetailCo"
        assert len(data["contacts"]) == 1

    def test_get_customer_not_found_returns_404(self, client):
        r = client.get("/api/customers/99999")
        assert r.status_code == 404


class TestUpdateCustomer:
    """PUT /api/customers/{id} — update."""

    def test_update_customer_name(self, client):
        r = client.post("/api/customers", json={"name": "OldName"})
        cust_id = r.json()["id"]
        r = client.put(f"/api/customers/{cust_id}", json={"name": "NewName"})
        assert r.status_code == 200
        assert r.json()["name"] == "NewName"

    def test_update_customer_contacts_replaces_all(self, client):
        r = client.post("/api/customers", json={
            "name": "ReplaceCo",
            "contacts": [{"name": "Old", "phone": "555-0000"}],
        })
        cust_id = r.json()["id"]
        r = client.put(f"/api/customers/{cust_id}", json={
            "contacts": [{"name": "New", "phone": "555-9999"}],
        })
        assert r.status_code == 200
        data = r.json()
        assert len(data["contacts"]) == 1
        assert data["contacts"][0]["name"] == "New"

    def test_update_customer_not_found(self, client):
        r = client.put("/api/customers/99999", json={"name": "Nope"})
        assert r.status_code == 404


class TestDeleteCustomer:
    """DELETE /api/customers/{id} — cascade."""

    def test_delete_customer_returns_204(self, client):
        r = client.post("/api/customers", json={"name": "DeleteMe"})
        cust_id = r.json()["id"]
        r = client.delete(f"/api/customers/{cust_id}")
        assert r.status_code == 204

    def test_delete_customer_cascades_contacts(self, client):
        r = client.post("/api/customers", json={
            "name": "CascadeCo",
            "contacts": [{"name": "C1", "phone": "555-0001"}],
        })
        cust_id = r.json()["id"]
        client.delete(f"/api/customers/{cust_id}")

        import sqlite3
        conn = sqlite3.connect(str(TEST_DB))
        rows = conn.execute(
            "SELECT COUNT(*) FROM customer_contacts WHERE customer_id = ?", (cust_id,)
        ).fetchone()
        conn.close()
        assert rows[0] == 0

    def test_delete_customer_not_found(self, client):
        r = client.delete("/api/customers/99999")
        assert r.status_code == 404


# ─── Phase B: Locations API ───────────────────────────────────────────────


class TestCreateLocation:
    """POST /api/locations — create with all fields."""

    @pytest.fixture
    def customer_id(self, client):
        r = client.post("/api/customers", json={"name": "LocCustomer"})
        return r.json()["id"]

    def test_create_location_returns_201(self, client, customer_id):
        payload = {"customer_id": customer_id, "name": "Main Office"}
        r = client.post("/api/locations", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["name"] == "Main Office"

    def test_create_location_with_all_fields(self, client, customer_id):
        payload = {
            "customer_id": customer_id,
            "name": "HQ",
            "address": "123 Main St",
            "building_name": "Tower A",
            "building_number": "B1",
            "floor": "5",
            "trailer_number": "T-1",
            "site_hours": "9-5",
            "access_notes": "Ring buzzer",
            "walking_directions": "Through lobby",
            "map_link": "https://maps.example.com/1",
        }
        r = client.post("/api/locations", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["address"] == "123 Main St"
        assert data["building_name"] == "Tower A"
        assert data["site_hours"] == "9-5"

    def test_create_location_defaults_empty(self, client, customer_id):
        payload = {"name": "Sparse"}
        r = client.post("/api/locations", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["address"] == ""


class TestListLocations:
    """GET /api/locations — list, filter by customer_id."""

    def test_list_locations_returns_array(self, client):
        r = client.get("/api/locations")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_locations_filter_by_customer(self, client):
        r1 = client.post("/api/customers", json={"name": "CustA"})
        r2 = client.post("/api/customers", json={"name": "CustB"})
        c1, c2 = r1.json()["id"], r2.json()["id"]
        client.post("/api/locations", json={"name": "LocA", "customer_id": c1})
        client.post("/api/locations", json={"name": "LocB", "customer_id": c2})
        r = client.get(f"/api/locations?customer_id={c1}")
        data = r.json()
        assert len(data) == 1
        assert data[0]["name"] == "LocA"


class TestGetLocation:
    """GET /api/locations/{id} — detail with rooms."""

    def test_get_location_with_rooms(self, client):
        r = client.post("/api/customers", json={"name": "RoomCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "RoomLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        client.post("/api/rooms", json={"location_id": loc_id, "name": "Room 101"})
        client.post("/api/rooms", json={"location_id": loc_id, "name": "Room 102"})

        r = client.get(f"/api/locations/{loc_id}")
        assert r.status_code == 200
        data = r.json()
        assert data["name"] == "RoomLoc"
        assert len(data["rooms"]) == 2
        assert data["rooms"][0]["name"] == "Room 101"

    def test_get_location_not_found(self, client):
        r = client.get("/api/locations/99999")
        assert r.status_code == 404


class TestUpdateLocation:
    """PUT /api/locations/{id} — update."""

    def test_update_location_fields(self, client):
        r = client.post("/api/customers", json={"name": "UpdCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "OldLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        r = client.put(f"/api/locations/{loc_id}", json={
            "name": "NewLoc", "building_name": "Wing B",
        })
        assert r.status_code == 200
        data = r.json()
        assert data["name"] == "NewLoc"
        assert data["building_name"] == "Wing B"

    def test_update_location_not_found(self, client):
        r = client.put("/api/locations/99999", json={"name": "Nope"})
        assert r.status_code == 404


class TestDeleteLocation:
    """DELETE /api/locations/{id} — cascade rooms."""

    def test_delete_location_returns_204(self, client):
        r = client.post("/api/customers", json={"name": "DelCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "DelLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        r = client.delete(f"/api/locations/{loc_id}")
        assert r.status_code == 204

    def test_delete_location_cascades_rooms(self, client):
        r = client.post("/api/customers", json={"name": "CascadeCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "CascadeLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        client.post("/api/rooms", json={"location_id": loc_id, "name": "R1"})
        client.delete(f"/api/locations/{loc_id}")

        import sqlite3
        conn = sqlite3.connect(str(TEST_DB))
        rows = conn.execute(
            "SELECT COUNT(*) FROM rooms WHERE location_id = ?", (loc_id,)
        ).fetchone()
        conn.close()
        assert rows[0] == 0

    def test_delete_location_not_found(self, client):
        r = client.delete("/api/locations/99999")
        assert r.status_code == 404


# ─── Phase B: Rooms API ───────────────────────────────────────────────────


class TestCreateRoom:
    """POST /api/rooms — create under location."""

    @pytest.fixture
    def location_id(self, client):
        r = client.post("/api/customers", json={"name": "RoomCust2"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "RoomLoc2", "customer_id": c_id})
        return r.json()["id"]

    def test_create_room_returns_201(self, client, location_id):
        payload = {"location_id": location_id, "name": "Server Room"}
        r = client.post("/api/rooms", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["name"] == "Server Room"
        assert data["location_id"] == location_id

    def test_create_room_with_floor(self, client, location_id):
        payload = {"location_id": location_id, "name": "Basement", "floor": "B1"}
        r = client.post("/api/rooms", json=payload)
        assert r.status_code == 201
        assert r.json()["floor"] == "B1"

    def test_create_room_default_floor_empty(self, client, location_id):
        payload = {"location_id": location_id, "name": "No Floor"}
        r = client.post("/api/rooms", json=payload)
        assert r.json()["floor"] == ""


class TestUpdateRoom:
    """PUT /api/rooms/{id} — update."""

    def test_update_room_name(self, client):
        r = client.post("/api/customers", json={"name": "RUCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "RULoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        r = client.post("/api/rooms", json={"location_id": loc_id, "name": "Old Room"})
        room_id = r.json()["id"]
        r = client.put(f"/api/rooms/{room_id}", json={"name": "New Room"})
        assert r.status_code == 200
        assert r.json()["name"] == "New Room"

    def test_update_room_not_found(self, client):
        r = client.put("/api/rooms/99999", json={"name": "Nope"})
        assert r.status_code == 404


class TestDeleteRoom:
    """DELETE /api/rooms/{id}."""

    def test_delete_room_returns_204(self, client):
        r = client.post("/api/customers", json={"name": "DRCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "DRLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        r = client.post("/api/rooms", json={"location_id": loc_id, "name": "DelRoom"})
        room_id = r.json()["id"]
        r = client.delete(f"/api/rooms/{room_id}")
        assert r.status_code == 204

    def test_delete_room_not_found(self, client):
        r = client.delete("/api/rooms/99999")
        assert r.status_code == 404


class TestListRooms:
    """GET /api/rooms — list rooms with optional location_id filter."""

    @pytest.fixture
    def _setup(self, client):
        """Create customer, location, and two rooms. Returns location_id and room names."""
        r = client.post("/api/customers", json={"name": "LR Cust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "LR Loc", "customer_id": c_id})
        loc_id = r.json()["id"]
        client.post("/api/rooms", json={"location_id": loc_id, "name": "Room A"})
        client.post("/api/rooms", json={"location_id": loc_id, "name": "Room B"})
        # Second location with one room
        r = client.post("/api/locations", json={"name": "LR Loc2", "customer_id": c_id})
        loc2_id = r.json()["id"]
        client.post("/api/rooms", json={"location_id": loc2_id, "name": "Room C"})
        return loc_id, loc2_id

    def test_list_all_rooms_returns_array(self, client):
        """Listing rooms returns an array."""
        r = client.get("/api/rooms")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_rooms_includes_created(self, client, _setup):
        """Created rooms appear in the list."""
        r = client.get("/api/rooms")
        names = {room["name"] for room in r.json()}
        assert "Room A" in names
        assert "Room B" in names
        assert "Room C" in names

    def test_list_rooms_filter_by_location(self, client, _setup):
        """Filtering by location_id returns only rooms for that location."""
        loc_id, _ = _setup
        r = client.get(f"/api/rooms?location_id={loc_id}")
        assert r.status_code == 200
        names = {room["name"] for room in r.json()}
        assert names == {"Room A", "Room B"}

    def test_list_rooms_empty_for_nonexistent_location(self, client):
        """Filtering by nonexistent location returns empty list."""
        r = client.get("/api/rooms?location_id=99999")
        assert r.status_code == 200
        assert r.json() == []

    def test_list_rooms_returns_room_fields(self, client, _setup):
        """Each room has expected fields."""
        r = client.get("/api/rooms")
        room = r.json()[0]
        for field in ("id", "location_id", "name", "floor", "created_at", "updated_at"):
            assert field in room


class TestGetRoom:
    """GET /api/rooms/{id} — get a single room."""

    def test_get_room_returns_200(self, client):
        """Get an existing room returns 200 with room data."""
        r = client.post("/api/customers", json={"name": "GR Cust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "GR Loc", "customer_id": c_id})
        loc_id = r.json()["id"]
        r = client.post("/api/rooms", json={"location_id": loc_id, "name": "GetMe"})
        room_id = r.json()["id"]

        r = client.get(f"/api/rooms/{room_id}")
        assert r.status_code == 200
        data = r.json()
        assert data["name"] == "GetMe"
        assert data["location_id"] == loc_id
        assert data["id"] == room_id

    def test_get_room_not_found_returns_404(self, client):
        """Get a nonexistent room returns 404."""
        r = client.get("/api/rooms/99999")
        assert r.status_code == 404
        assert "Room not found" in r.json()["detail"]


# ─── Phase B: Settings API ────────────────────────────────────────────────


def _settings_crud_tests(entity, create_payload, update_payload, update_check_key, update_check_val):
    """Generate CRUD test classes for a settings entity."""

    class TestCreate:
        def test_create_returns_201(self, client):
            r = client.post(f"/api/settings/{entity}", json=create_payload)
            assert r.status_code == 201
            data = r.json()
            assert "id" in data

        def test_create_duplicate_name_rejected(self, client):
            client.post(f"/api/settings/{entity}", json=create_payload)
            r = client.post(f"/api/settings/{entity}", json=create_payload)
            assert r.status_code == 409

    class TestList:
        def test_list_returns_array(self, client):
            r = client.get(f"/api/settings/{entity}")
            assert r.status_code == 200
            assert isinstance(r.json(), list)

        def test_list_includes_created(self, client):
            client.post(f"/api/settings/{entity}", json=create_payload)
            r = client.get(f"/api/settings/{entity}")
            assert len(r.json()) >= 1

    class TestUpdate:
        def test_update_returns_200(self, client):
            r = client.post(f"/api/settings/{entity}", json=create_payload)
            eid = r.json()["id"]
            r = client.put(f"/api/settings/{entity}/{eid}", json=update_payload)
            assert r.status_code == 200
            if update_check_key:
                assert r.json()[update_check_key] == update_check_val

        def test_update_not_found(self, client):
            r = client.put(f"/api/settings/{entity}/99999", json=update_payload)
            assert r.status_code == 404

    class TestDelete:
        def test_delete_returns_204(self, client):
            r = client.post(f"/api/settings/{entity}", json=create_payload)
            eid = r.json()["id"]
            r = client.delete(f"/api/settings/{entity}/{eid}")
            assert r.status_code == 204

        def test_delete_not_found(self, client):
            r = client.delete(f"/api/settings/{entity}/99999")
            assert r.status_code == 404

    class TestGet:
        def test_get_returns_200(self, client):
            r = client.post(f"/api/settings/{entity}", json=create_payload)
            eid = r.json()["id"]
            r = client.get(f"/api/settings/{entity}/{eid}")
            assert r.status_code == 200
            assert r.json()["id"] == eid

        def test_get_not_found_returns_404(self, client):
            r = client.get(f"/api/settings/{entity}/99999")
            assert r.status_code == 404

    return TestCreate, TestList, TestUpdate, TestDelete, TestGet


# Generate test classes for each settings entity
_TestCatCreate, _TestCatList, _TestCatUpdate, _TestCatDelete, _TestCatGet = _settings_crud_tests(
    "categories", {"name": "TestCategory", "icon": "🧪"},
    {"name": "UpdatedCategory"}, "name", "UpdatedCategory",
)

_TestMakeCreate, _TestMakeList, _TestMakeUpdate, _TestMakeDelete, _TestMakeGet = _settings_crud_tests(
    "makes", {"name": "TestMake"},
    {"name": "UpdatedMake"}, "name", "UpdatedMake",
)

_TestKeyNameCreate, _TestKeyNameList, _TestKeyNameUpdate, _TestKeyNameDelete, _TestKeyNameGet = _settings_crud_tests(
    "key_names", {"name": "TestKey"},
    {"name": "UpdatedKey"}, "name", "UpdatedKey",
)

_TestKeyTypeCreate, _TestKeyTypeList, _TestKeyTypeUpdate, _TestKeyTypeDelete, _TestKeyTypeGet = _settings_crud_tests(
    "key_types", {"name": "TestType"},
    {"name": "UpdatedType"}, "name", "UpdatedType",
)

_TestBadgeTypeCreate, _TestBadgeTypeList, _TestBadgeTypeUpdate, _TestBadgeTypeDelete, _TestBadgeTypeGet = _settings_crud_tests(
    "badge_types", {"name": "TestBadge"},
    {"name": "UpdatedBadge"}, "name", "UpdatedBadge",
)


class TestSettingsCategoriesCreate(_TestCatCreate):
    pass


class TestSettingsCategoriesList(_TestCatList):
    pass


class TestSettingsCategoriesUpdate(_TestCatUpdate):
    pass


class TestSettingsCategoriesDelete(_TestCatDelete):
    pass


class TestSettingsCategoriesGet(_TestCatGet):
    pass


class TestSettingsMakesCreate(_TestMakeCreate):
    pass


class TestSettingsMakesList(_TestMakeList):
    pass


class TestSettingsMakesUpdate(_TestMakeUpdate):
    pass


class TestSettingsMakesDelete(_TestMakeDelete):
    pass


class TestSettingsMakesGet(_TestMakeGet):
    pass


class TestSettingsKeyNamesCreate(_TestKeyNameCreate):
    pass


class TestSettingsKeyNamesList(_TestKeyNameList):
    pass


class TestSettingsKeyNamesUpdate(_TestKeyNameUpdate):
    pass


class TestSettingsKeyNamesDelete(_TestKeyNameDelete):
    pass


class TestSettingsKeyNamesGet(_TestKeyNameGet):
    pass


class TestSettingsKeyTypesCreate(_TestKeyTypeCreate):
    pass


class TestSettingsKeyTypesList(_TestKeyTypeList):
    pass


class TestSettingsKeyTypesUpdate(_TestKeyTypeUpdate):
    pass


class TestSettingsKeyTypesDelete(_TestKeyTypeDelete):
    pass


class TestSettingsKeyTypesGet(_TestKeyTypeGet):
    pass


class TestSettingsBadgeTypesCreate(_TestBadgeTypeCreate):
    pass


class TestSettingsBadgeTypesList(_TestBadgeTypeList):
    pass


class TestSettingsBadgeTypesUpdate(_TestBadgeTypeUpdate):
    pass


class TestSettingsBadgeTypesDelete(_TestBadgeTypeDelete):
    pass


class TestSettingsBadgeTypesGet(_TestBadgeTypeGet):
    pass


class TestSettingsModels:
    """Models entity has FK to makes — requires special create/update."""

    @pytest.fixture
    def make_id(self, client):
        r = client.post("/api/settings/makes", json={"name": "ModelMake"})
        return r.json()["id"]

    def test_create_model_returns_201(self, client, make_id):
        payload = {"make_id": make_id, "name": "Model X"}
        r = client.post("/api/settings/models", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["name"] == "Model X"
        assert data["make_id"] == make_id

    def test_create_model_requires_make_id(self, client):
        r = client.post("/api/settings/models", json={"name": "No Make"})
        assert r.status_code == 422

    def test_list_models_returns_array(self, client):
        r = client.get("/api/settings/models")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_update_model_name(self, client, make_id):
        r = client.post("/api/settings/models", json={"make_id": make_id, "name": "OldModel"})
        mid = r.json()["id"]
        r = client.put(f"/api/settings/models/{mid}", json={"name": "NewModel"})
        assert r.status_code == 200
        assert r.json()["name"] == "NewModel"

    def test_delete_model_returns_204(self, client, make_id):
        r = client.post("/api/settings/models", json={"make_id": make_id, "name": "DelModel"})
        mid = r.json()["id"]
        r = client.delete(f"/api/settings/models/{mid}")
        assert r.status_code == 204

    def test_models_not_found(self, client):
        r = client.get("/api/settings/models/99999")
        assert r.status_code == 404

    def test_get_model_returns_200(self, client, make_id):
        """GET /api/settings/models/{id} returns the model."""
        r = client.post("/api/settings/models", json={"make_id": make_id, "name": "GetModel"})
        mid = r.json()["id"]
        r = client.get(f"/api/settings/models/{mid}")
        assert r.status_code == 200
        assert r.json()["name"] == "GetModel"
        assert r.json()["id"] == mid


# ─── Phase B: Enhanced Asset Endpoints ────────────────────────────────────


class TestCreateAssetWithKeysAndBadges:
    """POST /api/assets with keys[] and badges[] arrays."""

    def test_create_asset_with_keys(self, client):
        payload = {
            "machine_id": "KEY001",
            "name": "Asset With Keys",
            "keys": [
                {"key_name": "MK500", "key_type": "Round Short"},
                {"key_name": "Green Dot", "key_type": "Standard"},
            ],
        }
        r = client.post("/api/assets", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert len(data["keys"]) == 2
        assert data["keys"][0]["key_name"] == "MK500"

    def test_create_asset_with_badges(self, client):
        payload = {
            "machine_id": "BADGE001",
            "name": "Asset With Badges",
            "badges": ["Disney Contractor Base", "Visitor Badge"],
        }
        r = client.post("/api/assets", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert len(data["badges"]) == 2
        assert data["badges"][0]["badge_name"] == "Disney Contractor Base"

    def test_create_asset_keys_default_empty(self, client):
        payload = {"machine_id": "NOKEY001", "name": "No Keys"}
        r = client.post("/api/assets", json=payload)
        assert r.status_code == 201
        assert r.json()["keys"] == []

    def test_create_asset_badges_default_empty(self, client):
        payload = {"machine_id": "NOBADGE001", "name": "No Badges"}
        r = client.post("/api/assets", json=payload)
        assert r.status_code == 201
        assert r.json()["badges"] == []


class TestGetAssetDetail:
    """GET /api/assets/{id} — detail with keys, badges, customer/location names."""

    def test_get_asset_includes_keys(self, client):
        payload = {
            "machine_id": "DETAIL001",
            "name": "Detail Asset",
            "keys": [{"key_name": "MK500", "key_type": "Round Short"}],
        }
        r = client.post("/api/assets", json=payload)
        asset_id = r.json()["id"]
        r = client.get(f"/api/assets/{asset_id}")
        assert r.status_code == 200
        data = r.json()
        assert "keys" in data
        assert len(data["keys"]) == 1

    def test_get_asset_includes_badges(self, client):
        payload = {
            "machine_id": "DETAIL002",
            "name": "Badge Asset",
            "badges": ["Disney Contractor Base"],
        }
        r = client.post("/api/assets", json=payload)
        asset_id = r.json()["id"]
        r = client.get(f"/api/assets/{asset_id}")
        data = r.json()
        assert "badges" in data
        assert len(data["badges"]) == 1

    def test_get_asset_includes_customer_name(self, client):
        r = client.post("/api/customers", json={"name": "DetailCustomer"})
        cust_id = r.json()["id"]
        payload = {"machine_id": "DETAIL003", "name": "Customer Asset", "customer_id": cust_id}
        r = client.post("/api/assets", json=payload)
        asset_id = r.json()["id"]
        r = client.get(f"/api/assets/{asset_id}")
        data = r.json()
        assert data.get("customer_name") == "DetailCustomer"

    def test_get_asset_includes_location_name(self, client):
        r = client.post("/api/customers", json={"name": "LocCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "DetailLocation", "customer_id": c_id})
        loc_id = r.json()["id"]
        payload = {"machine_id": "DETAIL004", "name": "Location Asset", "location_id": loc_id}
        r = client.post("/api/assets", json=payload)
        asset_id = r.json()["id"]
        r = client.get(f"/api/assets/{asset_id}")
        data = r.json()
        assert data.get("location_name") == "DetailLocation"


class TestListAssetsExtendedFilters:
    """GET /api/assets with extended v2 filters."""

    def test_list_assets_filter_by_make(self, client):
        client.post("/api/assets", json={"machine_id": "F1", "name": "A1", "make": "Hobart"})
        client.post("/api/assets", json={"machine_id": "F2", "name": "A2", "make": "Vollrath"})
        r = client.get("/api/assets?make=Hobart")
        data = r.json()
        assert len(data) == 1
        assert data[0]["make"] == "Hobart"

    def test_list_assets_filter_by_model(self, client):
        client.post("/api/assets", json={"machine_id": "F3", "name": "A3", "model": "HLX-200"})
        client.post("/api/assets", json={"machine_id": "F4", "name": "A4", "model": "VX-500"})
        r = client.get("/api/assets?model=VX-500")
        data = r.json()
        assert len(data) == 1
        assert data[0]["model"] == "VX-500"

    def test_list_assets_filter_by_customer_id(self, client):
        r = client.post("/api/customers", json={"name": "FilterCust"})
        cid = r.json()["id"]
        client.post("/api/assets", json={"machine_id": "F5", "name": "CustAsset", "customer_id": cid})
        client.post("/api/assets", json={"machine_id": "F6", "name": "NoCust"})
        r = client.get(f"/api/assets?customer_id={cid}")
        data = r.json()
        assert len(data) == 1

    def test_list_assets_filter_by_location_id(self, client):
        r = client.post("/api/customers", json={"name": "FLocCust"})
        c_id = r.json()["id"]
        r = client.post("/api/locations", json={"name": "FLoc", "customer_id": c_id})
        loc_id = r.json()["id"]
        client.post("/api/assets", json={"machine_id": "F7", "name": "LocAsset", "location_id": loc_id})
        client.post("/api/assets", json={"machine_id": "F8", "name": "NoLoc"})
        r = client.get(f"/api/assets?location_id={loc_id}")
        data = r.json()
        assert len(data) == 1

    def test_list_assets_filter_by_assigned_to(self, client):
        client.post("/api/assets", json={"machine_id": "F9", "name": "Assigned", "assigned_to": 1})
        client.post("/api/assets", json={"machine_id": "F10", "name": "Unassigned"})
        r = client.get("/api/assets?assigned_to=1")
        data = r.json()
        assert len(data) == 1

    def test_list_assets_multiple_filters(self, client):
        client.post("/api/assets", json={"machine_id": "F11", "name": "Match", "category": "Equipment", "make": "Hobart"})
        client.post("/api/assets", json={"machine_id": "F12", "name": "NoMatch", "category": "Equipment", "make": "Vollrath"})
        r = client.get("/api/assets?category=Equipment&make=Hobart")
        data = r.json()
        assert len(data) == 1
        assert data[0]["name"] == "Match"

    def test_list_assets_filter_no_dex_days(self, client):
        """no_dex_days=N returns assets with no dex report or older than N days."""
        client.post("/api/assets", json={"machine_id": "DEX1", "name": "HasDex", "dex_report_date": "2026-05-23"})
        client.post("/api/assets", json={"machine_id": "DEX2", "name": "NoDex"})
        r = client.get("/api/assets?no_dex_days=5")
        data = r.json()
        assert len(data) == 1
        assert data[0]["name"] == "NoDex"


# ─── Phase C: Users API ──────────────────────────────────────────────────


class TestCreateUser:
    """POST /api/users — admin creates user accounts."""

    def test_create_user_returns_201(self, client):
        payload = {"username": "tech1", "password": "secret123", "role": "technician"}
        r = client.post("/api/users", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["username"] == "tech1"
        assert data["role"] == "technician"
        assert "password_hash" not in data

    def test_create_user_default_role_technician(self, client):
        payload = {"username": "tech2", "password": "pass456"}
        r = client.post("/api/users", json=payload)
        assert r.status_code == 201
        assert r.json()["role"] == "technician"

    def test_create_user_requires_username(self, client):
        r = client.post("/api/users", json={"password": "pw"})
        assert r.status_code == 422

    def test_create_user_requires_password(self, client):
        r = client.post("/api/users", json={"username": "u1"})
        assert r.status_code == 422

    def test_create_user_duplicate_username_rejected(self, client):
        client.post("/api/users", json={"username": "dup", "password": "pw1"})
        r = client.post("/api/users", json={"username": "dup", "password": "pw2"})
        assert r.status_code == 409

    def test_create_user_invalid_role_rejected(self, client):
        r = client.post("/api/users", json={"username": "bad", "password": "pw", "role": "superadmin"})
        assert r.status_code == 422


class TestListUsers:
    """GET /api/users — list all users."""

    def test_list_users_includes_admin(self, client):
        r = client.get("/api/users")
        assert r.status_code == 200
        data = r.json()
        assert isinstance(data, list)
        usernames = {u["username"] for u in data}
        assert "admin" in usernames

    def test_list_users_returns_created(self, client):
        client.post("/api/users", json={"username": "listme", "password": "pw"})
        r = client.get("/api/users")
        usernames = {u["username"] for u in r.json()}
        assert "listme" in usernames

    def test_list_users_no_passwords(self, client):
        r = client.get("/api/users")
        for u in r.json():
            assert "password_hash" not in u


class TestGetUser:
    """GET /api/users/{id} — user detail."""

    def test_get_user_returns_200(self, client):
        r = client.post("/api/users", json={"username": "detail", "password": "pw"})
        uid = r.json()["id"]
        r = client.get(f"/api/users/{uid}")
        assert r.status_code == 200
        assert r.json()["username"] == "detail"

    def test_get_user_not_found(self, client):
        r = client.get("/api/users/99999")
        assert r.status_code == 404

    def test_get_user_no_password(self, client):
        r = client.post("/api/users", json={"username": "safe", "password": "pw"})
        uid = r.json()["id"]
        r = client.get(f"/api/users/{uid}")
        assert "password_hash" not in r.json()


class TestUpdateUser:
    """PUT /api/users/{id} — update user."""

    def test_update_user_role(self, client):
        r = client.post("/api/users", json={"username": "upgrade", "password": "pw"})
        uid = r.json()["id"]
        r = client.put(f"/api/users/{uid}", json={"role": "admin"})
        assert r.status_code == 200
        assert r.json()["role"] == "admin"

    def test_update_user_password(self, client):
        r = client.post("/api/users", json={"username": "pwchange", "password": "old"})
        uid = r.json()["id"]
        r = client.put(f"/api/users/{uid}", json={"password": "newpassword"})
        assert r.status_code == 200

    def test_update_user_not_found(self, client):
        r = client.put("/api/users/99999", json={"role": "admin"})
        assert r.status_code == 404

    def test_update_user_invalid_role(self, client):
        r = client.post("/api/users", json={"username": "badrole", "password": "pw"})
        uid = r.json()["id"]
        r = client.put(f"/api/users/{uid}", json={"role": "invalid"})
        assert r.status_code == 422


class TestDeleteUser:
    """DELETE /api/users/{id} — delete user."""

    def test_delete_user_returns_204(self, client):
        r = client.post("/api/users", json={"username": "delme", "password": "pw"})
        uid = r.json()["id"]
        r = client.delete(f"/api/users/{uid}")
        assert r.status_code == 204

    def test_delete_user_not_found(self, client):
        r = client.delete("/api/users/99999")
        assert r.status_code == 404

    def test_delete_user_gone_after_delete(self, client):
        r = client.post("/api/users", json={"username": "gone", "password": "pw"})
        uid = r.json()["id"]
        client.delete(f"/api/users/{uid}")
        r = client.get(f"/api/users/{uid}")
        assert r.status_code == 404


# ─── Phase C: Auth API ───────────────────────────────────────────────────


class TestAuthLogin:
    """POST /api/auth/login — simple password auth."""

    def test_login_admin_returns_token(self, client):
        r = client.post("/api/auth/login", json={"username": "admin", "password": "changeme"})
        assert r.status_code == 200
        data = r.json()
        assert data["username"] == "admin"
        assert data["role"] == "admin"
        assert "token" in data
        assert "password_hash" not in data

    def test_login_created_user(self, client):
        client.post("/api/users", json={"username": "logger", "password": "mypassword"})
        r = client.post("/api/auth/login", json={"username": "logger", "password": "mypassword"})
        assert r.status_code == 200
        assert r.json()["username"] == "logger"
        assert r.json()["role"] == "technician"

    def test_login_wrong_password_returns_401(self, client):
        r = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"})
        assert r.status_code == 401

    def test_login_nonexistent_user_returns_401(self, client):
        r = client.post("/api/auth/login", json={"username": "nobody", "password": "pw"})
        assert r.status_code == 401

    def test_login_requires_username(self, client):
        r = client.post("/api/auth/login", json={"password": "pw"})
        assert r.status_code == 422

    def test_login_requires_password(self, client):
        r = client.post("/api/auth/login", json={"username": "admin"})
        assert r.status_code == 422

    def test_login_updated_password(self, client):
        r = client.post("/api/users", json={"username": "upw", "password": "first"})
        uid = r.json()["id"]
        client.put(f"/api/users/{uid}", json={"password": "second"})
        r = client.post("/api/auth/login", json={"username": "upw", "password": "first"})
        assert r.status_code == 401
        r = client.post("/api/auth/login", json={"username": "upw", "password": "second"})
        assert r.status_code == 200


# ─── Phase C: Check-ins Extension ────────────────────────────────────────


class TestCheckinWithUser:
    """POST /api/checkins — with user_id field."""

    @pytest.fixture
    def asset_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "CHKU001", "name": "User Checkin"})
        return r.json()["id"]

    @pytest.fixture
    def user_id(self, client):
        r = client.post("/api/users", json={"username": "checker", "password": "pw"})
        return r.json()["id"]

    def test_create_checkin_with_user_id(self, client, asset_id, user_id):
        payload = {"asset_id": asset_id, "user_id": user_id, "notes": "Checked by user"}
        r = client.post("/api/checkins", json=payload)
        assert r.status_code == 201
        assert r.json()["user_id"] == user_id

    def test_create_checkin_without_user_id_allowed(self, client, asset_id):
        payload = {"asset_id": asset_id, "notes": "No user"}
        r = client.post("/api/checkins", json=payload)
        assert r.status_code == 201
        assert r.json()["user_id"] is None


class TestListCheckinsByUser:
    """GET /api/checkins — filter by user_id."""

    def test_filter_checkins_by_user_id(self, client):
        r = client.post("/api/users", json={"username": "filterme", "password": "pw"})
        uid = r.json()["id"]
        r = client.post("/api/assets", json={"machine_id": "CKU1", "name": "A1"})
        a1 = r.json()["id"]
        r = client.post("/api/assets", json={"machine_id": "CKU2", "name": "A2"})
        a2 = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": a1, "user_id": uid, "notes": "From uid"})
        client.post("/api/checkins", json={"asset_id": a2, "notes": "No user"})
        r = client.get(f"/api/checkins?user_id={uid}")
        data = r.json()
        assert len(data) == 1
        assert data[0]["notes"] == "From uid"


# ─── Phase C: Checkin CRUD (get/update/delete) ────────────────────────────


class TestGetCheckin:
    """GET /api/checkins/{id} — get single checkin."""

    @pytest.fixture
    def checkin_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "GETCK001", "name": "Get Checkin"})
        aid = r.json()["id"]
        r = client.post("/api/checkins", json={"asset_id": aid, "notes": "Test checkin", "latitude": 40.0, "longitude": -74.0})
        return r.json()["id"]

    def test_get_checkin_returns_200(self, client, checkin_id):
        r = client.get(f"/api/checkins/{checkin_id}")
        assert r.status_code == 200
        assert r.json()["notes"] == "Test checkin"
        assert r.json()["latitude"] == 40.0

    def test_get_checkin_not_found_returns_404(self, client):
        r = client.get("/api/checkins/99999")
        assert r.status_code == 404
        assert "Checkin not found" in r.json()["detail"]

    def test_get_checkin_has_all_fields(self, client, checkin_id):
        r = client.get(f"/api/checkins/{checkin_id}")
        data = r.json()
        for field in ("id", "asset_id", "latitude", "longitude", "accuracy",
                       "photo_path", "notes", "user_id", "created_at"):
            assert field in data


class TestUpdateCheckin:
    """PUT /api/checkins/{id} — update checkin fields."""

    @pytest.fixture
    def checkin_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "UPCK001", "name": "Update Checkin"})
        aid = r.json()["id"]
        r = client.post("/api/checkins", json={"asset_id": aid, "notes": "Original", "latitude": 40.0, "longitude": -74.0})
        return r.json()["id"]

    def test_update_checkin_notes(self, client, checkin_id):
        r = client.put(f"/api/checkins/{checkin_id}", json={"notes": "Updated notes"})
        assert r.status_code == 200
        assert r.json()["notes"] == "Updated notes"

    def test_update_checkin_location(self, client, checkin_id):
        r = client.put(f"/api/checkins/{checkin_id}", json={"latitude": 34.0522, "longitude": -118.2437})
        assert r.status_code == 200
        data = r.json()
        assert data["latitude"] == 34.0522
        assert data["longitude"] == -118.2437

    def test_update_checkin_accuracy(self, client, checkin_id):
        r = client.put(f"/api/checkins/{checkin_id}", json={"accuracy": 5.5})
        assert r.status_code == 200
        assert r.json()["accuracy"] == 5.5

    def test_update_checkin_user_id(self, client, checkin_id):
        r = client.put(f"/api/checkins/{checkin_id}", json={"user_id": 1})
        assert r.status_code == 200
        assert r.json()["user_id"] == 1

    def test_update_checkin_partial_preserves_other_fields(self, client, checkin_id):
        """Updating one field should not change others."""
        r = client.put(f"/api/checkins/{checkin_id}", json={"notes": "New note only"})
        assert r.status_code == 200
        data = r.json()
        assert data["notes"] == "New note only"
        assert data["latitude"] == 40.0  # unchanged

    def test_update_checkin_not_found_returns_404(self, client):
        r = client.put("/api/checkins/99999", json={"notes": "Nope"})
        assert r.status_code == 404
        assert "Checkin not found" in r.json()["detail"]

    def test_update_checkin_empty_body_no_change(self, client, checkin_id):
        """Sending empty body should not error — returns current state."""
        r = client.put(f"/api/checkins/{checkin_id}", json={})
        assert r.status_code == 200
        assert r.json()["notes"] == "Original"


class TestDeleteCheckin:
    """DELETE /api/checkins/{id} — delete checkin."""

    @pytest.fixture
    def checkin_id(self, client):
        r = client.post("/api/assets", json={"machine_id": "DELCK001", "name": "Delete Checkin"})
        aid = r.json()["id"]
        r = client.post("/api/checkins", json={"asset_id": aid, "notes": "To delete"})
        return r.json()["id"]

    def test_delete_checkin_returns_204(self, client, checkin_id):
        r = client.delete(f"/api/checkins/{checkin_id}")
        assert r.status_code == 204

    def test_delete_checkin_gone_after_delete(self, client, checkin_id):
        client.delete(f"/api/checkins/{checkin_id}")
        r = client.get(f"/api/checkins/{checkin_id}")
        assert r.status_code == 404

    def test_delete_checkin_not_found_returns_404(self, client):
        r = client.delete("/api/checkins/99999")
        assert r.status_code == 404
        assert "Checkin not found" in r.json()["detail"]


class TestCheckinCascade:
    """Checkin cascade: deleting an asset should cascade-delete its checkins."""

    def test_delete_asset_cascades_checkins(self, client):
        r = client.post("/api/assets", json={"machine_id": "CASC001", "name": "Cascade Asset"})
        aid = r.json()["id"]

        # Create multiple checkins
        client.post("/api/checkins", json={"asset_id": aid, "notes": "Checkin 1"})
        client.post("/api/checkins", json={"asset_id": aid, "notes": "Checkin 2"})

        # Verify checkins exist
        r = client.get(f"/api/checkins?asset_id={aid}")
        assert len(r.json()) == 2

        # Delete the asset
        client.delete(f"/api/assets/{aid}")

        # Verify checkins are gone
        r = client.get(f"/api/checkins?asset_id={aid}")
        assert r.json() == []


# ─── Phase C: Geofences API ──────────────────────────────────────────────


class TestCreateGeofence:
    """POST /api/geofences — create geofence area."""

    def test_create_geofence_returns_201(self, client):
        payload = {
            "name": "Warehouse Zone",
            "points": [[40.7128, -74.0060], [40.7130, -74.0050], [40.7120, -74.0040]],
            "color": "#ff0000",
        }
        r = client.post("/api/geofences", json=payload)
        assert r.status_code == 201
        data = r.json()
        assert data["name"] == "Warehouse Zone"
        assert data["color"] == "#ff0000"
        assert "id" in data

    def test_create_geofence_requires_name(self, client):
        r = client.post("/api/geofences", json={"points": [[0, 0]]})
        assert r.status_code == 422

    def test_create_geofence_requires_points(self, client):
        r = client.post("/api/geofences", json={"name": "Zone"})
        assert r.status_code == 422

    def test_create_geofence_requires_points_to_be_array_of_arrays(self, client):
        r = client.post("/api/geofences", json={"name": "Bad", "points": "not an array"})
        assert r.status_code == 422

    def test_create_geofence_default_color(self, client):
        payload = {"name": "Default Color", "points": [[0, 0], [1, 1]]}
        r = client.post("/api/geofences", json=payload)
        assert r.status_code == 201
        assert r.json()["color"] == "#3388ff"


class TestListGeofences:
    """GET /api/geofences — list all."""

    def test_list_geofences_returns_array(self, client):
        r = client.get("/api/geofences")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_geofences_includes_created(self, client):
        client.post("/api/geofences", json={"name": "ListZone", "points": [[0, 0], [1, 1]]})
        r = client.get("/api/geofences")
        data = r.json()
        assert len(data) == 1
        assert data[0]["name"] == "ListZone"


class TestUpdateGeofence:
    """PUT /api/geofences/{id} — update."""

    def test_update_geofence_name(self, client):
        r = client.post("/api/geofences", json={"name": "OldZone", "points": [[0, 0], [1, 1]]})
        gid = r.json()["id"]
        r = client.put(f"/api/geofences/{gid}", json={"name": "NewZone"})
        assert r.status_code == 200
        assert r.json()["name"] == "NewZone"

    def test_update_geofence_points(self, client):
        r = client.post("/api/geofences", json={"name": "MoveZone", "points": [[0, 0], [1, 1]]})
        gid = r.json()["id"]
        new_points = [[10, 20], [30, 40], [50, 60]]
        r = client.put(f"/api/geofences/{gid}", json={"points": new_points})
        assert r.status_code == 200
        assert r.json()["points"] == new_points

    def test_update_geofence_not_found(self, client):
        r = client.put("/api/geofences/99999", json={"name": "Nope"})
        assert r.status_code == 404


class TestDeleteGeofence:
    """DELETE /api/geofences/{id} — delete."""

    def test_delete_geofence_returns_204(self, client):
        r = client.post("/api/geofences", json={"name": "DelZone", "points": [[0, 0], [1, 1]]})
        gid = r.json()["id"]
        r = client.delete(f"/api/geofences/{gid}")
        assert r.status_code == 204

    def test_delete_geofence_not_found(self, client):
        r = client.delete("/api/geofences/99999")
        assert r.status_code == 404


# ─── Phase C: Visits API ─────────────────────────────────────────────────


class TestAutoVisitLogging:
    """Auto-visit logging: if user has check-ins at same asset within 10 min window."""

    @pytest.fixture
    def setup_data(self, client):
        r = client.post("/api/users", json={"username": "visitor1", "password": "pw"})
        uid = r.json()["id"]
        r = client.post("/api/assets", json={"machine_id": "VIS001", "name": "Visit Asset"})
        aid = r.json()["id"]
        return uid, aid

    def test_auto_visit_logged_on_checkin(self, client, setup_data):
        uid, aid = setup_data
        # First checkin
        client.post("/api/checkins", json={"asset_id": aid, "user_id": uid})
        # Second checkin same asset, same user — should trigger visit
        client.post("/api/checkins", json={"asset_id": aid, "user_id": uid})

        r = client.get("/api/visits")
        visits = r.json()
        # A visit should be logged for the two checkins within a short window
        assert len(visits) >= 1
        assert visits[0]["user_id"] == uid
        assert visits[0]["asset_id"] == aid

    def test_no_visit_for_single_checkin(self, client, setup_data):
        uid, aid = setup_data
        client.post("/api/checkins", json={"asset_id": aid, "user_id": uid})
        r = client.get(f"/api/visits?asset_id={aid}")
        visits = r.json()
        # Single checkin — no visit logged (needs at least 2 in window)
        assert len(visits) == 0


class TestListVisits:
    """GET /api/visits — list visits with filters."""

    def test_list_visits_returns_array(self, client):
        r = client.get("/api/visits")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_visits_filter_by_asset_id(self, client):
        r = client.get("/api/visits?asset_id=1")
        assert r.status_code == 200

    def test_list_visits_filter_by_user_id(self, client):
        r = client.get("/api/visits?user_id=1")
        assert r.status_code == 200

    def test_list_visits_filter_by_date_range(self, client):
        r = client.get("/api/visits?date_from=2024-01-01&date_to=2024-12-31")
        assert r.status_code == 200

    def test_list_visits_pagination(self, client):
        r = client.get("/api/visits?limit=5&offset=0")
        assert r.status_code == 200


class TestVisitStats:
    """GET /api/visits/stats — aggregate visit data."""

    def test_visit_stats_returns_json(self, client):
        r = client.get("/api/visits/stats")
        assert r.status_code == 200
        data = r.json()
        assert isinstance(data, dict)
        assert "total_visits" in data

    def test_visit_stats_empty_state(self, client):
        data = client.get("/api/visits/stats").json()
        assert data["total_visits"] == 0


class TestCreateVisit:
    """POST /api/visits — manually log a visit."""

    def test_create_visit_returns_201(self, client):
        r = client.post("/api/assets", json={"machine_id": "VST001", "name": "Visit Test Asset"})
        aid = r.json()["id"]
        r = client.post("/api/visits", json={
            "asset_id": aid,
            "user_id": None,
            "latitude": 40.7128,
            "longitude": -74.0060,
            "duration_minutes": 10,
        })
        assert r.status_code == 201
        data = r.json()
        assert data["asset_id"] == aid
        assert data["duration_minutes"] == 10
        assert "id" in data

    def test_create_visit_requires_asset_id(self, client):
        r = client.post("/api/visits", json={"duration_minutes": 5})
        assert r.status_code == 422

    def test_create_visit_nonexistent_asset_returns_404(self, client):
        r = client.post("/api/visits", json={
            "asset_id": 99999,
            "duration_minutes": 5,
        })
        assert r.status_code == 404

    def test_create_visit_appears_in_list(self, client):
        r = client.post("/api/assets", json={"machine_id": "VST002", "name": "List Visit Asset"})
        aid = r.json()["id"]
        client.post("/api/visits", json={
            "asset_id": aid,
            "user_id": None,
            "duration_minutes": 12,
        })
        r = client.get("/api/visits")
        assert r.status_code == 200
        visits = r.json()
        assert len(visits) >= 1
        assert any(v["asset_id"] == aid and v["duration_minutes"] == 12 for v in visits)

    def test_create_visit_appears_in_stats(self, client):
        r = client.post("/api/assets", json={"machine_id": "VST003", "name": "Stats Visit Asset"})
        aid = r.json()["id"]
        client.post("/api/visits", json={
            "asset_id": aid,
            "user_id": None,
            "duration_minutes": 15,
        })
        data = client.get("/api/visits/stats").json()
        assert data["total_visits"] >= 1
        assert any(v["name"] == "Stats Visit Asset" and v["count"] >= 1
                   for v in data.get("visits_per_asset", []))

    def test_create_visit_activity_logged(self, client):
        r = client.post("/api/assets", json={"machine_id": "VST004", "name": "Activity Visit Asset"})
        aid = r.json()["id"]
        client.post("/api/visits", json={"asset_id": aid, "duration_minutes": 8})
        r = client.get("/api/activity")
        data = r.json()
        assert any(e["entity_type"] == "visit" and e["action"] == "created" for e in data)


# ─── Phase C: Activity Feed API ──────────────────────────────────────────


class TestActivityLogging:
    """Activity is auto-logged on CRUD operations."""

    def test_activity_logged_on_asset_create(self, client):
        client.post("/api/assets", json={"machine_id": "ACT001", "name": "Activity Asset"})
        r = client.get("/api/activity")
        data = r.json()
        assert len(data) >= 1
        assert any(e["entity_type"] == "asset" and e["action"] == "created" for e in data)

    def test_activity_logged_on_customer_create(self, client):
        client.post("/api/customers", json={"name": "ActivityCustomer"})
        r = client.get("/api/activity")
        data = r.json()
        assert any(e["entity_type"] == "customer" and e["action"] == "created" for e in data)

    def test_activity_logged_on_checkin(self, client):
        r = client.post("/api/assets", json={"machine_id": "ACTCK1", "name": "Ck Asset"})
        aid = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": aid})
        r = client.get("/api/activity")
        data = r.json()
        assert any(e["entity_type"] == "checkin" and e["action"] == "created" for e in data)


class TestListActivity:
    """GET /api/activity — list with filters and pagination."""

    def test_list_activity_returns_array(self, client):
        r = client.get("/api/activity")
        assert r.status_code == 200
        assert isinstance(r.json(), list)

    def test_list_activity_filter_by_user_id(self, client):
        r = client.get("/api/activity?user_id=1")
        assert r.status_code == 200

    def test_list_activity_filter_by_entity_type(self, client):
        r = client.get("/api/activity?entity_type=asset")
        assert r.status_code == 200

    def test_list_activity_filter_by_date_range(self, client):
        r = client.get("/api/activity?date_from=2024-01-01&date_to=2024-12-31")
        assert r.status_code == 200

    def test_list_activity_pagination(self, client):
        r = client.get("/api/activity?limit=10&offset=0")
        assert r.status_code == 200

    def test_list_activity_ordered_newest_first(self, client):
        r = client.get("/api/activity")
        data = r.json()
        if len(data) >= 2:
            assert data[0]["id"] >= data[1]["id"]


# ─── Phase C: Enhanced Stats API ─────────────────────────────────────────


class TestEnhancedStats:
    """GET /api/stats — extended with top_visited, time_on_site, by_make."""

    def test_enhanced_stats_has_top_visited(self, client):
        data = client.get("/api/stats").json()
        assert "top_visited" in data

    def test_enhanced_stats_has_time_on_site(self, client):
        data = client.get("/api/stats").json()
        assert "time_on_site" in data

    def test_enhanced_stats_has_by_make(self, client):
        data = client.get("/api/stats").json()
        assert "by_make" in data

    def test_enhanced_stats_by_make_reflects_data(self, client):
        client.post("/api/assets", json={"machine_id": "SM1", "name": "M1", "make": "Hobart"})
        client.post("/api/assets", json={"machine_id": "SM2", "name": "M2", "make": "Hobart"})
        client.post("/api/assets", json={"machine_id": "SM3", "name": "M3", "make": "Vollrath"})
        data = client.get("/api/stats").json()
        by_make = data.get("by_make", {})
        assert by_make.get("Hobart") == 2
        assert by_make.get("Vollrath") == 1


# ─── Phase C: Export Extensions ──────────────────────────────────────────


class TestExportExtensions:
    """Extended export endpoints."""

    def test_export_assets_includes_v2_fields(self, client):
        client.post("/api/assets", json={
            "machine_id": "EXPV2001", "name": "Export V2",
            "serial_number": "SN-999", "make": "Hobart", "model": "HLX-200",
        })
        r = client.get("/api/export/assets")
        body = r.text
        assert "serial_number" in body
        assert "make" in body
        assert "model" in body

    def test_export_checkins_includes_user_id(self, client):
        r = client.post("/api/users", json={"username": "exporter", "password": "pw"})
        uid = r.json()["id"]
        r = client.post("/api/assets", json={"machine_id": "EXPCK99", "name": "Export CK"})
        aid = r.json()["id"]
        client.post("/api/checkins", json={"asset_id": aid, "user_id": uid, "notes": "export test"})
        r = client.get("/api/export/checkins")
        body = r.text
        assert "user_id" in body

    def test_export_service_summary_returns_csv(self, client):
        client.post("/api/customers", json={"name": "ServiceCust"})
        client.post("/api/assets", json={"machine_id": "EXPSVC1", "name": "Svc Asset"})
        r = client.get("/api/export/service-summary")
        assert r.status_code == 200
        assert "text/csv" in r.headers["content-type"]


# ─── Phase D: File Upload Endpoints ────────────────────────────────────────

import tempfile as _tempfile_mod
import shutil as _shutil_mod

_UPLOADS_TMP = Path(_tempfile_mod.mkdtemp(prefix="canteen_test_uploads_"))
os.environ["CANTEEN_UPLOADS_DIR"] = str(_UPLOADS_TMP)


def pytest_sessionfinish(session):
    """Clean up temp uploads dir at session end."""
    if _UPLOADS_TMP.exists():
        _shutil_mod.rmtree(_UPLOADS_TMP, ignore_errors=True)


class TestServeUploads:
    """Phase D — static file serving from /uploads/{filename}."""

    def test_serve_uploaded_file(self, client):
        import io
        content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 200
        files = {"file": ("icon.png", io.BytesIO(content), "image/png")}
        r = client.post("/api/upload/icon", files=files)
        data = r.json()
        path = data["path"]
        # GET the served file
        r2 = client.get(path)
        assert r2.status_code == 200
        assert r2.content == content

    def test_serve_nonexistent_file_returns_404(self, client):
        r = client.get("/uploads/nonexistent_12345.png")
        assert r.status_code == 404


# ─── Phase E: Auth Enforcement Tests ───────────────────────────────────────


class TestAuthRequired:
    """Verify that unauthenticated requests to protected endpoints return 401."""

    def test_unauth_assets_list_returns_401(self, unauth_client):
        r = unauth_client.get("/api/assets")
        assert r.status_code == 401

    def test_unauth_assets_create_returns_401(self, unauth_client):
        r = unauth_client.post("/api/assets", json={"machine_id": "X", "name": "X"})
        assert r.status_code == 401

    def test_unauth_assets_update_returns_401(self, unauth_client):
        r = unauth_client.put("/api/assets/1", json={"name": "X"})
        assert r.status_code == 401

    def test_unauth_assets_delete_returns_401(self, unauth_client):
        r = unauth_client.delete("/api/assets/1")
        assert r.status_code == 401

    def test_unauth_customers_returns_401(self, unauth_client):
        r = unauth_client.get("/api/customers")
        assert r.status_code == 401

    def test_unauth_locations_returns_401(self, unauth_client):
        r = unauth_client.get("/api/locations")
        assert r.status_code == 401

    def test_unauth_users_returns_401(self, unauth_client):
        r = unauth_client.get("/api/users")
        assert r.status_code == 401

    def test_unauth_geofences_returns_401(self, unauth_client):
        r = unauth_client.get("/api/geofences")
        assert r.status_code == 401

    def test_unauth_stats_returns_401(self, unauth_client):
        r = unauth_client.get("/api/stats")
        assert r.status_code == 401

    def test_unauth_checkins_returns_401(self, unauth_client):
        r = unauth_client.get("/api/checkins")
        assert r.status_code == 401

    def test_unauth_activity_returns_401(self, unauth_client):
        r = unauth_client.get("/api/activity")
        assert r.status_code == 401

    def test_unauth_visits_returns_401(self, unauth_client):
        r = unauth_client.get("/api/visits")
        assert r.status_code == 401

    def test_unauth_upload_returns_401(self, unauth_client):
        import io
        files = {"file": ("icon.png", io.BytesIO(b"\x89PNG\r\n\x1a\n\x00" * 50), "image/png")}
        r = unauth_client.post("/api/upload/icon", files=files)
        assert r.status_code == 401

    def test_unauth_export_returns_401(self, unauth_client):
        r = unauth_client.get("/api/export/assets")
        assert r.status_code == 401

    def test_health_remains_public(self, unauth_client):
        r = unauth_client.get("/health")
        assert r.status_code == 200

    def test_login_remains_public(self, unauth_client):
        r = unauth_client.post("/api/auth/login", json={"username": "admin", "password": "changeme"})
        assert r.status_code == 200

    def test_unauth_with_bad_header_returns_401(self, unauth_client):
        r = unauth_client.get("/api/assets", headers={"Authorization": "Bearer invalidtoken123"})
        assert r.status_code == 401

    def test_unauth_with_malformed_header_returns_401(self, unauth_client):
        r = unauth_client.get("/api/assets", headers={"Authorization": "Basic dXNlcjpwYXNz"})
        assert r.status_code == 401

    def test_unauth_with_no_header_returns_401(self, unauth_client):
        r = unauth_client.get("/api/assets")
        assert r.status_code == 401
        assert "Authentication required" in r.json()["detail"]

    def test_unauth_with_expired_token_returns_401(self, unauth_client):
        r = unauth_client.get("/api/assets", headers={"Authorization": "Bearer " + "a" * 64})
        assert r.status_code == 401
        assert "Invalid or expired token" in r.json()["detail"]

    def test_authenticated_assets_works(self, auth_client):
        """Sanity check: authenticated requests work."""
        r = auth_client.get("/api/assets")
        assert r.status_code == 200


class TestAuthMe:
    """GET /api/auth/me — returns current authenticated user."""

    def test_auth_me_returns_user_with_token(self, auth_client):
        r = auth_client.get("/api/auth/me")
        assert r.status_code == 200
        data = r.json()
        assert data["username"] == "admin"
        assert data["role"] == "admin"
        assert "password_hash" not in data

    def test_auth_me_unauth_returns_401(self, unauth_client):
        r = unauth_client.get("/api/auth/me")
        assert r.status_code == 401

    def test_auth_me_bad_token_returns_401(self, unauth_client):
        r = unauth_client.get("/api/auth/me", headers={"Authorization": "Bearer invalidtoken123456"})
        assert r.status_code == 401
        assert "Invalid or expired token" in r.json()["detail"]

    def test_auth_me_missing_header_returns_401(self, unauth_client):
        r = unauth_client.get("/api/auth/me")
        assert r.status_code == 401
        assert "Authentication required" in r.json()["detail"]

    def test_auth_me_malformed_header_returns_401(self, unauth_client):
        r = unauth_client.get("/api/auth/me", headers={"Authorization": "NotBearer token"})
        assert r.status_code == 401


class TestAuthEnforcementExtra:
    """Additional auth enforcement edge cases."""

    def test_checkin_get_unauth_returns_401(self, unauth_client):
        r = unauth_client.get("/api/checkins/1")
        assert r.status_code == 401

    def test_checkin_update_unauth_returns_401(self, unauth_client):
        r = unauth_client.put("/api/checkins/1", json={"notes": "x"})
        assert r.status_code == 401

    def test_checkin_delete_unauth_returns_401(self, unauth_client):
        r = unauth_client.delete("/api/checkins/1")
        assert r.status_code == 401

    def test_auth_me_public(self, auth_client):
        """Authenticated users can access /api/auth/me."""
        r = auth_client.get("/api/auth/me")
        assert r.status_code == 200
        assert r.json()["username"] == "admin"


# ─── Phase E: Role-Based Access Tests ──────────────────────────────────────


class TestRoleBasedAccess:
    """Verify role system — user roles, auth/me, and cross-role access."""

    @staticmethod
    def _login_as(client, username: str, password: str = "testpass123"):
        """Create a user (if needed), login, and return _AuthTestClient wrapper + role."""
        # Try to create the user (may already exist from seed)
        import importlib, sys
        r = client.post("/api/users", json={
            "username": username, "password": password,
            "role": "technician",
        })
        # Login
        r = client.post("/api/auth/login", json={"username": username, "password": password})
        if r.status_code != 200:
            # User might exist with a different password — try admin default
            r = client.post("/api/auth/login", json={"username": username, "password": "changeme"})
        token = r.json()["token"]
        auth_headers = {"Authorization": f"Bearer {token}"}
        # Get role from /api/auth/me
        r2 = client.get("/api/auth/me", headers=auth_headers)
        role = r2.json()["role"]
        wrapper = _AuthTestClient(client, auth_headers)
        return wrapper, role

    # ── role validation ──

    def test_create_user_with_valid_roles(self, client):
        """Users can be created with admin, technician, or readonly role."""
        for role in ("admin", "technician", "readonly"):
            r = client.post("/api/users", json={
                "username": f"roletest_{role}",
                "password": "test123",
                "role": role,
            })
            assert r.status_code == 201, f"Failed for role={role}: {r.json()}"
            assert r.json()["role"] == role

    def test_create_user_invalid_role_rejected(self, client):
        """Invalid roles should be rejected."""
        r = client.post("/api/users", json={
            "username": "badrole",
            "password": "test123",
            "role": "superadmin",
        })
        assert r.status_code == 422
        assert "Invalid role" in r.json()["detail"]

    def test_create_user_defaults_to_technician(self, client):
        """Users without explicit role default to technician."""
        r = client.post("/api/users", json={
            "username": "defaultrole",
            "password": "test123",
        })
        assert r.status_code == 201
        assert r.json()["role"] == "technician"

    def test_update_user_role(self, client):
        """Updating a user's role works."""
        r = client.post("/api/users", json={
            "username": "roleupdateme",
            "password": "test123",
            "role": "technician",
        })
        uid = r.json()["id"]
        r = client.put(f"/api/users/{uid}", json={"role": "admin"})
        assert r.status_code == 200
        assert r.json()["role"] == "admin"

    def test_update_user_invalid_role_rejected(self, client):
        """Updating to an invalid role should be rejected."""
        r = client.post("/api/users", json={
            "username": "badupdaterole",
            "password": "test123",
        })
        uid = r.json()["id"]
        r = client.put(f"/api/users/{uid}", json={"role": "bogus"})
        assert r.status_code == 422
        assert "Invalid role" in r.json()["detail"]

    # ── auth/me returns correct role ──

    def test_auth_me_returns_admin_role(self, client):
        """auth/me returns role='admin' for admin users."""
        r = client.post("/api/users", json={
            "username": "authme_admin", "password": "test123", "role": "admin",
        })
        r = client.post("/api/auth/login", json={"username": "authme_admin", "password": "test123"})
        token = r.json()["token"]
        r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200
        assert r.json()["role"] == "admin"
        assert r.json()["username"] == "authme_admin"

    def test_auth_me_returns_technician_role(self, client):
        """auth/me returns role='technician' for technician users."""
        r = client.post("/api/users", json={
            "username": "authme_tech", "password": "test123", "role": "technician",
        })
        r = client.post("/api/auth/login", json={"username": "authme_tech", "password": "test123"})
        token = r.json()["token"]
        r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200
        assert r.json()["role"] == "technician"

    def test_auth_me_returns_readonly_role(self, client):
        """auth/me returns role='readonly' for readonly users."""
        r = client.post("/api/users", json={
            "username": "authme_ro", "password": "test123", "role": "readonly",
        })
        r = client.post("/api/auth/login", json={"username": "authme_ro", "password": "test123"})
        token = r.json()["token"]
        r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200
        assert r.json()["role"] == "readonly"

    # ── all roles can access protected endpoints ──

    def test_readonly_user_can_access_assets(self, client):
        """Readonly users can access protected GET endpoints."""
        r = client.post("/api/users", json={
            "username": "ro_access", "password": "test123", "role": "readonly",
        })
        r = client.post("/api/auth/login", json={"username": "ro_access", "password": "test123"})
        token = r.json()["token"]
        r = client.get("/api/assets", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200

    def test_technician_user_can_access_assets(self, client):
        """Technician users can access protected endpoints."""
        r = client.post("/api/users", json={
            "username": "tech_access", "password": "test123", "role": "technician",
        })
        r = client.post("/api/auth/login", json={"username": "tech_access", "password": "test123"})
        token = r.json()["token"]
        r = client.get("/api/assets", headers={"Authorization": f"Bearer {token}"})
        assert r.status_code == 200

    # ── role stored correctly in user list ──

    def test_list_users_includes_role(self, client):
        """User listing includes the role field."""
        r = client.post("/api/users", json={
            "username": "listrole", "password": "test123", "role": "admin",
        })
        r = client.get("/api/users")
        users = r.json()
        list_user = [u for u in users if u["username"] == "listrole"]
        assert len(list_user) == 1
        assert list_user[0]["role"] == "admin"

    # ── password_hash never leaked ──

    def test_auth_me_never_leaks_password_hash(self, client):
        """auth/me must not expose password_hash regardless of role."""
        for role in ("admin", "technician", "readonly"):
            username = f"noleak_{role}"
            r = client.post("/api/users", json={
                "username": username, "password": "test123", "role": role,
            })
            r = client.post("/api/auth/login", json={"username": username, "password": "test123"})
            token = r.json()["token"]
            r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"})
            assert "password_hash" not in r.json(), f"password_hash leaked for role={role}"

    def test_list_users_never_leaks_password_hash(self, client):
        """User listing must not expose password_hash."""
        r = client.get("/api/users")
        for user in r.json():
            assert "password_hash" not in user, f"password_hash leaked for {user.get('username')}"


# ─── Geolocation: lat/lng columns and geofence_radius_meters ──────────────


def test_create_asset_with_latlng(client):
    resp = client.post("/api/assets", json={
        "machine_id": "LATLNG-000001",
        "name": "Geo Fridge",
        "category": "Appliances",
        "latitude": 40.7128,
        "longitude": -74.0060,
        "geofence_radius_meters": 150,
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["latitude"] == 40.7128
    assert data["longitude"] == -74.0060
    assert data["geofence_radius_meters"] == 150


def test_update_asset_latlng(client):
    # Create first
    resp = client.post("/api/assets", json={
        "machine_id": "LATLNG-000002",
        "name": "Updatable Geo",
        "category": "Equipment",
    })
    assert resp.status_code == 201
    asset_id = resp.json()["id"]
    # Update
    resp = client.put(f"/api/assets/{asset_id}", json={
        "latitude": 34.0522,
        "longitude": -118.2437,
        "geofence_radius_meters": 200,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert data["latitude"] == 34.0522
    assert data["longitude"] == -118.2437
    assert data["geofence_radius_meters"] == 200


def test_create_location_with_latlng(client):
    # Need a customer first
    resp = client.post("/api/customers", json={"name": "Geo Customer"})
    assert resp.status_code == 201
    cust_id = resp.json()["id"]

    resp = client.post("/api/locations", json={
        "customer_id": cust_id,
        "name": "Geo Location",
        "latitude": 51.5074,
        "longitude": -0.1278,
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["latitude"] == 51.5074
    assert data["longitude"] == -0.1278


def test_update_location_latlng(client):
    resp = client.post("/api/customers", json={"name": "Geo Customer 2"})
    cust_id = resp.json()["id"]
    resp = client.post("/api/locations", json={
        "customer_id": cust_id,
        "name": "Updatable Location",
    })
    loc_id = resp.json()["id"]

    resp = client.put(f"/api/locations/{loc_id}", json={
        "latitude": 48.8566,
        "longitude": 2.3522,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert data["latitude"] == 48.8566
    assert data["longitude"] == 2.3522


def test_asset_latlng_defaults(client):
    resp = client.post("/api/assets", json={
        "machine_id": "LATLNG-000005",
        "name": "No Coords Asset",
        "category": "Other",
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["latitude"] is None
    assert data["longitude"] is None
    assert data["geofence_radius_meters"] == 50  # default


def test_location_latlng_defaults(client):
    resp = client.post("/api/customers", json={"name": "Default Cust"})
    cust_id = resp.json()["id"]
    resp = client.post("/api/locations", json={
        "customer_id": cust_id,
        "name": "Default Location",
    })
    assert resp.status_code == 201
    data = resp.json()
    assert data["latitude"] is None
    assert data["longitude"] is None


def test_get_asset_returns_latlng(client):
    resp = client.post("/api/assets", json={
        "machine_id": "LATLNG-GET-001",
        "name": "GET Test Geo",
        "category": "Furniture",
        "latitude": -33.8688,
        "longitude": 151.2093,
        "geofence_radius_meters": 75,
    })
    asset_id = resp.json()["id"]

    resp = client.get(f"/api/assets/{asset_id}")
    assert resp.status_code == 200
    data = resp.json()
    assert data["latitude"] == -33.8688
    assert data["longitude"] == 151.2093
    assert data["geofence_radius_meters"] == 75


# ─── Phase 0.2: Proximity API Tests ──────────────────────────────────────────


def test_proximity_returns_nearby_assets(client):
    """Assets with coordinates within radius should be returned, sorted by distance."""
    # Seed asset near NYC
    resp = client.post("/api/assets", json={
        "machine_id": "PROX-000001",
        "name": "Nearby Fridge",
        "category": "Appliances",
        "latitude": 40.7128, "longitude": -74.0060,
        "geofence_radius_meters": 100,
    })
    assert resp.status_code == 201

    # Seed asset ~10km away
    resp = client.post("/api/assets", json={
        "machine_id": "PROX-000002",
        "name": "Far Freezer",
        "category": "Appliances",
        "latitude": 40.8000, "longitude": -74.1000,
        "geofence_radius_meters": 100,
    })
    assert resp.status_code == 201

    # Query from near the first asset
    resp = client.get("/api/proximity?lat=40.7129&lng=-74.0061&radius_meters=200")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) >= 1
    assert data[0]["machine_id"] == "PROX-000001"  # Closest first
    assert "distance_meters" in data[0]
    assert data[0]["distance_meters"] < 200


def test_proximity_no_nearby_assets(client):
    """No assets within radius returns empty list."""
    resp = client.post("/api/assets", json={
        "machine_id": "PROX-EMPTY-001",
        "name": "Lonely Asset",
        "category": "Equipment",
        "latitude": 40.7128, "longitude": -74.0060,
    })
    assert resp.status_code == 201

    # Query from far away (London)
    resp = client.get("/api/proximity?lat=51.5074&lng=-0.1278&radius_meters=100")
    assert resp.status_code == 200
    data = resp.json()
    assert data == []


def test_proximity_missing_params(client):
    """Missing lat/lng returns 422."""
    resp = client.get("/api/proximity?lat=40.7")
    assert resp.status_code == 422
    resp = client.get("/api/proximity?lng=-74.0")
    assert resp.status_code == 422


def test_proximity_invalid_radius(client):
    """radius_meters below 1 or above 50000 returns 422."""
    resp = client.get("/api/proximity?lat=40.7&lng=-74.0&radius_meters=0")
    assert resp.status_code == 422
    resp = client.get("/api/proximity?lat=40.7&lng=-74.0&radius_meters=50001")
    assert resp.status_code == 422


def test_proximity_excludes_null_coordinates(client):
    """Assets without lat/lng should be excluded from proximity results."""
    resp = client.post("/api/assets", json={
        "machine_id": "PROX-NULL-001",
        "name": "No Coords Asset",
        "category": "Other",
    })
    assert resp.status_code == 201

    resp = client.get("/api/proximity?lat=40.7128&lng=-74.0060&radius_meters=50000")
    assert resp.status_code == 200
    data = resp.json()
    machine_ids = [a["machine_id"] for a in data]
    assert "PROX-NULL-001" not in machine_ids


# ─── Phase 0.3: Geofence Point-Check Tests ───────────────────────────────────


def _create_geofence(client, name, points, color="#3388ff"):
    resp = client.post("/api/geofences", json={
        "name": name,
        "points": points,
        "color": color,
    })
    assert resp.status_code == 201
    return resp.json()["id"]


def test_geofence_point_check_inside(client):
    """Point inside polygon returns the geofence."""
    # Square around NYC
    points = [
        {"lat": 40.70, "lng": -74.02},
        {"lat": 40.70, "lng": -73.98},
        {"lat": 40.74, "lng": -73.98},
        {"lat": 40.74, "lng": -74.02},
    ]
    _create_geofence(client, "NYC Zone", points)

    # Point inside the square
    resp = client.post("/api/geofences/check", json={
        "lat": 40.72, "lng": -74.00,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) >= 1
    assert data[0]["name"] == "NYC Zone"


def test_geofence_point_check_outside(client):
    """Point outside polygon returns empty."""
    points = [
        {"lat": 40.70, "lng": -74.02},
        {"lat": 40.70, "lng": -73.98},
        {"lat": 40.74, "lng": -73.98},
        {"lat": 40.74, "lng": -74.02},
    ]
    _create_geofence(client, "NYC Zone", points)

    # Point far outside (LA)
    resp = client.post("/api/geofences/check", json={
        "lat": 34.05, "lng": -118.24,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert data == []


def test_geofence_point_check_empty(client):
    """No geofences defined returns empty list."""
    resp = client.post("/api/geofences/check", json={
        "lat": 40.72, "lng": -74.00,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert data == []


def test_geofence_point_check_multiple_matches(client):
    """Point inside overlapping geofences returns all matches."""
    points1 = [
        {"lat": 40.70, "lng": -74.02},
        {"lat": 40.70, "lng": -73.98},
        {"lat": 40.74, "lng": -73.98},
        {"lat": 40.74, "lng": -74.02},
    ]
    points2 = [
        {"lat": 40.71, "lng": -74.01},
        {"lat": 40.71, "lng": -73.99},
        {"lat": 40.73, "lng": -73.99},
        {"lat": 40.73, "lng": -74.01},
    ]
    _create_geofence(client, "Outer Zone", points1)
    _create_geofence(client, "Inner Zone", points2)

    resp = client.post("/api/geofences/check", json={
        "lat": 40.72, "lng": -74.00,
    })
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 2
    names = {g["name"] for g in data}
    assert names == {"Outer Zone", "Inner Zone"}


# ─── Helpers for upload / OCR tests ───────────────────────────────────────

import io
import struct
import zlib

def _minimal_png_bytes() -> bytes:
    """Return bytes of a minimal 1×1 white PNG (valid, ~68 bytes)."""
    def _chunk(ctype: bytes, data: bytes) -> bytes:
        c = ctype + data
        return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)

    ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
    idat = zlib.compress(b"\x00" + b"\xff\xff\xff")  # filter-byte + white pixel
    return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")

PNG_BYTES = _minimal_png_bytes()


def _make_jpeg_bytes() -> bytes:
    """Return bytes of a minimal JPEG (valid, ~631 bytes)."""
    from PIL import Image as PILImage
    buf = io.BytesIO()
    img = PILImage.new("RGB", (1, 1), color=(255, 255, 255))
    img.save(buf, format="JPEG")
    return buf.getvalue()


def _oversized_bytes(mb: int) -> bytes:
    """Return a byte-string larger than *mb* megabytes."""
    return b"x" * (mb * 1024 * 1024 + 1)


# ─── Phase F: File Uploads ────────────────────────────────────────────────


class TestIconUpload:
    """POST /api/upload/icon — icon file upload endpoint."""

    def test_upload_png_icon_returns_201(self, client):
        r = client.post(
            "/api/upload/icon",
            files={"file": ("icon.png", io.BytesIO(PNG_BYTES), "image/png")},
        )
        assert r.status_code == 201
        data = r.json()
        assert "path" in data
        assert data["path"].startswith("/uploads/icons/")
        assert data["path"].endswith(".png")

    def test_upload_jpg_icon_returns_201(self, client):
        jpg_bytes = _make_jpeg_bytes()
        r = client.post(
            "/api/upload/icon",
            files={"file": ("icon.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
        )
        assert r.status_code == 201
        assert r.json()["path"].startswith("/uploads/icons/")

    def test_upload_svg_icon_returns_201(self, client):
        svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40"/></svg>'
        r = client.post(
            "/api/upload/icon",
            files={"file": ("icon.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
        )
        assert r.status_code == 201
        assert r.json()["path"].endswith(".svg")

    def test_upload_icon_rejects_gif_extension(self, client):
        r = client.post(
            "/api/upload/icon",
            files={"file": ("icon.gif", io.BytesIO(PNG_BYTES), "image/gif")},
        )
        assert r.status_code == 400
        assert "Invalid file type" in r.json()["detail"]

    def test_upload_icon_rejects_no_extension(self, client):
        r = client.post(
            "/api/upload/icon",
            files={"file": ("icon", io.BytesIO(PNG_BYTES), "application/octet-stream")},
        )
        assert r.status_code == 400

    def test_upload_icon_rejects_oversized_file(self, client):
        """ICON_MAX_SIZE = 2 MB; send >2 MB."""
        big = _oversized_bytes(3)
        r = client.post(
            "/api/upload/icon",
            files={"file": ("big.png", io.BytesIO(big), "image/png")},
        )
        assert r.status_code == 413
        assert "too large" in r.json()["detail"].lower()

    def test_upload_icon_file_saved_to_disk(self, client):
        """Uploaded file must exist on disk under UPLOADS_DIR/icons/."""
        r = client.post(
            "/api/upload/icon",
            files={"file": ("disk.png", io.BytesIO(PNG_BYTES), "image/png")},
        )
        assert r.status_code == 201
        rel_path = r.json()["path"]           # e.g. /uploads/icons/abc123.png
        from pathlib import Path
        from server import UPLOADS_DIR
        abs_path = UPLOADS_DIR / Path(rel_path).relative_to("/uploads")
        assert abs_path.exists(), f"Expected file at {abs_path}"
        assert abs_path.read_bytes() == PNG_BYTES


class TestPhotoUpload:
    """POST /api/upload/photo — photo file upload endpoint."""

    def test_upload_png_photo_returns_201(self, client):
        r = client.post(
            "/api/upload/photo",
            files={"file": ("photo.png", io.BytesIO(PNG_BYTES), "image/png")},
        )
        assert r.status_code == 201
        data = r.json()
        assert "path" in data
        assert data["path"].startswith("/uploads/photos/")

    def test_upload_jpg_photo_returns_201(self, client):
        jpg_bytes = _make_jpeg_bytes()
        r = client.post(
            "/api/upload/photo",
            files={"file": ("photo.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
        )
        assert r.status_code == 201
        assert r.json()["path"].startswith("/uploads/photos/")

    def test_upload_photo_rejects_svg_extension(self, client):
        """SVG is not allowed for photos (photo is raster-only)."""
        svg_bytes = b"<svg></svg>"
        r = client.post(
            "/api/upload/photo",
            files={"file": ("photo.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
        )
        assert r.status_code == 400
        assert "Invalid file type" in r.json()["detail"]

    def test_upload_photo_rejects_gif_extension(self, client):
        r = client.post(
            "/api/upload/photo",
            files={"file": ("photo.gif", io.BytesIO(PNG_BYTES), "image/gif")},
        )
        assert r.status_code == 400

    def test_upload_photo_rejects_no_extension(self, client):
        r = client.post(
            "/api/upload/photo",
            files={"file": ("photo", io.BytesIO(PNG_BYTES), "application/octet-stream")},
        )
        assert r.status_code == 400

    def test_upload_photo_rejects_oversized_file(self, client):
        """PHOTO_MAX_SIZE = 10 MB; send >10 MB."""
        big = _oversized_bytes(12)
        r = client.post(
            "/api/upload/photo",
            files={"file": ("big.jpg", io.BytesIO(big), "image/jpeg")},
        )
        assert r.status_code == 413
        assert "too large" in r.json()["detail"].lower()

    def test_upload_photo_file_saved_to_disk(self, client):
        jpg_bytes = _make_jpeg_bytes()
        r = client.post(
            "/api/upload/photo",
            files={"file": ("disk.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
        )
        assert r.status_code == 201
        rel_path = r.json()["path"]           # e.g. /uploads/photos/abc123.jpg
        from pathlib import Path
        from server import UPLOADS_DIR
        abs_path = UPLOADS_DIR / Path(rel_path).relative_to("/uploads")
        assert abs_path.exists(), f"Expected file at {abs_path}"
        assert abs_path.read_bytes() == jpg_bytes


# ─── Phase F: OCR Endpoint ────────────────────────────────────────────────


class TestOCR:
    """POST /api/ocr — sticker OCR endpoint."""

    def _make_ocr_image(self, text: str) -> io.BytesIO:
        """Create a PNG image with *text* drawn on it, readable by Tesseract."""
        from PIL import Image as PILImage, ImageDraw, ImageFont
        img = PILImage.new("L", (600, 120), color=255)  # white background, grayscale
        draw = ImageDraw.Draw(img)
        # Use a clear bold font for reliable OCR
        try:
            font = ImageFont.truetype("DejaVuSans-Bold.ttf", size=28)
        except OSError:
            font = ImageFont.load_default()
        draw.text((10, 30), text, fill=0, font=font)
        buf = io.BytesIO()
        img.save(buf, format="PNG")
        buf.seek(0)
        return buf

    def test_ocr_extracts_machine_id_pattern(self, client):
        """Image containing '12345-678901' should return high-confidence match."""
        buf = self._make_ocr_image("Machine ID: 12345-678901")
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker.png", buf, "image/png")},
        )
        assert r.status_code == 200
        data = r.json()
        assert data["machine_id"] == "12345-678901"
        assert data["confidence"] == "high"
        assert "raw_text" in data

    def test_ocr_loose_match_fallback(self, client):
        """Image with 5+ digits but no hyphen pattern gets low confidence."""
        buf = self._make_ocr_image("Serial: 12345678")
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker.png", buf, "image/png")},
        )
        assert r.status_code == 200
        data = r.json()
        assert data["machine_id"] == "12345678"
        assert data["confidence"] == "low"

    def test_ocr_no_match_returns_none(self, client):
        """Image with no digit patterns returns confidence=none."""
        buf = self._make_ocr_image("Hello World!")
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker.png", buf, "image/png")},
        )
        assert r.status_code == 200
        data = r.json()
        assert data["machine_id"] is None
        assert data["confidence"] == "none"
        assert "detail" in data

    def test_ocr_with_hyphen_but_no_digits(self, client):
        """Image with hyphen but no digit pattern returns no match."""
        buf = self._make_ocr_image("ABC-DEFGHI")
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker.png", buf, "image/png")},
        )
        assert r.status_code == 200
        data = r.json()
        # No digits -> no match
        assert data["confidence"] == "none"

    def test_ocr_rejects_invalid_extension(self, client):
        """Non-image extensions like .txt are rejected with 400."""
        r = client.post(
            "/api/ocr",
            files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")},
        )
        assert r.status_code == 400
        assert "Unsupported image format" in r.json()["detail"]

    def test_ocr_rejects_no_extension(self, client):
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker", io.BytesIO(PNG_BYTES), "application/octet-stream")},
        )
        assert r.status_code == 400
        assert "Unsupported image format" in r.json()["detail"]

    def test_ocr_rejects_oversized_file(self, client):
        """OCR max = 10 MB; send >10 MB."""
        big = _oversized_bytes(12)
        r = client.post(
            "/api/ocr",
            files={"file": ("big.png", io.BytesIO(big), "image/png")},
        )
        assert r.status_code == 400
        assert "too large" in r.json()["detail"].lower()

    def test_ocr_accepts_jpeg(self, client):
        """OCR should accept JPEG uploads."""
        jpg_bytes = _make_jpeg_bytes()
        r = client.post(
            "/api/ocr",
            files={"file": ("sticker.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
        )
        # It might fail OCR (no text in a blank JPEG) but should not be rejected on format
        assert r.status_code == 200
        data = r.json()
        assert data["confidence"] == "none"

    def test_ocr_handles_corrupt_image(self, client):
        """A corrupt/malformed image file should return 500."""
        r = client.post(
            "/api/ocr",
            files={"file": ("bad.png", io.BytesIO(b"this is not a PNG"), "image/png")},
        )
        assert r.status_code == 500
        assert "OCR processing failed" in r.json()["detail"]
