"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup.

All functions accept an explicit SQLAlchemy Session; callers are responsible
for committing or rolling back the transaction.

Design decisions
----------------
- ``meter_at``:       half-open interval ``[started_at, ended_at)`` lookup;
  returns the meter whose epoch covers *ts* for the given commodity.
  SQLite naive datetime is normalised via ``_as_utc()`` before comparison.

- ``declare_meter``:  validates that the new ``started_at`` is **not earlier
  than** the current active meter's ``started_at`` (rejects back-dating below
  the active meter's own start).  Equal timestamps are allowed because the
  typical "swap now" use-case sets ``started_at`` to the current moment, which
  coincides with the active meter's ``started_at`` only in degenerate test
  scenarios — but blocking equal values would make that workflow impossible.
  The old active meter is closed (``ended_at = started_at``) and a new active
  meter is opened in the same operation, guaranteeing continuity: the old
  meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous,
  no gap, no overlap).

- ``update_meter``:   when ``started_at`` is modified, the service keeps the
  timeline contiguous by also updating the **previous** meter's ``ended_at``
  (the one whose ``ended_at`` matched the old ``started_at``) to the new
  ``started_at``.  Validation ensures the new ``started_at``:
    * is strictly after the previous meter's own ``started_at``
      (cannot push the boundary before the previous meter even started);
    * is strictly before the current meter's ``ended_at``, if set
      (cannot push the boundary past where the current meter was already
      closed).

- Mutual exclusion (at most one active meter per commodity) is enforced by the
  service layer; no DB-level unique partial index is added to keep migrations
  simple and to allow the application to return a meaningful error message.

SQLite timezone note
--------------------
SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on
read-back they come out as **timezone-naive** datetimes.  Wherever this code
compares timestamps from the DB against timezone-aware values, it calls
``_as_utc()`` to make both sides comparable without tripping on
"offset-naive vs offset-aware" TypeErrors.
"""

from __future__ import annotations

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

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.energy import Meter

logger = logging.getLogger(__name__)


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


def _as_utc(dt: datetime) -> datetime:
    """Return *dt* as a timezone-aware UTC datetime.

    SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive
    UTC strings and gives them back as naive datetimes on read.  This helper
    re-attaches the UTC timezone info when it is missing, making cross-origin
    comparisons safe.
    """
    if dt.tzinfo is None:
        return dt.replace(tzinfo=UTC)
    return dt


# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------


class MeterError(ValueError):
    """Base class for meter service validation errors."""


class MeterOverlapError(MeterError):
    """Raised when a new meter's ``started_at`` would create an overlap or backdate.

    Specifically: the new meter's ``started_at`` must be greater than or equal
    to the current active meter's ``started_at``.  Allowing a value strictly
    earlier than the active meter's start would mean the new epoch begins before
    the current one, which is chronologically inconsistent.
    """


class MeterIntervalError(MeterError):
    """Raised when an ``update_meter`` call would produce an inconsistent interval.

    Examples of inconsistent intervals:
    - New ``started_at`` ≥ this meter's ``ended_at`` (epoch would be empty/inverted).
    - New ``started_at`` ≤ the previous meter's own ``started_at`` (previous meter
      would become empty/inverted after its ``ended_at`` is updated).
    """


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


def _active_meter(session: Session, commodity: str) -> Optional[Meter]:
    """Return the current active (``ended_at IS NULL``) meter for *commodity*, or None."""
    return session.execute(
        select(Meter)
        .where(
            Meter.commodity == commodity,
            Meter.ended_at.is_(None),
        )
        .limit(1)
    ).scalar_one_or_none()


def _meter_before(session: Session, meter: Meter) -> Optional[Meter]:
    """Return the meter whose ``ended_at`` equals *meter*'s ``started_at``.

    This is the meter that was closed when *meter* was opened; its ``ended_at``
    needs to stay equal to *meter*'s ``started_at`` to maintain timeline
    continuity.  Returns None if *meter* is the first epoch for its commodity.

    The lookup compares naive/aware datetimes via string to avoid SQLite timezone
    quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause.
    We rely on the fact that the service layer always stores the same timestamp
    object as both ``prev.ended_at`` and ``new.started_at``, so their string
    representations are identical.
    """
    # Normalise the target to a tz-aware UTC datetime for comparison.
    # We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware
    # mismatch issues when comparing DateTime columns against tz-aware values.
    target = _as_utc(meter.started_at)

    # Fetch all closed meters of the same commodity and find the one whose
    # ended_at equals this meter's started_at (the standard contiguous handoff).
    candidates = session.execute(
        select(Meter)
        .where(
            Meter.commodity == meter.commodity,
            Meter.ended_at.is_not(None),
        )
    ).scalars().all()

    for candidate in candidates:
        if candidate.id == meter.id:
            continue
        candidate_ended = _as_utc(candidate.ended_at)
        if candidate_ended == target:
            return candidate

    return None


# ---------------------------------------------------------------------------
# Core service functions
# ---------------------------------------------------------------------------


def meter_at(
    session: Session,
    ts: datetime,
    commodity: str = "electricity",
) -> Optional[Meter]:
    """Return the meter epoch that covers *ts* for *commodity*.

    A meter covers *ts* when:
        ``started_at ≤ ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``)

    This is the standard half-open interval ``[started_at, ended_at)`` lookup,
    consistent with ``active_contract_version_at`` in ``contracts.py``.

    Returns ``None`` when no meter covers *ts* (e.g. before the first epoch
    was declared, or after a gap).

    Parameters
    ----------
    session:
        Active SQLAlchemy session (read-only usage).
    ts:
        UTC datetime to look up.
    commodity:
        Energy commodity (default ``"electricity"``).

    Returns
    -------
    Meter | None

    Implementation note — why the upper bound is pushed into SQL
    ------------------------------------------------------------
    When ``declare_meter`` is called with ``started_at == active.started_at``
    (the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a
    zero-width epoch ``[T0, T0)`` and the new active meter also has
    ``started_at == T0``.  Two rows share the same ``started_at``; SQLite's
    row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by
    rowid (i.e. insertion order), which returns the older zero-width row first.

    If the upper bound were checked in Python *after* fetching that one row, the
    condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because
    ``ended_at == T0``), causing the function to return ``None`` and making the
    new active meter permanently invisible.

    Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity:
    the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is
    applied, so only the genuinely covering row survives.

    SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the
    ``tzinfo`` from aware datetimes when binding parameters (it does *not*
    convert to UTC first).  Since all datetimes in this codebase are UTC (either
    naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping
    the tzinfo leaves the wall-clock value unchanged and comparisons remain
    correct.  This is consistent with how ``contracts.py`` handles the same
    situation (see OBS 2 in the M7-T02 review notes).
    """
    # Push both the lower and upper bounds into the SQL WHERE clause so that
    # zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1
    # is applied.  This prevents an equal-timestamp swap from making the new
    # active meter invisible (see implementation note above).
    stmt = (
        select(Meter)
        .where(
            Meter.commodity == commodity,
            Meter.started_at <= ts,
            (Meter.ended_at.is_(None)) | (Meter.ended_at > ts),
        )
        .order_by(Meter.started_at.desc())
        .limit(1)
    )
    return session.execute(stmt).scalar_one_or_none()


def declare_meter(
    session: Session,
    *,
    label: str,
    started_at: datetime,
    reason: str,
    commodity: str = "electricity",
    note: Optional[str] = None,
) -> Meter:
    """Declare a meter swap or initial meter epoch.

    Closes the current active meter for *commodity* (if one exists) by setting
    its ``ended_at`` to *started_at*, then opens a new active meter.  The
    resulting timeline is **contiguous**: old meter's ``ended_at`` equals new
    meter's ``started_at``.

    If there is no current active meter (first-ever declaration for this
    commodity), the new meter is simply opened without closing anything.

    Validation
    ----------
    - If a current active meter exists, *started_at* must be **≥** that meter's
      own ``started_at``.  A value strictly earlier would place the new epoch
      entirely before the current active meter, which is a chronological
      contradiction ("back-dating before the active epoch's start").  Raises
      ``MeterOverlapError`` when this constraint is violated.

    Note: equal timestamps (``started_at == active.started_at``) are allowed
    because that scenario effectively replaces the current meter at the same
    logical moment (e.g. correcting a mis-entry), which is a valid use-case.
    The old meter is then closed with ``ended_at == started_at`` (a zero-width
    epoch), which is intentional and auditable.

    Parameters
    ----------
    session:
        Active SQLAlchemy session.  Caller must commit after this returns.
    label:
        Human-readable label for the new meter.
    started_at:
        UTC datetime at which this meter epoch starts.  May be in the past.
    reason:
        Why this epoch was created (``"initial"``, ``"meter_swap"``,
        ``"home_move"``, or ``"other"``).
    commodity:
        Energy commodity (default ``"electricity"``).
    note:
        Optional free-form note.

    Returns
    -------
    Meter
        The newly created, not-yet-committed active meter.

    Raises
    ------
    MeterOverlapError
        If *started_at* is strictly earlier than the current active meter's
        ``started_at`` (chronological backdate below the active epoch's start).
    """
    active = _active_meter(session, commodity)

    if active is not None:
        # Reject back-dating: new started_at must be ≥ current active's started_at.
        if _as_utc(started_at) < _as_utc(active.started_at):
            raise MeterOverlapError(
                f"New meter started_at ({started_at.isoformat()}) must be ≥ the current "
                f"active {commodity!r} meter's started_at "
                f"({active.started_at.isoformat()}).  "
                "Declare a started_at on or after the active meter's start to avoid "
                "a chronologically inconsistent epoch ordering."
            )
        # Close the current active meter at the swap point (contiguous handoff).
        active.ended_at = started_at
        logger.info(
            "Closed active %r meter id=%d (ended_at=%s)",
            commodity,
            active.id,
            started_at.isoformat(),
        )

    now = datetime.now(UTC)
    new_meter = Meter(
        label=label,
        commodity=commodity,
        started_at=started_at,
        ended_at=None,
        reason=reason,
        note=note,
        created_at=now,
    )
    session.add(new_meter)

    logger.info(
        "Declared new %r meter %r (started_at=%s, reason=%s)",
        commodity,
        label,
        started_at.isoformat(),
        reason,
    )
    return new_meter


def list_meters(
    session: Session,
    commodity: Optional[str] = None,
) -> list[Meter]:
    """List all meter epochs, ordered by started_at ascending.

    Active meters (``ended_at IS NULL``) sort naturally to the end of the
    timeline since they have the latest ``started_at``.  Within a single
    commodity, the ascending ``started_at`` order reflects the historical
    sequence of installed meters.

    Parameters
    ----------
    session:
        Active SQLAlchemy session (read-only usage).
    commodity:
        If provided, filter to this commodity only.  If ``None``, return all
        meters across all commodities.

    Returns
    -------
    list[Meter]
        Meters ordered by (started_at ASC).
    """
    stmt = select(Meter).order_by(Meter.started_at.asc())
    if commodity is not None:
        stmt = stmt.where(Meter.commodity == commodity)

    return list(session.execute(stmt).scalars().all())


def update_meter(
    session: Session,
    meter: Meter,
    *,
    label: Optional[str] = None,
    note: Optional[str] = None,
    started_at: Optional[datetime] = None,
) -> Meter:
    """Update a meter's mutable fields (label, note, started_at).

    Passing ``None`` for a field leaves it unchanged.  At least one keyword
    argument must be non-``None``; calling with all-``None`` is a no-op but
    is not an error.

    Updating ``started_at`` (retroactive correction)
    ------------------------------------------------
    When ``started_at`` is provided the service maintains **timeline
    continuity** across the adjacent meter boundaries:

    1. **Previous meter's ``ended_at``** — if the meter immediately before
       this one has ``ended_at == meter.started_at`` (the standard contiguous
       handoff), its ``ended_at`` is updated to the new ``started_at`` so the
       boundary between the two epochs stays seamless.

    2. **Validation** — the new ``started_at`` is checked for consistency:
       a. It must be **strictly after** the previous meter's own ``started_at``
          (otherwise the previous meter's epoch would collapse to zero or
          invert).
       b. It must be **strictly before** this meter's ``ended_at`` (if set),
          so this meter's epoch remains non-empty.

    Note: triggering a billing recompute (``recompute_range``) after a
    retroactive ``started_at`` change is **out of scope** for this service
    layer; that is the API layer's responsibility (M7-T05).

    Parameters
    ----------
    session:
        Active SQLAlchemy session.  Caller must commit after this returns.
    meter:
        The ``Meter`` ORM instance to update (already loaded from the session).
    label:
        New human-readable label; ``None`` = keep existing.
    note:
        New free-form note; ``None`` = keep existing.
    started_at:
        New start timestamp for this meter epoch; ``None`` = keep existing.

    Returns
    -------
    Meter
        The updated ``Meter`` instance (not yet committed).

    Raises
    ------
    MeterIntervalError
        If the new ``started_at`` would produce an invalid (empty or inverted)
        epoch for this meter or the immediately preceding one.
    """
    if label is not None:
        meter.label = label
        logger.info("Updated meter id=%d label=%r", meter.id, label)

    if note is not None:
        meter.note = note
        logger.info("Updated meter id=%d note=%r", meter.id, note)

    if started_at is not None:
        old_started_at = meter.started_at

        # --- Validate upper bound: new started_at must be < this meter's ended_at (if set).
        if meter.ended_at is not None:
            if _as_utc(started_at) >= _as_utc(meter.ended_at):
                raise MeterIntervalError(
                    f"New started_at ({started_at.isoformat()}) must be strictly before "
                    f"this meter's ended_at ({meter.ended_at.isoformat()}).  "
                    "The meter epoch would become empty or inverted."
                )

        # --- Find the immediately preceding meter (its ended_at == meter's old started_at).
        prev = _meter_before(session, meter)

        # --- Validate lower bound: new started_at must be strictly after prev's started_at.
        if prev is not None:
            if _as_utc(started_at) <= _as_utc(prev.started_at):
                raise MeterIntervalError(
                    f"New started_at ({started_at.isoformat()}) must be strictly after "
                    f"the previous meter's started_at ({prev.started_at.isoformat()}).  "
                    "Moving the boundary that far back would collapse the previous meter's epoch."
                )
            # Maintain continuity: update the previous meter's ended_at to match the new start.
            prev.ended_at = started_at
            logger.info(
                "Updated previous meter id=%d ended_at=%s (boundary shift from %s)",
                prev.id,
                started_at.isoformat(),
                old_started_at.isoformat(),
            )

        meter.started_at = started_at
        logger.info(
            "Updated meter id=%d started_at=%s (was %s)",
            meter.id,
            started_at.isoformat(),
            old_started_at.isoformat(),
        )

    return meter
