"""
Caprail Eloquent-like ORM Model.

Active Record pattern built on SQLAlchemy 2.x async.

Usage::

    class User(Model):
        table = "users"
        fillable = ["name", "email"]

    user = await User.find(1)
    users = await User.where("active", True).order_by("name").get()
    new_user = await User.create(name="Alice", email="alice@example.com")
"""
from __future__ import annotations

import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any, ClassVar

import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from caprail.orm.query_builder import QueryBuilder

# Global engine/session factory (configured by DatabaseServiceProvider)
_engine = None
_session_factory: async_sessionmaker | None = None


def configure_database(url: str, **kwargs) -> None:
    global _engine, _session_factory
    _engine = create_async_engine(url, **kwargs)
    _session_factory = async_sessionmaker(_engine, expire_on_commit=False)


@asynccontextmanager
async def _get_session():
    if _session_factory is None:
        raise RuntimeError(
            "Database not configured. Call configure_database() or use DatabaseServiceProvider."
        )
    async with _session_factory() as session:
        yield session


class ModelMeta(type):
    """Metaclass that keeps a registry of all models."""
    _registry: dict[str, type] = {}

    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if name != "Model":
            ModelMeta._registry[name] = cls
        return cls


class ModelNotFoundError(Exception):
    pass


class Model(metaclass=ModelMeta):
    """
    Base model class. Subclass this for each database table.

    Class variables to configure::

        table      — table name (defaults to snake_case plural of class name)
        primary_key — column name (default: "id")
        fillable   — list of mass-assignable columns
        guarded    — list of columns that cannot be mass-assigned
        timestamps — auto-manage created_at / updated_at (default: True)
        hidden     — columns excluded from to_dict()
    """

    table: ClassVar[str] = ""
    primary_key: ClassVar[str] = "id"
    fillable: ClassVar[list[str]] = []
    guarded: ClassVar[list[str]] = ["id"]
    timestamps: ClassVar[bool] = True
    hidden: ClassVar[list[str]] = []
    ModelNotFoundError = ModelNotFoundError

    def __init__(self, **attributes) -> None:
        self._attributes: dict[str, Any] = {}
        self._original: dict[str, Any] = {}
        self._exists: bool = False
        for key, value in attributes.items():
            self._attributes[key] = value

    # ------------------------------------------------------------------
    # Attribute access
    # ------------------------------------------------------------------

    def __getattr__(self, name: str) -> Any:
        if name.startswith("_"):
            raise AttributeError(name)
        if name in self._attributes:
            return self._attributes[name]
        raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")

    def __setattr__(self, name: str, value: Any) -> None:
        if name.startswith("_") or name in type(self).__dict__:
            object.__setattr__(self, name, value)
        else:
            self._attributes[name] = value

    def get_attribute(self, key: str, default: Any = None) -> Any:
        return self._attributes.get(key, default)

    def set_attribute(self, key: str, value: Any) -> None:
        self._attributes[key] = value

    def fill(self, attributes: dict[str, Any]) -> "Model":
        for key, value in attributes.items():
            if self.fillable and key not in self.fillable:
                continue
            if key in self.guarded:
                continue
            self._attributes[key] = value
        return self

    def to_dict(self) -> dict[str, Any]:
        return {k: v for k, v in self._attributes.items() if k not in self.hidden}

    def to_json(self) -> str:
        import json
        return json.dumps(self.to_dict(), default=str)

    def is_dirty(self, *keys: str) -> bool:
        if not keys:
            return self._attributes != self._original
        return any(self._attributes.get(k) != self._original.get(k) for k in keys)

    # ------------------------------------------------------------------
    # Table name inference
    # ------------------------------------------------------------------

    @classmethod
    def _table_name(cls) -> str:
        if cls.table:
            return cls.table
        import re
        name = re.sub(r"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()
        return name + "s"

    @classmethod
    def _get_table(cls) -> sa.Table:
        meta = sa.MetaData()
        return sa.Table(cls._table_name(), meta, autoload_with=None)

    # ------------------------------------------------------------------
    # Query builder factory
    # ------------------------------------------------------------------

    @classmethod
    def query(cls) -> QueryBuilder:
        return QueryBuilder(cls)

    @classmethod
    def where(cls, column: str, operator_or_value: Any, value: Any = None) -> QueryBuilder:
        return cls.query().where(column, operator_or_value, value)

    @classmethod
    def or_where(cls, column: str, operator_or_value: Any, value: Any = None) -> QueryBuilder:
        return cls.query().or_where(column, operator_or_value, value)

    @classmethod
    def where_in(cls, column: str, values: list) -> QueryBuilder:
        return cls.query().where_in(column, values)

    @classmethod
    def order_by(cls, column: str, direction: str = "asc") -> QueryBuilder:
        return cls.query().order_by(column, direction)

    @classmethod
    def latest(cls, column: str = "created_at") -> QueryBuilder:
        return cls.query().latest(column)

    @classmethod
    def limit(cls, value: int) -> QueryBuilder:
        return cls.query().limit(value)

    @classmethod
    def with_relations(cls, *relations: str) -> QueryBuilder:
        return cls.query().with_relations(*relations)

    # ------------------------------------------------------------------
    # Finder methods
    # ------------------------------------------------------------------

    @classmethod
    async def find(cls, pk) -> "Model | None":
        return await cls.where(cls.primary_key, pk).first()

    @classmethod
    async def find_or_fail(cls, pk) -> "Model":
        instance = await cls.find(pk)
        if instance is None:
            raise ModelNotFoundError(f"{cls.__name__} with {cls.primary_key}={pk!r} not found.")
        return instance

    @classmethod
    async def all(cls) -> list["Model"]:
        return await cls.query().get()

    @classmethod
    async def first(cls) -> "Model | None":
        return await cls.query().first()

    @classmethod
    async def count(cls) -> int:
        return await cls.query().count()

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------

    @classmethod
    async def create(cls, **attributes) -> "Model":
        instance = cls()
        instance.fill(attributes)
        await instance.save()
        return instance

    async def save(self) -> bool:
        async with _get_session() as session:
            if self.timestamps:
                now = datetime.utcnow()
                if not self._exists:
                    self._attributes.setdefault("created_at", now)
                self._attributes["updated_at"] = now

            if self._exists:
                await self._perform_update(session)
            else:
                await self._perform_insert(session)
            await session.commit()
        self._original = dict(self._attributes)
        return True

    async def _perform_insert(self, session: AsyncSession) -> None:
        table = await self._reflect_table(session)
        result = await session.execute(
            sa.insert(table).values(**self._attributes)
        )
        pk = result.inserted_primary_key
        if pk:
            self._attributes[self.primary_key] = pk[0]
        self._exists = True

    async def _perform_update(self, session: AsyncSession) -> None:
        table = await self._reflect_table(session)
        pk_val = self._attributes.get(self.primary_key)
        await session.execute(
            sa.update(table)
            .where(table.c[self.primary_key] == pk_val)
            .values(**{k: v for k, v in self._attributes.items() if k != self.primary_key})
        )

    async def delete(self) -> bool:
        async with _get_session() as session:
            table = await self._reflect_table(session)
            pk_val = self._attributes.get(self.primary_key)
            await session.execute(
                sa.delete(table).where(table.c[self.primary_key] == pk_val)
            )
            await session.commit()
        self._exists = False
        return True

    async def fresh(self) -> "Model | None":
        pk_val = self._attributes.get(self.primary_key)
        return await type(self).find(pk_val)

    async def refresh(self) -> None:
        fresh = await self.fresh()
        if fresh:
            self._attributes = fresh._attributes

    # ------------------------------------------------------------------
    # Reflection helper
    # ------------------------------------------------------------------

    @classmethod
    async def _reflect_table(cls, session: AsyncSession) -> sa.Table:
        meta = sa.MetaData()
        async with session.bind.connect() as conn:
            await conn.run_sync(meta.reflect, only=[cls._table_name()])
        return meta.tables[cls._table_name()]

    # ------------------------------------------------------------------
    # Row hydration
    # ------------------------------------------------------------------

    @classmethod
    def _from_row(cls, row) -> "Model":
        instance = cls(**dict(row))
        instance._exists = True
        instance._original = dict(row)
        return instance

    def __repr__(self) -> str:
        pk = self._attributes.get(self.primary_key)
        return f"<{type(self).__name__} {self.primary_key}={pk!r}>"

    def __eq__(self, other) -> bool:
        if not isinstance(other, type(self)):
            return False
        return self._attributes.get(self.primary_key) == other._attributes.get(self.primary_key)
