"""Modbus device CRUD, readings, metrics, and test-read API (M5-T05).

All endpoints are under /api/modbus, require an authenticated session, and
write endpoints (POST/PATCH/DELETE + test-read) additionally require a
non-empty X-CSRF-Token header.

Deletion safety:
    DELETE /api/modbus/devices/{uuid} queries the application layer for
    existing modbus_reading rows before deleting.  Without ``cascade`` it
    returns 409 and suggests disabling the device instead — a friendly guard
    rather than letting the DB raise.  With ``cascade=true`` it removes the
    readings (and expose toggles) first, then the device.  SQLite FK RESTRICT
    *is* enforced at runtime here (the app sets PRAGMA foreign_keys=ON; see
    app/db.py), so the cascade delete order matters.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete as sa_delete, func, select
from sqlalchemy.orm import Session

from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_db
from app.integrations.modbus import driver as modbus_driver
from app.integrations.modbus.driver import ModbusDriverError
from app.integrations.modbus.profiles import (
    ProfileNotFoundError,
    decode as decode_profile,
    list_profiles,
    load_profile,
)
from app.models.expose import ExposedEntityToggle
from app.models.modbus import ModbusDevice, ModbusReading
from app.schemas.modbus import (
    MetricInfo,
    ModbusDeleteResponse,
    ModbusDeviceCreate,
    ModbusDeviceListResponse,
    ModbusDeviceResponse,
    ModbusDeviceUpdate,
    ModbusLatestResponse,
    ModbusMetricsResponse,
    ModbusProfilesResponse,
    ModbusReadingResponse,
    ModbusReadingsResponse,
    ModbusTestReadResponse,
    ProfileSummary,
)
from app.services.auth import AuthenticatedSession

logger = logging.getLogger(__name__)

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

# Maximum number of readings that can be returned per request.
_READINGS_LIMIT_MAX = 5000


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


def _get_device_or_404(db: Session, uuid: str) -> ModbusDevice:
    """Return the device with the given UUID or raise 404."""
    device = db.execute(
        select(ModbusDevice).where(ModbusDevice.uuid == uuid)
    ).scalar_one_or_none()
    if device is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Device '{uuid}' not found.",
        )
    return device


def _label_from_key(key: str) -> str:
    """Derive a human-readable label from a metric key.

    Example: ``"active_power"`` → ``"Active Power"``
    """
    return key.replace("_", " ").title()


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


@router.get("/profiles", response_model=ModbusProfilesResponse)
def get_profiles(
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusProfilesResponse:
    """List all available Modbus YAML profiles (name + description).

    Intended for the front-end's device-creation profile drop-down.
    """
    pairs = list_profiles()
    return ModbusProfilesResponse(
        profiles=[ProfileSummary(name=name, description=desc) for name, desc in pairs]
    )


# ---------------------------------------------------------------------------
# Device CRUD
# ---------------------------------------------------------------------------


@router.get("/devices", response_model=ModbusDeviceListResponse)
def list_devices(
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceListResponse:
    """Return all Modbus devices (no pagination — device counts stay small)."""
    devices = db.execute(select(ModbusDevice)).scalars().all()
    items = [ModbusDeviceResponse.model_validate(d) for d in devices]
    return ModbusDeviceListResponse(items=items, total=len(items))


@router.post(
    "/devices",
    response_model=ModbusDeviceResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_device(
    body: ModbusDeviceCreate,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
    """Create a new Modbus device.

    - Validates that the referenced ``profile`` exists; returns 422 if not.
    - Returns 201 with the created device on success.
    """
    # Validate profile exists (422 if not)
    try:
        load_profile(body.profile)
    except ProfileNotFoundError:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Profile '{body.profile}' does not exist.",
        )

    now = datetime.now(UTC)
    device = ModbusDevice(
        friendly_name=body.friendly_name,
        transport=body.transport,
        host=body.host,
        port=body.port,
        unit_id=body.unit_id,
        profile=body.profile,
        poll_interval_s=body.poll_interval_s,
        enabled=body.enabled,
        created_at=now,
        updated_at=now,
    )
    db.add(device)
    db.commit()
    db.refresh(device)
    logger.info("Created Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
    return ModbusDeviceResponse.model_validate(device)


@router.get("/devices/{uuid}", response_model=ModbusDeviceResponse)
def get_device(
    uuid: str,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusDeviceResponse:
    """Return a single Modbus device by UUID."""
    device = _get_device_or_404(db, uuid)
    return ModbusDeviceResponse.model_validate(device)


@router.patch("/devices/{uuid}", response_model=ModbusDeviceResponse)
def patch_device(
    uuid: str,
    body: ModbusDeviceUpdate,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> ModbusDeviceResponse:
    """Partially update a Modbus device (including enable/disable).

    Only fields explicitly provided in the request body are updated.
    Providing ``profile`` triggers a profile-existence check (422 if unknown).
    """
    device = _get_device_or_404(db, uuid)

    update_data = body.model_dump(exclude_none=True)

    # If profile is being changed, validate it exists.
    if "profile" in update_data:
        try:
            load_profile(update_data["profile"])
        except ProfileNotFoundError:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
                detail=f"Profile '{update_data['profile']}' does not exist.",
            )

    for field, value in update_data.items():
        setattr(device, field, value)

    device.updated_at = datetime.now(UTC)
    db.commit()
    db.refresh(device)
    logger.info("Updated Modbus device %r (uuid=%s)", device.friendly_name, device.uuid)
    return ModbusDeviceResponse.model_validate(device)


@router.delete(
    "/devices/{uuid}",
    status_code=status.HTTP_204_NO_CONTENT,
    response_model=None,
)
def delete_device(
    uuid: str,
    cascade: bool = Query(default=False),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> None | ModbusDeleteResponse:
    """Delete a Modbus device.

    **Default behaviour (cascade=false)**:
    Returns 409 Conflict if the device has any associated readings; use
    ``enabled=false`` to disable it instead.

    **Cascade delete (cascade=true)**:
    Permanently deletes the device together with all its readings and any
    ``ExposedEntityToggle`` rows whose key matches ``modbus.<uuid>.*``.
    Also makes a best-effort attempt to clear the device's HA Discovery
    config topics from MQTT (empty retained payload) before the DB rows
    are removed.  MQTT failures are swallowed — the DB deletion proceeds
    regardless.
    Returns HTTP 200 with a ``ModbusDeleteResponse`` JSON body on success.

    **Application-layer safety**: the 409 guard uses an explicit SELECT COUNT
    query to return a friendly message.  FK RESTRICT is enforced at runtime
    (the app sets ``PRAGMA foreign_keys=ON``), so the cascade path deletes
    readings before the device.
    """
    from app.services.ha_discovery import clear_device_discovery

    device = _get_device_or_404(db, uuid)

    # Application-layer guard: refuse deletion if any readings exist.
    reading_count: int = db.execute(
        select(func.count(ModbusReading.id)).where(ModbusReading.device_id == device.id)
    ).scalar_one()

    if reading_count > 0 and not cascade:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=(
                f"Device '{uuid}' has {reading_count} associated reading(s) and cannot be "
                "deleted.  Disable the device (set enabled=false) instead to stop polling "
                "without losing historical data."
            ),
        )

    if cascade:
        # --- Cascade deletion path ---
        logger.info(
            "Cascade-deleting Modbus device %r (uuid=%s): %d reading(s)",
            device.friendly_name,
            uuid,
            reading_count,
        )

        # 1. Best-effort HA Discovery cleanup (before DB rows are gone).
        try:
            clear_device_discovery(db, uuid)
        except Exception:  # noqa: BLE001
            logger.exception(
                "delete_device(cascade): HA discovery cleanup raised for uuid=%s; continuing",
                uuid,
            )

        # 2. Delete all readings for this device (must happen before device row).
        deleted_readings_result = db.execute(
            sa_delete(ModbusReading).where(ModbusReading.device_id == device.id)
        )
        readings_deleted: int = deleted_readings_result.rowcount

        # 3. Delete all ExposedEntityToggle rows for this device.
        #    Keys follow the pattern "modbus.<uuid>.<metric>".
        toggle_prefix = f"modbus.{uuid}.%"
        deleted_toggles_result = db.execute(
            sa_delete(ExposedEntityToggle).where(ExposedEntityToggle.key.like(toggle_prefix))
        )
        toggles_deleted: int = deleted_toggles_result.rowcount

        # 4. Delete the device itself.
        db.delete(device)
        db.commit()

        logger.info(
            "Cascade-delete complete for device uuid=%s: %d reading(s), %d toggle(s) removed",
            uuid,
            readings_deleted,
            toggles_deleted,
        )

        from fastapi.responses import JSONResponse
        return JSONResponse(
            status_code=status.HTTP_200_OK,
            content={
                "deleted": True,
                "readings_deleted": readings_deleted,
                "toggles_deleted": toggles_deleted,
            },
        )

    # --- Non-cascade path (no readings exist at this point) ---
    logger.info("Deleting Modbus device %r (uuid=%s)", device.friendly_name, uuid)
    db.delete(device)
    db.commit()


# ---------------------------------------------------------------------------
# Readings endpoints
# ---------------------------------------------------------------------------


@router.get("/devices/{uuid}/latest", response_model=ModbusLatestResponse)
def get_latest_reading(
    uuid: str,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusLatestResponse:
    """Return the most recent reading for a device.

    If no readings exist yet, returns ``{"found": false, "recorded_at": null,
    "payload": null}`` (200, not 404) so the front-end can distinguish
    "device exists but has no data" from "device not found".
    """
    device = _get_device_or_404(db, uuid)

    row = db.execute(
        select(ModbusReading)
        .where(ModbusReading.device_id == device.id)
        .order_by(ModbusReading.recorded_at.desc())
        .limit(1)
    ).scalar_one_or_none()

    if row is None:
        return ModbusLatestResponse(found=False, recorded_at=None, payload=None)

    return ModbusLatestResponse(
        found=True,
        recorded_at=row.recorded_at,
        payload=row.payload,
    )


@router.get("/devices/{uuid}/readings", response_model=ModbusReadingsResponse)
def get_readings(
    uuid: str,
    start: datetime | None = Query(default=None),
    end: datetime | None = Query(default=None),
    limit: int = Query(default=500, ge=1, le=_READINGS_LIMIT_MAX),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusReadingsResponse:
    """Return time-range readings for a device.

    When the window contains more rows than ``limit``, the **most recent** N rows
    are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
    ascending order before being sent to the client.  This ensures that for long
    time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
    rather than the oldest segment of the window.

    When the window has fewer rows than ``limit`` the full window is returned in
    ascending order — behaviour is identical to a plain ascending query.

    The response schema is unchanged: items are always ``recorded_at`` ascending.

    The query uses the ``(device_id, recorded_at)`` composite index for
    efficient time-window scans.

    Query parameters:
    - ``start``: inclusive lower bound (ISO8601 datetime)
    - ``end``: inclusive upper bound (ISO8601 datetime)
    - ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
    """
    device = _get_device_or_404(db, uuid)

    stmt = (
        select(ModbusReading)
        .where(ModbusReading.device_id == device.id)
        .order_by(ModbusReading.recorded_at.desc())
        .limit(limit)
    )

    if start is not None:
        stmt = stmt.where(ModbusReading.recorded_at >= start)
    if end is not None:
        stmt = stmt.where(ModbusReading.recorded_at <= end)

    # Fetch most-recent N rows, then reverse to restore ascending order for the response.
    rows = list(reversed(db.execute(stmt).scalars().all()))
    items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows]
    return ModbusReadingsResponse(items=items)


# ---------------------------------------------------------------------------
# Metrics endpoint
# ---------------------------------------------------------------------------


@router.get("/devices/{uuid}/metrics", response_model=ModbusMetricsResponse)
def get_metrics(
    uuid: str,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> ModbusMetricsResponse:
    """Return the metric catalogue for a device's profile.

    Each entry has ``key``, ``label`` (derived from key if the profile has
    none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
    This is the authoritative metadata source for front-end card labels and
    chart axis labels.
    """
    device = _get_device_or_404(db, uuid)

    try:
        profile = load_profile(device.profile)
    except ProfileNotFoundError:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Device profile '{device.profile}' could not be loaded.",
        )

    metrics = [
        MetricInfo(
            key=m.key,
            label=_label_from_key(m.key),
            unit=m.unit,
            device_class=m.device_class,
        )
        for m in profile.metrics
    ]

    return ModbusMetricsResponse(profile=profile.name, metrics=metrics)


# ---------------------------------------------------------------------------
# Test-read endpoint
# ---------------------------------------------------------------------------


@router.post("/devices/{uuid}/test", response_model=ModbusTestReadResponse)
def test_read(
    uuid: str,
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> ModbusTestReadResponse:
    """Immediately read and decode the device once without persisting any data.

    Useful for validating gateway connectivity and Modbus address settings
    before relying on the background polling job.

    This is a write-class endpoint (it initiates network I/O on demand) and
    therefore requires a CSRF token.  The response payload is never stored.
    """
    device = _get_device_or_404(db, uuid)

    try:
        profile = load_profile(device.profile)
    except ProfileNotFoundError:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Device profile '{device.profile}' could not be loaded.",
        )

    try:
        registers = modbus_driver.read_blocks(
            device.host,
            device.port,
            device.unit_id,
            [{"start": b.start, "count": b.count} for b in profile.blocks],
            function_code=profile.function_code,
        )
        payload: dict[str, Any] = decode_profile(profile, registers)
        return ModbusTestReadResponse(ok=True, payload=payload)

    except ModbusDriverError as exc:
        logger.warning(
            "Test read failed for device %r (uuid=%s): %s",
            device.friendly_name,
            uuid,
            exc,
        )
        return ModbusTestReadResponse(ok=False, error=str(exc))
    except Exception as exc:  # noqa: BLE001
        logger.warning(
            "Unexpected error during test read for device %r (uuid=%s): %s",
            device.friendly_name,
            uuid,
            exc,
        )
        return ModbusTestReadResponse(ok=False, error=f"Unexpected error: {exc}")
