from __future__ import annotations

from typing import List, Optional, Tuple
import re
from uuid import uuid5, NAMESPACE_URL

from analogai.foundation.settings import config as infra_config
from analogai.foundation.modules.fact.basefact import BaseFact
from analogai.foundation.modules.fact.source.fact_source import FactSource, SourceType
from analogai.foundation.modules.fact.enums.fact_reference_type import FactReferenceType
from analogai.foundation.modules.fact.repositories.fact_repository import FactRepository
from analogai.foundation.modules.fact.repositories.infrastructure.fact_storage_mapper import (
    FactStorageMapper,
)


class _RepositoryFactResolver:
    def __init__(self, repository: "FactRepository") -> None:
        self._repository = repository

    def resolve(self, fact_id: str) -> Optional[BaseFact]:
        return self._repository.get_fact(fact_id)


class QdrantFactRepository(FactRepository):
    def __init__(
        self,
        url: Optional[str] = None,
        host: Optional[str] = None,
        port: Optional[int] = None,
        api_key: Optional[str] = None,
        collection: Optional[str] = None,
        links_collection: Optional[str] = None,
        turn_index_collection: Optional[str] = None,
        vector_size: Optional[int] = None,
    ) -> None:
        try:
            from qdrant_client import QdrantClient  # type: ignore[import-not-found]
            from qdrant_client.http import models as qmodels  # type: ignore[import-not-found]
        except Exception as exc:  # pragma: no cover - optional dependency
            raise ImportError("qdrant-client is required for QdrantFactRepository") from exc
        self._qmodels = qmodels
        self._vector_size = vector_size or self._parse_int(infra_config.get("QDRANT_VECTOR_SIZE"))
        if self._vector_size:
            suffix = f"_{self._vector_size}"
        else:
            embed_model = str(infra_config.get("EMBED__MODEL", "")).strip().lower()
            sanitized_model = self._sanitize_collection_suffix(embed_model)
            suffix = f"_{sanitized_model}" if sanitized_model else ""

        self._collection = collection or infra_config.get(
            "QDRANT_FACT_COLLECTION",
            f"facts{suffix}",
        )
        self._links_collection = links_collection or infra_config.get(
            "QDRANT_FACT_LINKS_COLLECTION",
            f"fact_links{suffix}",
        )
        self._turn_index_collection = turn_index_collection or infra_config.get(
            "QDRANT_FACT_TURN_INDEX_COLLECTION",
            f"fact_turn_index{suffix}",
        )

        client_url = url or infra_config.get("QDRANT_URL")
        client_host = host or infra_config.get("QDRANT_HOST", "localhost")
        client_port = port or self._parse_int(infra_config.get("QDRANT_PORT", 6333)) or 6333
        client_key = api_key or infra_config.get("QDRANT_API_KEY")
        client_timeout = self._parse_float(infra_config.get("QDRANT_TIMEOUT_SECONDS"))
        client_pool_size = self._parse_int(infra_config.get("QDRANT_POOL_SIZE"))
        client_grpc_port = self._parse_int(infra_config.get("QDRANT_GRPC_PORT", 6334)) or 6334
        client_prefer_grpc = self._parse_bool(infra_config.get("QDRANT_PREFER_GRPC", True))
        client_https = self._parse_bool(infra_config.get("QDRANT_HTTPS", None))

        if client_url:
            self._client = QdrantClient(
                url=client_url,
                api_key=client_key,
                timeout=client_timeout,
                prefer_grpc=client_prefer_grpc,
                grpc_port=client_grpc_port,
                https=client_https,
                pool_size=client_pool_size,
            )
        else:
            self._client = QdrantClient(
                host=client_host,
                port=client_port,
                api_key=client_key,
                timeout=client_timeout,
                prefer_grpc=client_prefer_grpc,
                grpc_port=client_grpc_port,
                https=client_https,
                pool_size=client_pool_size,
            )

        self._resolver = _RepositoryFactResolver(self)
        self._ensure_meta_collections()
        if self._vector_size:
            self._ensure_fact_collection(self._vector_size)

    def _parse_int(self, value: object | None) -> Optional[int]:
        if value is None:
            return None
        try:
            return int(value)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            return None

    def _parse_float(self, value: object | None) -> Optional[float]:
        if value is None:
            return None
        try:
            return float(value)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            return None

    def _parse_bool(self, value: object | None) -> Optional[bool]:
        if value is None:
            return None
        if isinstance(value, bool):
            return value
        text = str(value).strip().lower()
        if text in {"1", "true", "yes", "y", "on"}:
            return True
        if text in {"0", "false", "no", "n", "off"}:
            return False
        return None

    @staticmethod
    def _sanitize_collection_suffix(value: str) -> str:
        if not value:
            return ""
        cleaned = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
        return cleaned

    def _ensure_meta_collections(self) -> None:
        for name in (self._links_collection, self._turn_index_collection):
            self._ensure_collection(name, vector_size=1)

    def _ensure_fact_collection(self, vector_size: int) -> None:
        self._ensure_collection(self._collection, vector_size=vector_size)

    def _ensure_collection(self, name: str, vector_size: int) -> None:
        try:
            info = self._client.get_collection(name)
        except Exception:
            info = None
        if info is not None:
            config = getattr(info, "config", None)
            params = getattr(config, "params", None) if config else None
            vectors = getattr(params, "vectors", None) if params else None
            expected = int(vector_size)
            actual: Optional[int] = None
            if vectors is not None:
                if isinstance(vectors, dict):
                    first = next(iter(vectors.values()), None)
                    actual = int(getattr(first, "size", expected)) if first is not None else None
                else:
                    actual = int(getattr(vectors, "size", expected))
            if actual is not None and actual != expected:
                raise ValueError(
                    f"Qdrant collection '{name}' vector size mismatch: "
                    f"expected {expected}, got {actual}. "
                    "Set QDRANT_VECTOR_SIZE to match or recreate the collection."
                )
            return
        try:
            self._client.create_collection(
                collection_name=name,
                vectors_config=self._qmodels.VectorParams(
                    size=int(vector_size),
                    distance=self._qmodels.Distance.COSINE,
                ),
            )
        except Exception as exc:
            message = str(exc)
            if "already exists" in message or "409" in message:
                return
            raise

    def _ensure_vector_size(self, embedding: Optional[List[float]]) -> int:
        if self._vector_size:
            return self._vector_size
        if embedding:
            self._vector_size = len(embedding)
            self._ensure_fact_collection(self._vector_size)
            return self._vector_size
        raise ValueError("Qdrant vector size is not configured and embedding is missing")

    def _zero_vector(self, size: int) -> List[float]:
        return [0.0 for _ in range(size)]

    @staticmethod
    def _is_collection_not_found_error(exc: Exception) -> bool:
        message = str(exc)
        return "Not found: Collection" in message or "doesn't exist" in message

    def _key_to_point_id(self, key: str) -> str:
        return str(uuid5(NAMESPACE_URL, key))

    def _fact_payload(self, fact: BaseFact) -> dict:
        doc = FactStorageMapper.to_document(fact)
        doc.pop("embedding", None)
        doc.pop("_id", None)
        doc["id"] = fact.id
        return doc

    def _point_to_fact(self, point) -> Optional[BaseFact]:
        if point is None:
            return None
        payload = dict(point.payload or {})
        vector = None
        try:
            vector = list(point.vector) if point.vector is not None else None
        except Exception:
            vector = None
        doc = {"_id": str(point.id), "embedding": vector}
        doc.update(payload)
        return FactStorageMapper.from_document(doc)

    def register_turn(self, user_id: str, agent_id: str, turn_id: str) -> None:
        key = f"{user_id}:{agent_id}"
        point_id = self._key_to_point_id(key)
        existing = self._client.retrieve(
            collection_name=self._turn_index_collection,
            ids=[point_id],
            with_payload=True,
            with_vectors=False,
        )
        payload: dict[str, object] = {"_id": key, "user_id": user_id, "agent_id": agent_id}
        if existing:
            turn_ids = list((existing[0].payload or {}).get("turn_ids", []))
        else:
            turn_ids = []
        turn_ids.append(turn_id)
        payload["turn_ids"] = turn_ids
        self._client.upsert(
            collection_name=self._turn_index_collection,
            points=[
                self._qmodels.PointStruct(
                    id=point_id,
                    vector=self._zero_vector(1),
                    payload=payload,
                )
            ],
        )

    def save_fact(self, fact: BaseFact) -> None:
        vector_size = self._ensure_vector_size(fact.embedding)
        vector = fact.embedding if fact.embedding is not None else self._zero_vector(vector_size)
        payload = self._fact_payload(fact)
        self._client.upsert(
            collection_name=self._collection,
            points=[
                self._qmodels.PointStruct(
                    id=fact.id,
                    vector=vector,
                    payload=payload,
                )
            ],
        )

    def get_fact(self, id: str) -> Optional[BaseFact]:
        points = self._client.retrieve(
            collection_name=self._collection,
            ids=[id],
            with_payload=True,
            with_vectors=True,
        )
        if not points:
            return None
        fact = self._point_to_fact(points[0])
        return self._attach_resolver(fact) if fact else None

    def delete_fact(self, id: str) -> Optional[BaseFact]:
        if not id:
            return None
        points = self._client.retrieve(
            collection_name=self._collection,
            ids=[id],
            with_payload=True,
            with_vectors=True,
        )
        if not points:
            return None
        fact = self._point_to_fact(points[0])
        selector = self._qmodels.PointIdsList(points=[id])
        self._client.delete(
            collection_name=self._collection,
            points_selector=selector,
        )
        return self._attach_resolver(fact) if fact else None

    def update_facts_statement_id(self, turn_id: str, statement_id: str) -> None:
        if not turn_id or not statement_id:
            return
        self._set_payload_for_turn(turn_id, {
            "statement_id": statement_id,
            "reference_type": FactReferenceType.STATEMENT.value,
        })

    def update_facts_notion_id(self, turn_id: str, notion_id: str) -> None:
        if not turn_id or not notion_id:
            return
        self._set_payload_for_turn(turn_id, {
            "notion_id": notion_id,
            "reference_type": FactReferenceType.NOTION.value,
        })

    def update_facts_hypothetical_statement_id(self, turn_id: str, statement_id: str) -> None:
        if not turn_id or not statement_id:
            return
        self._set_payload_for_turn(turn_id, {
            "statement_id": statement_id,
            "reference_type": FactReferenceType.HYPOTHETICAL_STATEMENT.value,
        })

    def _set_payload_for_turn(self, turn_id: str, payload: dict) -> None:
        flt = self._qmodels.Filter(
            must=[self._qmodels.FieldCondition(key="turn_id", match=self._qmodels.MatchValue(value=turn_id))]
        )
        offset = None
        while True:
            points, offset = self._client.scroll(
                collection_name=self._collection,
                with_payload=False,
                with_vectors=False,
                limit=100,
                scroll_filter=flt,
                offset=offset,
            )
            if not points:
                break
            point_ids = [point.id for point in points if point and point.id is not None]
            if point_ids:
                self._client.set_payload(
                    collection_name=self._collection,
                    payload=payload,
                    points=point_ids,
                )
            if offset is None:
                break

    def get_facts_for_turn(self, turn_id: str) -> List[BaseFact]:
        flt = self._qmodels.Filter(
            must=[self._qmodels.FieldCondition(key="turn_id", match=self._qmodels.MatchValue(value=turn_id))]
        )
        points, _ = self._client.scroll(
            collection_name=self._collection,
            with_payload=True,
            with_vectors=True,
            limit=100,
            scroll_filter=flt,
        )
        results: List[BaseFact] = []
        for point in points:
            fact = self._point_to_fact(point)
            if fact:
                results.append(self._attach_resolver(fact))
        return results

    def get_facts_by_source(self, source_id: str) -> List[BaseFact]:
        flt = self._build_source_filter(source_id)
        if flt is None:
            return []
        results: List[BaseFact] = []
        offset = None
        while True:
            points, offset = self._client.scroll(
                collection_name=self._collection,
                with_payload=True,
                with_vectors=True,
                limit=100,
                scroll_filter=flt,
                offset=offset,
            )
            if not points:
                break
            for point in points:
                fact = self._point_to_fact(point)
                if fact:
                    results.append(self._attach_resolver(fact))
            if offset is None:
                break
        return results

    def list_source_ids(self) -> List[str]:
        sources: dict[str, str] = {}
        offset = None
        while True:
            points, offset = self._client.scroll(
                collection_name=self._collection,
                with_payload=True,
                with_vectors=False,
                limit=100,
                offset=offset,
            )
            if not points:
                break
            for point in points:
                payload = point.payload or {}
                source_id = _source_id_from_payload(payload)
                if not source_id:
                    continue
                sources.setdefault(source_id, source_id)
            if offset is None:
                break
        return list(sources.keys())

    def get_turn_ids(self, user_id: str, agent_id: str) -> List[str]:
        key = f"{user_id}:{agent_id}"
        point_id = self._key_to_point_id(key)
        points = self._client.retrieve(
            collection_name=self._turn_index_collection,
            ids=[point_id],
            with_payload=True,
            with_vectors=False,
        )
        if not points:
            return []
        return [str(item) for item in (points[0].payload or {}).get("turn_ids", []) if item]

    def get_statement_ids_for_turn(self, turn_id: str) -> List[str]:
        return self._get_linked_ids_for_turn("statement", turn_id)

    def get_belief_ids_for_turn(self, turn_id: str) -> List[str]:
        return self._get_linked_ids_for_turn("belief", turn_id)

    def link_statement(self, statement_id: str, turn_id: str) -> None:
        if statement_id and turn_id:
            self._set_link("statement", statement_id, turn_id)

    def link_belief(self, belief_id: str, turn_id: str) -> None:
        if belief_id and turn_id:
            self._set_link("belief", belief_id, turn_id)

    def link_notion(self, notion_id: str, turn_id: str) -> None:
        if notion_id and turn_id:
            self._set_link("notion", notion_id, turn_id)

    def link_hypothetical_statement(self, statement_id: str, turn_id: str) -> None:
        if statement_id and turn_id:
            self._set_link("hypothetical_statement", statement_id, turn_id)

    def get_turn_id_for_statement(self, statement_id: str) -> Optional[str]:
        return self._get_link("statement", statement_id)

    def get_turn_id_for_belief(self, belief_id: str) -> Optional[str]:
        return self._get_link("belief", belief_id)

    def get_turn_id_for_notion(self, notion_id: str) -> Optional[str]:
        return self._get_link("notion", notion_id)

    def get_turn_id_for_hypothetical_statement(self, statement_id: str) -> Optional[str]:
        return self._get_link("hypothetical_statement", statement_id)

    def search_text(self, user_id: str, agent_id: str, query: str, top_k: int) -> List[BaseFact]:
        query_lower = query.lower()
        results: List[BaseFact] = []
        flt = self._qmodels.Filter(
            must=[
                self._qmodels.FieldCondition(key="user_id", match=self._qmodels.MatchValue(value=user_id)),
                self._qmodels.FieldCondition(key="agent_id", match=self._qmodels.MatchValue(value=agent_id)),
            ]
        )
        offset = None
        while len(results) < max(1, int(top_k)):
            points, offset = self._client.scroll(
                collection_name=self._collection,
                with_payload=True,
                with_vectors=True,
                limit=100,
                scroll_filter=flt,
                offset=offset,
            )
            if not points:
                break
            for point in points:
                payload = point.payload or {}
                text = str(payload.get("text") or "")
                if query_lower in text.lower():
                    fact = self._point_to_fact(point)
                    if fact:
                        results.append(self._attach_resolver(fact))
                        if len(results) >= max(1, int(top_k)):
                            break
        return results

    def search_similar(
        self,
        user_id: str,
        agent_id: str,
        embedding: List[float],
        top_k: int,
        similarity_cutoff: Optional[float] = None,
    ) -> List[BaseFact]:
        self._ensure_vector_size(embedding)
        flt = self._qmodels.Filter(
            must=[
                self._qmodels.FieldCondition(key="user_id", match=self._qmodels.MatchValue(value=user_id)),
                self._qmodels.FieldCondition(key="agent_id", match=self._qmodels.MatchValue(value=agent_id)),
            ]
        )
        results = self._search_points(
            collection_name=self._collection,
            embedding=embedding,
            limit=max(1, int(top_k)),
            flt=flt,
            score_threshold=similarity_cutoff,
        )
        facts: List[BaseFact] = []
        for item in results:
            fact = self._point_to_fact(item)
            if fact:
                facts.append(self._attach_resolver(fact))
        return facts

    def delete_facts_by_source(self, source_id: str) -> List[BaseFact]:
        flt = self._build_source_filter(source_id)
        if flt is None:
            return []
        deleted: List[BaseFact] = []
        point_ids: List[str] = []
        offset = None
        while True:
            points, offset = self._client.scroll(
                collection_name=self._collection,
                with_payload=True,
                with_vectors=True,
                limit=100,
                scroll_filter=flt,
                offset=offset,
            )
            if not points:
                break
            for point in points:
                if point and point.id is not None:
                    point_ids.append(point.id)
                fact = self._point_to_fact(point)
                if fact:
                    deleted.append(self._attach_resolver(fact))
            if offset is None:
                break
        if point_ids:
            selector = self._qmodels.PointIdsList(points=point_ids)
            self._client.delete(
                collection_name=self._collection,
                points_selector=selector,
            )
        return deleted

    def _search_points(
        self,
        collection_name: str,
        embedding: List[float],
        limit: int,
        flt,
        score_threshold: Optional[float],
    ):
        client = self._client
        inner_client = getattr(self._client, "_client", None)
        if hasattr(client, "query_points"):
            try:
                response = client.query_points(
                    collection_name=collection_name,
                    query=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
            except TypeError:
                try:
                    response = client.query_points(
                        collection_name=collection_name,
                        query_vector=embedding,
                        limit=limit,
                        with_payload=True,
                        with_vectors=True,
                        query_filter=flt,
                        score_threshold=score_threshold,
                    )
                except Exception as exc:
                    if self._is_collection_not_found_error(exc):
                        self._ensure_vector_size(embedding)
                        response = client.query_points(
                            collection_name=collection_name,
                            query_vector=embedding,
                            limit=limit,
                            with_payload=True,
                            with_vectors=True,
                            query_filter=flt,
                            score_threshold=score_threshold,
                        )
                    else:
                        raise
            except Exception as exc:
                if self._is_collection_not_found_error(exc):
                    self._ensure_vector_size(embedding)
                    response = client.query_points(
                        collection_name=collection_name,
                        query=embedding,
                        limit=limit,
                        with_payload=True,
                        with_vectors=True,
                        query_filter=flt,
                        score_threshold=score_threshold,
                    )
                else:
                    raise
            return getattr(response, "points", response)
        if hasattr(client, "search"):
            return client.search(
                collection_name=collection_name,
                query_vector=embedding,
                limit=limit,
                with_payload=True,
                with_vectors=True,
                query_filter=flt,
                score_threshold=score_threshold,
            )
        if hasattr(client, "search_points"):
            try:
                return client.search_points(
                    collection_name=collection_name,
                    query_vector=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
            except TypeError:
                return client.search_points(
                    collection_name=collection_name,
                    vector=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
        if inner_client is not None and hasattr(inner_client, "query_points"):
            try:
                response = inner_client.query_points(
                    collection_name=collection_name,
                    query=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
            except TypeError:
                try:
                    response = inner_client.query_points(
                        collection_name=collection_name,
                        query_vector=embedding,
                        limit=limit,
                        with_payload=True,
                        with_vectors=True,
                        query_filter=flt,
                        score_threshold=score_threshold,
                    )
                except Exception as exc:
                    if self._is_collection_not_found_error(exc):
                        self._ensure_vector_size(embedding)
                        response = inner_client.query_points(
                            collection_name=collection_name,
                            query_vector=embedding,
                            limit=limit,
                            with_payload=True,
                            with_vectors=True,
                            query_filter=flt,
                            score_threshold=score_threshold,
                        )
                    else:
                        raise
            except Exception as exc:
                if self._is_collection_not_found_error(exc):
                    self._ensure_vector_size(embedding)
                    response = inner_client.query_points(
                        collection_name=collection_name,
                        query=embedding,
                        limit=limit,
                        with_payload=True,
                        with_vectors=True,
                        query_filter=flt,
                        score_threshold=score_threshold,
                    )
                else:
                    raise
            return getattr(response, "points", response)
        if inner_client is not None and hasattr(inner_client, "search"):
            return inner_client.search(
                collection_name=collection_name,
                query_vector=embedding,
                limit=limit,
                with_payload=True,
                with_vectors=True,
                query_filter=flt,
                score_threshold=score_threshold,
            )
        if inner_client is not None and hasattr(inner_client, "search_points"):
            try:
                return inner_client.search_points(
                    collection_name=collection_name,
                    query_vector=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
            except TypeError:
                return inner_client.search_points(
                    collection_name=collection_name,
                    vector=embedding,
                    limit=limit,
                    with_payload=True,
                    with_vectors=True,
                    query_filter=flt,
                    score_threshold=score_threshold,
                )
        raise AttributeError("QdrantClient does not support vector search methods")

    def _set_link(self, kind: str, entity_id: str, turn_id: str) -> None:
        key = f"{kind}:{entity_id}"
        point_id = self._key_to_point_id(key)
        payload = {"_id": key, "kind": kind, "entity_id": entity_id, "turn_id": turn_id}
        self._client.upsert(
            collection_name=self._links_collection,
            points=[
                self._qmodels.PointStruct(
                    id=point_id,
                    vector=self._zero_vector(1),
                    payload=payload,
                )
            ],
        )

    def _get_link(self, kind: str, entity_id: str) -> Optional[str]:
        key = f"{kind}:{entity_id}"
        point_id = self._key_to_point_id(key)
        points = self._client.retrieve(
            collection_name=self._links_collection,
            ids=[point_id],
            with_payload=True,
            with_vectors=False,
        )
        if not points:
            return None
        return (points[0].payload or {}).get("turn_id")

    def _get_linked_ids_for_turn(self, kind: str, turn_id: str) -> List[str]:
        if not turn_id:
            return []
        flt = self._qmodels.Filter(
            must=[
                self._qmodels.FieldCondition(key="kind", match=self._qmodels.MatchValue(value=kind)),
                self._qmodels.FieldCondition(key="turn_id", match=self._qmodels.MatchValue(value=turn_id)),
            ]
        )
        results: List[str] = []
        offset = None
        while True:
            points, offset = self._client.scroll(
                collection_name=self._links_collection,
                with_payload=True,
                with_vectors=False,
                limit=100,
                scroll_filter=flt,
                offset=offset,
            )
            if not points:
                break
            for point in points:
                payload = point.payload or {}
                entity_id = payload.get("entity_id")
                if entity_id:
                    results.append(str(entity_id))
            if offset is None:
                break
        return results

    def _build_source_filter(self, source_id: str | None):
        if not source_id:
            return None
        return self._qmodels.Filter(
            must=[
                self._qmodels.FieldCondition(
                    key="source_id",
                    match=self._qmodels.MatchValue(value=source_id),
                )
            ]
        )

    def _attach_resolver(self, fact: BaseFact) -> BaseFact:
        fact._resolver = self._resolver
        fact._deleter = self.delete_fact
        return fact


def _source_id_from_payload(payload: dict) -> Optional[str]:
    if not payload:
        return None
    source_id = payload.get("source_id")
    if source_id:
        return str(source_id)
    source_type = payload.get("source_type")
    source_value = payload.get("source_value")
    if source_type is None or source_value is None:
        return None
    try:
        stype = SourceType(str(source_type))
    except Exception:
        try:
            stype = SourceType[str(source_type).upper()]
        except Exception:
            stype = SourceType.OTHER
    agent_id = payload.get("agent_id")
    return FactSource(stype, str(source_value), str(agent_id) if agent_id else None).id
