"""ORM models for the Feature Store and model serving deployments (#15).

Epic #1082 (Gap 2 — MLflow → KServe automation) M1 — adds k8s state
columns to ``model_deployments`` so the API can track real cluster lifecycle
(``deployment_status`` phase, ``k8s_resource_name``, ``last_apply_at``,
``error_message``, ``traffic_split`` JSON, ``serving_runtime``,
``resource_profile``). These remain *additive* — ADR-002 baseline policy
keeps schema drift fixes inside ``_COLUMN_MIGRATIONS`` rather than a new
Alembic migration (see ``apps/api/src/gend_api/db/init_db.py``).
"""

import uuid
from datetime import UTC, datetime

import sqlalchemy as sa
from sqlalchemy import (
    Boolean,
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    Integer,
    String,
    Text,
)
from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column

from ._base import Base

# ── Enum-like constants (Epic #1082 M1 — exported via __init__.py) ─────────
#
# We expose tuples rather than ``enum.Enum`` so they can be re-exported from
# the package ``__all__`` and consumed by router validation / CHECK constraint
# DDL without introducing an extra ``str.value`` indirection. Mirrors the
# ML_BATCH_*_STATUS_VALUES pattern already used in ``ml_batch.py``.

MODEL_DEPLOYMENT_STATUS_VALUES: tuple[str, ...] = (
    "pending",
    "deploying",
    "ready",
    "degraded",
    "failed",
    "deleting",
)
"""K8s reconciliation phase tracked in ``model_deployments.deployment_status``.

- ``pending``: row created, KServeDeployer.apply not yet invoked.
- ``deploying``: apply succeeded, waiting for KServe controller ``Ready=True``.
- ``ready``: ``InferenceService.status.conditions[?type=Ready].status == True``.
- ``degraded``: previously ready, currently not-ready (Pod crash / scale 0).
- ``failed``: apply or reconcile error — see ``error_message``.
- ``deleting``: delete() in flight, K8s tombstone may still be present.
"""

MODEL_DEPLOYMENT_RESOURCE_PROFILE_VALUES: tuple[str, ...] = (
    "small",
    "medium",
    "large",
    "gpu-t4",
)
"""Pre-defined resource request/limit presets for predictor pods.

Concrete CPU/RAM values are mapped in
``services/serving/templates.py:_RESOURCE_PROFILE_PRESETS``. ``gpu-t4`` is
reserved for M3 (no GPU node pool in M1/M2). Operators choose a profile to
keep ServingRuntime resource decisions out of router payloads (avoids
arbitrary client-side CPU/RAM injection that could violate quota policy).
"""


# ── Feature Store (#15) ──────────────────────────────────────────────────────


class FeatureDefinition(Base):
    """Feature Store — feature definition metadata."""

    __tablename__ = "feature_definitions"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
    )
    feature_name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    entity_key: Mapped[str] = mapped_column(String(255), nullable=False)
    value_type: Mapped[str] = mapped_column(String(50), nullable=False)
    source_table: Mapped[str] = mapped_column(String(500), nullable=False)
    source_column: Mapped[str] = mapped_column(String(255), nullable=False)
    tags: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
    owner: Mapped[str | None] = mapped_column(String(255), nullable=True)
    # Phase 5 — online serving readiness flag. Default false; actual
    # online toggle/materialize is out of scope for this slice. Exposes
    # real data to the UI "online" placeholder.
    online_enabled: Mapped[bool] = mapped_column(
        Boolean, nullable=False, server_default=sa.false()
    )
    # #1018 M2 Step 7 — Workspace tenant scope. **NOT NULL** (#2798 P4b 시행 · #2927 모델 정렬).
    workspace_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("workspaces.id", ondelete="RESTRICT"),
        nullable=False,
        index=True,
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
        onupdate=lambda: datetime.now(UTC),
    )


class ModelDeployment(Base):
    """Model Serving — deployment record.

    Epic #1082 M1 — extended with k8s lifecycle state columns. ``status`` is
    preserved for backward compatibility with PR #1056 callers (BI / UI
    columns that still read ``status``). New code SHOULD read
    ``deployment_status`` which carries the canonical K8s reconciliation
    phase (``MODEL_DEPLOYMENT_STATUS_VALUES``).
    """

    __tablename__ = "model_deployments"
    __table_args__ = (
        # CHECK constraint mirrors ``MODEL_DEPLOYMENT_STATUS_VALUES`` so direct
        # SQL inserts cannot smuggle in unexpected phase values. SQLite (test)
        # also honours CHECK constraints — guard exercised in unit tests.
        CheckConstraint(
            "deployment_status IN ('pending', 'deploying', 'ready', "
            "'degraded', 'failed', 'deleting')",
            name="ck_model_deployments_deployment_status",
        ),
        # Hot-path filter: "show me everything not-ready" / "show failed in
        # last 24h" — both predicates land on ``deployment_status``.
        Index("ix_model_deployments_status", "deployment_status"),
    )

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
    )
    model_name: Mapped[str] = mapped_column(String(255), nullable=False)
    model_version: Mapped[str] = mapped_column(String(50), nullable=False)
    mlflow_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
    endpoint_name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
    replicas: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
    canary_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

    # ── Epic #1082 M1 — k8s lifecycle state ──────────────────────────────
    # Canonical K8s reconciliation phase. ``status`` above is preserved for
    # BC; ``deployment_status`` is the source of truth for the new /deploy
    # /rollback /k8s-status routes.
    deployment_status: Mapped[str] = mapped_column(
        String(50), nullable=False, default="pending", server_default="pending",
    )
    # DNS-1123 normalised K8s object name. Differs from ``endpoint_name``
    # (user-friendly slug) — see ADR-003 (#1056). NULL until first apply.
    k8s_resource_name: Mapped[str | None] = mapped_column(
        String(253), nullable=True,
    )
    # Namespace owning the InferenceService. Default ``gend`` matches the
    # main app namespace; multi-tenant deployments may pin per-workspace
    # namespaces later (M3).
    k8s_namespace: Mapped[str] = mapped_column(
        String(63), nullable=False, default="gend", server_default="gend",
    )
    # Last successful server-side apply timestamp (UTC). Set in the /deploy
    # router after ``KServeDeployer.apply()`` returns 2xx.
    last_apply_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True,
    )
    # First moment ``Ready=True`` was observed for the current revision.
    # Reset to NULL on rollback so the watcher can re-arm.
    last_ready_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True,
    )
    # Last reconcile error surfaced from KServe controller or apply path.
    # Cleared (NULL) when status transitions back to ``ready``.
    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
    # Multi-version weighted routing ``{"v1": 90, "v2": 10}``. M1 is single
    # version (M2 will set this). JSONB on PG, JSON on SQLite — declared via
    # ``with_variant`` so test DB does not require JSONB extension.
    traffic_split: Mapped[dict | None] = mapped_column(
        JSON().with_variant(JSONB, "postgresql"),
        nullable=True,
    )
    # KServe ServingRuntime name (e.g. ``kserve-mlserver``, ``kserve-torch``).
    # ``kserve-mlserver`` covers sklearn/xgboost/lightgbm/pyfunc via MLServer.
    serving_runtime: Mapped[str] = mapped_column(
        String(100),
        nullable=False,
        default="kserve-mlserver",
        server_default="kserve-mlserver",
    )
    # Resource preset key. See ``MODEL_DEPLOYMENT_RESOURCE_PROFILE_VALUES``.
    # Concrete CPU/RAM/GPU translation lives in
    # ``services/serving/templates.py``.
    resource_profile: Mapped[str] = mapped_column(
        String(50),
        nullable=False,
        default="small",
        server_default="small",
    )

    # #1018 M2 Step 7 — Workspace tenant scope. **NOT NULL** (#2798 P4b 시행 · #2927 모델 정렬).
    workspace_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("workspaces.id", ondelete="RESTRICT"),
        nullable=False,
        index=True,
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(UTC),
        onupdate=lambda: datetime.now(UTC),
    )
