# ---------------------------------------------------------------------
#  POMA integration for Qdrant
# ---------------------------------------------------------------------
"""
Sync POMA–Qdrant integration with dense, sparse, and hybrid search.

- BasePomaQdrantCore: shared logic (prepare points, RRF fusion, cheatsheet inputs).
- SyncClientOps: thin Qdrant I/O adapter.
- PomaQdrant: supports fastembed, external_*, and inference_* modes.
- fastembed mode is environment-dependent: it requires a Qdrant client with FastEmbed integration
  (upsert/query_points with models.Document). For production, pass a pre-configured client
  or use embedding_mode='external_dense' (or external_*).
"""

import inspect
import os
import uuid
from collections.abc import Callable, Sequence
from dataclasses import dataclass

from qdrant_client import QdrantClient, models as qcm
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http import models as qmodels

from poma.client import extract_chunks_and_chunksets_from_poma_archive
from poma.retrieval import generate_cheatsheets

DEFAULT_CLOUD_DENSE_MODEL = "openai/text-embedding-3-large"
DEFAULT_CLOUD_DENSE_DIMENSIONS = 1536
DEFAULT_CLOUD_SPARSE_MODEL = "Qdrant/bm25"


__all__ = [
    "PomaQdrant",
    "QdrantConfig",
    "VectorConfig",
    "InferenceConfig",
    "QdrantResponseError",
    "SearchResult",
    "chunk_uuid_string",
    "DenseEmbedSync",
    "SparseEmbedSync",
]


def _chunk_data_from_input(
    chunk_data: dict | str | os.PathLike[str],
) -> dict:
    """Accept chunk_data dict or path to a .poma file; return chunk_data dict."""
    if isinstance(chunk_data, (str, os.PathLike)):
        path = os.fspath(chunk_data)
        if not path.lower().endswith(".poma"):
            raise ValueError(
                "Path must point to a .poma file; got {!r}".format(path)
            )
        return extract_chunks_and_chunksets_from_poma_archive(
            poma_archive_path=chunk_data
        )
    return chunk_data

@dataclass
class SearchResult:
    """Type-safe search result; matches the dict shape returned by search()."""
    id: str
    score: float
    text: str
    metadata: dict


class QdrantResponseError(RuntimeError):
    """SDK-level wrapper for qdrant-client UnexpectedResponse errors."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int | None = None,
        reason_phrase: str | None = None,
        raw_content: str | None = None,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.reason_phrase = reason_phrase
        self.raw_content = raw_content


def chunk_uuid_string(file_id: str, chunkset_index: int) -> str:
    """
    Deterministic RFC4122 UUID (v5) from (file_id, chunkset_index).
    Same inputs -> same UUID. Different index -> different UUID.
    """
    if chunkset_index < 0:
        raise ValueError("chunkset_index must be non-negative")
    name = f"{file_id}#{chunkset_index}"
    return str(uuid.uuid5(uuid.NAMESPACE_URL, name))


# Dense: one float-vector per text.
# Callables may accept Sequence[str] for flexibility.
DenseEmbedSync = Callable[[Sequence[str]], list[list[float]]]

# Sparse: one sparse vector per text.
# Accepted per-item return types:
#   - qmodels.SparseVector(indices=[...], values=[...])
#   - tuple[list[int], list[float]]  -> (indices, values)
#   - dict with keys {"indices": [...], "values": [...]}
SparseEmbedSync = Callable[[Sequence[str]], list[object]]


@dataclass(frozen=True)
class QdrantConfig:
    """
    Qdrant deployment options:
      - mode="memory": in-process, RAM-only (sync only)
      - mode="path":   in-process, persistent folder on disk (sync only)
      - mode="url":    HTTP endpoint (local server or cloud) (sync)
      - api_key:      API key for cloud inference (required if cloud_inference is True)
      - cloud_inference: if True, use cloud inference (default is False)
      - client_kwargs: optional pass-through kwargs for QdrantClient(...)
        (e.g. {"timeout": 120, "pool_size": 20})
    """
    mode: str = "memory"          # "memory" | "path" | "url"
    path: str | None = None       # required if mode="path"
    url: str | None = None        # required if mode="url"
    api_key: str | None = None    # optional (cloud)
    cloud_inference: bool = False  # optional (cloud)
    client_kwargs: dict[str, object] | None = None


@dataclass(frozen=True)
class VectorConfig:
    """
    Vector/index configuration used when embedding_mode is external_* or inference_* (we create collections explicitly).
    """
    distance: qmodels.Distance = qmodels.Distance.COSINE
    dense_size: int | None = None          # required for external_dense/external_hybrid and inference_dense/inference_hybrid
    sparse_name: str = "sparse"            # name for sparse vector space
    dense_name: str = "dense"              # name for dense vector space


@dataclass(frozen=True)
class InferenceConfig:
    """
    Inference configuration for embedding_mode inference_* (Qdrant Inference API).

    dense_model examples:
      - "sentence-transformers/all-minilm-l6-v2"
      - "openai/text-embedding-3-large"
    sparse_model examples:
      - "Qdrant/bm25"

    dense_options / sparse_options: provider-specific options.
    Dense API key option names used by Qdrant Cloud Inference include:
      - OpenAI:     {"openai-api-key": "..."}
      - Cohere:     {"cohere-api-key": "..."}
      - Jina AI:    {"jina-api-key": "..."}
      - OpenRouter: {"openrouter-api-key": "..."}
    You can also pass provider-specific parameters like {"dimensions": 512}.
    """
    dense_model: str | None = None
    sparse_model: str | None = None
    dense_options: dict | None = None
    sparse_options: dict | None = None

    def dense_document(self, text: str) -> qcm.Document:
        if not self.dense_model:
            raise ValueError(
                "InferenceConfig.dense_model is required for inference_dense/inference_hybrid"
            )
        return qcm.Document(
            text=text,
            model=self.dense_model,
            options=self.dense_options,
        )

    def sparse_document(self, text: str) -> qcm.Document:
        if not self.sparse_model:
            raise ValueError(
                "InferenceConfig.sparse_model is required for inference_sparse/inference_hybrid"
            )
        return qcm.Document(
            text=text,
            model=self.sparse_model,
            options=self.sparse_options,
        )


class BasePomaQdrantCore:
    """
    Shared, client-agnostic core: builds payloads/points, fuses rankings, builds cheatsheet inputs.
    """

    @staticmethod
    def _to_sparse_vector(item: object) -> qmodels.SparseVector:
        if isinstance(item, qmodels.SparseVector):
            return item
        if isinstance(item, tuple) and len(item) == 2:
            indices, values = item
            return qmodels.SparseVector(indices=list(indices), values=list(values))
        if isinstance(item, dict):
            indices = item.get("indices")
            values = item.get("values")
            if indices is None or values is None:
                raise ValueError("Sparse embedding dict must contain 'indices' and 'values'")
            return qmodels.SparseVector(indices=list(indices), values=list(values))
        raise ValueError(
            "Sparse embedder must return SparseVector OR (indices, values) tuple OR dict{'indices','values'}"
        )

    @staticmethod
    def _get_file_id_fallback(chunksets: list[dict], chunks: list[dict]) -> str:
        file_id = None
        if chunksets:
            file_id = chunksets[0].get("file_id")
        if not file_id and chunks:
            file_id = chunks[0].get("file_id") or chunks[0].get("tag")
        return file_id or "unknown"

    @classmethod
    def prepare_points_from_chunk_data(
        cls,
        chunk_data: dict,
        *,
        store_chunk_details: bool = True,
    ) -> tuple[list[str], list[str], list[dict]]:
        """
        Returns: (ids, documents, payloads)
        - ids: deterministic UUID strings (file_id + chunkset_index)
        - documents: chunkset.contents
        - payloads: metadata including "text"; "chunk_details" only if store_chunk_details=True
        """
        chunksets: list[dict] = chunk_data.get("chunksets", [])
        chunks: list[dict] = chunk_data.get("chunks", [])
        if not chunksets:
            return [], [], []

        file_id = cls._get_file_id_fallback(chunksets, chunks)
        chunks_by_index = {c.get("chunk_index"): c for c in chunks}

        ids: list[str] = []
        documents: list[str] = []
        payloads: list[dict] = []

        for idx, chunkset in enumerate(chunksets):
            content = chunkset.get("contents", "")
            if not content:
                continue

            chunkset_index = chunkset.get("chunkset_index")
            if chunkset_index is None:
                chunkset_index = idx

            chunkset_file_id = chunkset.get("file_id") or file_id
            point_id = chunk_uuid_string(chunkset_file_id, int(chunkset_index))

            raw_chunks = chunkset.get("chunks", [])
            chunk_indices = list(raw_chunks) if isinstance(raw_chunks, (list, tuple)) else []

            payload: dict = {
                "chunkset_index": int(chunkset_index),
                "chunks": chunk_indices,
                "file_id": chunkset_file_id,
                "text": content,
            }
            if store_chunk_details:
                relevant_chunks = [chunks_by_index.get(ci) for ci in chunk_indices if ci in chunks_by_index]
                payload["chunk_details"] = relevant_chunks

            ids.append(point_id)
            documents.append(content)
            payloads.append(payload)

        return ids, documents, payloads

    @staticmethod
    def rrf_fuse(
        ranked_lists: list[list[str]],
        *,
        k: int,
        weights: list[float] | None = None,
    ) -> dict[str, float]:
        """
        Reciprocal Rank Fusion (optionally weighted):
          score(id) = sum_i weight_i / (k + rank_i)
        If weights is None, all weights are 1.0. Order: [dense, sparse] for hybrid.
        If weights is provided, its length must equal len(ranked_lists).
        """
        n = len(ranked_lists)
        if weights is None:
            w = [1.0] * n
        else:
            if len(weights) != n:
                raise ValueError(
                    f"rrf_fuse: len(weights)={len(weights)} must equal len(ranked_lists)={n}"
                )
            w = list(weights)
        scores: dict[str, float] = {}
        for lst, weight in zip(ranked_lists, w, strict=False):
            for rank, pid in enumerate(lst, start=1):
                scores[pid] = scores.get(pid, 0.0) + (weight / (k + rank))
        return scores

    @staticmethod
    def results_to_cheatsheet_inputs(results: list[dict]) -> tuple[list[dict], list[dict]]:
        """
        Extract relevant_chunksets and all_chunks from search results for generate_cheatsheets.

        When store_chunk_details=False, chunk_details in payload is empty, so all_chunks
        will only be populated from results. Pass chunk_data to search_cheatsheets() to
        supply full chunks for cheatsheet generation.
        """
        relevant_chunksets: list[dict] = []
        all_chunks: list[dict] = []
        seen_chunks: set[int] = set()

        for r in results:
            meta = r.get("metadata", {}) or {}
            relevant_chunksets.append(
                {
                    "chunkset_index": meta.get("chunkset_index"),
                    "chunks": meta.get("chunks", []),
                    "file_id": meta.get("file_id", "unknown"),
                }
            )

            chunk_details = meta.get("chunk_details", []) or []
            for ch in chunk_details:
                if not isinstance(ch, dict):
                    continue
                cidx = ch.get("chunk_index")
                if isinstance(cidx, int) and cidx not in seen_chunks:
                    seen_chunks.add(cidx)
                    all_chunks.append(ch)

        return relevant_chunksets, all_chunks

    @staticmethod
    def _validate_dense_size(dense_size: int | None, mode: str) -> None:
        if dense_size is None:
            raise ValueError(f'embedding_mode="{mode}" requires vectors.dense_size')
        try:
            size = int(dense_size)
        except (TypeError, ValueError) as e:
            raise ValueError(f"vectors.dense_size must be an integer, got {type(dense_size).__name__}") from e
        if size <= 0:
            raise ValueError(f'embedding_mode="{mode}" requires vectors.dense_size > 0')

    @staticmethod
    def _validate_dense_vectors(vectors: list[list[float]], expected_size: int, label: str) -> None:
        for i, v in enumerate(vectors):
            if len(v) != expected_size:
                raise ValueError(
                    f"{label} vector at index {i} has length {len(v)}, expected {expected_size} (vectors.dense_size)"
                )

    @staticmethod
    def _fastembed_dense_spec(client: object, text: str) -> tuple[str, qcm.Document]:
        dense_model = getattr(client, "embedding_model_name", None)
        if not isinstance(dense_model, str) or not dense_model:
            raise RuntimeError(
                "FastEmbed client is missing a valid embedding_model_name."
            )
        get_dense_name = getattr(client, "get_vector_field_name", None)
        if not callable(get_dense_name):
            raise RuntimeError(
                "FastEmbed client is missing get_vector_field_name()."
            )
        dense_name = get_dense_name()
        if not isinstance(dense_name, str) or not dense_name:
            raise RuntimeError(
                "FastEmbed client returned an invalid dense vector field name."
            )
        return dense_name, qcm.Document(text=text, model=dense_model)

    @staticmethod
    def _fastembed_sparse_spec(client: object, text: str) -> tuple[str, qcm.Document] | None:
        sparse_model = getattr(client, "sparse_embedding_model_name", None)
        if not sparse_model:
            return None
        get_sparse_name = getattr(client, "get_sparse_vector_field_name", None)
        if not callable(get_sparse_name):
            raise RuntimeError(
                "FastEmbed sparse model is configured but client is missing get_sparse_vector_field_name()."
            )
        sparse_name = get_sparse_name()
        if not isinstance(sparse_name, str) or not sparse_name:
            raise RuntimeError(
                "FastEmbed sparse model is configured but sparse vector field name is unavailable."
            )
        return sparse_name, qcm.Document(text=text, model=str(sparse_model))

    @classmethod
    def _fastembed_vector_payload(cls, client: object, text: str) -> dict[str, qcm.Document]:
        dense_name, dense_doc = cls._fastembed_dense_spec(client, text)
        vector: dict[str, qcm.Document] = {dense_name: dense_doc}
        sparse_spec = cls._fastembed_sparse_spec(client, text)
        if sparse_spec is not None:
            sparse_name, sparse_doc = sparse_spec
            vector[sparse_name] = sparse_doc
        return vector


class SyncClientOps:
    """
    Minimal sync wrappers for the QdrantClient I/O surface used by this module.
    """

    def __init__(self, client: QdrantClient):
        self.client = client

    def get_collections_names(self) -> set[str]:
        return {c.name for c in self.client.get_collections().collections}

    def create_collection_dense(self, collection_name: str, *, vectors: VectorConfig) -> None:
        self.client.create_collection(
            collection_name=collection_name,
            vectors_config={
                vectors.dense_name: qmodels.VectorParams(size=int(vectors.dense_size), distance=vectors.distance)
            },
        )

    def create_collection_sparse(self, collection_name: str, *, vectors: VectorConfig) -> None:
        try:
            self.client.create_collection(
                collection_name=collection_name,
                vectors_config={},
                sparse_vectors_config={vectors.sparse_name: qmodels.SparseVectorParams()},
            )
        except Exception as e:
            raise RuntimeError(
                "Creating a sparse-only collection failed (some Qdrant setups require at least one dense vector). "
                "Use embedding_mode='external_hybrid' with dense + sparse if needed."
            ) from e

    def create_collection_hybrid(self, collection_name: str, *, vectors: VectorConfig) -> None:
        self.client.create_collection(
            collection_name=collection_name,
            vectors_config={
                vectors.dense_name: qmodels.VectorParams(size=int(vectors.dense_size), distance=vectors.distance)
            },
            sparse_vectors_config={vectors.sparse_name: qmodels.SparseVectorParams()},
        )

    def ensure_fastembed_collection(self, collection_name: str) -> None:
        existing = self.get_collections_names()
        if collection_name in existing:
            return

        get_dense_params = getattr(self.client, "get_fastembed_vector_params", None)
        if not callable(get_dense_params):
            raise RuntimeError(
                "FastEmbed client is missing get_fastembed_vector_params()."
            )
        vectors_config = get_dense_params()
        if not vectors_config:
            raise RuntimeError(
                "FastEmbed client returned empty dense vector params."
            )

        get_sparse_params = getattr(self.client, "get_fastembed_sparse_vector_params", None)
        sparse_vectors_config = (
            get_sparse_params() if callable(get_sparse_params) else None
        )

        kwargs: dict = {
            "collection_name": collection_name,
            "vectors_config": vectors_config,
        }
        if sparse_vectors_config:
            kwargs["sparse_vectors_config"] = sparse_vectors_config
        self.client.create_collection(**kwargs)

    def add_documents(self, collection_name: str, *, documents: list[str], payloads: list[dict], ids: list[str]) -> None:
        self.ensure_fastembed_collection(collection_name)
        points: list[qmodels.PointStruct] = []
        for pid, doc, payload in zip(ids, documents, payloads, strict=True):
            vector = BasePomaQdrantCore._fastembed_vector_payload(self.client, doc)
            points.append(qmodels.PointStruct(id=pid, vector=vector, payload=payload))
        self.upsert_points(collection_name, points=points)

    def upsert_points(self, collection_name: str, *, points: list[qmodels.PointStruct]) -> None:
        self.client.upsert(collection_name=collection_name, points=points)

    def retrieve_ids(self, collection_name: str, *, ids: list[str]) -> set[str]:
        """Return the set of point ids that exist in the collection (for insert_only write_mode)."""
        if not ids:
            return set()
        try:
            points = self.client.retrieve(collection_name=collection_name, ids=ids)
            return {str(p.id) for p in points}
        except Exception:
            return set()

    def query_text(
        self,
        collection_name: str,
        *,
        query: str,
        limit: int,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        dense_name, dense_doc = BasePomaQdrantCore._fastembed_dense_spec(
            self.client, query
        )
        return self.query_points(
            collection_name,
            query=dense_doc,
            using=dense_name,
            limit=limit,
            query_filter=query_filter,
        )

    def search_named_vector(
        self,
        collection_name: str,
        *,
        name: str,
        vector: object,
        limit: int,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        return self.query_points(
            collection_name,
            query=vector,
            using=name,
            limit=limit,
            query_filter=query_filter,
        )

    def query_points(
        self,
        collection_name: str,
        *,
        query: object,
        using: str | None,
        limit: int,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        kwargs: dict = {
            "collection_name": collection_name,
            "query": query,
            "limit": limit,
            "with_payload": True,
        }
        if using is not None:
            kwargs["using"] = using
        try:
            res = self.client.query_points(**kwargs, query_filter=query_filter)
        except TypeError:
            res = self.client.query_points(**kwargs, filter=query_filter)
        hits = getattr(res, "points", res) if not isinstance(res, list) else res
        out: list[dict] = []
        for h in hits:
            payload = getattr(h, "payload", None) or {}
            out.append(
                {
                    "id": str(getattr(h, "id", "") or ""),
                    "score": float(getattr(h, "score", 0.0) or 0.0),
                    "text": payload.get("text", "") if isinstance(payload, dict) else "",
                    "metadata": payload if isinstance(payload, dict) else {},
                }
            )
        return out

    def query_points_inference(
        self,
        collection_name: str,
        *,
        query: object,
        using: str | None,
        limit: int,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        """Query using an inference object (e.g. qcm.Document)."""
        return self.query_points(
            collection_name,
            query=query,
            using=using,
            limit=limit,
            query_filter=query_filter,
        )


class PomaQdrant(BasePomaQdrantCore):
    """
    Sync implementation.

    embedding_mode:
      - "fastembed"        : upsert/query_points with models.Document
      - "external_dense"   : dense_embed_fn + upsert/search on named dense vector
      - "external_sparse"  : sparse_embed_fn + upsert/search on named sparse vector
      - "external_hybrid"  : both embedders + two searches + RRF fusion
      - "inference_dense"   : Qdrant Inference API (inference_config.dense_model)
      - "inference_sparse" : Qdrant Inference API (inference_config.sparse_model)
      - "inference_hybrid" : Qdrant Inference API (dense + sparse + RRF)

    fastembed: Requires a client with FastEmbed integration (upsert/query_points with models.Document).
    Not guaranteed in all environments; for production, pass a
    pre-configured qdrant_client or use external_* mode. See _ensure_fastembed_client().

    write_mode: "upsert" (default) or "insert_only" (skip points that already exist).
    batch_size: chunk ingest into batches of this size (default 256).
    store_chunk_details: if True (default), payload includes chunk_details so search_cheatsheets can build cheatsheets without chunk_data.
    """

    _INFERENCE_MODES = {"inference_dense", "inference_sparse", "inference_hybrid"}
    _DENSE_PROVIDER_KEY_RULES: tuple[
        tuple[tuple[str, ...], str, tuple[str, ...], str],
        ...,
    ] = (
        (("openai/",), "openai-api-key", ("OPENAI_API_KEY",), "OpenAI"),
        (("cohere/",), "cohere-api-key", ("COHERE_API_KEY",), "Cohere"),
        (("jinaai/", "jina/"), "jina-api-key", ("JINA_API_KEY", "JINAAI_API_KEY"), "Jina AI"),
        (("openrouter/",), "openrouter-api-key", ("OPENROUTER_API_KEY",), "OpenRouter"),
    )

    @staticmethod
    def _resolve_cloud_inference_enabled(
        qdrant_client: QdrantClient | None,
        cfg: QdrantConfig,
    ) -> bool:
        if qdrant_client is None:
            return bool(cfg.cloud_inference)
        client_flag = getattr(qdrant_client, "cloud_inference", None)
        if client_flag is None:
            return bool(cfg.cloud_inference)
        return bool(client_flag)

    @staticmethod
    def _copy_options_dict(options: dict | None, field_name: str) -> dict:
        if options is None:
            return {}
        if not isinstance(options, dict):
            raise ValueError(
                f"InferenceConfig.{field_name} must be a dict, got {type(options).__name__}"
            )
        return dict(options)

    @staticmethod
    def _with_dense_size(vectors: VectorConfig, dense_size: int) -> VectorConfig:
        return VectorConfig(
            distance=vectors.distance,
            dense_size=dense_size,
            sparse_name=vectors.sparse_name,
            dense_name=vectors.dense_name,
        )

    @staticmethod
    def _copy_client_kwargs(client_kwargs: dict[str, object] | None) -> dict[str, object]:
        if client_kwargs is None:
            return {}
        if not isinstance(client_kwargs, dict):
            raise ValueError(
                f"qdrant.client_kwargs must be a dict, got {type(client_kwargs).__name__}"
            )
        return dict(client_kwargs)

    @staticmethod
    def _validate_client_kwargs_conflicts(mode: str, client_kwargs: dict[str, object]) -> None:
        blocked_by_mode = {
            "url": {"url", "api_key", "cloud_inference"},
            "path": {"path"},
            "memory": {"location"},
        }
        blocked = blocked_by_mode.get(mode, set())
        conflicts = sorted(k for k in client_kwargs if k in blocked)
        if not conflicts:
            return
        joined = ", ".join(conflicts)
        raise ValueError(
            f'qdrant.client_kwargs for mode="{mode}" cannot include: {joined}. '
            "Set these values via QdrantConfig fields instead."
        )

    def _apply_dense_provider_api_key_defaults(
        self,
        *,
        dense_model: str,
        dense_options: dict,
    ) -> None:
        model_name = dense_model.strip().lower()
        for prefixes, option_key, env_names, provider_name in self._DENSE_PROVIDER_KEY_RULES:
            if not any(model_name.startswith(prefix) for prefix in prefixes):
                continue
            # Explicit user config always wins.
            if option_key in dense_options:
                return
            for env_name in env_names:
                env_api_key = os.getenv(env_name)
                if env_api_key:
                    dense_options[option_key] = env_api_key
                    return
            env_hint = " or ".join(env_names)
            raise ValueError(
                f"cloud_inference=True with {provider_name} dense inference requires an API key. "
                f"Set InferenceConfig.dense_options['{option_key}'] or {env_hint}."
            )
        # Unknown provider prefix: leave unchanged.

    def _apply_cloud_inference_defaults(self) -> None:
        dense_mode = self.embedding_mode in {"inference_dense", "inference_hybrid"}
        sparse_mode = self.embedding_mode in {"inference_sparse", "inference_hybrid"}

        cfg = self.inference_config or InferenceConfig()
        dense_model = cfg.dense_model
        sparse_model = cfg.sparse_model
        dense_options = self._copy_options_dict(cfg.dense_options, "dense_options")
        sparse_options = self._copy_options_dict(cfg.sparse_options, "sparse_options")

        if dense_mode and not dense_model:
            dense_model = DEFAULT_CLOUD_DENSE_MODEL

        if sparse_mode and not sparse_model:
            sparse_model = DEFAULT_CLOUD_SPARSE_MODEL

        normalized_vector_dense_size: int | None = None
        if dense_mode and self.vectors.dense_size is not None:
            try:
                normalized_vector_dense_size = int(self.vectors.dense_size)
            except (TypeError, ValueError) as e:
                raise ValueError("vectors.dense_size must be an integer") from e
            if normalized_vector_dense_size <= 0:
                raise ValueError("vectors.dense_size must be > 0")
            if normalized_vector_dense_size != self.vectors.dense_size:
                self.vectors = self._with_dense_size(
                    self.vectors, normalized_vector_dense_size
                )

        normalized_dense_dim: int | None = None
        if dense_mode and dense_model:
            dense_dim_raw = dense_options.get("dimensions")
            if dense_dim_raw is not None:
                try:
                    normalized_dense_dim = int(dense_dim_raw)
                except (TypeError, ValueError) as e:
                    raise ValueError(
                        "InferenceConfig.dense_options['dimensions'] must be an integer"
                    ) from e
                if normalized_dense_dim <= 0:
                    raise ValueError(
                        "InferenceConfig.dense_options['dimensions'] must be > 0"
                    )
                dense_options["dimensions"] = normalized_dense_dim

            # Allow setting dimension in exactly one place (vectors OR dense_options).
            if (
                dense_model == DEFAULT_CLOUD_DENSE_MODEL
                and normalized_dense_dim is None
                and normalized_vector_dense_size is None
            ):
                normalized_dense_dim = DEFAULT_CLOUD_DENSE_DIMENSIONS
                dense_options["dimensions"] = normalized_dense_dim

            if normalized_dense_dim is None and normalized_vector_dense_size is not None:
                normalized_dense_dim = normalized_vector_dense_size
                if dense_model.startswith("openai/"):
                    dense_options["dimensions"] = normalized_dense_dim
            elif normalized_dense_dim is not None and normalized_vector_dense_size is None:
                self.vectors = self._with_dense_size(
                    self.vectors, normalized_dense_dim
                )
            elif (
                normalized_dense_dim is not None
                and normalized_vector_dense_size is not None
                and normalized_dense_dim != normalized_vector_dense_size
            ):
                raise ValueError(
                    "vectors.dense_size must match InferenceConfig.dense_options['dimensions']: "
                    f"{normalized_vector_dense_size} != {normalized_dense_dim}"
                )

            self._apply_dense_provider_api_key_defaults(
                dense_model=dense_model,
                dense_options=dense_options,
            )

        self.inference_config = InferenceConfig(
            dense_model=dense_model,
            sparse_model=sparse_model,
            dense_options=dense_options or None,
            sparse_options=sparse_options or None,
        )

    def _raise_qdrant_response(
        self,
        exc: UnexpectedResponse,
        *,
        operation: str,
    ) -> None:
        content = exc.content.decode("utf-8", errors="replace")
        message = f"Qdrant response error during `{operation}`: {exc}"
        lowered = f"{exc}\n{content}".lower()
        if self.cloud_inference and (
            "endpoint configuration not found for model" in lowered
            or "inference can't process request" in lowered
        ):
            message = (
                f"{message}. The model/provider is not configured for this Qdrant Cloud cluster "
                "(check the cluster Inference tab)."
            )
        raise QdrantResponseError(
            message,
            status_code=exc.status_code,
            reason_phrase=exc.reason_phrase,
            raw_content=content,
        ) from exc

    def __init__(
        self,
        collection_name: str = "poma_documents",
        *,
        qdrant: QdrantConfig | None = None,
        qdrant_client: QdrantClient | None = None,
        embedding_mode: str | None = None,
        dense_embed_fn: DenseEmbedSync | None = None,
        sparse_embed_fn: SparseEmbedSync | None = None,
        vectors: VectorConfig | None = None,
        inference_config: InferenceConfig | None = None,
        fusion_k: int = 60,
        fusion_candidates: int = 50,
        fusion_weights: tuple[float, float] | None = None,
        write_mode: str = "upsert",
        batch_size: int = 256,
        store_chunk_details: bool = True,
    ):
        self.collection_name = collection_name
        cfg = qdrant or QdrantConfig()
        self.cloud_inference = self._resolve_cloud_inference_enabled(qdrant_client, cfg)
        mode_text = (embedding_mode or "").strip().lower()
        if mode_text:
            self.embedding_mode = mode_text
        else:
            # Strong default: cloud inference uses inference_dense; otherwise keep fastembed.
            self.embedding_mode = "inference_dense" if self.cloud_inference else "fastembed"
        self.dense_embed_fn = dense_embed_fn
        self.sparse_embed_fn = sparse_embed_fn
        self.vectors = vectors or VectorConfig()
        self.inference_config = inference_config
        self.fusion_k = max(1, int(fusion_k))
        self.fusion_candidates = max(1, int(fusion_candidates))
        self.fusion_weights = fusion_weights  # (dense_weight, sparse_weight) for hybrid; None = (1, 1)
        self.write_mode = (write_mode or "upsert").strip().lower()
        self.batch_size = max(1, int(batch_size))
        self.store_chunk_details = bool(store_chunk_details)
        if self.write_mode not in ("upsert", "insert_only"):
            raise ValueError('write_mode must be one of: "upsert", "insert_only"')

        allowed = {
            "fastembed", "external_dense", "external_sparse", "external_hybrid",
            "inference_dense", "inference_sparse", "inference_hybrid",
        }
        if self.embedding_mode not in allowed:
            raise ValueError(f"embedding_mode must be one of: {sorted(allowed)}")

        if self.cloud_inference and self.embedding_mode == "fastembed":
            raise ValueError(
                "cloud_inference=True is not supported with embedding_mode='fastembed'. "
                "Use cloud_inference=False for local FastEmbed or switch to an inference_* mode "
                "with InferenceConfig."
            )
        if self.cloud_inference and self.embedding_mode in self._INFERENCE_MODES:
            self._apply_cloud_inference_defaults()

        # Fail-fast: validate external_* requirements at init
        if self.embedding_mode == "external_dense":
            if self.dense_embed_fn is None:
                raise ValueError('embedding_mode="external_dense" requires dense_embed_fn')
            self._validate_dense_size(self.vectors.dense_size, "external_dense")
        elif self.embedding_mode == "external_sparse":
            if self.sparse_embed_fn is None:
                raise ValueError('embedding_mode="external_sparse" requires sparse_embed_fn')
        elif self.embedding_mode == "external_hybrid":
            if self.dense_embed_fn is None or self.sparse_embed_fn is None:
                raise ValueError('embedding_mode="external_hybrid" requires dense_embed_fn AND sparse_embed_fn')
            self._validate_dense_size(self.vectors.dense_size, "external_hybrid")
        elif self.embedding_mode == "inference_dense":
            if self.inference_config is None:
                raise ValueError('embedding_mode="inference_dense" requires inference_config')
            self.inference_config.dense_document("")  # validate dense_model set
            self._validate_dense_size(self.vectors.dense_size, "inference_dense")
        elif self.embedding_mode == "inference_sparse":
            if self.inference_config is None:
                raise ValueError('embedding_mode="inference_sparse" requires inference_config')
            self.inference_config.sparse_document("")  # validate sparse_model set
        elif self.embedding_mode == "inference_hybrid":
            if self.inference_config is None:
                raise ValueError('embedding_mode="inference_hybrid" requires inference_config')
            self.inference_config.dense_document("")
            self.inference_config.sparse_document("")
            self._validate_dense_size(self.vectors.dense_size, "inference_hybrid")

        if qdrant_client is not None:
            self.client = qdrant_client
        else:
            mode = (cfg.mode or "").strip().lower()
            client_kwargs = self._copy_client_kwargs(cfg.client_kwargs)
            if mode == "url":
                self._validate_client_kwargs_conflicts(mode, client_kwargs)
                if not cfg.url:
                    raise ValueError('qdrant.mode="url" requires qdrant.url="http(s)://..."')
                self.client = QdrantClient(
                    url=cfg.url,
                    api_key=cfg.api_key,
                    cloud_inference=cfg.cloud_inference,
                    **client_kwargs,
                )
            elif mode == "path":
                self._validate_client_kwargs_conflicts(mode, client_kwargs)
                if not cfg.path:
                    raise ValueError('qdrant.mode="path" requires qdrant.path="..."')
                self.client = QdrantClient(path=cfg.path, **client_kwargs)
            elif mode == "memory":
                self._validate_client_kwargs_conflicts(mode, client_kwargs)
                self.client = QdrantClient(location=":memory:", **client_kwargs)
            else:
                raise ValueError('qdrant.mode must be one of: "memory", "path", "url"')

        self.ops = SyncClientOps(self.client)
        self._ensure_collection_if_needed_sync()

    def _ensure_fastembed_client(self) -> None:
        """Raise with a clear message if fastembed mode is used but client lacks FastEmbed Document support."""
        query_points_fn = getattr(self.client, "query_points", None)
        upsert_fn = getattr(self.client, "upsert", None)
        get_dense_name_fn = getattr(self.client, "get_vector_field_name", None)
        get_dense_params_fn = getattr(self.client, "get_fastembed_vector_params", None)
        if (
            not callable(query_points_fn)
            or not callable(upsert_fn)
            or not callable(get_dense_name_fn)
            or not callable(get_dense_params_fn)
        ):
            raise RuntimeError(
                "embedding_mode='fastembed' requires a Qdrant client with FastEmbed integration "
                "(client.query_points(..., query=models.Document(...)) and client.upsert(...)). "
                "Pass a pre-configured client or use embedding_mode='external_dense' (or external_sparse/external_hybrid) "
                "with your own embedder."
            )
        dense_model = getattr(self.client, "embedding_model_name", None)
        if not isinstance(dense_model, str) or not dense_model:
            raise RuntimeError(
                "embedding_mode='fastembed' requires a valid embedding_model_name on the client."
            )
        try:
            sig_query_points = inspect.signature(query_points_fn)
            sig_upsert = inspect.signature(upsert_fn)
        except (ValueError, TypeError):
            raise RuntimeError(
                "embedding_mode='fastembed': client query_points/upsert could not be inspected. "
                "Use a Qdrant client with FastEmbed integration or embedding_mode='external_dense'."
            )
        if "query" not in sig_query_points.parameters:
            raise RuntimeError(
                "embedding_mode='fastembed' requires client.query_points(..., query=..., ...). "
                "This client's query_points() does not accept 'query'. Use a FastEmbed-enabled client or external_* mode."
            )
        if "points" not in sig_upsert.parameters:
            raise RuntimeError(
                "embedding_mode='fastembed' requires client.upsert(..., points=...). "
                "This client's upsert() does not accept 'points'. Use a FastEmbed-enabled client or external_* mode."
            )

    def _ensure_collection_if_needed_sync(self) -> None:
        existing = self.ops.get_collections_names()
        if self.collection_name in existing:
            return

        if self.embedding_mode in ("external_dense", "inference_dense"):
            self.ops.create_collection_dense(self.collection_name, vectors=self.vectors)
        elif self.embedding_mode in ("external_sparse", "inference_sparse"):
            self.ops.create_collection_sparse(self.collection_name, vectors=self.vectors)
        elif self.embedding_mode in ("external_hybrid", "inference_hybrid"):
            self.ops.create_collection_hybrid(self.collection_name, vectors=self.vectors)
        # fastembed: created lazily in SyncClientOps.add_documents().

    def _filter_new_ids_sync(self, ids: list[str]) -> tuple[list[str], list[int]]:
        """For insert_only: return (ids that are new, indices into original list)."""
        existing = self.ops.retrieve_ids(self.collection_name, ids=ids)
        new_ids = [pid for pid in ids if pid not in existing]
        new_set = set(new_ids)
        indices = [i for i, pid in enumerate(ids) if pid in new_set]
        return new_ids, indices

    def ingest(
        self,
        chunk_data: dict | str | os.PathLike[str],
    ) -> None:
        """
        Ingest chunk data into the collection.
        chunk_data: either a dict with ``chunks`` and ``chunksets`` keys,
        or a path to a ``.poma`` file (will be extracted first).
        """
        try:
            chunk_data = _chunk_data_from_input(chunk_data)
            ids, documents, payloads = self.prepare_points_from_chunk_data(
                chunk_data, store_chunk_details=self.store_chunk_details
            )
            if not documents:
                return

            if self.embedding_mode != "fastembed":
                self._ensure_collection_if_needed_sync()

            if self.write_mode == "insert_only" and ids:
                new_ids, indices = self._filter_new_ids_sync(ids)
                if not new_ids:
                    return
                ids = new_ids
                documents = [documents[i] for i in indices]
                payloads = [payloads[i] for i in indices]

            if self.embedding_mode == "fastembed":
                self._ensure_fastembed_client()
                for start in range(0, len(ids), self.batch_size):
                    end = start + self.batch_size
                    self.ops.add_documents(
                        self.collection_name,
                        documents=documents[start:end],
                        payloads=payloads[start:end],
                        ids=ids[start:end],
                    )
                return

            if self.embedding_mode == "external_dense":
                dense_vecs = self.dense_embed_fn(documents)
                if len(dense_vecs) != len(documents):
                    raise ValueError("dense_embed_fn returned a different number of vectors than documents")
                self._validate_dense_vectors(dense_vecs, int(self.vectors.dense_size), "dense_embed_fn")
                points = [
                    qmodels.PointStruct(id=pid, vector={self.vectors.dense_name: vec}, payload=payload)
                    for pid, vec, payload in zip(ids, dense_vecs, payloads, strict=True)
                ]
                for start in range(0, len(points), self.batch_size):
                    self.ops.upsert_points(self.collection_name, points=points[start : start + self.batch_size])
                return

            if self.embedding_mode == "external_sparse":
                sparse_items = self.sparse_embed_fn(documents)
                if len(sparse_items) != len(documents):
                    raise ValueError("sparse_embed_fn returned a different number of vectors than documents")
                points: list[qmodels.PointStruct] = []
                for pid, item, payload in zip(ids, sparse_items, payloads, strict=True):
                    svec = self._to_sparse_vector(item)
                    points.append(
                        qmodels.PointStruct(id=pid, vector={self.vectors.sparse_name: svec}, payload=payload)
                    )
                for start in range(0, len(points), self.batch_size):
                    self.ops.upsert_points(self.collection_name, points=points[start : start + self.batch_size])
                return

            if self.embedding_mode == "inference_dense":
                points_inf = [
                    qmodels.PointStruct(
                        id=pid,
                        vector={self.vectors.dense_name: self.inference_config.dense_document(doc)},
                        payload=payload,
                    )
                    for pid, doc, payload in zip(ids, documents, payloads, strict=True)
                ]
                for start in range(0, len(points_inf), self.batch_size):
                    self.ops.upsert_points(self.collection_name, points=points_inf[start : start + self.batch_size])
                return

            if self.embedding_mode == "inference_sparse":
                points_inf = [
                    qmodels.PointStruct(
                        id=pid,
                        vector={self.vectors.sparse_name: self.inference_config.sparse_document(doc)},
                        payload=payload,
                    )
                    for pid, doc, payload in zip(ids, documents, payloads, strict=True)
                ]
                for start in range(0, len(points_inf), self.batch_size):
                    self.ops.upsert_points(self.collection_name, points=points_inf[start : start + self.batch_size])
                return

            if self.embedding_mode == "inference_hybrid":
                points_inf = [
                    qmodels.PointStruct(
                        id=pid,
                        vector={
                            self.vectors.dense_name: self.inference_config.dense_document(doc),
                            self.vectors.sparse_name: self.inference_config.sparse_document(doc),
                        },
                        payload=payload,
                    )
                    for pid, doc, payload in zip(ids, documents, payloads, strict=True)
                ]
                for start in range(0, len(points_inf), self.batch_size):
                    self.ops.upsert_points(self.collection_name, points=points_inf[start : start + self.batch_size])
                return

            # external_hybrid
            dense_vecs = self.dense_embed_fn(documents)
            sparse_items = self.sparse_embed_fn(documents)
            if len(dense_vecs) != len(documents) or len(sparse_items) != len(documents):
                raise ValueError("Hybrid embedders returned a different number of vectors than documents")
            self._validate_dense_vectors(dense_vecs, int(self.vectors.dense_size), "dense_embed_fn")
            points_list: list[qmodels.PointStruct] = []
            for pid, dvec, sitem, payload in zip(ids, dense_vecs, sparse_items, payloads, strict=True):
                svec = self._to_sparse_vector(sitem)
                points_list.append(
                    qmodels.PointStruct(
                        id=pid,
                        vector={self.vectors.dense_name: dvec, self.vectors.sparse_name: svec},
                        payload=payload,
                    )
                )
            for start in range(0, len(points_list), self.batch_size):
                self.ops.upsert_points(self.collection_name, points=points_list[start : start + self.batch_size])
        except UnexpectedResponse as exc:
            self._raise_qdrant_response(exc, operation="ingest")

    def search(
        self,
        query: str,
        *,
        limit: int = 5,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        """Search; returns list[dict] with keys id, score, text, metadata (same shape as SearchResult)."""
        limit = max(1, int(limit))
        try:
            if self.embedding_mode == "fastembed":
                self._ensure_fastembed_client()
                dense_name, dense_doc = self._fastembed_dense_spec(self.client, query)
                sparse_spec = self._fastembed_sparse_spec(self.client, query)
                if sparse_spec is None:
                    return self.ops.query_points(
                        self.collection_name,
                        query=dense_doc,
                        using=dense_name,
                        limit=limit,
                        query_filter=query_filter,
                    )

                cand = max(limit, self.fusion_candidates)
                sparse_name, sparse_doc = sparse_spec
                dense_hits = self.ops.query_points(
                    self.collection_name,
                    query=dense_doc,
                    using=dense_name,
                    limit=cand,
                    query_filter=query_filter,
                )
                sparse_hits = self.ops.query_points(
                    self.collection_name,
                    query=sparse_doc,
                    using=sparse_name,
                    limit=cand,
                    query_filter=query_filter,
                )
                dense_ids = [str(h["id"]) for h in dense_hits]
                sparse_ids = [str(h["id"]) for h in sparse_hits]
                fused = self.rrf_fuse([dense_ids, sparse_ids], k=self.fusion_k, weights=None)
                payload_by_id: dict[str, dict] = {}
                for h in dense_hits + sparse_hits:
                    payload_by_id.setdefault(str(h["id"]), h.get("metadata", {}) or {})
                ranked = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)[:limit]
                return [
                    {
                        "id": pid,
                        "score": score,
                        "text": payload_by_id.get(pid, {}).get("text", ""),
                        "metadata": payload_by_id.get(pid, {}) or {},
                    }
                    for pid, score in ranked
                ]

            self._ensure_collection_if_needed_sync()

            if self.embedding_mode == "external_dense":
                qvec = self.dense_embed_fn([query])[0]
                return self.ops.search_named_vector(
                    self.collection_name,
                    name=self.vectors.dense_name,
                    vector=qvec,
                    limit=limit,
                    query_filter=query_filter,
                )

            if self.embedding_mode == "external_sparse":
                sitem = self.sparse_embed_fn([query])[0]
                svec = self._to_sparse_vector(sitem)
                return self.ops.search_named_vector(
                    self.collection_name,
                    name=self.vectors.sparse_name,
                    vector=svec,
                    limit=limit,
                    query_filter=query_filter,
                )

            if self.embedding_mode == "inference_dense":
                query_doc = self.inference_config.dense_document(query)
                return self.ops.query_points_inference(
                    self.collection_name,
                    query=query_doc,
                    using=self.vectors.dense_name,
                    limit=limit,
                    query_filter=query_filter,
                )

            if self.embedding_mode == "inference_sparse":
                query_doc = self.inference_config.sparse_document(query)
                return self.ops.query_points_inference(
                    self.collection_name,
                    query=query_doc,
                    using=self.vectors.sparse_name,
                    limit=limit,
                    query_filter=query_filter,
                )

            if self.embedding_mode == "inference_hybrid":
                cand = max(limit, self.fusion_candidates)
                weights = list(self.fusion_weights) if self.fusion_weights else None
                dense_doc = self.inference_config.dense_document(query)
                sparse_doc = self.inference_config.sparse_document(query)
                dense_hits = self.ops.query_points_inference(
                    self.collection_name,
                    query=dense_doc,
                    using=self.vectors.dense_name,
                    limit=cand,
                    query_filter=query_filter,
                )
                sparse_hits = self.ops.query_points_inference(
                    self.collection_name,
                    query=sparse_doc,
                    using=self.vectors.sparse_name,
                    limit=cand,
                    query_filter=query_filter,
                )
                dense_ids = [str(h["id"]) for h in dense_hits]
                sparse_ids = [str(h["id"]) for h in sparse_hits]
                fused = self.rrf_fuse([dense_ids, sparse_ids], k=self.fusion_k, weights=weights)
                payload_by_id: dict[str, dict] = {}
                for h in dense_hits + sparse_hits:
                    payload_by_id.setdefault(str(h["id"]), h.get("metadata", {}) or {})
                ranked = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)[:limit]
                return [
                    {
                        "id": pid,
                        "score": score,
                        "text": payload_by_id.get(pid, {}).get("text", ""),
                        "metadata": payload_by_id.get(pid, {}) or {},
                    }
                    for pid, score in ranked
                ]

            # external_hybrid: two searches + weighted RRF fusion
            cand = max(limit, self.fusion_candidates)
            weights = list(self.fusion_weights) if self.fusion_weights else None

            dense_q = self.dense_embed_fn([query])[0]
            sparse_item = self.sparse_embed_fn([query])[0]
            sparse_q = self._to_sparse_vector(sparse_item)

            dense_hits = self.ops.search_named_vector(
                self.collection_name,
                name=self.vectors.dense_name,
                vector=dense_q,
                limit=cand,
                query_filter=query_filter,
            )
            sparse_hits = self.ops.search_named_vector(
                self.collection_name,
                name=self.vectors.sparse_name,
                vector=sparse_q,
                limit=cand,
                query_filter=query_filter,
            )

            dense_ids = [str(h["id"]) for h in dense_hits]
            sparse_ids = [str(h["id"]) for h in sparse_hits]
            fused = self.rrf_fuse([dense_ids, sparse_ids], k=self.fusion_k, weights=weights)

            payload_by_id: dict[str, dict] = {}
            for h in dense_hits + sparse_hits:
                payload_by_id.setdefault(str(h["id"]), h.get("metadata", {}) or {})

            ranked = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)[:limit]
            return [
                {
                    "id": pid,
                    "score": score,
                    "text": payload_by_id.get(pid, {}).get("text", ""),
                    "metadata": payload_by_id.get(pid, {}) or {},
                }
                for pid, score in ranked
            ]
        except UnexpectedResponse as exc:
            self._raise_qdrant_response(exc, operation="search")

    def close(self) -> None:
        """Close the Qdrant client connection (e.g. HTTP session). No-op if client has no close."""
        if hasattr(self.client, "close") and callable(self.client.close):
            self.client.close()

    def search_cheatsheets(
        self,
        query: str,
        *,
        limit: int = 5,
        chunk_data: dict | None = None,
        query_filter: qmodels.Filter | None = None,
    ) -> list[dict]:
        results = self.search(query, limit=limit, query_filter=query_filter)
        if not results:
            return []
        relevant_chunksets, all_chunks = self.results_to_cheatsheet_inputs(results)
        if chunk_data:
            all_chunks = chunk_data.get("chunks", []) or []
        return generate_cheatsheets(relevant_chunksets, all_chunks) or []

    # Legacy compatibility
    def store_chunksets(self, chunksets: list[dict], chunks: list[dict], *, file_id: str | None = None) -> None:
        self.ingest({"chunksets": chunksets, "chunks": chunks})

    def write(self, chunk_data: dict, *, file_id: str | None = None) -> None:
        self.ingest(chunk_data)

    def query_chunksets(self, query: str, *, top_k: int = 5) -> list[dict]:
        return [r["metadata"] for r in self.search(query, limit=top_k)]

    def query_and_generate_cheatsheet(self, query: str, *, top_k: int = 5, all_chunks: list[dict] | None = None):
        chunk_data = {"chunks": all_chunks} if all_chunks else None
        return self.search_cheatsheets(query, limit=top_k, chunk_data=chunk_data)
