import json
from typing import Any, Dict, List, Optional, Tuple, Union

from elasticsearch import AsyncElasticsearch

from .base import DBClient
from ..query import Query, WhereCondition, WhereGroup, QuerySet
from ..query.operators import Eq, Gt, Gte, Lt, Lte, In, Like, ILike, NotEq


class ElasticsearchClient(DBClient):
    def __init__(self, hosts: Union[List[str], str], **kwargs):
        if isinstance(hosts, str):
            hosts = [hosts]
        self.client = AsyncElasticsearch(hosts=hosts, **kwargs)

    async def connect(self):
        """Open a connection and verify the cluster is reachable."""
        await self.client.info()

    async def disconnect(self):
        await self.client.close()

    def table(self, index: str) -> "QueryBuilder":
        from ..query import QueryBuilder
        return QueryBuilder(self, index)

    def raw_query(self, query: Query) -> Tuple[str, Dict[str, Any]]:
        """Returns the Elasticsearch search body as a JSON string and empty params dict."""
        body = self._build_search_body(query)
        return json.dumps(body, indent=2), {}

    def debug_query(self, query: Query) -> str:
        """
        Returns a formatted debug string showing the Elasticsearch query body.
        Useful for logging and debugging.

        Example:
            qb = es.table("users").where("age", ">", 18)
            print(es.debug_query(qb.query))
        """
        json_body, _ = self.raw_query(query)
        return f"ES Query: {json_body}"

    async def explain(self, query: Query, analyze: bool = False) -> str:
        """Return validate/explain output for the query.

        When analyze=False, uses the validate API with explain=True.
        When analyze=True, runs the actual search with profiling enabled.
        """
        if analyze:
            body = self._build_search_body(query)
            response = await self.client.search(
                index=query.table,
                profile=True,
                **body,
            )
            return json.dumps(response.body, indent=2)

        body = self._build_search_body(query)
        es_query = body.get("query", {"match_all": {}})
        response = await self.client.indices.validate_query(
            index=query.table,
            query=es_query,
            explain=True,
        )
        return json.dumps(response.body, indent=2)

    # ------------------------------------------------------------------
    # Index lifecycle management
    # ------------------------------------------------------------------

    async def index_exists(self, index: str) -> bool:
        """Check whether an index exists.

        Example::

            if not await es.index_exists("telegram_messages"):
                await es.create_index("telegram_messages", body)
        """
        return await self.client.indices.exists(index=index)

    async def create_index(self, index: str, body: dict) -> dict:
        """Create an index with the given settings/mappings body.

        The *body* dict should contain ``"settings"`` and/or ``"mappings"``
        keys.  Use :func:`ESMappingBuilder.build_create_body` to generate
        it from an :class:`ESIndexDef`.

        Example::

            from db_core.schema.es_builder import ESMappingBuilder

            body = ESMappingBuilder.build_create_body(index_def)
            await es.create_index("telegram_messages", body)
        """
        return await self.client.indices.create(index=index, body=body)

    async def delete_index(self, index: str) -> dict:
        """Delete an index.

        Raises ``elasticsearch.NotFoundError`` if the index does not exist.
        Use :meth:`index_exists` to guard the call if needed.
        """
        return await self.client.indices.delete(index=index)

    async def put_mapping(self, index: str, body: dict) -> dict:
        """Update the mapping for an existing index.

        The *body* dict should contain a ``"properties"`` key.  Use
        :func:`ESMappingBuilder.build_mapping` to generate it.

        Example::

            mapping = ESMappingBuilder.build_mapping(index_def)
            await es.put_mapping("telegram_messages", mapping)
        """
        return await self.client.indices.put_mapping(index=index, body=body)

    async def get_mapping(self, index: str) -> dict:
        """Retrieve the current mapping for an index.

        Returns the raw ES response dict.
        """
        return await self.client.indices.get_mapping(index=index)

    async def index_document(self, index: str, id: str, doc: dict) -> dict:
        """Index (create or replace) a single document.

        Args:
            index: Target index name.
            id: Document ID.
            doc: Document body dict.

        Returns:
            The ES response dict.

        Example::

            doc = message.to_search()
            await es.index_document("telegram_messages", str(message.id), doc)
        """
        return await self.client.index(index=index, id=id, document=doc)

    async def delete_document(self, index: str, id: str) -> dict:
        """Delete a single document by ID.

        Args:
            index: Target index name.
            id: Document ID.

        Returns:
            The ES response dict (includes ``"result"`` key with
            ``"deleted"`` or ``"not_found"``).

        Example::

            result = await es.delete_document("telegram_messages", str(msg_id))
            deleted = result.get("result") == "deleted"
        """
        return await self.client.delete(index=index, id=id)

    async def execute_raw(
        self,
        sql: str,
        params: dict[str, Any] | None = None,
        *,
        multi_statement: bool = False,
    ) -> Any:
        """Elasticsearch does not support raw SQL.  Use the query builder for
        structured search, or the low-level ``self.client`` attribute for
        direct Elasticsearch API calls."""
        raise NotImplementedError(
            "execute_raw() is not supported for ElasticsearchClient. "
            "Use the query builder (self.table(index).where(...).all()) "
            "or access self.client for raw ES API calls."
        )

    async def _execute(self, query: Query) -> QuerySet:
        body = self._build_search_body(query)
        response = await self.client.search(index=query.table, **body)
        hits = response["hits"]["hits"]
        rows = [hit["_source"] for hit in hits]
        return QuerySet(rows=rows, client=self, table=query.table, query=query)

    def _build_search_body(self, query: Query) -> dict:
        body: Dict[str, Any] = {}

        body["query"] = self._build_es_query(query.wheres)

        if query.selects and query.selects != ["*"]:
            body["source"] = query.selects

        if query.order_by:
            sort = []
            for col in query.order_by:
                if col.startswith("-"):
                    sort.append({col[1:]: {"order": "desc"}})
                else:
                    sort.append({col: {"order": "asc"}})
            body["sort"] = sort

        if query.limit is not None:
            body["size"] = query.limit
        if query.offset is not None:
            body["from_"] = query.offset

        return body

    def _build_es_query(self, conditions: List[WhereCondition]) -> dict:
        if not conditions:
            return {"match_all": {}}

        must: list = []
        must_not: list = []
        should: list = []

        for cond in conditions:
            if cond.group is not None:
                clause = self._build_group_query(cond.group)
                if cond.is_or:
                    should.append(clause)
                else:
                    must.append(clause)
            else:
                clause, is_negation = self._build_single_es_condition(cond)
                if clause is not None:
                    if is_negation:
                        must_not.append(clause)
                    elif cond.is_or:
                        should.append(clause)
                    else:
                        must.append(clause)

        bool_query: dict = {}
        if must:
            bool_query["must"] = must
        if must_not:
            bool_query["must_not"] = must_not
        if should:
            bool_query["should"] = should

        if bool_query:
            return {"bool": bool_query}
        return {"match_all": {}}

    def _build_group_query(self, group: WhereGroup) -> dict:
        if not group.conditions:
            return {"match_all": {}}

        must: list = []
        must_not: list = []
        should: list = []

        for cond in group.conditions:
            if cond.group is not None:
                clause = self._build_group_query(cond.group)
                is_neg = False
            else:
                clause, is_neg = self._build_single_es_condition(cond)

            if clause is not None:
                if is_neg:
                    must_not.append(clause)
                elif cond.is_or:
                    should.append(clause)
                else:
                    must.append(clause)

        bool_query: dict = {}
        if must:
            bool_query["must"] = must
        if must_not:
            bool_query["must_not"] = must_not
        if should:
            bool_query["should"] = should

        if bool_query:
            nested_path = getattr(group, "nested_path", None)
            if nested_path:
                return {"nested": {"path": nested_path, "query": {"bool": bool_query}}}
            return {"bool": bool_query}
        return {"match_all": {}}

    def _build_single_es_condition(
        self, cond: WhereCondition
    ) -> Tuple[Optional[dict], bool]:
        """Returns (es_clause, is_negation). is_negation=True means the clause
        belongs in a must_not block."""
        if cond.column is None:
            return None, False

        field = cond.column
        op = cond.operator

        if isinstance(op, Eq):
            return {"term": {field: op.value}}, False
        elif isinstance(op, NotEq):
            return {"term": {field: op.value}}, True
        elif isinstance(op, Gt):
            return {"range": {field: {"gt": op.value}}}, False
        elif isinstance(op, Gte):
            return {"range": {field: {"gte": op.value}}}, False
        elif isinstance(op, Lt):
            return {"range": {field: {"lt": op.value}}}, False
        elif isinstance(op, Lte):
            return {"range": {field: {"lte": op.value}}}, False
        elif isinstance(op, In):
            return {"terms": {field: list(op.values)}}, False
        elif isinstance(op, Like):
            pattern = op.pattern.replace("%", "*").replace("_", "?")
            return {"wildcard": {field: {"value": pattern}}}, False
        elif isinstance(op, ILike):
            pattern = op.pattern.replace("%", "*").replace("_", "?")
            return {"wildcard": {field: {"value": pattern, "case_insensitive": True}}}, False
        return {"term": {field: getattr(op, "value", op)}}, False

    async def _bulk_update(self, table: str, ids: List[Any], values: Dict[str, Any]) -> int:
        """Update multiple documents matching the given id field values."""
        script_parts = [f"ctx._source.{k} = params.{k}" for k in values.keys()]
        script_source = "; ".join(script_parts)
        response = await self.client.update_by_query(
            index=table,
            query={"terms": {"id": ids}},
            script={"source": script_source, "params": values},
        )
        return response["updated"]

    async def _bulk_delete(self, table: str, ids: List[Any]) -> int:
        """Delete multiple documents matching the given id field values."""
        response = await self.client.delete_by_query(
            index=table,
            query={"terms": {"id": ids}},
        )
        return response["deleted"]
