"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05).

All endpoints are under /api/energy/meters, require an authenticated session,
and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token
header.

Route semantics
---------------
GET    /api/energy/meters         — list all meter epochs (ascending started_at)
POST   /api/energy/meters         — declare a meter swap / initial meter epoch
PATCH  /api/energy/meters/{id}    — edit label / note, or correct started_at (retroactive)

Retroactive recompute
---------------------
Whenever a write operation changes a meter's ``started_at`` (new declaration
or PATCH correction), the affected billing window is re-judged via
``recompute_range``:

- **POST** (new meter, possibly retroactive):
    window = [new_meter.started_at, now)
    Rationale: the new meter's ``started_at`` closes the previous meter at that
    point; all periods from that boundary forward may have a different meter
    attribution.  Using ``now`` as the upper bound is safe because
    ``recompute_range`` only processes closed quarters and the operation is
    idempotent.

- **PATCH started_at** (retroactive correction):
    window = [min(old_started_at, new_started_at), now)
    Rationale: shifting the boundary in either direction affects all periods
    between the old and new boundary (and potentially beyond if re-attribution
    cascades).  Using the minimum of the two timestamps guarantees the entire
    affected range is covered; using ``now`` as the upper bound is safe and
    idempotent.

``started_at`` localisation (Principle A, FU10 convention)
----------------------------------------------------------
If the client sends a timezone-naive ``started_at`` value, it is interpreted as
the **server's local wall-clock time** and converted to UTC before storage.
Timezone-aware values are converted to UTC as-is.  This is identical to the
``_localize_effective_from`` convention used in ``energy_contracts.py``.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.models.energy import Meter
from app.schemas.meter import (
    MeterDeclareRequest,
    MeterListResponse,
    MeterPatchRequest,
    MeterResponse,
)
from app.services import timezone as _tz_mod
from app.services.auth import AuthenticatedSession
from app.services.energy_cost import recompute_range
from app.services.meters import (
    MeterIntervalError,
    MeterOverlapError,
    declare_meter,
    list_meters,
    update_meter,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"])


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


def _trigger_discovery_republish(session: Session) -> None:
    """Call publish_discovery after a meter write operation (best-effort).

    No-op if MQTT / discovery is not enabled or the broker is not connected
    (publish_discovery guards internally).  All errors are swallowed so that a
    discovery failure never breaks the API response.

    Must be called **after** db.commit() so that publish_discovery sees the
    final committed state of the meter table when it rebuilds the catalog.
    """
    try:
        from app.services.ha_discovery import publish_discovery

        publish_discovery(session)
    except Exception:
        logger.exception("_trigger_discovery_republish: publish_discovery raised an error")


def _get_meter_or_404(db: Session, meter_id: int) -> Meter:
    """Return the meter with the given id or raise 404."""
    meter: Optional[Meter] = db.get(Meter, meter_id)
    if meter is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Meter {meter_id!r} not found.",
        )
    return meter


def _localize_started_at(dt: datetime) -> datetime:
    """Resolve *dt* to an aware UTC datetime for storage.

    Follows the same Principle-A convention as ``_localize_effective_from``
    in ``energy_contracts.py`` (FU10):

    - Timezone-aware → convert to UTC as-is.
    - Timezone-naive → interpret as server local wall-clock time, localize
      with ``local_tz()``, then convert to UTC.

    A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted
    as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z),
    not as UTC midnight.
    """
    if dt.tzinfo is not None:
        return dt.astimezone(UTC)
    tz = _tz_mod.local_tz()
    local_dt = dt.replace(tzinfo=tz)
    return local_dt.astimezone(UTC)


def _trigger_recompute(db: Session, start: datetime, label: str) -> int:
    """Trigger recompute_range from *start* to now (UTC).

    This is the standard "retroactive window" call: everything from the
    affected boundary up to the current moment needs re-attribution.
    Using ``now`` as the upper bound is safe because ``recompute_range``
    only touches closed quarter-hour periods and the operation is idempotent.
    """
    end = datetime.now(UTC)
    if start >= end:
        # started_at is in the future — nothing to recompute.
        logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start)
        return 0
    n = recompute_range(db, start, end)
    logger.info(
        "%s: recomputed %d period(s) in window [%s, %s).",
        label,
        n,
        start.isoformat(),
        end.isoformat(),
    )
    return n


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


@router.get("/meters", response_model=MeterListResponse)
def list_energy_meters(
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> MeterListResponse:
    """List all meter epochs in ascending ``started_at`` order.

    Returns the full historical sequence of meter installations across all
    commodities.  The active meter (``ended_at=null``) appears last because it
    has the latest ``started_at``.
    """
    meters = list_meters(db)
    items = [MeterResponse.model_validate(m) for m in meters]
    return MeterListResponse(items=items, total=len(items))


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


@router.post(
    "/meters",
    response_model=MeterResponse,
    status_code=status.HTTP_201_CREATED,
)
def declare_energy_meter(
    body: MeterDeclareRequest,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> MeterResponse:
    """Declare a new meter epoch (swap, home move, or initial declaration).

    Closes the current active meter for the given commodity at ``started_at``
    and opens a new active meter.  If no active meter exists, the new meter is
    simply created without closing anything.

    **Validation**: ``started_at`` must be **≥** the current active meter's
    own ``started_at`` (no chronological backdate below the active epoch's
    start).  Equal timestamps are allowed (replaces the current meter at the
    same logical moment).  Violation → 422.

    **Retroactive recompute**: if ``started_at`` is in the past, billing
    records from that point forward are re-judged via ``recompute_range`` to
    reflect the new meter attribution.  The recompute is transparent — the
    response body is the created meter (``MeterResponse``) only and does **not**
    include a recompute count; callers should re-fetch costs if they need the
    updated totals.
    """
    started_at_utc = _localize_started_at(body.started_at)

    try:
        new_meter = declare_meter(
            db,
            label=body.label,
            started_at=started_at_utc,
            reason=body.reason.value,
            commodity=body.commodity,
            note=body.note,
        )
    except MeterOverlapError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=str(exc),
        )

    db.flush()  # assign PK before recompute (recompute uses session, needs meter in DB)

    # Retroactive recompute: re-judge attribution from the new boundary onward.
    now = datetime.now(UTC)
    if started_at_utc < now:
        _trigger_recompute(db, started_at_utc, "POST /api/energy/meters")

    db.commit()
    db.refresh(new_meter)

    # Trigger HA discovery re-publish so the new active meter's energy-cost
    # device/sensor configuration is pushed to Home Assistant.  Best-effort:
    # failures are logged and swallowed; the API response is not affected.
    _trigger_discovery_republish(db)

    logger.info(
        "POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s",
        body.commodity,
        new_meter.id,
        new_meter.label,
        started_at_utc.isoformat(),
    )
    return MeterResponse.model_validate(new_meter)


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


@router.patch("/meters/{meter_id}", response_model=MeterResponse)
def patch_energy_meter(
    meter_id: int,
    body: MeterPatchRequest,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> MeterResponse:
    """Partially update a meter epoch: rename, edit note, or correct started_at.

    - ``label``: updates the human-readable label.
    - ``note``: updates the free-form note.
    - ``started_at``: **retroactive correction** — shifts this meter's start
      boundary.  The service layer maintains timeline continuity by also
      updating the preceding meter's ``ended_at``.  Validation:
        * Must be strictly after the previous meter's own ``started_at``.
        * Must be strictly before this meter's ``ended_at`` (if closed).
      Violation → 422.

    **Retroactive recompute when ``started_at`` changes**: billing records in
    the window ``[min(old, new), now)`` are re-judged to reflect the corrected
    meter attribution.

    Not found → 404.
    """
    meter = _get_meter_or_404(db, meter_id)

    # Capture old started_at before mutation (needed for recompute window).
    old_started_at: Optional[datetime] = meter.started_at

    # Localise started_at if provided.
    new_started_at_utc: Optional[datetime] = None
    if body.started_at is not None:
        new_started_at_utc = _localize_started_at(body.started_at)

    try:
        update_meter(
            db,
            meter,
            label=body.label,
            note=body.note,
            started_at=new_started_at_utc,
        )
    except MeterIntervalError as exc:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=str(exc),
        )

    # Retroactive recompute if started_at was changed.
    if new_started_at_utc is not None and old_started_at is not None:
        # Normalise old_started_at to UTC-aware for comparison.
        if old_started_at.tzinfo is None:
            old_started_at = old_started_at.replace(tzinfo=UTC)
        # Window = [min(old, new), now) — covers all periods whose attribution
        # may have changed due to the boundary shift in either direction.
        window_start = min(old_started_at, new_started_at_utc)
        _trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}")

    db.commit()
    db.refresh(meter)

    # Trigger HA discovery re-publish so label renames on the active meter
    # propagate to the HA device name.  Best-effort: failures are logged and
    # swallowed; the API response is not affected.
    _trigger_discovery_republish(db)

    logger.info(
        "PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s",
        meter_id,
        meter.label,
        meter.started_at,
    )
    return MeterResponse.model_validate(meter)
