"""Tests for M7-T05: Meter CRUD + swap declaration + retroactive recompute API.

Coverage matrix
---------------
GET  /api/energy/meters
  - unauthenticated → 401
  - authenticated, no meters → 200, items=[]
  - after declaring meters → items ordered by started_at asc

POST /api/energy/meters
  - unauthenticated → 401
  - missing CSRF    → 403
  - valid (first meter, no active) → 201, MeterResponse
  - valid swap (active meter exists) → 201, old meter closed, new meter active
  - retroactive started_at → 201, triggers recompute for affected window
  - started_at before active meter's started_at (overlap) → 422

PATCH /api/energy/meters/{id}
  - unauthenticated → 401
  - missing CSRF    → 403
  - not found       → 404
  - rename label    → 200, label updated
  - edit note       → 200, note updated
  - correct started_at (retroactive) → 200, triggers recompute for affected window
  - started_at interval violation → 422

Retroactive recompute integration
  - PATCH started_at change triggers recompute over min(old, new)..now window
  - POST retroactive declaration triggers recompute from new started_at
"""

from __future__ import annotations

from datetime import UTC, datetime
from unittest.mock import patch

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

from app.models.energy import Meter

# ---------------------------------------------------------------------------
# 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 _declare_payload(**overrides) -> dict:
    base = {
        "label": "Test Meter",
        "started_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat(),
        "reason": "initial",
        "commodity": "electricity",
    }
    base.update(overrides)
    return base


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


@pytest.fixture(autouse=True)
def mock_publish_discovery():
    """Auto-mock publish_discovery for all tests in this module.

    The meters API now calls _trigger_discovery_republish (best-effort) after
    every successful write.  publish_discovery is lazy-imported inside that
    helper, so we patch it at its canonical source path
    (app.services.ha_discovery.publish_discovery).  Tests that need to assert
    the call receive this fixture explicitly; all others benefit from the
    isolation it provides (no live MQTT broker required).
    """
    with patch(
        "app.services.ha_discovery.publish_discovery",
        return_value=None,
    ) as mock:
        yield mock


@pytest.fixture()
def meters_client(auth_database):
    """TestClient + SQLAlchemy engine for Meter 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()


# ---------------------------------------------------------------------------
# GET /api/energy/meters
# ---------------------------------------------------------------------------


def test_list_meters_unauthenticated_returns_401(meters_client):
    client, _ = meters_client
    resp = client.get("/api/energy/meters")
    assert resp.status_code == 401


def test_list_meters_empty(meters_client):
    client, _ = meters_client
    _login(client)
    resp = client.get("/api/energy/meters")
    assert resp.status_code == 200
    body = resp.json()
    assert body["items"] == []
    assert body["total"] == 0


def test_list_meters_ordered_by_started_at(meters_client):
    """After declaring two meters, list returns them in ascending started_at order."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        # Declare first meter (initial)
        resp1 = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp1.status_code == 201

        # Declare swap meter
        resp2 = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp2.status_code == 201

    resp = client.get("/api/energy/meters")
    assert resp.status_code == 200
    body = resp.json()
    assert body["total"] == 2
    items = body["items"]
    # Ordered by started_at ascending: Meter A first, Meter B second
    assert items[0]["label"] == "Meter A"
    assert items[1]["label"] == "Meter B"
    # Meter B is active (ended_at is null)
    assert items[1]["ended_at"] is None
    # Meter A is closed
    assert items[0]["ended_at"] is not None


# ---------------------------------------------------------------------------
# POST /api/energy/meters
# ---------------------------------------------------------------------------


def test_declare_meter_unauthenticated_returns_401(meters_client):
    client, _ = meters_client
    resp = client.post(
        "/api/energy/meters",
        json=_declare_payload(),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_declare_meter_missing_csrf_returns_403(meters_client):
    client, _ = meters_client
    _login(client)
    resp = client.post("/api/energy/meters", json=_declare_payload())
    assert resp.status_code == 403


def test_declare_meter_first_no_active(meters_client):
    """Declaring the first meter succeeds without closing any previous meter."""
    client, engine = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="First Meter", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
    assert resp.status_code == 201
    body = resp.json()
    assert body["label"] == "First Meter"
    assert body["commodity"] == "electricity"
    assert body["reason"] == "initial"
    assert body["ended_at"] is None  # active

    # DB check: one meter, active
    with Session(engine) as s:
        meters = s.execute(select(Meter)).scalars().all()
    assert len(meters) == 1
    assert meters[0].ended_at is None


def test_declare_meter_swap_closes_previous(meters_client):
    """Declaring a swap closes the previous active meter at started_at."""
    client, engine = meters_client
    _login(client)

    t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2025, 3, 15, 12, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        # First meter
        resp1 = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Old Meter", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp1.status_code == 201
        old_id = resp1.json()["id"]

        # Swap
        resp2 = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="New Meter", started_at=t1.isoformat(), reason="meter_swap"),
            headers={"X-CSRF-Token": _CSRF},
        )
    assert resp2.status_code == 201
    body2 = resp2.json()
    assert body2["label"] == "New Meter"
    assert body2["ended_at"] is None  # new meter is active

    # DB check: old meter is closed at t1
    with Session(engine) as s:
        old_meter = s.get(Meter, old_id)
        assert old_meter is not None
        assert old_meter.ended_at is not None
        # ended_at should equal t1 (modulo naive/aware round-trip)
        ended_naive = old_meter.ended_at
        if ended_naive.tzinfo is None:
            ended_naive = ended_naive.replace(tzinfo=UTC)
        assert ended_naive == t1


def test_declare_meter_overlap_returns_422(meters_client):
    """Declaring a meter with started_at before active meter's started_at → 422."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
    t_before = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        # Declare first meter
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 201

    # Attempt to declare a meter before t0 → overlap error
    # (t_before is in the past so would trigger recompute, but service layer raises first)
    resp2 = client.post(
        "/api/energy/meters",
        json=_declare_payload(
            label="Backdated Meter", started_at=t_before.isoformat(), reason="meter_swap"
        ),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp2.status_code == 422
    assert "started_at" in resp2.json()["detail"].lower()


def test_declare_meter_missing_fields_returns_422(meters_client):
    """Missing required fields (started_at, reason) → 422 from Pydantic validation."""
    client, _ = meters_client
    _login(client)

    resp = client.post(
        "/api/energy/meters",
        json={"label": "No Reason Meter"},  # missing started_at and reason
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 422


def test_declare_meter_response_fields(meters_client):
    """POST response contains all expected MeterResponse fields."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(
                label="Full Fields Meter",
                started_at=t0.isoformat(),
                reason="home_move",
                note="Testing note",
            ),
            headers={"X-CSRF-Token": _CSRF},
        )
    assert resp.status_code == 201
    body = resp.json()
    for field in ("id", "label", "commodity", "started_at", "ended_at", "reason", "note", "created_at"):
        assert field in body, f"Missing field {field!r} in response"
    assert body["note"] == "Testing note"
    assert body["reason"] == "home_move"


def test_declare_meter_retroactive_triggers_recompute(meters_client):
    """Declaring a meter with started_at in the past triggers recompute_range.

    Both POST calls are made with recompute_range mocked so the test does not
    spend time iterating over thousands of empty quarter-hour periods.
    """
    client, _ = meters_client
    _login(client)

    t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
    t_past = datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC)

    with patch(
        "app.api.routes.api.meters.recompute_range", return_value=5
    ) as mock_recompute:
        # First meter (initial); also in the past, so recompute is called here too.
        client.post(
            "/api/energy/meters",
            json=_declare_payload(started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        # Reset call count before the swap we are actually testing.
        mock_recompute.reset_mock()

        # Retroactive swap: started_at in the past → should trigger recompute
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(
                label="Retroactive Swap", started_at=t_past.isoformat(), reason="meter_swap"
            ),
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 201
        # recompute_range should have been called with start == t_past
        assert mock_recompute.called
        call_args = mock_recompute.call_args
        recompute_start = call_args[0][1]  # positional arg index 1 (session is 0)
        # Normalise for comparison
        if recompute_start.tzinfo is None:
            recompute_start = recompute_start.replace(tzinfo=UTC)
        assert recompute_start <= t_past


# ---------------------------------------------------------------------------
# PATCH /api/energy/meters/{id}
# ---------------------------------------------------------------------------


def test_patch_meter_unauthenticated_returns_401(meters_client):
    client, _ = meters_client
    resp = client.patch(
        "/api/energy/meters/1",
        json={"label": "Renamed"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 401


def test_patch_meter_missing_csrf_returns_403(meters_client):
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
    meter_id = resp.json()["id"]

    resp = client.patch(f"/api/energy/meters/{meter_id}", json={"label": "Renamed"})
    assert resp.status_code == 403


def test_patch_meter_not_found_returns_404(meters_client):
    client, _ = meters_client
    _login(client)
    resp = client.patch(
        "/api/energy/meters/99999",
        json={"label": "Does Not Exist"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 404


def test_patch_meter_rename_label(meters_client):
    """PATCH label updates the meter's human-readable label."""
    client, engine = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
    meter_id = resp.json()["id"]

    resp = client.patch(
        f"/api/energy/meters/{meter_id}",
        json={"label": "Updated Label"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    assert resp.json()["label"] == "Updated Label"

    # DB check
    with Session(engine) as s:
        m = s.get(Meter, meter_id)
        assert m.label == "Updated Label"


def test_patch_meter_edit_note(meters_client):
    """PATCH note updates the meter's note field."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(started_at=t0.isoformat(), note=None),
            headers={"X-CSRF-Token": _CSRF},
        )
    meter_id = resp.json()["id"]

    resp = client.patch(
        f"/api/energy/meters/{meter_id}",
        json={"note": "Added a note"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    assert resp.json()["note"] == "Added a note"


def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client):
    """PATCH started_at triggers recompute over min(old, new)..now window."""
    client, _ = meters_client
    _login(client)

    # Set up two meters: initial + swap.  All POST calls are mocked to avoid
    # running recompute over thousands of empty historical periods.
    t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        resp_b = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
            headers={"X-CSRF-Token": _CSRF},
        )
    meter_b_id = resp_b.json()["id"]

    # Correct Meter B's started_at to a slightly different past timestamp
    t1_corrected = datetime(2024, 12, 15, 0, 0, 0, tzinfo=UTC)  # earlier than t1

    with patch(
        "app.api.routes.api.meters.recompute_range", return_value=10
    ) as mock_recompute:
        resp = client.patch(
            f"/api/energy/meters/{meter_b_id}",
            json={"started_at": t1_corrected.isoformat()},
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 200
        # recompute should be triggered
        assert mock_recompute.called
        call_args = mock_recompute.call_args
        recompute_start = call_args[0][1]
        if recompute_start.tzinfo is None:
            recompute_start = recompute_start.replace(tzinfo=UTC)
        # Window start should be min(t1_corrected, t1) = t1_corrected
        assert recompute_start <= t1_corrected


def test_patch_meter_started_at_interval_violation_returns_422(meters_client):
    """PATCH started_at that would create an invalid interval → 422."""
    client, _ = meters_client
    _login(client)

    # Set up: initial meter A, then swap to B.  POST calls mocked to avoid slow recompute.
    t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp_a = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        meter_a_id = resp_a.json()["id"]

        client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
            headers={"X-CSRF-Token": _CSRF},
        )

    # Try to set Meter A's started_at to after its ended_at (t1) → interval error
    t_too_late = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC)
    resp = client.patch(
        f"/api/energy/meters/{meter_a_id}",
        json={"started_at": t_too_late.isoformat()},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 422


def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
    """PATCH that only changes label does NOT trigger recompute."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0) as mock_recompute:
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
        meter_id = resp.json()["id"]
        mock_recompute.reset_mock()

        resp = client.patch(
            f"/api/energy/meters/{meter_id}",
            json={"label": "Renamed Only"},
            headers={"X-CSRF-Token": _CSRF},
        )
        assert resp.status_code == 200
        # recompute should NOT be triggered (no started_at change)
        assert not mock_recompute.called


# ---------------------------------------------------------------------------
# Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
# ---------------------------------------------------------------------------


def test_swap_timeline_continuity(meters_client):
    """After two swaps, meter timeline is contiguous and self-consistent."""
    client, engine = meters_client
    _login(client)

    t0 = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
    t2 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)

    # Declare 3 meters in sequence.  POST calls mocked to avoid slow recompute over
    # years of empty quarter-hour periods.
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        for label, ts, reason in [
            ("M1", t0, "initial"),
            ("M2", t1, "meter_swap"),
            ("M3", t2, "meter_swap"),
        ]:
            resp = client.post(
                "/api/energy/meters",
                json=_declare_payload(label=label, started_at=ts.isoformat(), reason=reason),
                headers={"X-CSRF-Token": _CSRF},
            )
            assert resp.status_code == 201

    # Verify timeline via list endpoint
    resp = client.get("/api/energy/meters")
    items = resp.json()["items"]
    assert len(items) == 3

    # M1: [t0, t1); M2: [t1, t2); M3: [t2, None)
    m1 = next(i for i in items if i["label"] == "M1")
    m2 = next(i for i in items if i["label"] == "M2")
    m3 = next(i for i in items if i["label"] == "M3")

    assert m1["ended_at"] is not None
    assert m2["ended_at"] is not None
    assert m3["ended_at"] is None  # active

    # ended_at of M1 == started_at of M2 (contiguous)
    m1_ended = datetime.fromisoformat(m1["ended_at"]).replace(tzinfo=None)
    m2_started = datetime.fromisoformat(m2["started_at"]).replace(tzinfo=None)
    assert m1_ended == m2_started

    m2_ended = datetime.fromisoformat(m2["ended_at"]).replace(tzinfo=None)
    m3_started = datetime.fromisoformat(m3["started_at"]).replace(tzinfo=None)
    assert m2_ended == m3_started


# ---------------------------------------------------------------------------
# Reason enum validation
# ---------------------------------------------------------------------------


def test_declare_meter_invalid_reason_returns_422(meters_client):
    """Unknown reason value → 422 from Pydantic enum validation."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    resp = client.post(
        "/api/energy/meters",
        json=_declare_payload(started_at=t0.isoformat(), reason="unknown_reason_xyz"),
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 422


# ---------------------------------------------------------------------------
# Retroactive recompute & boundary update (recompute mocked) — window coverage check
# ---------------------------------------------------------------------------


def test_patch_started_at_earlier_updates_boundary(meters_client):
    """Moving started_at earlier should update the previous meter's ended_at."""
    client, engine = meters_client
    _login(client)

    t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC)
    t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    t1_earlier = datetime(2024, 12, 1, 0, 0, 0, tzinfo=UTC)

    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp_a = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"),
            headers={"X-CSRF-Token": _CSRF},
        )
        meter_a_id = resp_a.json()["id"]

        resp_b = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"),
            headers={"X-CSRF-Token": _CSRF},
        )
        meter_b_id = resp_b.json()["id"]

        # Correct Meter B's started_at to t1_earlier (moves boundary earlier)
        resp = client.patch(
            f"/api/energy/meters/{meter_b_id}",
            json={"started_at": t1_earlier.isoformat()},
            headers={"X-CSRF-Token": _CSRF},
        )
    assert resp.status_code == 200
    assert resp.json()["id"] == meter_b_id

    # DB check: Meter A's ended_at should now equal t1_earlier
    with Session(engine) as s:
        meter_a = s.get(Meter, meter_a_id)
        assert meter_a is not None
        ended = meter_a.ended_at
        if ended is not None and ended.tzinfo is None:
            ended = ended.replace(tzinfo=UTC)
        assert ended == t1_earlier


# ---------------------------------------------------------------------------
# FUE-T06: HA discovery re-publish triggered after meter writes
# ---------------------------------------------------------------------------


def test_declare_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
    """POST /api/energy/meters triggers publish_discovery after successful commit."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Discovery Meter", started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
    assert resp.status_code == 201
    # publish_discovery must have been called exactly once after the declare.
    mock_publish_discovery.assert_called_once()


def test_patch_meter_triggers_publish_discovery(meters_client, mock_publish_discovery):
    """PATCH /api/energy/meters/{id} triggers publish_discovery after successful commit."""
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with patch("app.api.routes.api.meters.recompute_range", return_value=0):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Original Label", started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
    meter_id = resp.json()["id"]
    # Reset call count: the POST above also called publish_discovery.
    mock_publish_discovery.reset_mock()

    resp = client.patch(
        f"/api/energy/meters/{meter_id}",
        json={"label": "Renamed Label"},
        headers={"X-CSRF-Token": _CSRF},
    )
    assert resp.status_code == 200
    # publish_discovery must have been called exactly once after the PATCH.
    mock_publish_discovery.assert_called_once()


def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
    """publish_discovery raising an exception must NOT cause POST declare to return 500.

    The _trigger_discovery_republish helper is best-effort: it swallows all
    exceptions so that a broken MQTT / discovery layer never breaks the API.
    """
    client, _ = meters_client
    _login(client)

    t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC)
    with (
        patch("app.api.routes.api.meters.recompute_range", return_value=0),
        patch(
            "app.services.ha_discovery.publish_discovery",
            side_effect=RuntimeError("MQTT broker unreachable"),
        ),
    ):
        resp = client.post(
            "/api/energy/meters",
            json=_declare_payload(label="Best Effort Meter", started_at=t0.isoformat()),
            headers={"X-CSRF-Token": _CSRF},
        )
    # The meter must be created successfully despite the discovery failure.
    assert resp.status_code == 201
    assert resp.json()["label"] == "Best Effort Meter"
