"""SQLAlchemy models for the energy pricing and DSMR metering subsystem.

Six tables:
  - meter:                    physical electricity meter lifecycle epoch.
  - dsmr_reading:             raw DSMR telegram blobs (10-second down-sampled).
  - energy_contract:          contract head (manual or tibber, one active at a time).
  - energy_contract_version:  versioned pricing values; append-only for auditability.
  - tibber_price:             cached Tibber 15-minute spot prices (immutable).
  - energy_cost_period:       computed 15-minute billing periods (immutable snapshot).
"""

from __future__ import annotations

import uuid as _uuid
from datetime import datetime

from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON

from app.db import Base


def _uuid4_str() -> str:
    return str(_uuid.uuid4())


class Meter(Base):
    """One physical electricity meter's installation epoch.

    A ``meter`` record represents the period ``[started_at, ended_at)`` during
    which a particular physical meter was installed and active.  Replacing a meter
    (swap, home move, etc.) is modelled by closing the current record
    (``ended_at = swap_timestamp``) and opening a new one
    (``started_at = swap_timestamp``).

    **Invariant**: for each ``commodity`` there is at most one active meter
    (``ended_at IS NULL``) at any point in time.  The service layer enforces
    this — no DB-level constraint is added to keep the migration simple and to
    allow the application to return a meaningful error message.

    ``commodity`` defaults to ``"electricity"``; the field is a free-form string
    (no CHECK constraint) so future commodities (``gas``, ``heating``) can be
    added without a schema change.

    ``reason`` captures why this epoch started — one of ``initial``,
    ``meter_swap``, ``home_move``, or ``other`` — stored as a plain string so
    the application layer controls the allowed set.
    """

    __tablename__ = "meter"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # Stable internal identity — used as HA Discovery unique_id anchor.
    uuid: Mapped[str] = mapped_column(
        String(36), unique=True, nullable=False, default=_uuid4_str
    )

    # Human-readable label for this physical meter (e.g. address, serial, tariff zone).
    label: Mapped[str] = mapped_column(String(255), nullable=False)

    # Energy commodity this meter measures.  Defaults to "electricity".
    commodity: Mapped[str] = mapped_column(String(32), nullable=False, default="electricity")

    # UTC timestamp when this meter epoch starts (inclusive).  May be in the past
    # (retroactive declaration); effective billing start = max(started_at, data start).
    started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

    # UTC timestamp when this meter epoch ends (exclusive).  NULL = currently active.
    ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

    # Why this epoch was created.  Application-layer validation enforces the
    # allowed set; no CHECK constraint to keep migrations simple.
    reason: Mapped[str] = mapped_column(String(64), nullable=False)

    # Free-form note (e.g. location, physical meter id, reason details).
    note: Mapped[str | None] = mapped_column(String(1024), nullable=True)

    # UTC timestamp of when this row was created.
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

    # Relationship to cost periods attributed to this meter epoch (not loaded eagerly).
    cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
        back_populates="meter", cascade="save-update, merge"
    )


class DsmrReading(Base):
    """One down-sampled DSMR telegram stored as a full JSON blob.

    Identity & idempotency are **independent of the DSMR Reader's telegram id**
    (that field overflows and must be manually reset to zero — a known DSMR
    quirk — so relying on it for uniqueness risks silently dropping new data).
    The table's own autoincrement ``id`` PK is the stable internal identity, and
    ``recorded_at`` (the telegram timestamp) is the UNIQUE de-duplication key: a
    single P1 meter emits exactly one telegram per timestamp.

    ``recorded_at`` is a real column (not inside the payload) so time-range
    queries are efficient.  The entire telegram frame is stored verbatim in
    ``payload``; no field allow-list is applied so future commodities (gas,
    heating, three-phase) are accommodated without a schema change.
    """

    __tablename__ = "dsmr_reading"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # UTC timestamp of the sample — real column, UNIQUE (telegram-id-independent
    # idempotency key).  The unique index also serves time-range queries.
    recorded_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, unique=True
    )

    # Telegram's own id (DSMR Reader assigns it).  Stored only as a reference /
    # debugging aid — NOT used for uniqueness or idempotency (it overflows and
    # gets reset to zero).  Nullable because some DSMR sources may not emit one.
    source_id: Mapped[int | None] = mapped_column(Integer, nullable=True)

    # Full telegram frame as a JSON object; values are typically JSON strings
    # (e.g. "20915.154") — callers must cast to Decimal before arithmetic.
    payload: Mapped[dict] = mapped_column(JSON, nullable=False)


class EnergyContract(Base):
    """Contract head: a named energy contract with a chosen pricing strategy.

    ``kind`` determines which price strategy is used (``manual`` for fixed
    dual-tariff rates entered by the user, ``tibber`` for dynamic API prices).
    Only one contract may be ``active`` at a time; the service layer enforces
    mutual exclusion.  Specific pricing values live in ``EnergyContractVersion``
    so that price changes can be tracked without modifying historical records.
    """

    __tablename__ = "energy_contract"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # Human-readable label; freely editable by the user.
    name: Mapped[str] = mapped_column(String(255), nullable=False)

    # Strategy selector: "manual" or "tibber".  Application-layer validation
    # enforces the allowed set; no DB CHECK constraint is added to keep the
    # migration simple and the strategy registry extensible.
    kind: Mapped[str] = mapped_column(String(32), nullable=False)

    # Whether this is the currently active contract (at most one should be True).
    active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

    # ISO 4217 currency code for all monetary values in this contract.
    currency: Mapped[str] = mapped_column(String(8), nullable=False, default="EUR")

    # Audit timestamps.
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

    # Relationship to versions (back-reference; not loaded eagerly).
    versions: Mapped[list["EnergyContractVersion"]] = relationship(
        back_populates="contract", cascade="save-update, merge"
    )


class EnergyContractVersion(Base):
    """One time-bounded version of an energy contract's pricing values.

    Pricing changes are modelled as new versions (append-only); existing versions
    are never modified so that historical ``EnergyCostPeriod`` records remain
    fully auditable.  ``effective_to`` is ``NULL`` for the currently open version.
    """

    __tablename__ = "energy_contract_version"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # FK to the parent contract.  RESTRICT prevents deletion of a contract that
    # still has versioned pricing rows attached to it.
    contract_id: Mapped[int] = mapped_column(
        ForeignKey("energy_contract.id", ondelete="RESTRICT"), nullable=False
    )

    # Start of this version's validity window (inclusive, UTC).
    effective_from: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )

    # End of this version's validity window (exclusive, UTC).  NULL means open-ended
    # (i.e. this is the most recent / current version).
    effective_to: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True
    )

    # Pricing values as a JSON object conforming to the profile structure for
    # ``contract.kind`` (validated by the application layer against the YAML profile).
    values: Mapped[dict] = mapped_column(JSON, nullable=False)

    # Creation timestamp.
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

    # Relationship back to the parent contract.
    contract: Mapped["EnergyContract"] = relationship(back_populates="versions")

    # Relationship to cost periods that reference this version.
    cost_periods: Mapped[list["EnergyCostPeriod"]] = relationship(
        back_populates="contract_version", cascade="save-update, merge"
    )


class TibberPrice(Base):
    """Cached Tibber 15-minute spot price point (immutable once fetched).

    ``starts_at`` is unique so that upserts are idempotent.  Past prices are
    never overwritten; the fetch job only adds rows for future time slots.
    """

    __tablename__ = "tibber_price"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # UTC start of the 15-minute slot; unique so upsert is idempotent.
    starts_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, unique=True
    )

    # Resolution label as returned by the Tibber API (e.g. "QUARTER_HOURLY").
    resolution: Mapped[str] = mapped_column(String(32), nullable=False)

    # Price components in the contract currency (all include VAT, user-facing).
    energy: Mapped[float] = mapped_column(Float, nullable=False)
    tax: Mapped[float] = mapped_column(Float, nullable=False)
    total: Mapped[float] = mapped_column(Float, nullable=False)

    # Tibber price level (e.g. "NORMAL", "CHEAP", "EXPENSIVE"); may be absent.
    level: Mapped[str | None] = mapped_column(String(32), nullable=True)

    # ISO 4217 currency code as returned by the API.
    currency: Mapped[str] = mapped_column(String(8), nullable=False)

    # UTC timestamp of when this row was fetched/inserted.
    fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)


class EnergyCostPeriod(Base):
    """Computed billing record for one 15-minute metering period (immutable snapshot).

    Each row captures the per-register kWh deltas, the resulting import cost and
    export revenue, and a full snapshot of the pricing values used so that the
    calculation is fully auditable and reproducible without re-querying the
    contract version.  Rows are written once and never modified; explicit
    recomputation via the API is the only way to overwrite a period.
    """

    __tablename__ = "energy_cost_period"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # UTC start of the 15-minute period; unique so upsert is idempotent.
    period_start: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, unique=True
    )

    # Per-register kWh deltas for the period (end minus start of cumulative registers).
    # _1 = dal/low-tariff, _2 = normal/high-tariff (NL convention).
    d1_kwh: Mapped[float] = mapped_column(Float, nullable=False)  # delivered low
    d2_kwh: Mapped[float] = mapped_column(Float, nullable=False)  # delivered high
    r1_kwh: Mapped[float] = mapped_column(Float, nullable=False)  # returned low
    r2_kwh: Mapped[float] = mapped_column(Float, nullable=False)  # returned high

    # Computed monetary amounts for the period (in ``currency``).
    import_cost: Mapped[float] = mapped_column(Float, nullable=False)
    export_revenue: Mapped[float] = mapped_column(Float, nullable=False)
    net_cost: Mapped[float] = mapped_column(Float, nullable=False)

    # ISO 4217 currency code matching the contract.
    currency: Mapped[str] = mapped_column(String(8), nullable=False)

    # Full snapshot of the pricing inputs used during computation.  This makes
    # each row self-contained and auditable even if the contract is later changed.
    pricing: Mapped[dict] = mapped_column(JSON, nullable=False)

    # FK to the exact contract version whose values were used.  RESTRICT prevents
    # deletion of a version that has cost records attached.  Nullable to support
    # periods computed in ``degraded`` mode (missing price data).
    contract_version_id: Mapped[int | None] = mapped_column(
        ForeignKey("energy_contract_version.id", ondelete="RESTRICT"), nullable=True
    )

    # FK to the meter epoch this period belongs to.  RESTRICT prevents deletion of
    # a meter that still has attributed cost periods.  Nullable for backwards
    # compatibility (pre-M7 rows) and degraded periods where the meter was not
    # determinable.
    meter_id: Mapped[int | None] = mapped_column(
        ForeignKey("meter.id", ondelete="RESTRICT"), nullable=True
    )

    # True when the period was computed with incomplete data (missing readings or
    # missing price); serves as a flag for later recomputation.
    degraded: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

    # UTC timestamp of when this row was computed/inserted.
    computed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

    # Relationship back to the contract version.
    contract_version: Mapped["EnergyContractVersion | None"] = relationship(
        back_populates="cost_periods"
    )

    # Relationship back to the meter epoch.
    meter: Mapped["Meter | None"] = relationship(back_populates="cost_periods")


# Index on recorded_at for efficient time-range queries on DSMR readings.
# (The ORM-level index=True on recorded_at already creates ix_dsmr_reading_recorded_at;
# no composite index is needed for single-meter deployments.)

# Index on period_start is covered by the unique constraint (SQLite creates an
# implicit index for UNIQUE columns), so no additional index is required.

# Index on starts_at for TibberPrice is covered by the unique constraint similarly.
