"""Tests for M5-T05: Modbus JSON API (app/api/routes/api/modbus.py).

Coverage:
  - GET  /api/modbus/profiles          — list profiles
  - GET  /api/modbus/devices           — list devices
  - POST /api/modbus/devices           — create device (profile validation, auth/CSRF)
  - GET  /api/modbus/devices/{uuid}    — get single device
  - PATCH /api/modbus/devices/{uuid}   — update device (enable/disable, profile validation)
  - DELETE /api/modbus/devices/{uuid}  — delete with 409 guard (app-layer, not FK)
  - GET  /api/modbus/devices/{uuid}/latest   — latest reading (found / not-found)
  - GET  /api/modbus/devices/{uuid}/readings — time-range + limit cap
  - GET  /api/modbus/devices/{uuid}/metrics  — profile metric catalogue
  - POST /api/modbus/devices/{uuid}/test     — test-read (mocked driver, not persisted)

Auth/CSRF matrix:
  - Unauthenticated read  → 401
  - Authenticated write without CSRF → 403
  - Authenticated read → 200/201/204
  - Authenticated write + CSRF → 200/201/204
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session

from app.models.modbus import ModbusDevice, ModbusReading

# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

_CSRF = "test-csrf-token"


def _login(client: TestClient) -> None:
    resp = client.post(
        "/api/auth/login",
        json={"username": "admin", "password": "test-password"},
    )
    assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"


def _create_device_payload(**overrides) -> dict[str, Any]:
    base: dict[str, Any] = {
        "friendly_name": "Test Meter",
        "host": "10.0.0.1",
        "port": 502,
        "unit_id": 1,
        "profile": "sdm120",
        "poll_interval_s": 5,
        "enabled": True,
    }
    base.update(overrides)
    return base


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture()
def modbus_client(auth_database):
    """TestClient + engine for Modbus API tests."""
    from app.main import create_app

    app_url = auth_database["app_url"]
    engine = create_engine(app_url, connect_args={"check_same_thread": False})
    fastapi_app = create_app()
    with TestClient(fastapi_app) as test_client:
        yield test_client, engine
    engine.dispose()


def _make_device(engine, **kwargs) -> ModbusDevice:
    """Insert a ModbusDevice row directly and return it."""
    now = datetime.now(UTC)
    with Session(engine) as session:
        device = ModbusDevice(
            friendly_name=kwargs.get("friendly_name", "Test Meter"),
            host=kwargs.get("host", "10.0.0.1"),
            port=kwargs.get("port", 502),
            unit_id=kwargs.get("unit_id", 1),
            profile=kwargs.get("profile", "sdm120"),
            poll_interval_s=kwargs.get("poll_interval_s", 5),
            enabled=kwargs.get("enabled", True),
            created_at=now,
            updated_at=now,
        )
        session.add(device)
        session.commit()
        session.refresh(device)
        # Detach from this session so caller can inspect plain attributes.
        device_id = device.id
        device_uuid = device.uuid
        device_friendly_name = device.friendly_name
        device_host = device.host
        device_profile = device.profile

    # Return a simple namespace-like object (uuid is what we mostly need).
    class _DeviceStub:
        id = device_id
        uuid = device_uuid
        friendly_name = device_friendly_name
        host = device_host
        profile = device_profile

    return _DeviceStub()


def _make_reading(engine, device_id: int, recorded_at: datetime, payload: dict) -> None:
    """Insert a ModbusReading row directly."""
    with Session(engine) as session:
        reading = ModbusReading(
            device_id=device_id,
            recorded_at=recorded_at,
            payload=payload,
        )
        session.add(reading)
        session.commit()


# ---------------------------------------------------------------------------
# GET /api/modbus/profiles
# ---------------------------------------------------------------------------


def test_profiles_unauthenticated_returns_401(modbus_client):
    client, _ = modbus_client
    resp = client.get("/api/modbus/profiles")
    assert resp.status_code == 401


def test_profiles_returns_list(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.get("/api/modbus/profiles")
    assert resp.status_code == 200
    body = resp.json()
    assert "profiles" in body
    assert isinstance(body["profiles"], list)
    # sdm120 profile must be present
    names = [p["name"] for p in body["profiles"]]
    assert "sdm120" in names
    # Each profile has name + description
    for p in body["profiles"]:
        assert "name" in p
        assert "description" in p


# ---------------------------------------------------------------------------
# GET /api/modbus/devices
# ---------------------------------------------------------------------------


def test_list_devices_unauthenticated_returns_401(modbus_client):
    client, _ = modbus_client
    resp = client.get("/api/modbus/devices")
    assert resp.status_code == 401


def test_list_devices_empty(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.get("/api/modbus/devices")
    assert resp.status_code == 200
    body = resp.json()
    assert body["items"] == []
    assert body["total"] == 0


def test_list_devices_returns_created_device(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.get("/api/modbus/devices")
    assert resp.status_code == 200
    body = resp.json()
    assert body["total"] == 1
    assert body["items"][0]["uuid"] == device.uuid


# ---------------------------------------------------------------------------
# POST /api/modbus/devices
# ---------------------------------------------------------------------------


def test_create_device_unauthenticated_returns_401(modbus_client):
    client, _ = modbus_client
    resp = client.post(
        "/api/modbus/devices",
        json=_create_device_payload(),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_create_device_missing_csrf_returns_403(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.post("/api/modbus/devices", json=_create_device_payload())
    assert resp.status_code == 403


def test_create_device_success(modbus_client):
    client, engine = modbus_client
    _login(client)
    resp = client.post(
        "/api/modbus/devices",
        json=_create_device_payload(),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 201
    body = resp.json()
    assert body["friendly_name"] == "Test Meter"
    assert "uuid" in body
    assert len(body["uuid"]) == 36  # uuid4 format

    # Verify row exists in DB
    with Session(engine) as session:
        devices = session.query(ModbusDevice).all()
    assert len(devices) == 1


def test_create_device_invalid_profile_returns_422(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.post(
        "/api/modbus/devices",
        json=_create_device_payload(profile="nonexistent_profile_xyz"),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 422
    assert "nonexistent_profile_xyz" in resp.json()["detail"]


# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------


def test_get_device_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}")
    assert resp.status_code == 401


def test_get_device_not_found_returns_404(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.get("/api/modbus/devices/00000000-0000-0000-0000-000000000000")
    assert resp.status_code == 404


def test_get_device_returns_correct_fields(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine, friendly_name="My Meter")
    resp = client.get(f"/api/modbus/devices/{device.uuid}")
    assert resp.status_code == 200
    body = resp.json()
    assert body["uuid"] == device.uuid
    assert body["friendly_name"] == "My Meter"
    assert body["profile"] == "sdm120"
    assert "last_poll_at" in body
    assert "last_poll_ok" in body
    assert "created_at" in body
    assert "updated_at" in body


# ---------------------------------------------------------------------------
# PATCH /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------


def test_patch_device_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"enabled": False},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_patch_device_missing_csrf_returns_403(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"enabled": False},
    )
    assert resp.status_code == 403


def test_patch_device_enable_disable(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine, enabled=True)

    # Disable
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"enabled": False},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    assert resp.json()["enabled"] is False

    # Re-enable
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"enabled": True},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    assert resp.json()["enabled"] is True


def test_patch_device_invalid_profile_returns_422(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"profile": "nonexistent_xyz"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 422


def test_patch_device_updates_correct_fields(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.patch(
        f"/api/modbus/devices/{device.uuid}",
        json={"friendly_name": "Renamed Meter", "port": 503},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["friendly_name"] == "Renamed Meter"
    assert body["port"] == 503
    # Other fields unchanged
    assert body["uuid"] == device.uuid


# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}
# ---------------------------------------------------------------------------


def test_delete_device_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_delete_device_missing_csrf_returns_403(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.delete(f"/api/modbus/devices/{device.uuid}")
    assert resp.status_code == 403


def test_delete_device_no_readings_succeeds(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 204

    # Confirm device is gone
    with Session(engine) as session:
        devices = session.query(ModbusDevice).all()
    assert len(devices) == 0


def test_delete_device_with_readings_returns_409(modbus_client):
    """Delete with existing readings must return 409 (application-layer check, not FK)."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    # Add a reading
    _make_reading(
        engine,
        device_id=device.id,
        recorded_at=datetime.now(UTC),
        payload={"voltage": 230.0},
    )

    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 409
    assert "reading" in resp.json()["detail"].lower()

    # Device still exists
    with Session(engine) as session:
        devices = session.query(ModbusDevice).all()
    assert len(devices) == 1


def test_delete_device_not_found_returns_404(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.delete(
        "/api/modbus/devices/00000000-0000-0000-0000-000000000000",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# DELETE /api/modbus/devices/{uuid}?cascade=true
# ---------------------------------------------------------------------------


def test_cascade_false_with_readings_still_returns_409(modbus_client):
    """cascade=false (default) + readings → 409, device and readings remain."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    _make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})

    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        params={"cascade": "false"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 409

    # Device and reading still in DB
    with Session(engine) as session:
        assert session.query(ModbusDevice).count() == 1
        assert session.query(ModbusReading).count() == 1


def test_cascade_true_with_readings_deletes_device_readings_and_toggles(modbus_client):
    """cascade=true + readings → device, readings, and toggle rows all deleted; 200 response."""
    from datetime import UTC
    from app.models.expose import ExposedEntityToggle

    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    # Add two readings
    now = datetime.now(UTC)
    _make_reading(engine, device.id, now, {"voltage": 230.0})
    _make_reading(engine, device.id, now - timedelta(minutes=1), {"voltage": 229.0})

    # Add an ExposedEntityToggle row for this device
    with Session(engine) as session:
        toggle = ExposedEntityToggle(
            key=f"modbus.{device.uuid}.voltage",
            enabled=True,
            updated_at=now,
        )
        session.add(toggle)
        # Add one toggle for a different device to verify isolation
        other_toggle = ExposedEntityToggle(
            key="modbus.other-uuid.voltage",
            enabled=True,
            updated_at=now,
        )
        session.add(other_toggle)
        session.commit()

    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        params={"cascade": "true"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["deleted"] is True
    assert body["readings_deleted"] == 2
    assert body["toggles_deleted"] == 1

    # All rows for this device must be gone
    with Session(engine) as session:
        assert session.query(ModbusDevice).count() == 0
        assert session.query(ModbusReading).count() == 0
        # Only the other device's toggle remains
        remaining_toggles = session.query(ExposedEntityToggle).all()
        assert len(remaining_toggles) == 1
        assert remaining_toggles[0].key == "modbus.other-uuid.voltage"


def test_cascade_true_no_readings_deletes_device(modbus_client):
    """cascade=true + no readings → device deleted, response shows 0 counts."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        params={"cascade": "true"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["deleted"] is True
    assert body["readings_deleted"] == 0
    assert body["toggles_deleted"] == 0

    with Session(engine) as session:
        assert session.query(ModbusDevice).count() == 0


def test_cascade_unauthenticated_returns_401(modbus_client):
    """Unauthenticated cascade delete request → 401."""
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        params={"cascade": "true"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_cascade_missing_csrf_returns_403(modbus_client):
    """cascade=true without CSRF → 403."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.delete(
        f"/api/modbus/devices/{device.uuid}",
        params={"cascade": "true"},
    )
    assert resp.status_code == 403


def test_cascade_mqtt_unavailable_still_deletes(modbus_client):
    """When MQTT is not connected, cascade delete must still succeed (HA cleanup is best-effort)."""
    from unittest.mock import patch

    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    _make_reading(engine, device.id, datetime.now(UTC), {"voltage": 230.0})

    # Simulate MQTT not connected so clear_device_discovery is a no-op
    with patch("app.services.ha_discovery.mqtt_manager") as mock_mqtt:
        mock_mqtt.is_connected = False

        resp = client.delete(
            f"/api/modbus/devices/{device.uuid}",
            params={"cascade": "true"},
            headers={"X-CSRF-Token": _CSRF},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["deleted"] is True
    assert body["readings_deleted"] == 1

    # DB rows gone
    with Session(engine) as session:
        assert session.query(ModbusDevice).count() == 0
        assert session.query(ModbusReading).count() == 0


# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/latest
# ---------------------------------------------------------------------------


def test_latest_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
    assert resp.status_code == 401


def test_latest_no_readings_returns_found_false(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
    assert resp.status_code == 200
    body = resp.json()
    assert body["found"] is False
    assert body["recorded_at"] is None
    assert body["payload"] is None


def test_latest_returns_most_recent_reading(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    now = datetime.now(UTC)
    earlier = now - timedelta(minutes=10)
    later = now - timedelta(minutes=1)

    _make_reading(engine, device.id, earlier, {"voltage": 228.0})
    _make_reading(engine, device.id, later, {"voltage": 232.0})

    resp = client.get(f"/api/modbus/devices/{device.uuid}/latest")
    assert resp.status_code == 200
    body = resp.json()
    assert body["found"] is True
    assert body["payload"] == {"voltage": 232.0}
    assert body["recorded_at"] is not None


# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/readings
# ---------------------------------------------------------------------------


def test_readings_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
    assert resp.status_code == 401


def test_readings_empty_returns_empty_list(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
    assert resp.status_code == 200
    assert resp.json()["items"] == []


def test_readings_returns_all_by_default_ordered_ascending(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    now = datetime.now(UTC)
    t1 = now - timedelta(minutes=30)
    t2 = now - timedelta(minutes=20)
    t3 = now - timedelta(minutes=10)

    _make_reading(engine, device.id, t3, {"voltage": 233.0})
    _make_reading(engine, device.id, t1, {"voltage": 231.0})
    _make_reading(engine, device.id, t2, {"voltage": 232.0})

    resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
    assert resp.status_code == 200
    items = resp.json()["items"]
    assert len(items) == 3
    # Ascending order
    voltages = [item["payload"]["voltage"] for item in items]
    assert voltages == [231.0, 232.0, 233.0]


def test_readings_time_window_filters_correctly(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    now = datetime.now(UTC)
    t_old = now - timedelta(hours=2)
    t_mid = now - timedelta(hours=1)
    t_new = now - timedelta(minutes=5)

    _make_reading(engine, device.id, t_old, {"voltage": 228.0})
    _make_reading(engine, device.id, t_mid, {"voltage": 230.0})
    _make_reading(engine, device.id, t_new, {"voltage": 232.0})

    # Only the last 90 minutes
    start = (now - timedelta(minutes=90)).isoformat()
    resp = client.get(
        f"/api/modbus/devices/{device.uuid}/readings",
        params={"start": start},
    )
    assert resp.status_code == 200
    items = resp.json()["items"]
    assert len(items) == 2
    voltages = {item["payload"]["voltage"] for item in items}
    assert 228.0 not in voltages


def test_readings_limit_applied(modbus_client):
    """When limit < total rows, exactly *limit* items are returned."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    now = datetime.now(UTC)
    for i in range(10):
        _make_reading(engine, device.id, now - timedelta(minutes=i), {"voltage": float(i)})

    resp = client.get(
        f"/api/modbus/devices/{device.uuid}/readings",
        params={"limit": 3},
    )
    assert resp.status_code == 200
    assert len(resp.json()["items"]) == 3


def test_readings_limit_returns_most_recent_rows_ascending(modbus_client):
    """When window rows > limit, the MOST RECENT limit rows are returned, ascending.

    This is the regression test for REWORK-1: previously the endpoint returned
    the oldest N rows (ASC + LIMIT), causing 6h/24h charts to show stale data.
    Now it returns the most recent N rows (DESC + LIMIT, then reversed to ASC).
    """
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    now = datetime.now(UTC)
    # Insert 5 readings spread over the last 5 minutes (newest first, but order shouldn't matter).
    timestamps = [now - timedelta(minutes=i) for i in range(5)]  # t0=now, t1=now-1m, ..., t4=now-4m
    for idx, ts in enumerate(timestamps):
        _make_reading(engine, device.id, ts, {"voltage": float(200 + idx)})
    # Chronologically: t4 < t3 < t2 < t1 < t0
    # Voltages mapped:  204    203   202   201   200

    # Request only the most recent 3 rows (limit=3).
    resp = client.get(
        f"/api/modbus/devices/{device.uuid}/readings",
        params={"limit": 3},
    )
    assert resp.status_code == 200
    items = resp.json()["items"]
    assert len(items) == 3

    # Response must be in ascending recorded_at order.
    recorded_ats = [item["recorded_at"] for item in items]
    assert recorded_ats == sorted(recorded_ats), "Items must be ascending by recorded_at"

    # The 3 most recent are timestamps[0], [1], [2] → voltages 200, 201, 202.
    voltages = [item["payload"]["voltage"] for item in items]
    assert voltages == [202.0, 201.0, 200.0], (
        f"Expected most recent 3 rows (voltages 200/201/202 ascending), got {voltages}"
    )

    # The oldest row (voltage 204.0, at t4=now-4m) must NOT be in the response.
    assert 204.0 not in voltages, "Oldest row must not appear when limit truncates the window"
    assert 203.0 not in voltages, "Second-oldest row must not appear when limit truncates"


def test_readings_limit_exceeds_max_returns_422(modbus_client):
    """A limit > 5000 must be rejected by FastAPI query validation (422)."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.get(
        f"/api/modbus/devices/{device.uuid}/readings",
        params={"limit": 9999},
    )
    assert resp.status_code == 422


# ---------------------------------------------------------------------------
# GET /api/modbus/devices/{uuid}/metrics
# ---------------------------------------------------------------------------


def test_metrics_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
    assert resp.status_code == 401


def test_metrics_returns_profile_catalogue(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine, profile="sdm120")
    resp = client.get(f"/api/modbus/devices/{device.uuid}/metrics")
    assert resp.status_code == 200
    body = resp.json()
    assert body["profile"] == "sdm120"
    assert isinstance(body["metrics"], list)
    assert len(body["metrics"]) > 0
    # Each metric has required fields
    for m in body["metrics"]:
        assert "key" in m
        assert "label" in m
        assert "unit" in m
        assert "device_class" in m

    # SDM120 must include voltage
    keys = [m["key"] for m in body["metrics"]]
    assert "voltage" in keys

    # Label is human-readable (not raw snake_case for "active_power")
    active_power = next((m for m in body["metrics"] if m["key"] == "active_power"), None)
    assert active_power is not None
    assert "Active Power" == active_power["label"]


def test_metrics_label_derived_from_key(modbus_client):
    """Verify the label derivation helper converts snake_case to Title Case."""
    from app.api.routes.api.modbus import _label_from_key

    assert _label_from_key("voltage") == "Voltage"
    assert _label_from_key("active_power") == "Active Power"
    assert _label_from_key("import_energy") == "Import Energy"
    assert _label_from_key("power_factor") == "Power Factor"


# ---------------------------------------------------------------------------
# POST /api/modbus/devices/{uuid}/test
# ---------------------------------------------------------------------------


def test_test_read_unauthenticated_returns_401(modbus_client):
    client, engine = modbus_client
    device = _make_device(engine)
    resp = client.post(
        f"/api/modbus/devices/{device.uuid}/test",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_test_read_missing_csrf_returns_403(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    resp = client.post(f"/api/modbus/devices/{device.uuid}/test")
    assert resp.status_code == 403


def test_test_read_success_returns_payload_and_does_not_persist(modbus_client):
    """Successful test-read returns payload and stores nothing in modbus_reading."""
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    known_payload = {"voltage": 230.2, "current": 1.3}

    with (
        patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read,
        patch("app.api.routes.api.modbus.decode_profile") as mock_decode,
    ):
        mock_read.return_value = {0x0000: 0x4366, 0x0001: 0x3334}
        mock_decode.return_value = known_payload

        resp = client.post(
            f"/api/modbus/devices/{device.uuid}/test",
            headers={"X-CSRF-Token": _CSRF},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["ok"] is True
    assert body["payload"] == known_payload
    assert body["error"] is None

    # Verify nothing was persisted
    with Session(engine) as session:
        readings = session.query(ModbusReading).all()
    assert len(readings) == 0


def test_test_read_driver_failure_returns_ok_false(modbus_client):
    """When the driver raises, test-read returns ok=False with an error message."""
    from app.integrations.modbus.driver import ModbusConnectionError

    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)

    with patch("app.api.routes.api.modbus.modbus_driver.read_blocks") as mock_read:
        mock_read.side_effect = ModbusConnectionError("Cannot reach gateway")

        resp = client.post(
            f"/api/modbus/devices/{device.uuid}/test",
            headers={"X-CSRF-Token": _CSRF},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["ok"] is False
    assert body["error"] is not None
    assert "Cannot reach gateway" in body["error"]

    # Still no readings in DB
    with Session(engine) as session:
        readings = session.query(ModbusReading).all()
    assert len(readings) == 0


def test_test_read_not_found_returns_404(modbus_client):
    client, _ = modbus_client
    _login(client)
    resp = client.post(
        "/api/modbus/devices/00000000-0000-0000-0000-000000000000/test",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 404


# ---------------------------------------------------------------------------
# Row count integrity: CRUD operations produce the right row count
# ---------------------------------------------------------------------------


def test_crud_row_counts(modbus_client):
    """Create 2 devices, verify row count; delete one (no readings), verify count drops."""
    client, engine = modbus_client
    _login(client)

    resp1 = client.post(
        "/api/modbus/devices",
        json=_create_device_payload(friendly_name="Meter A"),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp1.status_code == 201
    uuid_a = resp1.json()["uuid"]

    resp2 = client.post(
        "/api/modbus/devices",
        json=_create_device_payload(friendly_name="Meter B"),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp2.status_code == 201

    # 2 devices exist
    with Session(engine) as session:
        count = session.query(ModbusDevice).count()
    assert count == 2

    # Delete Meter A (no readings)
    resp_del = client.delete(
        f"/api/modbus/devices/{uuid_a}",
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp_del.status_code == 204

    # 1 device remains
    with Session(engine) as session:
        count = session.query(ModbusDevice).count()
    assert count == 1


def test_readings_each_item_has_recorded_at_and_payload(modbus_client):
    client, engine = modbus_client
    _login(client)
    device = _make_device(engine)
    now = datetime.now(UTC)
    _make_reading(engine, device.id, now, {"voltage": 230.0, "current": 1.5})

    resp = client.get(f"/api/modbus/devices/{device.uuid}/readings")
    assert resp.status_code == 200
    items = resp.json()["items"]
    assert len(items) == 1
    item = items[0]
    assert "recorded_at" in item
    assert "payload" in item
    assert item["payload"]["voltage"] == 230.0
