"""Tests for M5-T12: Expose API (app/api/routes/api/expose.py).

Coverage:
  - GET  /api/expose — catalog + toggle state + MQTT/Discovery status
  - PUT  /api/expose — upsert toggles; triggers discovery re-publish
  - POST /api/expose/republish — manual re-publish

Auth/CSRF matrix:
  - Unauthenticated GET → 401
  - Authenticated GET (no CSRF needed) → 200
  - Authenticated PUT without CSRF → 403
  - Authenticated PUT + CSRF → 200; toggles persisted; publish_discovery called
  - Authenticated POST /republish without CSRF → 403
  - Authenticated POST /republish + CSRF + MQTT off → 200 ok=False
  - Authenticated POST /republish + CSRF + MQTT on → 200 ok=True; publish_discovery called

Value-getter non-serialisation:
  - value_getter field is NOT present in the API response payload.

Isolation strategy:
  - MqttManager and publish_discovery are always mocked so no real broker is needed.
  - The Modbus _modbus_provider is patched away to a simple fake provider for catalog
    tests, avoiding the need for DB-registered modbus devices in every test.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

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

# ---------------------------------------------------------------------------
# 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.text}"


# ---------------------------------------------------------------------------
# A minimal fake provider that returns a fixed entity (no real DB device needed)
# ---------------------------------------------------------------------------


def _make_fake_provider(entity_key: str = "modbus.abc.voltage"):
    """Return a provider callable that yields one hard-coded ExposableEntity."""

    def _provider(session):  # noqa: ANN001
        from app.integrations.expose import DeviceInfo, ExposableEntity

        return [
            ExposableEntity(
                key=entity_key,
                component="sensor",
                device=DeviceInfo(identifiers=("modbus", "abc"), name="Test Meter"),
                device_class="voltage",
                unit="V",
                name="Test Meter Voltage",
                value_getter=lambda sess: 230.2,  # non-serialisable callable
                state_class="measurement",
            )
        ]

    return _provider


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


@pytest.fixture()
def expose_client(auth_database):
    """TestClient + engine for Expose API tests, with real DB but no real broker."""
    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()


# ---------------------------------------------------------------------------
# Helpers to insert/read toggle rows directly
# ---------------------------------------------------------------------------


def _count_toggles(engine) -> int:
    with Session(engine) as session:
        from app.models.expose import ExposedEntityToggle

        return session.query(ExposedEntityToggle).count()


def _get_toggle(engine, key: str) -> bool | None:
    with Session(engine) as session:
        from app.models.expose import ExposedEntityToggle

        row = session.query(ExposedEntityToggle).filter(ExposedEntityToggle.key == key).first()
        return None if row is None else row.enabled


# ---------------------------------------------------------------------------
# Tests: GET /api/expose
# ---------------------------------------------------------------------------


class TestGetExpose:
    def test_unauthenticated_returns_401(self, expose_client):
        client, _engine = expose_client
        resp = client.get("/api/expose")
        assert resp.status_code == 401

    def test_authenticated_returns_catalog_and_status(self, expose_client):
        client, _engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"
        with patch(
            "app.integrations.expose._REGISTRY",
            [_make_fake_provider(fake_key)],
        ):
            resp = client.get("/api/expose")

        assert resp.status_code == 200
        data = resp.json()

        # Must have 'catalog' list and 'mqtt_status' dict
        assert "catalog" in data
        assert "mqtt_status" in data

        # Default toggle is False (no row in DB)
        assert len(data["catalog"]) == 1
        entry = data["catalog"][0]
        assert entry["entity"]["key"] == fake_key
        assert entry["enabled"] is False

        # value_getter must NOT appear in the serialised entity
        assert "value_getter" not in entry["entity"]

    def test_mqtt_status_fields_present(self, expose_client):
        client, _engine = expose_client
        _login(client)

        with patch("app.integrations.expose._REGISTRY", []):
            resp = client.get("/api/expose")

        assert resp.status_code == 200
        status_data = resp.json()["mqtt_status"]
        assert "mqtt_configured" in status_data
        assert "mqtt_connected" in status_data
        assert "discovery_enabled" in status_data

    def test_entity_fields_correct(self, expose_client):
        """Verify the entity schema shape matches ExposableEntitySchema."""
        client, _engine = expose_client
        _login(client)

        with patch(
            "app.integrations.expose._REGISTRY",
            [_make_fake_provider("modbus.abc.voltage")],
        ):
            resp = client.get("/api/expose")

        entity = resp.json()["catalog"][0]["entity"]
        for field in ("key", "component", "device", "device_class", "unit", "name"):
            assert field in entity, f"Missing field: {field}"
        # device sub-fields
        assert "identifiers" in entity["device"]
        assert "name" in entity["device"]


# ---------------------------------------------------------------------------
# Tests: PUT /api/expose
# ---------------------------------------------------------------------------


class TestPutExpose:
    def test_unauthenticated_returns_401(self, expose_client):
        client, _engine = expose_client
        resp = client.put(
            "/api/expose",
            json={"toggles": {}},
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 401

    def test_missing_csrf_returns_403(self, expose_client):
        client, _engine = expose_client
        _login(client)
        resp = client.put("/api/expose", json={"toggles": {}})
        assert resp.status_code == 403

    def test_toggles_persisted_in_db(self, expose_client):
        client, engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"

        with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
            with patch("app.api.routes.api.expose._trigger_republish"):
                resp = client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: True}},
                    headers={"X-CSRF-Token": _CSRF},
                )

        assert resp.status_code == 200
        # Row inserted
        assert _get_toggle(engine, fake_key) is True

    def test_toggle_upsert(self, expose_client):
        """Second PUT on same key updates the existing row rather than inserting another."""
        client, engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"

        with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
            with patch("app.api.routes.api.expose._trigger_republish"):
                # Enable
                client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: True}},
                    headers={"X-CSRF-Token": _CSRF},
                )
                # Disable (upsert)
                resp = client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: False}},
                    headers={"X-CSRF-Token": _CSRF},
                )

        assert resp.status_code == 200
        # Still exactly one row
        assert _count_toggles(engine) == 1
        assert _get_toggle(engine, fake_key) is False

    def test_toggle_change_triggers_discovery(self, expose_client):
        """PUT on toggles calls _trigger_republish (which calls publish_discovery)."""
        client, _engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"

        with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
            with patch(
                "app.api.routes.api.expose._trigger_republish"
            ) as mock_republish:
                resp = client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: True}},
                    headers={"X-CSRF-Token": _CSRF},
                )

        assert resp.status_code == 200
        mock_republish.assert_called_once()

    def test_response_contains_updated_catalog(self, expose_client):
        """PUT response reflects the newly set toggle state."""
        client, _engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"

        with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
            with patch("app.api.routes.api.expose._trigger_republish"):
                resp = client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: True}},
                    headers={"X-CSRF-Token": _CSRF},
                )

        data = resp.json()
        assert "catalog" in data
        # The entry for fake_key should now be enabled=True
        entries = {e["entity"]["key"]: e["enabled"] for e in data["catalog"]}
        assert entries[fake_key] is True

    def test_value_getter_not_in_put_response(self, expose_client):
        """value_getter callable must not appear in the PUT response."""
        client, _engine = expose_client
        _login(client)

        fake_key = "modbus.abc.voltage"

        with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
            with patch("app.api.routes.api.expose._trigger_republish"):
                resp = client.put(
                    "/api/expose",
                    json={"toggles": {fake_key: True}},
                    headers={"X-CSRF-Token": _CSRF},
                )

        entity = resp.json()["catalog"][0]["entity"]
        assert "value_getter" not in entity


# ---------------------------------------------------------------------------
# Tests: POST /api/expose/republish
# ---------------------------------------------------------------------------


class TestPostRepublish:
    def test_unauthenticated_returns_401(self, expose_client):
        client, _engine = expose_client
        resp = client.post(
            "/api/expose/republish",
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 401

    def test_missing_csrf_returns_403(self, expose_client):
        client, _engine = expose_client
        _login(client)
        resp = client.post("/api/expose/republish")
        assert resp.status_code == 403

    def test_republish_when_mqtt_not_configured(self, expose_client):
        """When MQTT is not enabled/connected, republish returns ok=False."""
        client, _engine = expose_client
        _login(client)

        # Ensure MQTT is not configured (default in test env)
        with patch.object(
            type(
                __import__(
                    "app.integrations.mqtt", fromlist=["mqtt_manager"]
                ).mqtt_manager
            ),
            "is_connected",
            new_callable=lambda: property(lambda self: False),
        ):
            resp = client.post(
                "/api/expose/republish",
                headers={"X-CSRF-Token": _CSRF},
            )

        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is False
        assert "message" in data

    def test_republish_calls_publish_discovery_when_mqtt_on(self, expose_client):
        """When MQTT is active, republish calls publish_discovery."""
        client, _engine = expose_client
        _login(client)

        # Patch settings to enable MQTT + discovery
        mock_settings: Any = MagicMock()
        mock_settings.mqtt_enabled = True
        mock_settings.ha_discovery_enabled = True
        mock_settings.ha_discovery_prefix = "homeassistant"

        with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
            with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
                mock_mqtt.is_configured.return_value = True
                mock_mqtt.is_connected = True
                with patch(
                    "app.api.routes.api.expose._trigger_republish"
                ) as mock_republish:
                    resp = client.post(
                        "/api/expose/republish",
                        headers={"X-CSRF-Token": _CSRF},
                    )

        assert resp.status_code == 200
        data = resp.json()
        assert data["ok"] is True
        mock_republish.assert_called_once()

    def test_trigger_republish_calls_publish_discovery(self):
        """Unit test: _trigger_republish calls publish_discovery with the session."""
        from app.api.routes.api.expose import _trigger_republish

        mock_session: Any = MagicMock()

        # publish_discovery is imported inside _trigger_republish; patch it
        # at the source module so all code paths see the mock.
        with patch("app.services.ha_discovery.publish_discovery") as mock_pd:
            _trigger_republish(mock_session)
            mock_pd.assert_called_once_with(mock_session)

    def test_republish_mqtt_not_connected_returns_false(self, expose_client):
        """Republish when MQTT not connected returns ok=False (not an exception)."""
        client, _engine = expose_client
        _login(client)

        # Ensure settings say MQTT is enabled but mqtt_manager says not connected
        mock_settings: Any = MagicMock()
        mock_settings.mqtt_enabled = True
        mock_settings.ha_discovery_enabled = True

        with patch("app.api.routes.api.expose.build_runtime_settings", return_value=mock_settings):
            with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
                mock_mqtt.is_connected = False
                resp = client.post(
                    "/api/expose/republish",
                    headers={"X-CSRF-Token": _CSRF},
                )

        assert resp.status_code == 200
        assert resp.json()["ok"] is False


# ---------------------------------------------------------------------------
# Tests: device grouping in catalog
# ---------------------------------------------------------------------------


class TestCatalogGrouping:
    def test_catalog_groups_entities_by_device(self, expose_client):
        """Multiple entities from the same device share the same device info."""
        client, _engine = expose_client
        _login(client)

        def _multi_entity_provider(session):
            from app.integrations.expose import DeviceInfo, ExposableEntity

            device_info = DeviceInfo(identifiers=("modbus", "uuid-1"), name="My Meter")
            return [
                ExposableEntity(
                    key="modbus.uuid-1.voltage",
                    component="sensor",
                    device=device_info,
                    device_class="voltage",
                    unit="V",
                    name="My Meter Voltage",
                ),
                ExposableEntity(
                    key="modbus.uuid-1.current",
                    component="sensor",
                    device=device_info,
                    device_class="current",
                    unit="A",
                    name="My Meter Current",
                ),
                ExposableEntity(
                    key="modbus.uuid-1.online",
                    component="binary_sensor",
                    device=device_info,
                    device_class="connectivity",
                    unit="",
                    name="My Meter Online",
                ),
            ]

        with patch("app.integrations.expose._REGISTRY", [_multi_entity_provider]):
            resp = client.get("/api/expose")

        assert resp.status_code == 200
        catalog = resp.json()["catalog"]
        assert len(catalog) == 3

        # All entities share the same device name
        device_names = {e["entity"]["device"]["name"] for e in catalog}
        assert device_names == {"My Meter"}

        # binary_sensor is present (not-sensor component)
        components = {e["entity"]["component"] for e in catalog}
        assert "binary_sensor" in components
        assert "sensor" in components


# ---------------------------------------------------------------------------
# M5-fix2: DB-config → mqtt_status runtime assertion (Issue 2)
# ---------------------------------------------------------------------------


class TestMqttStatusReadsFromDB:
    def test_get_expose_mqtt_configured_reflects_db_value(self, expose_client):
        """GET /api/expose must return mqtt_configured=True when MQTT is enabled+configured
        in the DB (app_config), even if the bootstrap env says disabled.

        This verifies that _get_mqtt_status uses build_runtime_settings (DB-merged)
        rather than bare get_settings() (bootstrap-only).
        """
        client, engine = expose_client
        _login(client)

        # Write MQTT_ENABLED=true and MQTT_BROKER_HOST into app_config directly.
        # The lifespan seeds all config keys on startup, so we must UPDATE existing rows.
        from datetime import UTC, datetime
        from app.models.config import AppConfigEntry

        now = datetime.now(UTC)
        with Session(engine) as session:
            for key, value in [("MQTT_ENABLED", "true"), ("MQTT_BROKER_HOST", "broker.test.local")]:
                row = session.query(AppConfigEntry).filter(AppConfigEntry.key == key).first()
                if row is None:
                    session.add(AppConfigEntry(key=key, value=value, updated_at=now))
                else:
                    row.value = value
                    row.updated_at = now
            session.commit()

        # The bootstrap env does NOT have MQTT enabled (default False in Settings).
        # But GET /api/expose should still report mqtt_configured=True because it reads DB.
        with patch("app.integrations.expose._REGISTRY", []):
            resp = client.get("/api/expose")

        assert resp.status_code == 200
        mqtt_status = resp.json()["mqtt_status"]
        assert mqtt_status["mqtt_configured"] is True, (
            f"Expected mqtt_configured=True from DB settings, "
            f"got {mqtt_status['mqtt_configured']!r}. Full status: {mqtt_status}"
        )
