# Copyright (c) Microsoft. All rights reserved.

import ast
import logging
import sys
from collections.abc import MutableSequence, Sequence
from typing import Any, ClassVar, Final, Generic, TypeVar

from chromadb import Client, Collection, GetResult, QueryResult
from chromadb.api import ClientAPI
from chromadb.api.collection_configuration import CreateCollectionConfiguration, CreateHNSWConfiguration
from chromadb.api.types import EmbeddingFunction, Space
from chromadb.config import Settings

from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase
from semantic_kernel.data.vector import (
    DistanceFunction,
    GetFilteredRecordOptions,
    IndexKind,
    KernelSearchResults,
    SearchType,
    TModel,
    VectorSearch,
    VectorSearchOptions,
    VectorSearchResult,
    VectorStore,
    VectorStoreCollection,
    VectorStoreCollectionDefinition,
    _get_collection_name_from_model,
)
from semantic_kernel.exceptions.vector_store_exceptions import (
    VectorStoreInitializationException,
    VectorStoreModelException,
    VectorStoreModelValidationError,
    VectorStoreOperationException,
)
from semantic_kernel.utils.feature_stage_decorator import release_candidate

if sys.version_info >= (3, 12):
    from typing import override  # pragma: no cover
else:
    from typing_extensions import override  # pragma: no cover

logger = logging.getLogger(__name__)

TKey = TypeVar("TKey", bound=str)


DISTANCE_FUNCTION_MAP: Final[dict[DistanceFunction, Space]] = {
    DistanceFunction.COSINE_SIMILARITY: "cosine",
    DistanceFunction.EUCLIDEAN_SQUARED_DISTANCE: "l2",
    DistanceFunction.DOT_PROD: "ip",
    DistanceFunction.DEFAULT: "l2",
}

INDEX_KIND_MAP: Final[dict[IndexKind, str]] = {
    IndexKind.HNSW: "hnsw",
    IndexKind.DEFAULT: "hnsw",
}


@release_candidate
class ChromaCollection(
    VectorStoreCollection[TKey, TModel],
    VectorSearch[TKey, TModel],
    Generic[TKey, TModel],
):
    """Chroma vector store collection."""

    client: ClientAPI
    embedding_func: EmbeddingFunction | None = None
    supported_key_types: ClassVar[set[str] | None] = {"str"}
    supported_search_types: ClassVar[set[SearchType]] = {SearchType.VECTOR}

    def __init__(
        self,
        record_type: type[object],
        definition: VectorStoreCollectionDefinition | None = None,
        collection_name: str | None = None,
        persist_directory: str | None = None,
        client_settings: "Settings | None" = None,
        client: "ClientAPI | None" = None,
        embedding_generator: EmbeddingGeneratorBase | None = None,
        embedding_func: EmbeddingFunction | None = None,
        **kwargs: Any,
    ):
        """Initialize the Chroma vector store collection.

        Args:
            record_type: The type of the data model.
            definition: The definition of the data model.
            collection_name: The name of the collection.
            persist_directory: The directory to persist the collection.
            client_settings: The settings for the Chroma client.
            client: The Chroma client.
            embedding_generator: The embedding generator to use.
                This is the Semantic Kernel embedding generator that will be used to generate the embeddings.
            embedding_func: The embedding function to use.
                This is a Chroma specific function that will be used to generate the embeddings.
            kwargs: Additional arguments to pass to the parent class.

        """
        if not collection_name:
            collection_name = _get_collection_name_from_model(record_type, definition)
        managed_client = not client
        if client is None:
            settings = client_settings or Settings()
            if persist_directory is not None:
                settings.is_persistent = True
                settings.persist_directory = persist_directory
            client = Client(settings)
        super().__init__(
            collection_name=collection_name,
            record_type=record_type,
            definition=definition,
            client=client,
            managed_client=managed_client,
            embedding_func=embedding_func,
            embedding_generator=embedding_generator,
            **kwargs,
        )

    def _get_collection(self) -> Collection:
        try:
            return self.client.get_collection(name=self.collection_name, embedding_function=self.embedding_func)
        except Exception as e:
            raise RuntimeError(f"Failed to get collection {self.collection_name}") from e

    @override
    async def collection_exists(self, **kwargs: Any) -> bool:
        """Check if the collection exists."""
        try:
            self.client.get_collection(name=self.collection_name, embedding_function=self.embedding_func)
            return True
        except Exception:
            return False

    @override
    async def ensure_collection_exists(self, **kwargs: Any) -> None:
        """Create the collection.

        Will create a metadata object with the hnsw arguments.
        By default only the distance function will be set based on the data model.
        To tweak the other hnsw parameters, pass them in the kwargs.

        For example:
        ```python
        await collection.create_collection(
            configuration={"hnsw": {"max_neighbors": 16, "ef_construction": 200, "ef_search": 200}}
        )
        ```
        if the `space` is set, it will be overridden, by the distance function set in the data model.

        To use the built-in Chroma embedding functions, set the `embedding_func` parameter in the class constructor.

        Args:
            kwargs: Additional arguments are passed to the metadata parameter of the create_collection method.
                See the Chroma documentation for more details.
        """
        if self.definition.vector_fields:
            configuration = kwargs.pop("configuration", {})
            configuration = CreateCollectionConfiguration(**configuration)
            vector_field = self.definition.vector_fields[0]
            if vector_field.index_kind not in INDEX_KIND_MAP:
                raise VectorStoreInitializationException(f"Index kind {vector_field.index_kind} is not supported.")
            if vector_field.distance_function not in DISTANCE_FUNCTION_MAP:
                raise VectorStoreInitializationException(
                    f"Distance function {vector_field.distance_function} is not supported."
                )
            if "hnsw" not in configuration or configuration["hnsw"] is None:
                configuration["hnsw"] = CreateHNSWConfiguration(
                    space=DISTANCE_FUNCTION_MAP[vector_field.distance_function]
                )
            else:
                configuration["hnsw"]["space"] = DISTANCE_FUNCTION_MAP[vector_field.distance_function]
            kwargs["configuration"] = configuration
        if "get_or_create" not in kwargs:
            kwargs["get_or_create"] = True

        self.client.create_collection(name=self.collection_name, embedding_function=self.embedding_func, **kwargs)

    @override
    async def ensure_collection_deleted(self, **kwargs: Any) -> None:
        """Delete the collection."""
        try:
            self.client.delete_collection(name=self.collection_name)
        except ValueError:
            logger.info(f"Collection {self.collection_name} could not be deleted because it doesn't exist.")
        except Exception as e:
            raise VectorStoreOperationException(
                f"Failed to delete collection {self.collection_name} with error: {e}"
            ) from e

    def _validate_data_model(self):
        super()._validate_data_model()
        if len(self.definition.vector_fields) > 1:
            raise VectorStoreModelValidationError(
                f"Chroma only supports one vector field, but {len(self.definition.vector_fields)} were provided."
            )

    @override
    def _serialize_dicts_to_store_models(self, records: Sequence[dict[str, Any]], **kwargs: Any) -> Sequence[Any]:
        vector_field = self.definition.vector_fields[0]
        id_field_name = self.definition.key_name
        store_models = []
        for record in records:
            store_model = {
                "id": record[id_field_name],
                "metadata": {
                    k: v
                    for k, v in record.items()
                    if k not in [id_field_name, vector_field.storage_name or vector_field.name]
                },
            }
            if self.embedding_func:
                store_model["document"] = (record[vector_field.storage_name or vector_field.name],)
            else:
                store_model["embedding"] = record[vector_field.storage_name or vector_field.name]
            if store_model["metadata"] == {}:
                store_model.pop("metadata")
            store_models.append(store_model)
        return store_models

    @override
    def _deserialize_store_models_to_dicts(self, records: Sequence[Any], **kwargs: Any) -> Sequence[dict[str, Any]]:
        vector_field = self.definition.vector_fields[0]
        # replace back the name of the vector, content and id fields
        for record in records:
            record[self.definition.key_name] = record.pop("id")
            record[vector_field.name] = record.pop("document", None) or record.pop("embedding", None)
        return records

    @override
    async def _inner_upsert(
        self,
        records: Sequence[Any],
        **kwargs: Any,
    ) -> Sequence[TKey]:
        upsert_obj: dict[str, Any] = {"ids": [], "metadatas": []}
        if self.embedding_func:
            upsert_obj["documents"] = []
        else:
            upsert_obj["embeddings"] = []
        for record in records:
            upsert_obj["ids"].append(record["id"])
            if "embedding" in record:
                upsert_obj["embeddings"].append(record["embedding"])
            if "document" in record:
                upsert_obj["documents"].append(record["document"])
            if "metadata" in record:
                upsert_obj["metadatas"].append(record["metadata"])
        if not upsert_obj["metadatas"]:
            upsert_obj.pop("metadatas")
        self._get_collection().add(**upsert_obj)
        return upsert_obj["ids"]

    @override
    async def _inner_get(
        self,
        keys: Sequence[str] | None = None,
        options: GetFilteredRecordOptions | None = None,
        **kwargs: Any,
    ) -> Sequence[Any] | None:
        include_vectors = kwargs.get("include_vectors", True)
        if self.embedding_func:
            include = ["documents", "metadatas"]
        elif include_vectors:
            include = ["embeddings", "metadatas"]
        else:
            include = ["metadatas"]
        args: dict[str, Any] = {"include": include}
        if keys:
            args["ids"] = keys
        if options:
            args["limit"] = options.top
            args["offset"] = options.skip
        results = self._get_collection().get(**args)
        return self._unpack_results(results, include_vectors)

    def _unpack_results(
        self, results: QueryResult | GetResult, include_vectors: bool, include_distances: bool = False
    ) -> Sequence[dict[str, Any]]:
        try:
            if isinstance(results["ids"][0], str):
                for k, v in results.items():
                    results[k] = [v]  # type: ignore
        except IndexError:
            return []
        records: MutableSequence[dict[str, Any]] = []

        # Determine available fields
        ids = results["ids"][0] if "ids" in results else []
        metadatas = results.get("metadatas")
        documents = results.get("documents")
        embeddings = results.get("embeddings")
        distances = results.get("distances")

        # Build records dynamically based on available fields
        for idx, id in enumerate(ids):
            record: dict[str, Any] = {"id": id}
            # Add vector field if present
            if documents is not None and documents[0] is not None and idx < len(documents[0]):
                record["document"] = documents[0][idx]
            elif embeddings is not None and embeddings[0] is not None and idx < len(embeddings[0]):
                record["embedding"] = embeddings[0][idx]
            # Add distance if present
            if distances is not None and distances[0] is not None and idx < len(distances[0]):  # type: ignore
                record["distance"] = distances[0][idx]  # type: ignore
            # Add metadata if present
            if metadatas is not None and metadatas[0] is not None and idx < len(metadatas[0]):
                metadata = metadatas[0] if isinstance(metadatas[0], dict) else metadatas[0][idx]  # type: ignore
                if metadata and isinstance(metadata, dict):
                    record.update(metadata)
            records.append(record)
        return records

    @override
    async def _inner_delete(self, keys: Sequence[TKey], **kwargs: Any) -> None:
        self._get_collection().delete(ids=keys)  # type: ignore

    @override
    async def _inner_search(
        self,
        search_type: SearchType,
        options: VectorSearchOptions,
        values: Any | None = None,
        vector: Sequence[float | int] | None = None,
        **kwargs: Any,
    ) -> KernelSearchResults[VectorSearchResult[TModel]]:
        vector_field = self.definition.try_get_vector_field(options.vector_property_name)
        if not vector_field:
            raise VectorStoreModelException(
                f"Vector field '{options.vector_property_name}' not found in the data model definition."
            )
        include = ["metadatas", "distances"]
        if options.include_vectors:
            include.append("documents" if self.embedding_func else "embeddings")
        args: dict[str, Any] = {
            "n_results": options.top,
            "include": include,
        }
        if filter := self._build_filter(options.filter):  # type: ignore
            args["where"] = filter if isinstance(filter, dict) else {"$and": filter}
        if self.embedding_func:
            args["query_texts"] = values
        elif vector is not None:
            args["query_embeddings"] = vector
        else:
            args["query_embeddings"] = await self._generate_vector_from_values(values, options)
        results = self._get_collection().query(**args)
        records = self._unpack_results(results, options.include_vectors, include_distances=True)
        return KernelSearchResults(
            results=self._get_vector_search_results_from_results(records), total_count=len(records) if records else 0
        )

    @override
    def _get_record_from_result(self, result: Any) -> Any:
        return result

    @override
    def _get_score_from_result(self, result: Any) -> float | None:
        return result["distance"]

    @override
    def _lambda_parser(self, node: ast.AST) -> dict[str, Any] | str | int | float | bool | None:  # type: ignore
        # Comparison operations
        match node:
            case ast.Compare():
                if len(node.ops) > 1:
                    # Chain comparisons (e.g., 1 < x < 3) become $and of each comparison
                    values = []
                    for idx in range(len(node.ops)):
                        left = node.left if idx == 0 else node.comparators[idx - 1]
                        right = node.comparators[idx]
                        op = node.ops[idx]
                        values.append(self._lambda_parser(ast.Compare(left=left, ops=[op], comparators=[right])))
                    return {"$and": values}
                left = self._lambda_parser(node.left)  # type: ignore
                right = self._lambda_parser(node.comparators[0])  # type: ignore
                op = node.ops[0]
                match op:
                    case ast.In():
                        return {left: {"$in": right}}  # type: ignore
                    case ast.NotIn():
                        return {left: {"$nin": right}}  # type: ignore
                    case ast.Eq():
                        # Chroma allows short form: {field: value}
                        return {left: right}  # type: ignore
                    case ast.NotEq():
                        return {left: {"$ne": right}}  # type: ignore
                    case ast.Gt():
                        return {left: {"$gt": right}}  # type: ignore
                    case ast.GtE():
                        return {left: {"$gte": right}}  # type: ignore
                    case ast.Lt():
                        return {left: {"$lt": right}}  # type: ignore
                    case ast.LtE():
                        return {left: {"$lte": right}}  # type: ignore
                raise NotImplementedError(f"Unsupported operator: {type(op)}")
            case ast.BoolOp():
                op = node.op  # type: ignore
                values = [self._lambda_parser(v) for v in node.values]
                if isinstance(op, ast.And):
                    return {"$and": values}
                if isinstance(op, ast.Or):
                    return {"$or": values}
                raise NotImplementedError(f"Unsupported BoolOp: {type(op)}")
            case ast.UnaryOp():
                raise NotImplementedError("Unary +, -, ~ and ! are not supported in Chroma filters.")
            case ast.Attribute():
                # Only allow attributes that are in the data model
                if node.attr not in self.definition.storage_names:
                    raise VectorStoreOperationException(
                        f"Field '{node.attr}' not in data model (storage property names are used)."
                    )
                return node.attr
            case ast.Name():
                # Only allow names that are in the data model
                if node.id not in self.definition.storage_names:
                    raise VectorStoreOperationException(
                        f"Field '{node.id}' not in data model (storage property names are used)."
                    )
                return node.id
            case ast.Constant():
                value = node.value
                if isinstance(value, str):
                    return value.replace("'", "''")
                if isinstance(value, bytes):
                    return value.decode("utf-8").replace("'", "''")
                if isinstance(value, (int, float, bool)) or value is None:
                    return value
                raise VectorStoreOperationException(f"Unsupported constant type: {type(value)}")
        raise NotImplementedError(f"Unsupported AST node: {type(node)}")


@release_candidate
class ChromaStore(VectorStore):
    """Chroma vector store."""

    client: ClientAPI

    def __init__(
        self,
        persist_directory: str | None = None,
        client_settings: "Settings | None" = None,
        client: ClientAPI | None = None,
        embedding_generator: EmbeddingGeneratorBase | None = None,
        **kwargs: Any,
    ):
        """Initialize the Chroma vector store."""
        managed_client = not client
        settings = client_settings or Settings()
        if persist_directory is not None:
            settings.is_persistent = True
            settings.persist_directory = persist_directory
        if client is None:
            client = Client(settings)
        super().__init__(
            client=client, managed_client=managed_client, embedding_generator=embedding_generator, **kwargs
        )

    @override
    def get_collection(
        self,
        record_type: type[TModel],
        *,
        definition: VectorStoreCollectionDefinition | None = None,
        collection_name: str | None = None,
        embedding_generator: EmbeddingGeneratorBase | None = None,
        **kwargs: Any,
    ) -> ChromaCollection:
        """Get a vector record store."""
        return ChromaCollection(
            client=self.client,
            collection_name=collection_name,
            record_type=record_type,
            definition=definition,
            embedding_generator=embedding_generator or self.embedding_generator,
            **kwargs,
        )

    @override
    async def list_collection_names(self, **kwargs) -> Sequence[str]:
        return [coll.name for coll in self.client.list_collections()]
