"""Energy data API: prices, costs, summary, DSMR, recompute, Tibber test (M6-T09).

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

Route prefix note
-----------------
This router shares the ``/api/energy`` prefix with ``energy_contracts.py``
(which handles contract CRUD at /contracts/* and /profiles).  The sub-paths
used here (/prices, /costs, /costs/summary, /costs/recompute, /dsmr/latest,
/tibber/test) are disjoint from the contract router's paths, so there is no
conflict.

Tibber token security
---------------------
``POST /api/energy/tibber/test`` calls the Tibber API but **never** echoes the
token in the response body or in log messages.  Three-state logic mirrors the
MQTT test endpoint (M5-T10, app/api/routes/api/config.py::post_mqtt_test):

  200 { result: "success",      message: ..., price: {...} }
  400 { result: "config-error", message: ...               }
  502 { result: "failed",       message: ...               }

Recompute safety
----------------
``POST /api/energy/costs/recompute`` is idempotent: it calls
``energy_cost.recompute_range`` which upserts existing rows without deleting
anything.  The endpoint enforces a maximum time-window of 366 days to avoid
unbounded recomputation triggered by erroneous client requests.

Prices endpoint behaviour
-------------------------
``GET /api/energy/prices`` queries the ``tibber_price`` table for tibber
contracts, or derives the effective fixed-tariff prices for manual contracts
using the same formula as the billing engine (_manual_strategy in strategies.py):

    buy_dal    = energy.buy.dal    + energy.energy_tax + energy.ode
    buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
    sell_dal   = energy.sell.dal    (no tax added to sell price)
    sell_normal = energy.sell.normal
"""

from __future__ import annotations

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

from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.api.routes.api.deps import require_csrf, require_session
from app.dependencies import get_app_settings, get_db
from app.config import Settings
from app.integrations.tibber.client import (
    TibberAuthError,
    TibberError,
    fetch_current_price,
)
from app.models.energy import DsmrReading, EnergyCostPeriod, TibberPrice
from app.schemas.energy import (
    CostPeriodSchema,
    CostsResponse,
    DsmrLatestResponse,
    ManualTariffSchema,
    PricePointSchema,
    PricesResponse,
    RecomputeResponse,
    SummaryResponse,
    TibberTestPriceSchema,
    TibberTestResponse,
)
from app.services.auth import AuthenticatedSession
from app.services.contracts import active_contract_version_at
from app.services.energy_cost import recompute_range, summarize
from app.services.timezone import local_midnight_utc, local_now

logger = logging.getLogger(__name__)

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

# Maximum number of cost periods returned per request (mirrors modbus readings cap).
_COSTS_LIMIT_MAX = 5000

# Maximum allowed time-window for recompute to prevent unbounded computation.
_RECOMPUTE_MAX_DAYS = 366


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _as_utc(dt: datetime) -> datetime:
    """Attach UTC tzinfo to a naive datetime (SQLite read-back workaround)."""
    if dt.tzinfo is None:
        return dt.replace(tzinfo=UTC)
    return dt


def _manual_tariff_from_values(values: dict[str, Any]) -> ManualTariffSchema:
    """Derive the effective fixed tariff from a manual contract version's values dict.

    Mirrors the _manual_strategy formula (strategies.py):
        buy_dal    = energy.buy.dal    + energy.energy_tax + energy.ode
        buy_normal = energy.buy.normal + energy.energy_tax + energy.ode
        sell_dal   = energy.sell.dal    (no tax added)
        sell_normal = energy.sell.normal
    """
    from decimal import Decimal

    def _d(v: Any) -> Decimal:
        return Decimal(str(v or 0))

    energy = values.get("energy", {})
    buy = energy.get("buy", {})
    sell = energy.get("sell", {})
    energy_tax = _d(energy.get("energy_tax", 0))
    ode = _d(energy.get("ode", 0))

    buy_dal = _d(buy.get("dal", 0)) + energy_tax + ode
    buy_normal = _d(buy.get("normal", 0)) + energy_tax + ode
    sell_dal = _d(sell.get("dal", 0))
    sell_normal = _d(sell.get("normal", 0))

    return ManualTariffSchema(
        buy_dal=float(buy_dal),
        buy_normal=float(buy_normal),
        sell_dal=float(sell_dal),
        sell_normal=float(sell_normal),
    )


# ---------------------------------------------------------------------------
# GET /api/energy/prices
# ---------------------------------------------------------------------------


@router.get("/prices", response_model=PricesResponse)
def get_prices(
    start: datetime | None = Query(
        default=None,
        description="Inclusive start of the time window (ISO 8601). "
        "Defaults to the start of today UTC when omitted.",
    ),
    end: datetime | None = Query(
        default=None,
        description="Inclusive end of the time window (ISO 8601). "
        "Defaults to the end of tomorrow UTC when omitted.",
    ),
    limit: int = Query(
        default=500,
        ge=1,
        le=_COSTS_LIMIT_MAX,
        description="Maximum number of Tibber price points to return.",
    ),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> PricesResponse:
    """Return the price curve for the active contract.

    **Tibber contracts** (kind="tibber"):
        Fetches ``tibber_price`` rows within ``[start, end]``, ordered ascending
        by ``starts_at``.  At most ``limit`` rows are returned (most recent first
        within the window, then reversed to ascending order — identical to the
        modbus readings pattern).

        Response ``points`` carries per-slot:
        - ``buy  = total``              (Tibber all-inclusive price)
        - ``sell = total − energy_tax − sell_adjust``  (from active version values)
        - ``level``                     (Tibber price level, may be null)

        ``tariff`` is null.

    **Manual contracts** (kind="manual"):
        ``points`` is empty.  ``tariff`` carries the four effective prices
        derived using the billing engine formula:
        - ``buy_dal    = energy.buy.dal    + energy_tax + ode``
        - ``buy_normal = energy.buy.normal + energy_tax + ode``
        - ``sell_dal   = energy.sell.dal``
        - ``sell_normal = energy.sell.normal``

    **No active contract**: returns kind=null, currency="EUR", points=[], tariff=null (200).
    """
    now = datetime.now(UTC)

    # Default window: today + tomorrow.
    if start is None:
        start = now.replace(hour=0, minute=0, second=0, microsecond=0)
    if end is None:
        end = (start + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)

    start_utc = _as_utc(start)
    end_utc = _as_utc(end)

    # Resolve the active contract version at the start of the window.
    version = active_contract_version_at(db, start_utc)

    if version is None:
        return PricesResponse(
            kind=None,
            currency="EUR",
            points=[],
            tariff=None,
        )

    contract = version.contract
    currency = contract.currency

    if contract.kind == "tibber":
        # Fetch tibber_price rows in the window.
        stmt = (
            select(TibberPrice)
            .where(
                TibberPrice.starts_at >= start_utc,
                TibberPrice.starts_at <= end_utc,
            )
            .order_by(TibberPrice.starts_at.desc())
            .limit(limit)
        )
        rows = list(reversed(db.execute(stmt).scalars().all()))

        # Derive sell price per-point using version values (energy_tax + sell_adjust).
        from decimal import Decimal

        def _d(v: Any) -> Decimal:
            return Decimal(str(v or 0))

        energy = version.values.get("energy", {}) if version.values else {}
        energy_tax = _d(energy.get("energy_tax", 0))
        sell_adjust = _d(energy.get("sell_adjust", 0))

        points = []
        for row in rows:
            total = _d(row.total)
            sell = float(total - energy_tax - sell_adjust)
            points.append(
                PricePointSchema(
                    starts_at=_as_utc(row.starts_at),
                    buy=row.total,
                    sell=sell,
                    level=row.level,
                )
            )

        return PricesResponse(
            kind="tibber",
            currency=currency,
            points=points,
            tariff=None,
        )

    elif contract.kind == "manual":
        tariff = _manual_tariff_from_values(version.values or {})
        return PricesResponse(
            kind="manual",
            currency=currency,
            points=[],
            tariff=tariff,
        )

    else:
        # Unknown kind — return empty response gracefully.
        return PricesResponse(
            kind=contract.kind,
            currency=currency,
            points=[],
            tariff=None,
        )


# ---------------------------------------------------------------------------
# GET /api/energy/costs
# ---------------------------------------------------------------------------


@router.get("/costs", response_model=CostsResponse)
def get_costs(
    start: datetime | None = Query(
        default=None,
        description="Inclusive lower bound for period_start (ISO 8601).",
    ),
    end: datetime | None = Query(
        default=None,
        description="Inclusive upper bound for period_start (ISO 8601).",
    ),
    limit: int = Query(
        default=500,
        ge=1,
        le=_COSTS_LIMIT_MAX,
        description=f"Maximum number of cost periods to return (default 500, max {_COSTS_LIMIT_MAX}).",
    ),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> CostsResponse:
    """Return energy_cost_period rows within a time window.

    Rows are ordered by ``period_start`` ascending.  When the window contains
    more rows than ``limit``, the **most recent** N rows are returned (DESC LIMIT),
    then reversed to ascending order — identical to the modbus readings pattern.

    Query parameters:
    - ``start``: inclusive lower bound on ``period_start`` (ISO 8601 datetime).
    - ``end``:   inclusive upper bound on ``period_start`` (ISO 8601 datetime).
    - ``limit``: max rows to return (default 500, max {_COSTS_LIMIT_MAX}).
    """
    stmt = (
        select(EnergyCostPeriod)
        .order_by(EnergyCostPeriod.period_start.desc())
        .limit(limit)
    )

    if start is not None:
        stmt = stmt.where(EnergyCostPeriod.period_start >= _as_utc(start))
    if end is not None:
        stmt = stmt.where(EnergyCostPeriod.period_start <= _as_utc(end))

    rows = list(reversed(db.execute(stmt).scalars().all()))
    items = [CostPeriodSchema.model_validate(r) for r in rows]
    return CostsResponse(items=items, total=len(items))


# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary
# ---------------------------------------------------------------------------


@router.get("/costs/summary", response_model=SummaryResponse)
def get_costs_summary(
    start: datetime | None = Query(
        default=None,
        description=(
            "Inclusive start of the summary interval (ISO 8601). "
            "Defaults to the start of the current UTC day."
        ),
    ),
    end: datetime | None = Query(
        default=None,
        description=(
            "Exclusive end of the summary interval (ISO 8601). "
            "Defaults to the start of the next UTC day (i.e. today's full data)."
        ),
    ),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> SummaryResponse:
    """Aggregate billing for a time interval.

    Calls ``energy_cost.summarize(session, start, end)`` which computes:

        total_payable = Σ(net_cost) + fixed_costs − credits

    where ``fixed_costs`` is (network_fee + management_fee) apportioned to the
    interval length in days (÷30 per month), and ``credits`` is heffingskorting
    apportioned similarly (÷365 per year).

    Both ``fixed_costs`` and ``credits`` are derived from the **currently active
    contract version at ``end``**.  When no active contract exists they are 0.
    """
    if start is None or end is None:
        # Default to the server's local today: [local_midnight, next_local_midnight).
        # This ensures "today" aligns with the local calendar day (NL time) rather
        # than UTC midnight.
        local_today = local_now().date()
        if start is None:
            start = local_midnight_utc(local_today)
        if end is None:
            end = local_midnight_utc(local_today + timedelta(days=1))

    result = summarize(db, _as_utc(start), _as_utc(end))
    return SummaryResponse(**result)


# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest
# ---------------------------------------------------------------------------


@router.get("/dsmr/latest", response_model=DsmrLatestResponse)
def get_dsmr_latest(
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
) -> DsmrLatestResponse:
    """Return the most recent dsmr_reading row.

    Returns ``{"found": false, "recorded_at": null, "payload": null}`` (200, not
    404) when no rows exist yet, so the front-end can distinguish "no data" from
    a server error.
    """
    row = db.execute(
        select(DsmrReading)
        .order_by(DsmrReading.recorded_at.desc())
        .limit(1)
    ).scalar_one_or_none()

    if row is None:
        return DsmrLatestResponse(found=False)

    return DsmrLatestResponse(
        found=True,
        recorded_at=_as_utc(row.recorded_at),
        payload=row.payload,
    )


# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute
# ---------------------------------------------------------------------------


@router.post(
    "/costs/recompute",
    responses={
        200: {"model": RecomputeResponse},
        422: {"description": "Validation error (missing window or range too large)"},
    },
)
def post_recompute(
    start: datetime = Query(
        ...,
        description="Inclusive start of the recompute window (ISO 8601). Required.",
    ),
    end: datetime = Query(
        ...,
        description="Exclusive end of the recompute window (ISO 8601). Required.",
    ),
    db: Session = Depends(get_db),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> RecomputeResponse:
    """Idempotently recompute billing records in a time window.

    Calls ``energy_cost.recompute_range(session, start, end)`` which overwrites
    existing rows (including successful ones) for every UTC quarter-hour boundary
    in ``[start, end)``.

    **Idempotency**: repeated calls with the same window produce the same
    outcome.  No rows are deleted; only upserted.

    **Window constraint**: the maximum allowed range is {_RECOMPUTE_MAX_DAYS} days.
    Requests exceeding this return 422.

    Returns the number of periods for which a billing record was written.
    Periods skipped due to missing contract or missing Tibber price are not counted.
    """
    start_utc = _as_utc(start)
    end_utc = _as_utc(end)

    if end_utc <= start_utc:
        from fastapi import HTTPException
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="'end' must be strictly after 'start'.",
        )

    span_days = (end_utc - start_utc).total_seconds() / 86400
    if span_days > _RECOMPUTE_MAX_DAYS:
        from fastapi import HTTPException
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=(
                f"Time window is {span_days:.1f} days which exceeds the maximum of "
                f"{_RECOMPUTE_MAX_DAYS} days.  Use a smaller window."
            ),
        )

    n = recompute_range(db, start_utc, end_utc)
    logger.info(
        "POST /api/energy/costs/recompute [%s, %s): wrote %d period(s).",
        start_utc.isoformat(),
        end_utc.isoformat(),
        n,
    )
    return RecomputeResponse(recomputed=n)


# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test
# ---------------------------------------------------------------------------


@router.post(
    "/tibber/test",
    responses={
        200: {"model": TibberTestResponse},
        400: {"model": TibberTestResponse},
        502: {"model": TibberTestResponse},
    },
)
def post_tibber_test(
    settings: Settings = Depends(get_app_settings),
    _auth: AuthenticatedSession = Depends(require_session),
    _csrf: None = Depends(require_csrf),
) -> JSONResponse:
    """Test Tibber API connectivity by fetching the current price point.

    Three possible outcomes:

    - **200** ``{ result: "success",      message: ..., price: {...} }``
      The Tibber API responded with a valid current price.  ``price`` contains
      starts_at, total, energy, tax, currency, and level.

    - **400** ``{ result: "config-error", message: ... }``
      The Tibber API token is empty or not configured.

    - **502** ``{ result: "failed",       message: ... }``
      The API call failed (authentication rejected, network error, timeout,
      unexpected response, etc.).

    The API token is **never** included in the response body or logged.
    """
    token = settings.tibber_api_token
    home_id = settings.tibber_home_id or None  # treat empty string as None

    if not token:
        logger.info("POST /api/energy/tibber/test: no token configured.")
        return JSONResponse(
            status_code=status.HTTP_400_BAD_REQUEST,
            content=TibberTestResponse(
                result="config-error",
                message=(
                    "Tibber API token is not configured.  "
                    "Set TIBBER_API_TOKEN in the Config page."
                ),
                price=None,
            ).model_dump(mode="json"),
        )

    try:
        price_point = fetch_current_price(token, home_id)
    except TibberAuthError:
        logger.warning("POST /api/energy/tibber/test: authentication failed.")
        return JSONResponse(
            status_code=status.HTTP_502_BAD_GATEWAY,
            content=TibberTestResponse(
                result="failed",
                message=(
                    "Tibber API authentication failed.  "
                    "Check that your API token is correct."
                ),
                price=None,
            ).model_dump(mode="json"),
        )
    except TibberError as exc:
        logger.warning("POST /api/energy/tibber/test: API call failed — %s", exc)
        return JSONResponse(
            status_code=status.HTTP_502_BAD_GATEWAY,
            content=TibberTestResponse(
                result="failed",
                message=f"Tibber API call failed: {exc}",
                price=None,
            ).model_dump(mode="json"),
        )

    price_schema = TibberTestPriceSchema(
        starts_at=price_point.starts_at,
        total=price_point.total,
        energy=price_point.energy,
        tax=price_point.tax,
        currency=price_point.currency,
        level=price_point.level,
    )

    logger.info(
        "POST /api/energy/tibber/test: success (starts_at=%s, total=%s %s).",
        price_point.starts_at.isoformat(),
        price_point.total,
        price_point.currency,
    )

    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content=TibberTestResponse(
            result="success",
            message=(
                f"Tibber API connected.  Current price: "
                f"{price_point.total} {price_point.currency}/kWh "
                f"(starts {price_point.starts_at.isoformat()})."
            ),
            price=price_schema,
        ).model_dump(mode="json"),
    )
