"""Tests for M7-T02: Meter service layer.

Coverage
--------
1. ``meter_at`` — half-open interval boundary semantics.
2. ``declare_meter`` — first declaration (no active meter), normal swap, backdate rejection.
3. Mutual exclusion — each commodity has at most one active meter after swaps.
4. Interval continuity — old meter's ended_at == new meter's started_at after swap.
5. Different commodities are independent (electricity swap doesn't touch gas meters).
6. ``list_meters`` — ordering and commodity filtering.
7. ``update_meter`` — label/note update, started_at retroactive correction with
   interval consistency, and validation errors.
8. ``update_meter`` — first-meter (no previous) retroactive started_at change.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from pathlib import Path

import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event as sa_event
from sqlalchemy.orm import Session

from app.models.energy import Meter
from app.services.meters import (
    MeterIntervalError,
    MeterOverlapError,
    declare_meter,
    list_meters,
    meter_at,
    update_meter,
)


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


def _make_app_alembic_config(database_url: str) -> Config:
    cfg = Config("alembic_app.ini")
    cfg.set_main_option("sqlalchemy.url", database_url)
    return cfg


def _engine_with_fk(db_url: str):
    """Create a SQLAlchemy engine with SQLite FK enforcement enabled."""
    engine = create_engine(db_url, connect_args={"check_same_thread": False})

    @sa_event.listens_for(engine, "connect")
    def _enable_fk(dbapi_conn, _rec):
        cursor = dbapi_conn.cursor()
        cursor.execute("PRAGMA foreign_keys = ON")
        cursor.close()

    return engine


@pytest.fixture()
def meter_db(tmp_path: Path):
    """Temporary SQLite DB upgraded to the current Alembic head with FK enforcement."""
    db_path = tmp_path / "meter_service_test.db"
    db_url = f"sqlite:///{db_path}"
    alembic_cfg = _make_app_alembic_config(db_url)
    command.upgrade(alembic_cfg, "head")
    engine = _engine_with_fk(db_url)
    yield engine
    engine.dispose()


@pytest.fixture()
def session(meter_db):
    """Provide a single SQLAlchemy session for a test, auto-rolling back on exit."""
    with Session(meter_db) as s:
        yield s
        # Tests that commit explicitly are fine; for read-only tests the context
        # manager handles cleanup.


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)  # base timestamp for tests


def _make_meter(
    session: Session,
    *,
    started_at: datetime,
    ended_at: datetime | None = None,
    label: str = "Test meter",
    commodity: str = "electricity",
    reason: str = "initial",
    note: str | None = None,
) -> Meter:
    """Insert a Meter row directly (bypassing service logic) for test setup."""
    m = Meter(
        label=label,
        commodity=commodity,
        started_at=started_at,
        ended_at=ended_at,
        reason=reason,
        note=note,
        created_at=datetime.now(UTC),
    )
    session.add(m)
    session.flush()
    return m


# ---------------------------------------------------------------------------
# 1. meter_at — half-open interval semantics
# ---------------------------------------------------------------------------


class TestMeterAt:
    def test_returns_none_when_no_meters(self, session: Session):
        """meter_at must return None when the table is empty."""
        assert meter_at(session, _T0) is None

    def test_returns_active_meter_for_ts_after_start(self, session: Session):
        """An active meter (ended_at IS NULL) covers any ts ≥ started_at."""
        m = _make_meter(session, started_at=_T0, ended_at=None)
        result = meter_at(session, _T0 + timedelta(hours=1))
        assert result is not None
        assert result.id == m.id

    def test_exact_started_at_is_inclusive(self, session: Session):
        """ts == started_at must be covered by that meter (left-closed boundary)."""
        m = _make_meter(session, started_at=_T0, ended_at=None)
        result = meter_at(session, _T0)
        assert result is not None
        assert result.id == m.id

    def test_ts_before_started_at_returns_none(self, session: Session):
        """ts < started_at must not be covered."""
        _make_meter(session, started_at=_T0, ended_at=None)
        result = meter_at(session, _T0 - timedelta(seconds=1))
        assert result is None

    def test_exact_ended_at_is_exclusive(self, session: Session):
        """ts == ended_at must NOT be covered by the closed meter (right-open boundary)."""
        t1 = _T0 + timedelta(hours=2)
        _make_meter(session, started_at=_T0, ended_at=t1, label="Old meter")
        # The new active meter starts exactly at t1.
        new = _make_meter(session, started_at=t1, ended_at=None, label="New meter")

        result = meter_at(session, t1)
        assert result is not None
        assert result.id == new.id

    def test_ts_just_before_ended_at_is_covered(self, session: Session):
        """ts just before ended_at must still be covered by the closing meter."""
        t1 = _T0 + timedelta(hours=2)
        m = _make_meter(session, started_at=_T0, ended_at=t1)
        result = meter_at(session, t1 - timedelta(seconds=1))
        assert result is not None
        assert result.id == m.id

    def test_commodity_filter(self, session: Session):
        """meter_at must only return the meter for the requested commodity."""
        m_elec = _make_meter(session, started_at=_T0, commodity="electricity")
        _make_meter(session, started_at=_T0, commodity="gas")

        result = meter_at(session, _T0, commodity="electricity")
        assert result is not None
        assert result.id == m_elec.id

        result_gas = meter_at(session, _T0, commodity="gas")
        assert result_gas is not None
        assert result_gas.commodity == "gas"

    def test_no_meter_for_unknown_commodity(self, session: Session):
        """meter_at returns None when no meter exists for the requested commodity."""
        _make_meter(session, started_at=_T0, commodity="electricity")
        assert meter_at(session, _T0, commodity="heating") is None

    def test_two_contiguous_epochs_correct_routing(self, session: Session):
        """With two contiguous meters, meter_at routes each ts to the correct epoch."""
        t1 = _T0 + timedelta(hours=3)
        m0 = _make_meter(session, started_at=_T0, ended_at=t1, label="Meter 0")
        m1 = _make_meter(session, started_at=t1, ended_at=None, label="Meter 1")

        # ts in first epoch
        assert meter_at(session, _T0 + timedelta(hours=1)).id == m0.id
        # ts exactly at boundary → second epoch
        assert meter_at(session, t1).id == m1.id
        # ts in second epoch
        assert meter_at(session, t1 + timedelta(hours=1)).id == m1.id

    def test_equal_started_at_swap_meter_at_still_returns_new_active(self, session: Session):
        """Regression: after equal-timestamp swap, meter_at must return the new active meter.

        When declare_meter is called with started_at == active.started_at (the
        "equal-timestamp replace" allowed by §3.5), the old meter becomes a
        zero-width epoch [T0, T0).  A previous bug caused meter_at to select
        the zero-width row first (same started_at, lower rowid) and then fail
        the upper-bound check, returning None for *any* ts >= T0.  This test
        pins the correct behaviour: meter_at(T0) and meter_at(T0+δ) must both
        return the new active meter, not None.
        """
        # Declare first meter at T0.
        declare_meter(session, label="M1 (original)", started_at=_T0, reason="initial")
        session.commit()

        # Declare second meter at *the same* T0 — equal-timestamp swap.
        new_m = declare_meter(
            session, label="M2 (replacement)", started_at=_T0, reason="meter_swap"
        )
        session.commit()

        # meter_at at exactly T0 must return the new active meter.
        result_at_T0 = meter_at(session, _T0)
        assert result_at_T0 is not None, (
            "meter_at(T0) returned None after equal-timestamp swap; "
            "the new active meter should cover T0"
        )
        assert result_at_T0.id == new_m.id, (
            f"meter_at(T0) returned meter id={result_at_T0.id} (label={result_at_T0.label!r}), "
            f"expected id={new_m.id} (the new active meter)"
        )

        # meter_at slightly after T0 must also return the new active meter.
        result_after_T0 = meter_at(session, _T0 + timedelta(seconds=1))
        assert result_after_T0 is not None, (
            "meter_at(T0+1s) returned None after equal-timestamp swap"
        )
        assert result_after_T0.id == new_m.id, (
            f"meter_at(T0+1s) returned meter id={result_after_T0.id}, "
            f"expected id={new_m.id} (the new active meter)"
        )


# ---------------------------------------------------------------------------
# 2 & 3. declare_meter — first declaration, swap, mutual exclusion
# ---------------------------------------------------------------------------


class TestDeclareMeter:
    def test_first_declaration_no_active(self, session: Session):
        """Declaring the first meter must create an active meter with ended_at IS NULL."""
        m = declare_meter(
            session,
            label="Initial meter",
            started_at=_T0,
            reason="initial",
        )
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched is not None
        assert fetched.ended_at is None
        assert fetched.commodity == "electricity"
        assert fetched.reason == "initial"

    def test_swap_closes_old_meter(self, session: Session):
        """Declaring a second meter must close the previous active meter."""
        first = declare_meter(
            session, label="First meter", started_at=_T0, reason="initial"
        )
        session.commit()
        first_id = first.id

        t1 = _T0 + timedelta(days=30)
        second = declare_meter(
            session, label="Second meter", started_at=t1, reason="meter_swap"
        )
        session.commit()

        # First meter must now be closed at exactly t1.
        closed = session.get(Meter, first_id)
        assert closed.ended_at is not None
        from app.services.meters import _as_utc
        assert _as_utc(closed.ended_at) == _as_utc(t1)

        # Second meter must be active.
        assert second.ended_at is None

    def test_swap_interval_contiguous(self, session: Session):
        """Old meter's ended_at must exactly equal new meter's started_at after swap."""
        declare_meter(session, label="M1", started_at=_T0, reason="initial")
        session.commit()

        t1 = _T0 + timedelta(days=10)
        new_m = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
        session.commit()

        # Query the old (now-closed) meter.
        meters = list_meters(session, commodity="electricity")
        assert len(meters) == 2
        old_m = next(m for m in meters if m.id != new_m.id)

        from app.services.meters import _as_utc
        assert _as_utc(old_m.ended_at) == _as_utc(new_m.started_at), (
            "Timeline gap or overlap: old ended_at must == new started_at"
        )

    def test_at_most_one_active_per_commodity_after_multiple_swaps(self, session: Session):
        """After N swaps, exactly one meter per commodity must be active."""
        declare_meter(session, label="M1", started_at=_T0, reason="initial")
        session.commit()
        declare_meter(
            session, label="M2", started_at=_T0 + timedelta(days=10), reason="meter_swap"
        )
        session.commit()
        declare_meter(
            session, label="M3", started_at=_T0 + timedelta(days=20), reason="meter_swap"
        )
        session.commit()

        active_meters = [m for m in list_meters(session, commodity="electricity") if m.ended_at is None]
        assert len(active_meters) == 1, f"Expected 1 active meter, got {len(active_meters)}"

    def test_backdate_rejected(self, session: Session):
        """started_at strictly before active meter's started_at must raise MeterOverlapError."""
        declare_meter(session, label="Current", started_at=_T0, reason="initial")
        session.commit()

        with pytest.raises(MeterOverlapError):
            declare_meter(
                session,
                label="Too early",
                started_at=_T0 - timedelta(hours=1),
                reason="meter_swap",
            )

    def test_backdate_raises_before_any_db_write(self, session: Session):
        """When backdate is rejected, no new meter row must be written."""
        declare_meter(session, label="Current", started_at=_T0, reason="initial")
        session.commit()

        meters_before = list_meters(session, commodity="electricity")
        count_before = len(meters_before)

        with pytest.raises(MeterOverlapError):
            declare_meter(
                session,
                label="Bad meter",
                started_at=_T0 - timedelta(minutes=5),
                reason="meter_swap",
            )
        # Rollback the failed operation explicitly (simulating what the caller would do).
        session.rollback()

        # Re-open session to verify state.
        with Session(session.get_bind()) as s2:
            meters_after = list_meters(s2, commodity="electricity")
            assert len(meters_after) == count_before, (
                "No extra meter must be written when backdate is rejected"
            )

    def test_equal_started_at_allowed(self, session: Session):
        """started_at == active meter's started_at must NOT raise (equal is allowed)."""
        declare_meter(session, label="M1", started_at=_T0, reason="initial")
        session.commit()

        # Should not raise — equal timestamps are valid (replaces meter at same instant).
        new_m = declare_meter(
            session, label="M2", started_at=_T0, reason="meter_swap"
        )
        session.commit()
        assert new_m.ended_at is None

    def test_note_and_commodity_stored(self, session: Session):
        """declare_meter must persist note and commodity correctly."""
        m = declare_meter(
            session,
            label="Gas meter",
            started_at=_T0,
            reason="initial",
            commodity="gas",
            note="Rotameter serial XYZ",
        )
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched.commodity == "gas"
        assert fetched.note == "Rotameter serial XYZ"

    def test_declare_meter_generates_uuid(self, session: Session):
        """declare_meter must auto-generate a non-empty uuid via ORM default."""
        import re
        UUID4_RE = re.compile(
            r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
            re.IGNORECASE,
        )

        m = declare_meter(
            session,
            label="Meter with UUID",
            started_at=_T0,
            reason="initial",
        )
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched is not None
        assert fetched.uuid is not None, "uuid must not be None after declare_meter"
        assert fetched.uuid != "", "uuid must not be empty"
        assert UUID4_RE.match(fetched.uuid), (
            f"uuid {fetched.uuid!r} does not look like a valid UUID v4"
        )

    def test_declare_meter_each_gets_distinct_uuid(self, session: Session):
        """Each declared meter must receive a distinct UUID (not duplicated)."""
        m1 = declare_meter(session, label="M1", started_at=_T0, reason="initial")
        session.commit()

        t1 = _T0 + timedelta(days=10)
        m2 = declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
        session.commit()

        assert m1.uuid != m2.uuid, (
            f"Two declared meters must have distinct UUIDs; both got {m1.uuid!r}"
        )


# ---------------------------------------------------------------------------
# 5. Different commodities are independent
# ---------------------------------------------------------------------------


class TestCommodityIsolation:
    def test_electricity_swap_does_not_affect_gas_meter(self, session: Session):
        """Swapping the electricity meter must not touch the gas meter's active state."""
        declare_meter(session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity")
        declare_meter(session, label="Gas 1", started_at=_T0, reason="initial", commodity="gas")
        session.commit()

        declare_meter(
            session,
            label="Elec 2",
            started_at=_T0 + timedelta(days=5),
            reason="meter_swap",
            commodity="electricity",
        )
        session.commit()

        # Gas meter must still be active.
        gas_meters = list_meters(session, commodity="gas")
        active_gas = [m for m in gas_meters if m.ended_at is None]
        assert len(active_gas) == 1, "Gas meter must remain active after electricity swap"
        assert active_gas[0].label == "Gas 1"

        # Electricity: exactly one active.
        elec_meters = list_meters(session, commodity="electricity")
        active_elec = [m for m in elec_meters if m.ended_at is None]
        assert len(active_elec) == 1
        assert active_elec[0].label == "Elec 2"

    def test_different_commodity_backdate_is_independent(self, session: Session):
        """Backdate validation is per-commodity: gas meter start does not constrain electricity."""
        # Declare gas meter at a later time.
        declare_meter(
            session, label="Gas 1", started_at=_T0 + timedelta(days=10), reason="initial", commodity="gas"
        )
        session.commit()

        # Declaring an electricity meter at an earlier time must succeed (no gas constraint).
        m = declare_meter(
            session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity"
        )
        session.commit()
        assert m is not None


# ---------------------------------------------------------------------------
# 6. list_meters
# ---------------------------------------------------------------------------


class TestListMeters:
    def test_returns_empty_list_when_no_meters(self, session: Session):
        assert list_meters(session) == []

    def test_ordered_by_started_at_asc(self, session: Session):
        """list_meters must return meters in ascending started_at order."""
        t1 = _T0 + timedelta(days=10)
        t2 = _T0 + timedelta(days=20)
        declare_meter(session, label="M1", started_at=_T0, reason="initial")
        session.commit()
        declare_meter(session, label="M2", started_at=t1, reason="meter_swap")
        session.commit()
        declare_meter(session, label="M3", started_at=t2, reason="meter_swap")
        session.commit()

        meters = list_meters(session, commodity="electricity")
        assert [m.label for m in meters] == ["M1", "M2", "M3"]

    def test_commodity_filter_returns_only_matching(self, session: Session):
        """list_meters with commodity kwarg must filter correctly."""
        declare_meter(session, label="Elec", started_at=_T0, reason="initial", commodity="electricity")
        declare_meter(session, label="Gas", started_at=_T0, reason="initial", commodity="gas")
        session.commit()

        elec = list_meters(session, commodity="electricity")
        gas = list_meters(session, commodity="gas")
        all_meters = list_meters(session)

        assert len(elec) == 1 and elec[0].commodity == "electricity"
        assert len(gas) == 1 and gas[0].commodity == "gas"
        assert len(all_meters) == 2


# ---------------------------------------------------------------------------
# 7 & 8. update_meter
# ---------------------------------------------------------------------------


class TestUpdateMeter:
    def test_update_label(self, session: Session):
        """update_meter must update label without touching other fields."""
        m = _make_meter(session, started_at=_T0)
        update_meter(session, m, label="New label")
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched.label == "New label"
        assert fetched.note is None  # unchanged

    def test_update_note(self, session: Session):
        """update_meter must update note without touching other fields."""
        m = _make_meter(session, started_at=_T0)
        update_meter(session, m, note="Some note")
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched.note == "Some note"
        assert fetched.label == "Test meter"  # unchanged

    def test_update_label_and_note_simultaneously(self, session: Session):
        """update_meter must update both label and note in a single call."""
        m = _make_meter(session, started_at=_T0)
        update_meter(session, m, label="Updated label", note="Updated note")
        session.commit()

        fetched = session.get(Meter, m.id)
        assert fetched.label == "Updated label"
        assert fetched.note == "Updated note"

    def test_update_started_at_first_meter(self, session: Session):
        """Changing started_at of the first (only) active meter must work without a previous meter."""
        m = _make_meter(session, started_at=_T0, ended_at=None)
        new_start = _T0 + timedelta(hours=2)

        update_meter(session, m, started_at=new_start)
        session.commit()

        fetched = session.get(Meter, m.id)
        from app.services.meters import _as_utc
        assert _as_utc(fetched.started_at) == _as_utc(new_start)

    def test_update_started_at_updates_previous_ended_at(self, session: Session):
        """Retroactive started_at change must propagate to the previous meter's ended_at."""
        t1 = _T0 + timedelta(days=10)
        prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev meter")
        curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr meter")
        session.commit()

        new_start = _T0 + timedelta(days=7)  # shift boundary 3 days earlier
        update_meter(session, curr, started_at=new_start)
        session.commit()

        from app.services.meters import _as_utc
        fetched_prev = session.get(Meter, prev.id)
        fetched_curr = session.get(Meter, curr.id)

        # The previous meter's ended_at must now equal the new started_at.
        assert _as_utc(fetched_prev.ended_at) == _as_utc(new_start), (
            "Previous meter's ended_at must be updated to maintain continuity"
        )
        # The current meter's started_at must reflect the change.
        assert _as_utc(fetched_curr.started_at) == _as_utc(new_start)

    def test_update_started_at_continuity_maintained(self, session: Session):
        """After retroactive started_at change, prev.ended_at == curr.started_at (no gap)."""
        t1 = _T0 + timedelta(days=10)
        prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
        curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
        session.commit()

        new_start = _T0 + timedelta(days=12)  # shift boundary 2 days later
        update_meter(session, curr, started_at=new_start)
        session.commit()

        from app.services.meters import _as_utc
        fetched_prev = session.get(Meter, prev.id)
        fetched_curr = session.get(Meter, curr.id)

        assert _as_utc(fetched_prev.ended_at) == _as_utc(fetched_curr.started_at), (
            "Timeline must remain contiguous after retroactive started_at shift"
        )

    def test_update_started_at_rejects_at_or_past_ended_at(self, session: Session):
        """New started_at ≥ ended_at must raise MeterIntervalError (empty/inverted epoch)."""
        t1 = _T0 + timedelta(days=10)
        m = _make_meter(session, started_at=_T0, ended_at=t1)

        with pytest.raises(MeterIntervalError):
            update_meter(session, m, started_at=t1)  # == ended_at → empty epoch

        with pytest.raises(MeterIntervalError):
            update_meter(session, m, started_at=t1 + timedelta(hours=1))  # > ended_at

    def test_update_started_at_rejects_at_or_before_prev_started_at(self, session: Session):
        """New started_at ≤ prev.started_at must raise MeterIntervalError."""
        t1 = _T0 + timedelta(days=10)
        _make_meter(session, started_at=_T0, ended_at=t1, label="Prev")
        curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr")
        session.commit()

        with pytest.raises(MeterIntervalError):
            # exactly at prev.started_at — would collapse prev epoch to zero
            update_meter(session, curr, started_at=_T0)

        with pytest.raises(MeterIntervalError):
            # strictly before prev.started_at — inverts prev epoch
            update_meter(session, curr, started_at=_T0 - timedelta(hours=1))

    def test_noop_call_does_not_raise(self, session: Session):
        """Calling update_meter with all None args must succeed without error."""
        m = _make_meter(session, started_at=_T0)
        result = update_meter(session, m)
        assert result is m

    def test_update_started_at_no_previous_meter_shift_backward(self, session: Session):
        """For the first meter (no previous), shifting started_at backward must succeed."""
        m = _make_meter(session, started_at=_T0, ended_at=None)
        earlier = _T0 - timedelta(days=5)
        update_meter(session, m, started_at=earlier)
        session.commit()

        from app.services.meters import _as_utc
        fetched = session.get(Meter, m.id)
        assert _as_utc(fetched.started_at) == _as_utc(earlier)
