from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Generic, TypeVar

from db_core import QueryBuilder
from repo_core.base.models import DomainModel
from repo_core.events.hooks import HookRegistry

T = TypeVar("T", bound=DomainModel)

# ═══════════════════════════════════════════════════════════════════════
# Internal: lazy backend singletons
# ═══════════════════════════════════════════════════════════════════════

_clients: dict[str, Any] = {}
_SUPPORTED_BACKENDS = frozenset({"postgres", "elasticsearch", "doris"})


def _get_client(kind: str):
    """Return a singleton client for *kind*, created lazily from env vars."""
    if kind not in _clients:
        if kind == "postgres":
            from db_core import create_postgres_client
            _clients[kind] = create_postgres_client()
        elif kind == "elasticsearch":
            from db_core import create_elasticsearch_client
            _clients[kind] = create_elasticsearch_client()
        elif kind == "doris":
            from db_core import create_doris_client
            _clients[kind] = create_doris_client()
        else:
            raise ValueError(f"Unknown backend: {kind!r}")
    return _clients[kind]


def _get_db_qb(table: str = "") -> QueryBuilder:
    return QueryBuilder(_get_client("postgres"), table)


def _get_search_qb(index: str = "") -> QueryBuilder:
    return QueryBuilder(_get_client("elasticsearch"), index)


def _get_doris_qb(table: str = "") -> QueryBuilder:
    return QueryBuilder(_get_client("doris"), table)


_AUTO_WIRE: dict[str, Any] = {
    "db": _get_db_qb,
    "search": _get_search_qb,
    "doris": _get_doris_qb,
}


class BaseRepository(Generic[T]):
    """Base class for all domain repositories.

    Holds ``QueryBuilder`` instances that wrap ``db_core`` clients
    internally.  Every data operation — read or write — flows through
    ``QueryBuilder``.  No raw clients are exposed by default.

    Accepts a **model** class (a :class:`DomainModel` subclass) that drives
    serialisation.  Conversion helpers (``_from_db``, ``_from_search``,
    ``_from_warehouse``) are provided but callers are free to use the model
    methods directly.

    Backend auto-wiring
    -------------------
    Subclasses declare which backends they need via ``_backends``::

        class MyRepo(BaseRepository[MyModel]):
            TABLE = "my_table"
            _backends = {"db"}                    # PostgreSQL only (default)
            # _backends = {"db", "search"}        # PG + Elasticsearch
            # _backends = {"db", "doris"}         # PG + Doris warehouse
            # _backends = {"db", "search", "doris"}  # all three

    When *db* or *search* is omitted, they are auto-wired from environment
    variables via lazy singletons.  Downstream services never need to
    import ``db_core``::

        repo = MyRepo(model=MyModel)
        async with repo:
            rows = await repo.all("my_table")

    Explicit overrides (for tests) still work and bypass auto-wiring::

        repo = MyRepo(model=MyModel, db=mock_query_builder)

    Lifecycle hooks defined with :func:`~repo_core.events.hooks.hook` on
    subclasses are automatically registered::

        class ArticleRepository(BaseRepository[Article]):
            @hook("before_create")
            async def validate(self, instance: Article) -> None:
                ...
    """

    _backends: set[str] = {"db"}  # override in subclasses that need search/doris

    def __init__(
        self,
        *,
        model: type[T] | None = None,
        db: QueryBuilder | None = None,
        search: QueryBuilder | None = None,
        warehouse: QueryBuilder | None = None,
    ) -> None:
        self.model = model

        # Store explicit backends (or None).  Auto-wiring happens lazily
        # in the ``db`` / ``search`` / ``warehouse`` properties — no
        # client is created until the repository is actually used.
        self._db = db
        self._search = search
        self._warehouse = warehouse
        self._needs_db = "db" in type(self)._backends
        self._needs_search = "search" in type(self)._backends
        self._needs_doris = "doris" in type(self)._backends

        self.hooks = HookRegistry()
        self._register_class_hooks()

    # ------------------------------------------------------------------
    # Model conversion helpers  (return raw data when no model set)
    # ------------------------------------------------------------------

    def _from_db(
        self, row: dict[str, Any] | None
    ) -> Any:
        if row is None or self.model is None:
            return row
        return self.model.from_db(row)

    def _from_db_many(self, rows: list[dict[str, Any]]) -> list[Any]:
        if self.model is None:
            return rows
        return [self.model.from_db(r) for r in rows]

    def _from_search(
        self, row: dict[str, Any] | None
    ) -> Any:
        if row is None or self.model is None:
            return row
        return self.model.from_search(row)

    def _from_search_many(self, rows: list[dict[str, Any]]) -> list[Any]:
        if self.model is None:
            return rows
        return [self.model.from_search(r) for r in rows]

    def _from_warehouse(self, row: dict[str, Any] | None) -> Any:
        if row is None or self.model is None:
            return row
        return self.model.from_warehouse(row)

    def _from_warehouse_many(self, rows: list[dict[str, Any]]) -> list[Any]:
        if self.model is None:
            return rows
        return [self.model.from_warehouse(r) for r in rows]

    # ------------------------------------------------------------------
    # QueryBuilder accessors  (raise RuntimeError when not configured)
    # ------------------------------------------------------------------

    @property
    def db(self) -> QueryBuilder:
        """The DB ``QueryBuilder`` held by this repository.

        For table-scoped operations prefer :meth:`table` which returns a
        fresh QueryBuilder targeting a specific table while reusing the
        same underlying client.

        Auto-wires from environment variables on first access if no
        explicit ``db`` was passed to ``__init__``.
        """
        if self._db is None:
            if self._needs_db:
                self._db = _get_db_qb()
            else:
                raise RuntimeError("DB QueryBuilder is not configured on this repository")
        return self._db

    @db.setter
    def db(self, value: QueryBuilder | None) -> None:
        self._db = value

    @property
    def search(self) -> QueryBuilder:
        """The search ``QueryBuilder`` held by this repository.

        For index-scoped queries prefer :meth:`search_table`.

        Auto-wires from environment variables on first access if no
        explicit ``search`` was passed to ``__init__``.
        """
        if self._search is None:
            if self._needs_search:
                self._search = _get_search_qb()
            else:
                raise RuntimeError("Search QueryBuilder is not configured on this repository")
        return self._search

    @search.setter
    def search(self, value: QueryBuilder | None) -> None:
        self._search = value

    @property
    def warehouse(self) -> QueryBuilder:
        """The warehouse ``QueryBuilder`` held by this repository.

        For table-scoped queries prefer :meth:`warehouse_table`.

        Auto-wires from environment variables on first access if no
        explicit ``warehouse`` was passed to ``__init__``.
        """
        if self._warehouse is None:
            if self._needs_doris:
                self._warehouse = _get_doris_qb()
            else:
                raise RuntimeError("Warehouse QueryBuilder is not configured on this repository")
        return self._warehouse

    @warehouse.setter
    def warehouse(self, value: QueryBuilder | None) -> None:
        self._warehouse = value

    # ------------------------------------------------------------------
    # Table / index scoping helpers
    # ------------------------------------------------------------------

    def table(self, name: str) -> QueryBuilder:
        """Return a fresh ``QueryBuilder`` for *name* that shares the
        underlying DB client.

        Usage::

            rows = await repo.table("users").where("status", "active").all()
            user = await repo.table("users").create({"name": "Alice"}, returning=["*"])
        """
        return QueryBuilder(self.db.client, name)

    def search_table(self, index: str) -> QueryBuilder:
        """Return a fresh ``QueryBuilder`` for *index* that shares the
        underlying search client.

        Usage::

            results = await repo.search_table("articles").where("title", "hello").all()
        """
        return QueryBuilder(self.search.client, index)

    def warehouse_table(self, name: str) -> QueryBuilder:
        """Return a fresh ``QueryBuilder`` for *name* that shares the
        underlying warehouse (Doris) client.

        Usage::

            rows = await repo.warehouse_table("events").where("type", "click").all()
        """
        return QueryBuilder(self.warehouse.client, name)

    # ------------------------------------------------------------------
    # DB CRUD convenience methods  (delegate to QueryBuilder)
    # ------------------------------------------------------------------
    # Domain repos *can* use ``self.table(name).where(...).all()`` directly
    # for complex queries.  These methods cover the common cases so
    # subclasses don't repeat the same QueryBuilder chains.

    # -- reads ----------------------------------------------------------

    async def find_by_id(
        self,
        table: str,
        id: Any,
        *,
        id_field: str = "id",
    ) -> T | None:
        """Return a single row by its primary key, or None."""
        row = await self.table(table).where(id_field, id).first()
        return self._from_db(row)

    async def first(
        self,
        table: str,
        conditions: dict[str, Any],
    ) -> T | None:
        """Return the first row matching *conditions*, or None."""
        qb = self.table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        row = await qb.first()
        return self._from_db(row)

    async def find(
        self,
        table: str,
        conditions: dict[str, Any] | None = None,
        *,
        limit: int | None = None,
        offset: int | None = None,
        order_by: list[str] | None = None,
    ) -> list[T]:
        """Return rows matching optional *conditions* with pagination."""
        qb = self.table(table)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        if order_by:
            qb = qb.order_by(*order_by)
        if limit is not None:
            qb = qb.limit(limit)
        if offset is not None:
            qb = qb.offset(offset)
        rows = await qb.all()
        return self._from_db_many(rows)

    async def all(
        self,
        table: str,
        *,
        limit: int | None = None,
        offset: int | None = None,
        order_by: list[str] | None = None,
    ) -> list[T]:
        """Return all rows from *table* with optional pagination."""
        return await self.find(table, conditions=None, limit=limit, offset=offset, order_by=order_by)

    async def count(
        self,
        table: str,
        conditions: dict[str, Any] | None = None,
    ) -> int:
        """Return the number of rows matching optional *conditions*."""
        qb = self.table(table)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        return await qb.count()

    async def exists(
        self,
        table: str,
        conditions: dict[str, Any],
    ) -> bool:
        """Return True if at least one row matches *conditions*."""
        return await self.count(table, conditions) > 0

    # -- writes ---------------------------------------------------------

    # ------------------------------------------------------------------
    # Timestamp helpers  (model-aware — respects _timestamps opt-out)
    # ------------------------------------------------------------------

    @staticmethod
    def _now() -> datetime:
        """Return the current time in UTC."""
        return datetime.now(timezone.utc)

    def _inject_model_timestamps(self, data: dict[str, Any]) -> dict[str, Any]:
        """Inject timestamp fields for an insert operation.

        Only sets fields that exist in the model's ``model_fields`` and
        are missing from *data*.  Timestamps are always ``created_at``
        and ``updated_at`` — the standard timestamp convention.
        """
        if self.model is None:
            return data
        now = self._now()
        model_fields = self.model.model_fields
        for field in ("created_at", "updated_at"):
            if field in model_fields and field not in data:
                data[field] = now
        return data

    def _inject_model_updated_at(self, values: dict[str, Any]) -> dict[str, Any]:
        """Inject ``updated_at`` for an update operation.

        Only sets if ``updated_at`` exists in the model's ``model_fields``
        and is missing from *values*.
        """
        if self.model is None:
            return values
        if "updated_at" in self.model.model_fields and "updated_at" not in values:
            values["updated_at"] = self._now()
        return values

    async def insert_one(
        self,
        table: str,
        data: dict[str, Any],
        *,
        returning: list[str] | None = ["*"],
    ) -> T:
        """Insert a row and return the model instance."""
        self._inject_model_timestamps(data)
        row = await self.table(table).create(data, returning=returning)
        return self._from_db(row)

    async def insert_many(
        self,
        table: str,
        data: list[dict[str, Any]],
        *,
        returning: list[str] | None = ["*"],
    ) -> list[T]:
        """Insert multiple rows and return model instances."""
        for item in data:
            self._inject_model_timestamps(item)
        rows = await self.table(table).create_many(data, returning=returning)
        return self._from_db_many(rows)

    async def save(
        self,
        table: str,
        data: dict[str, Any],
        *,
        id_field: str = "id",
        returning: list[str] | None = ["*"],
    ) -> T:
        """Upsert a row (insert if no id, update if id present).

        Only ``updated_at`` is injected — ``created_at`` is never
        overwritten for existing records.
        """
        self._inject_model_updated_at(data)
        row = await self.table(table).save(data, id_field=id_field, returning=returning)
        return self._from_db(row)

    async def update(
        self,
        table: str,
        conditions: dict[str, Any],
        values: dict[str, Any],
        *,
        limit: int | None = None,
    ) -> int:
        """Update rows matching *conditions* with *values*. Returns row count."""
        self._inject_model_updated_at(values)
        qb = self.table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        if limit is not None:
            qb = qb.limit(limit)
        return await qb.update(**values)

    async def update_by_id(
        self,
        table: str,
        id: Any,
        values: dict[str, Any],
        *,
        id_field: str = "id",
    ) -> T | None:
        """Update a row by primary key and return the refreshed instance."""
        self._inject_model_updated_at(values)
        await self.table(table).where(id_field, id).update(**values)
        row = await self.table(table).where(id_field, id).first()
        return self._from_db(row)

    async def remove(
        self,
        table: str,
        conditions: dict[str, Any],
        *,
        limit: int | None = None,
    ) -> int:
        """Delete rows matching *conditions*. Returns row count."""
        qb = self.table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        if limit is not None:
            qb = qb.limit(limit)
        return await qb.delete()

    async def remove_by_id(
        self,
        table: str,
        id: Any,
        *,
        id_field: str = "id",
    ) -> int:
        """Delete a row by primary key. Returns row count."""
        return await self.table(table).where(id_field, id).delete()

    # DB-prefixed aliases for disambiguation
    db_get = find_by_id
    db_first = first
    db_find = find
    db_all = all
    db_count = count
    db_exists = exists
    db_insert_one = insert_one
    db_insert_many = insert_many
    db_save = save
    db_update = update
    db_update_by_id = update_by_id
    db_remove = remove
    db_remove_by_id = remove_by_id

    # ------------------------------------------------------------------
    # Search CRUD convenience methods
    # ------------------------------------------------------------------

    async def search_find(
        self,
        index: str,
        conditions: dict[str, Any] | None = None,
        *,
        limit: int | None = None,
        offset: int | None = None,
        order_by: list[str] | None = None,
    ) -> list[T]:
        """Return search documents matching optional *conditions*."""
        qb = self.search_table(index)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        if order_by:
            qb = qb.order_by(*order_by)
        if limit is not None:
            qb = qb.limit(limit)
        if offset is not None:
            qb = qb.offset(offset)
        rows = await qb.all()
        return self._from_search_many(rows)

    async def search_first(
        self,
        index: str,
        conditions: dict[str, Any] | None = None,
    ) -> T | None:
        """Return the first search document matching *conditions*, or None."""
        qb = self.search_table(index)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        row = await qb.first()
        return self._from_search(row)

    async def search_get(
        self,
        index: str,
        id: Any,
        *,
        id_field: str = "id",
    ) -> T | None:
        """Return a search document by its ID, or None."""
        row = await self.search_table(index).where(id_field, id).first()
        return self._from_search(row)

    async def search_count(
        self,
        index: str,
        conditions: dict[str, Any] | None = None,
    ) -> int:
        """Return the number of search documents matching *conditions*."""
        qb = self.search_table(index)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        return await qb.count()

    # ------------------------------------------------------------------
    # Warehouse (Doris) CRUD convenience methods
    # ------------------------------------------------------------------

    # -- reads ----------------------------------------------------------

    async def warehouse_find(
        self,
        table: str,
        conditions: dict[str, Any] | None = None,
        *,
        limit: int | None = None,
        offset: int | None = None,
        order_by: list[str] | None = None,
    ) -> list[T]:
        """Return warehouse rows matching optional *conditions*."""
        qb = self.warehouse_table(table)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        if order_by:
            qb = qb.order_by(*order_by)
        if limit is not None:
            qb = qb.limit(limit)
        if offset is not None:
            qb = qb.offset(offset)
        rows = await qb.all()
        return self._from_warehouse_many(rows)

    async def warehouse_first(
        self,
        table: str,
        conditions: dict[str, Any],
    ) -> T | None:
        """Return the first warehouse row matching *conditions*, or None."""
        qb = self.warehouse_table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        row = await qb.first()
        return self._from_warehouse(row)

    async def warehouse_get(
        self,
        table: str,
        id: Any,
        *,
        id_field: str = "id",
    ) -> T | None:
        """Return a warehouse row by its ID, or None."""
        row = await self.warehouse_table(table).where(id_field, id).first()
        return self._from_warehouse(row)

    async def warehouse_all(
        self,
        table: str,
        *,
        limit: int | None = None,
        offset: int | None = None,
        order_by: list[str] | None = None,
    ) -> list[T]:
        """Return all rows from a warehouse *table* with optional pagination."""
        return await self.warehouse_find(table, conditions=None, limit=limit, offset=offset, order_by=order_by)

    async def warehouse_count(
        self,
        table: str,
        conditions: dict[str, Any] | None = None,
    ) -> int:
        """Return the number of warehouse rows matching optional *conditions*."""
        qb = self.warehouse_table(table)
        if conditions:
            for k, v in conditions.items():
                qb = qb.where(k, v)
        return await qb.count()

    async def warehouse_exists(
        self,
        table: str,
        conditions: dict[str, Any],
    ) -> bool:
        """Return True if at least one warehouse row matches *conditions*."""
        return await self.warehouse_count(table, conditions) > 0

    # -- writes ---------------------------------------------------------

    async def warehouse_insert_one(
        self,
        table: str,
        data: dict[str, Any],
        *,
        returning: list[str] | None = ["*"],
    ) -> T:
        """Insert a row into the warehouse and return the model instance."""
        self._inject_model_timestamps(data)
        row = await self.warehouse_table(table).create(data, returning=returning)
        return self._from_warehouse(row)

    async def warehouse_insert_many(
        self,
        table: str,
        data: list[dict[str, Any]],
        *,
        returning: list[str] | None = ["*"],
    ) -> list[T]:
        """Insert multiple rows into the warehouse and return model instances."""
        for item in data:
            self._inject_model_timestamps(item)
        rows = await self.warehouse_table(table).create_many(data, returning=returning)
        return self._from_warehouse_many(rows)

    async def warehouse_save(
        self,
        table: str,
        data: dict[str, Any],
        *,
        id_field: str = "id",
        returning: list[str] | None = ["*"],
    ) -> T:
        """Upsert a warehouse row (insert if no id, update if id present)."""
        self._inject_model_updated_at(data)
        row = await self.warehouse_table(table).save(data, id_field=id_field, returning=returning)
        return self._from_warehouse(row)

    async def warehouse_update(
        self,
        table: str,
        conditions: dict[str, Any],
        values: dict[str, Any],
        *,
        limit: int | None = None,
    ) -> int:
        """Update warehouse rows matching *conditions*. Returns row count."""
        self._inject_model_updated_at(values)
        qb = self.warehouse_table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        if limit is not None:
            qb = qb.limit(limit)
        return await qb.update(**values)

    async def warehouse_update_by_id(
        self,
        table: str,
        id: Any,
        values: dict[str, Any],
        *,
        id_field: str = "id",
    ) -> T | None:
        """Update a warehouse row by ID and return the refreshed instance."""
        self._inject_model_updated_at(values)
        await self.warehouse_table(table).where(id_field, id).update(**values)
        row = await self.warehouse_table(table).where(id_field, id).first()
        return self._from_warehouse(row)

    async def warehouse_remove(
        self,
        table: str,
        conditions: dict[str, Any],
        *,
        limit: int | None = None,
    ) -> int:
        """Delete warehouse rows matching *conditions*. Returns row count."""
        qb = self.warehouse_table(table)
        for k, v in conditions.items():
            qb = qb.where(k, v)
        if limit is not None:
            qb = qb.limit(limit)
        return await qb.delete()

    async def warehouse_remove_by_id(
        self,
        table: str,
        id: Any,
        *,
        id_field: str = "id",
    ) -> int:
        """Delete a warehouse row by ID. Returns row count."""
        return await self.warehouse_table(table).where(id_field, id).delete()

    # DW-prefixed aliases for disambiguation
    dw_find = warehouse_find
    dw_first = warehouse_first
    dw_get = warehouse_get
    dw_all = warehouse_all
    dw_count = warehouse_count
    dw_exists = warehouse_exists
    dw_insert_one = warehouse_insert_one
    dw_insert_many = warehouse_insert_many
    dw_save = warehouse_save
    dw_update = warehouse_update
    dw_update_by_id = warehouse_update_by_id
    dw_remove = warehouse_remove
    dw_remove_by_id = warehouse_remove_by_id

    # ------------------------------------------------------------------
    # Connection lifecycle
    # ------------------------------------------------------------------

    async def connect(self) -> None:
        """Connect all configured clients (via their QueryBuilder wrappers)."""
        for qb in self._query_builders():
            await qb.client.connect()

    async def disconnect(self) -> None:
        """Disconnect all configured clients."""
        for qb in self._query_builders():
            await qb.client.disconnect()

    async def __aenter__(self) -> "BaseRepository[T]":
        await self.connect()
        return self

    async def __aexit__(self, *_: Any) -> None:
        await self.disconnect()

    # ------------------------------------------------------------------
    # Hook helpers
    # ------------------------------------------------------------------

    async def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
        """Emit a hook event to all registered handlers."""
        await self.hooks.emit(event, *args, **kwargs)

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

    def _query_builders(self) -> list[QueryBuilder]:
        """Return all configured QueryBuilder instances.

        Goes through the ``db`` / ``search`` / ``warehouse`` properties
        so that auto-wired backends are resolved lazily on first use.
        """
        qbs: list[QueryBuilder] = []
        if self._needs_db or self._db is not None:
            qbs.append(self.db)
        if self._needs_search or self._search is not None:
            qbs.append(self.search)
        if self._needs_doris or self._warehouse is not None:
            qbs.append(self.warehouse)
        return qbs

    def _register_class_hooks(self) -> None:
        for name in dir(type(self)):
            method = getattr(type(self), name, None)
            if callable(method):
                events: list[str] | None = getattr(method, "_hook_events", None)
                if events is not None:
                    bound = getattr(self, name)
                    for event in events:
                        self.hooks.on(event, bound)
