"""Collection management module for bamboost.

This module provides the Collection class and related utilities for managing
collections of simulations in the bamboost framework. It includes functionality
for creating, filtering, querying, and manipulating simulation collections,
as well as integration with the underlying index and MPI communication.

Classes:
    Collection: Main interface for interacting with a simulation collection.
    _CollectionPicker: Helper for selecting collections by UID.
    _FilterKeys: Helper for key completion and filtering.

Functions:
    (See Collection methods for main API.)

"""

from __future__ import annotations

import pkgutil
from ctypes import ArgumentError
from dataclasses import dataclass, field
from datetime import datetime
from functools import cache, cached_property
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Generator,
    Iterable,
    Literal,
    Mapping,
    Optional,
    cast,
)

import numpy as np
import pandas as pd
import yaml
from typing_extensions import Self, deprecated

from bamboost._config import config
from bamboost._logger import BAMBOOST_LOGGER
from bamboost._typing import StrPath
from bamboost.core.simulation.base import Simulation, SimulationWriter
from bamboost.core.utilities import dedupe_str_iter, flatten_dict
from bamboost.exceptions import DuplicateSimulationError, InvalidCollectionError
from bamboost.index import (
    CollectionUID,
    Index,
    SimulationName,
    SimulationUID,
    create_identifier_file,
    get_identifier_filename,
)
from bamboost.index.filtering import Filter, Operator, Sorter, SortInstruction, _Key
from bamboost.index.scanner import load_collection_metadata
from bamboost.index.store import CollectionMetadata, CollectionRecord
from bamboost.mpi import Communicator
from bamboost.mpi.utilities import RootProcessMeta
from bamboost.plugins import ElligibleForPlugin

if TYPE_CHECKING:
    from bamboost.mpi import Comm

__all__ = [
    "Collection",
]

log = BAMBOOST_LOGGER.getChild("Collection")


class _CollectionPicker:
    def __getitem__(self, key: str, /) -> Collection:
        key = key.split(" - ", 1)[0]
        return Collection(uid=key)

    def _ipython_key_completions_(self):
        return (f"{i.uid} - {i.path[-30:]}" for i in Index.default.all_collections)


class _FilterKeys:
    def __init__(self, collection: Collection):
        self.collection = collection

    def __getitem__(self, key: str) -> _Key:
        return _Key(key)

    def _ipython_key_completions_(self):
        metadata_keys = (
            "collection_uid",
            "name",
            "created_at",
            "modified_at",
            "description",
            "status",
        )
        return (*self.collection._record.get_parameter_keys()[0], *metadata_keys)


@dataclass(frozen=False)
class CollectionMetadataStore(CollectionMetadata, metaclass=RootProcessMeta):
    _collection: Collection = field(repr=False, compare=False, init=False)
    _comm: Communicator = field(
        default_factory=Communicator, repr=False, compare=False, init=False
    )

    @classmethod
    def from_dict(cls, data: dict[str, Any], *, _collection: Collection) -> Self:
        obj = super().from_dict(data)
        obj._collection = _collection
        obj._comm = _collection._comm
        # attempting to preserve the order of keys as in the yaml file
        obj._keys_ordered = tuple(data.keys())  # type: ignore
        return obj

    def to_dict(self) -> dict[str, Any]:
        d = super().to_dict()

        # preserve the order of keys as in the yaml file
        if hasattr(self, "_keys_ordered"):
            order = self._keys_ordered  # type: ignore
            d = {k: d[k] for k in order if k in d} | {
                k: v for k, v in d.items() if k not in order
            }
        return d

    def __getitem__(self, key: str) -> Any:
        if key in self._extras:
            return self._extras[key]
        return getattr(self, key)

    def _ipython_key_completions_(self):
        return tuple(self._fields) + tuple(self._extras.keys())

    def save(self) -> None:
        file_path = self._collection.path.joinpath(
            get_identifier_filename(self._collection.uid)
        )
        if not file_path:
            raise RuntimeError("No path associated with this metadata.")

        # update the metadata in the database
        self._collection._index.upsert_collection(
            self.uid, self._collection.path, self.to_dict()
        )

        # update the yaml file
        with file_path.open("w") as f:
            yaml.safe_dump(self.to_dict(), f, sort_keys=False, indent=2)

    def update(self, data: dict[str, Any]) -> None:
        """Update the instance with values from a dictionary, storing unknown fields in
        the extras dictionary.

        Args:
            data: The input dictionary.
        """
        for k, v in data.items():
            if k in self._fields:
                setattr(self, k, v)
            else:
                self._extras[k] = v


class Collection(ElligibleForPlugin):
    """Represents a collection of simulations in the bamboost framework.

    The Collection class provides an interface for managing, querying, and manipulating
    a group of simulations stored in a directory, with support for filtering, indexing,
    and MPI communication.

    Args:
        path: Path to the directory of the collection. If it doesn't exist,
            a new collection will be created if `create_if_not_exist` is True.
        uid: Unique identifier (UID) of the collection. If provided, the collection
            is resolved by UID instead of path.
        create_if_not_exist: If True (default), creates the collection directory if it does not exist.
        comm: MPI communicator to use for parallel operations. If not provided,
            the default communicator is used.
        index_instance: Custom Index instance to use for managing collections.
            If not provided, the default index is used.
        sync_collection: If True (default), synchronizes the collection with the index/database
            on initialization.
        filter: Optional filter to apply to the collection, returning a filtered view.

    Examples:
        >>> db = Collection("path/to/collection")
        >>> db.df  # DataFrame of the collection
        >>> sim = db["simulation_name"]  # Access a simulation by name
        >>> filtered = db.filter(db.k["param"] == 42)
    """

    uid: CollectionUID
    """Unique identifier of the collection."""
    path: Path
    """Path to the collection directory."""
    fromUID = _CollectionPicker()
    """Helper for selecting collections by UID."""
    _comm = Communicator()
    _filter: Filter | None = None
    _sorter: Sorter | None = None
    _index: Index

    def __init__(
        self,
        path: Optional[StrPath] = None,
        *,
        uid: Optional[str] = None,
        create_if_not_exist: bool = True,
        comm: Optional[Comm] = None,
        index_instance: Optional[Index] = None,
        sync_collection: bool = True,
        filter: Optional[Filter] = None,
        sorter: Optional[Sorter] = None,
        include_links: Iterable[str] | Literal[True] | None = None,
    ):
        assert path or uid, "Either path or uid must be provided."
        assert not (path and uid), "Only one of path or uid must be provided."

        self._index = index_instance or Index()
        self._filter = filter
        self._sorter = sorter
        self._include_links = include_links

        # A key store with completion of all the parameters and metadata keys
        self.k = _FilterKeys(self)

        # Resolve the path (this updates the index if necessary)
        # reason for type ignore: if not path -> uid is guaranteed to be not None
        self.path = Path(path or self._index.resolve_path(uid.upper())).absolute()  # type: ignore

        # Create the diretory for the collection if necessary
        if not self.path.is_dir():
            if not create_if_not_exist:
                raise NotADirectoryError("Specified path does not exist.")

            if self._comm.rank == 0:
                self.path.mkdir(parents=True, exist_ok=False)  # create the directory
                log.info(f"Initialized directory for collection at {path}")

        try:
            # Use uid if provided, otherwise resolve it from the path
            self.uid = CollectionUID(uid or self._index.resolve_uid(self.path))
        except InvalidCollectionError:
            # If the collection does not exist, create it in the index
            # and generate a new UID
            self.uid = CollectionUID()
            self._index.upsert_collection(self.uid, self.path)

        # Check if identifier file exists (and create it if necessary)
        if not self.path.joinpath(get_identifier_filename(uid=self.uid)).exists():
            create_identifier_file(self.path, self.uid)

        if sync_collection:
            # Sync the SQL table with the filesystem
            # Making sure the collection is up to date in the index
            self._index.sync_collection(self.uid, self.path)

            # Wait for root process to finish syncing
            self._comm.barrier()

    def __len__(self) -> int:
        return len(self._record.simulations)

    def __getitem__(self, name_or_index: str | int) -> Simulation:
        """Retrieve a Simulation from the collection by name or index.

        Args:
            name_or_index: The name of the simulation (str) or its index (int) in the
                collection dataframe.

        Returns:
            Simulation: The corresponding Simulation object.

        Raises:
            IndexError: If the index is out of range.
            KeyError: If the simulation name does not exist in the collection.

        Examples:
            >>> sim = collection["simulation_name"]
            >>> sim = collection[0]
        """
        if isinstance(name_or_index, int):
            name = self.df.iloc[name_or_index]["name"]
        else:
            name = name_or_index
        return Simulation(name, self.path, self._comm, collection_uid=self.uid)

    def __iter__(self) -> Generator[Simulation, None, None]:
        """Iterate over all simulations in the collection."""
        for sim in self._record.simulations:
            yield Simulation(sim.name, self.path, self._comm, collection_uid=self.uid)

    @cache
    def _ipython_key_completions_(self):
        return tuple(s.name for s in self._record.simulations)

    def _repr_html_(self) -> str:
        """HTML repr for ipython/notebooks, using jinja2 for templating."""
        from jinja2 import Template

        html_string = pkgutil.get_data("bamboost", "_repr/manager.html").decode()  # type: ignore
        icon = pkgutil.get_data("bamboost", "_repr/icon.txt").decode()  # type: ignore
        template = Template(html_string)

        return template.render(
            icon=icon,
            db_path=f"<a href={self.path.as_posix()}>{self.path}</a>",
            db_uid=self.uid,
            db_size=len(self),
            _filter=self._filter,
            _sort=self._sorter,
        )

    def _replace(self, **changes) -> Self:
        """Return a shallow copy of this Collection with some attrs replaced."""
        new = self.__class__.__new__(self.__class__)
        new.__dict__.update(self.__dict__)
        new.__dict__.update(changes)
        return new

    @property
    def _record(self) -> CollectionRecord:
        """Returns the in-memory representation of the collection.

        If a filter is applied to the collection, returns a FilteredCollection
        object that represents the filtered view. Otherwise, returns the base
        CollectionRecord object for the collection.

        Returns:
            CollectionRecord or FilteredCollection: The object representing the collection,
            possibly filtered.
        """
        collection_record = self._index.collection(self.uid)
        if collection_record is None:
            raise RuntimeError("Collection not found in index. This should not happen.")

        if self._include_links:
            collection_record = self._index.insert_linked_sim_parameters(
                collection_record, self._include_links
            )
        return collection_record.filtered(self._filter, self._sorter)

    @cached_property
    def metadata(self) -> CollectionMetadataStore:
        """Returns the metadata of the collection.

        The metadata can include information such as the collection's UID,
        creation date, tags, and aliases.

        Returns:
            CollectionMetadata: An object containing the collection's metadata.
        """
        data: dict[str, Any]

        if self._comm.rank == 0:
            data = load_collection_metadata(self.path, self.uid) or {}
            data.setdefault("uid", str(self.uid))
            data.setdefault("created_at", datetime.now())
        else:
            data = {}

        data = self._comm.bcast(data, 0)

        return CollectionMetadataStore.from_dict(data, _collection=self)

    def include_links(self, *keys: str) -> Self:
        """Returns a new Collection that includes parameters of simulations linked with
        the specified keys.

        Args:
            *keys: One or more keys representing the links to include. If no keys are
                provided, all linked simulations will be included.

        Returns:
            Collection: A new Collection instance that includes the linked simulations.

        Examples:
            >>> linked_collection = collection.include_links("key1", "key2")
            >>> all_linked = collection.include_links()  # include all linked simulations
        """
        if not keys:
            return self._replace(_include_links=True)

        return self._replace(_include_links=keys)

    def to_pandas(self, flatten: bool = True) -> pd.DataFrame:
        """Returns a pandas DataFrame representing the collection and its parameter space.

        The DataFrame contains all simulations in the collection, including their
        parameters and metadata. The table is sorted according to the user-specified key
        and order in the configuration, if available.

        Also includes parameters of linked simulations if `include_links` is set. (use
        `coll.include_links(...).df` to include specific links)

        Args:
            flatten: If True (default), flatten nested parameter dictionaries into a single
                level using dot notation. If False, keep nested dictionaries as they are.

        Returns:
            pd.DataFrame: DataFrame of the collection's simulations and parameters.
        """
        df = self._record.to_pandas(flatten=flatten)

        # apply filtering if necessary
        if self._filter is not None:
            df = cast(pd.DataFrame, self._filter.apply(df))

        # Try to sort the dataframe with the user specified key
        try:
            if self._sorter is None:
                df.sort_values(
                    config.options.sortTableKey,
                    inplace=True,
                    ascending=config.options.sortTableOrder == "asc",
                    ignore_index=True,
                )
        except KeyError:
            pass

        return df

    @property
    def df(self) -> pd.DataFrame:
        """Returns a pandas DataFrame representing the collection and its parameter space.

        The DataFrame contains all simulations in the collection, including their
        parameters and metadata. The table is sorted according to the user-specified key
        and order in the configuration, if available.

        Also includes parameters of linked simulations if `include_links` is set. (use
        `coll.include_links(...).df` to include specific links)

        Returns:
            pd.DataFrame: DataFrame of the collection's simulations and parameters.
        """
        return self.to_pandas()

    def filter(
        self, *operators: Operator, tags: str | Iterable[str] | None = None
    ) -> Self:
        """Returns a new Collection filtered by the given operators.

        This method applies the specified filter operators to the collection and returns a
        new Collection instance representing the filtered view. The original collection
        remains unchanged.

        Args:
            *operators: One or more filter operators (e.g., comparisons using Collection.k)
                to apply to the collection.
            tags: Optional tag or iterable of tags to filter by.

        Returns:
            Collection: A new Collection instance containing only the simulations that
            match the specified filter criteria.

        Examples:
            >>> filtered = collection.filter(collection.k["param"] == 42)
        """
        tags = (tags,) if isinstance(tags, str) else tags  # handle single string case
        return self._replace(_filter=Filter(*operators, tags=tags) & self._filter)

    def sort(self, key: _Key | str, ascending: bool = True) -> Self:
        """Returns a new Collection sorted by the given instructions.

        This method applies the specified sort instructions to the collection and returns
        a new Collection instance representing the sorted view. The original collection
        remains unchanged.

        Args:
            key: A SortInstruction object or a string representing the parameter or
                metadata key to sort by.
            ascending: If True (default), sorts in ascending order. If False, sorts in
                descending order.

        Returns:
            Collection: A new Collection instance with simulations sorted according to
            the specified instructions.

        Examples:
            >>> sorted_collection = collection.sort(SortInstruction("param", ascending=False))
        """
        if isinstance(key, _Key):
            key = key._value

        if self._sorter is None:
            new_sorter = Sorter(SortInstruction(key, ascending))
        else:
            new_sorter = self._sorter & Sorter(SortInstruction(key, ascending))

        return self._replace(_sorter=new_sorter)

    def all_simulation_names(self) -> list[str]:
        """Returns a list of all simulation names in the collection.

        Returns:
            list[str]: A list containing the names of all simulations in the collection.
        """
        return [sim.name for sim in self._record.simulations]

    def sync_cache(self, *, force_all: bool = False) -> None:
        """Synchronize the database for this collection.

        This method updates the collection's cache by syncing the underlying index and
        filesystem. It ensures that the collection's metadata and simulation information
        are up to date. If `force_all` is True, a full rescan and update of all
        simulations in the collection will be performed, regardless of their current cache
        state.

        Args:
            force_all: If True, force a full resync of all simulations in the collection.
                If False (default), only update simulations that are out of sync.
        """
        self._index.sync_collection(self.uid, self.path, force_all=force_all)

    def add(
        self,
        name: Optional[str] = None,
        parameters: Optional[Mapping[str, Any]] = None,
        *,
        duplicate_action: Literal["ignore", "replace", "skip", "raise"] = "raise",
        description: Optional[str] = None,
        tags: Optional[Iterable[str]] = None,
        files: Optional[Iterable[StrPath]] = None,
        links: Optional[dict[str, str | SimulationUID]] = None,
        override: bool = False,
    ) -> SimulationWriter:
        """Create and initialize a new simulation in the collection, returning a
        SimulationWriter object.

        This method is designed for parallel use, such as in batch scripts or parameter
        sweeps, where multiple simulations may be created concurrently. It handles
        creation of the simulation directory, duplicate checking, copying files, and
        setting up metadata and parameters.

        Args:
            name: The name/UID for the simulation. If not specified, a unique random ID
                will be generated.
            parameters: Dictionary of simulation parameters. If provided, these parameters
                will be checked against existing simulations for duplication. If not provided,
                parameters can be set later via `bamboost.core.simulation.Simulation.parameters`.

                Note:
                    - Parameters are stored in the HDF5 file as attributes.
                    - If a value is a dict, it is flattened using `bamboost.core.utilities.flatten_dict`.
                    - If a value is a list or array, it is stored as a dataset.

            duplicate_action: Action to take if a simulation with the same parameters already exists.
                Options are: "ignore" (create anyway), "replace" (delete existing and create new),
                "skip" (return existing simulation), "raise" (default, raise DuplicateSimulationError).
            description: Optional description for the simulation.
            tags: Optional sequence of tags for the simulation metadata.
            files: Optional iterable of file paths to copy into the simulation directory.
                Each file will be copied with its original name.
            links: Optional dictionary of symbolic links to create in the simulation
                directory, mapping link names to target paths.
            override: If True, overwrite any existing simulation with the same name. If
                False (default), raises FileExistsError if a simulation with the same name
                exists.

        Returns:
            SimulationWriter: An object for writing data and metadata to the new
            simulation.

        Raises:
            FileExistsError: If a simulation with the same name already exists and override is False.
            ValueError, PermissionError: If there is an error during simulation creation.
            DuplicateSimulationError: If parameters are provided and a simulation with the same
                parameters already exists. Specify `duplicate_action` to control behavior.

        Examples:
            >>> db.add(parameters={"a": 1, "b": 2})

            >>> db.add(name="my_sim", parameters={"a": 1, "b": 2})

        Note:
            - This method is safe for use in parallel (MPI) environments.
            - Be cautious when using `duplicate_action="replace"` as it will delete
              existing simulations with matching parameters, without asking again.
        """
        import shutil

        assert duplicate_action in ("ignore", "replace", "skip", "raise"), (
            "Invalid duplicate_action. Must be one of: 'ignore', 'replace', 'skip', 'raise'."
        )

        name = SimulationName(name)  # Generates a unique id as name if not provided
        directory = self.path.joinpath(name)

        # Check if name is already in use, otherwise create a new directory
        if self._comm.rank == 0:
            exists = directory.exists()
            must_fail = exists and not override
            fail_msg = (
                f"Simulation {name} already exists in {self.path}" if must_fail else ""
            )
        else:
            exists = must_fail = None
            fail_msg = ""

        # Broadcast the decision to everyone
        must_fail = self._comm.bcast(must_fail, root=0)
        fail_msg = self._comm.bcast(fail_msg, root=0)

        if must_fail:
            raise FileExistsError(fail_msg)

        # Root process creates the directory if it does not exist
        if self._comm.rank == 0:
            # Check for duplicate parameters if provided
            if parameters and duplicate_action != "ignore" and not override:
                try:
                    self._check_duplicate(parameters)
                except DuplicateSimulationError as e:
                    if duplicate_action == "raise":
                        raise
                    elif duplicate_action == "skip":
                        log.info(
                            f"Simulation with parameters {parameters} already exists as"
                            f"{e.duplicates}. Skipping creation and returning first duplicate."
                        )
                        return self[e.duplicates[0]].edit()
                    elif duplicate_action == "replace":
                        log.info(
                            f"Removing (all) existing simulations with the given parameters: {e.duplicates}."
                        )
                        self.delete(e.duplicates)

            if exists and override:
                with RootProcessMeta.comm_self(self):
                    self._index.drop_simulation(self.uid, name)
                shutil.rmtree(directory)  # remove the old directory

            # finally create the new directory
            directory.mkdir(exist_ok=False)

        self._comm.barrier()

        try:
            # Create the simulation instance
            sim = SimulationWriter(
                name, self.path, self._comm, self._index, collection_uid=self.uid
            )
            with sim._file.open("w", driver="mpio"), self._index.sql_transaction():
                sim.initialize()  # create groups, set metadata and status
                sim.metadata.update(
                    {"description": description or "", "tags": dedupe_str_iter(tags)}
                )
                sim.parameters.update(parameters or {})
                sim.links.update(links or {})
                sim.copy_files(files or [])

            # Invalidate the file_map such that it is reloaded
            sim._file.file_map.invalidate()

            log.info(f"Created simulation {name} in {self.path}")
            return sim
        except (ValueError, PermissionError):
            log.error(
                f"Error occurred while creating simulation {name} at path {self.path}"
            )
            self._index.drop_simulation(self.uid, name)
            shutil.rmtree(directory)
            raise

    @deprecated(
        "Use `add` instead. This method has been renamed in v0.10.2 and will be"
        "removed in future versions."
    )
    def create_simulation(self, *args, **kwargs) -> SimulationWriter:
        """Deprecated alias of `add`. See `bamboost.core.collection.Collection.add` method."""
        return self.add(*args, **kwargs)

    def delete(self, name: str | Iterable[str]) -> None:
        """CAUTION. Deletes one or more simulations from the collection.

        This method removes the specified simulation(s) from both the filesystem and the
        index/database. It is a destructive operation and should be used with caution.

        Args:
            name: Name of the simulation to delete, or an iterable of names.

        Raises:
            ValueError: If any of the specified names are invalid or do not exist in the
                collection.
            PermissionError: If there is an error deleting the simulation directory.

        Examples:
            >>> db.delete("simulation_name")
            >>> db.delete(["sim1", "sim2", "sim3"])
        """
        import shutil

        if isinstance(name, str):
            names = [name]
        elif isinstance(name, Iterable):
            names = list(name)
        else:
            raise ArgumentError("name must be a string or an iterable of strings.")

        # Check that all names exist in the collection
        existing_names = set(self.all_simulation_names())
        for n in names:
            if n not in existing_names:
                raise ValueError(f"Simulation {n} does not exist in the collection.")

        # Only root process performs deletion
        if self._comm.rank == 0:
            with RootProcessMeta.comm_self(self), self._index.sql_transaction():
                for n in names:
                    self._index.drop_simulation(self.uid, n)
                    try:
                        shutil.rmtree(self.path.joinpath(n))
                    except PermissionError as e:
                        log.error(f"Error deleting simulation directory for {n}: {e}")
                        raise

        self._comm.barrier()

    def find(self, parameter_selection: Mapping[str, Any]) -> pd.DataFrame:
        """Find simulations matching the given parameter selection.

        The parameter_selection dictionary can specify exact values for parameters, or use
        callables (such as lambda functions) for more complex filtering, such as
        inequalities or custom logic.

        Args:
            parameter_selection: Dictionary mapping parameter names to values or
                callables. If a value is a callable, it will be used as a filter function
                applied to the corresponding parameter column.

        Returns:
            pd.DataFrame: DataFrame containing simulations that match the specified
            criteria.

        Examples:
            >>> db.find({"a": 1, "b": lambda x: x > 2})
            >>> db.find({"a": 1, "b": 2})
        """
        parameter_selection = flatten_dict(parameter_selection)
        params = {}
        filters = {}
        for key, val in parameter_selection.items():
            if callable(val):
                filters[key] = val
            else:
                params[key] = val

        df = self.df
        matches = self._list_duplicates(params, df=df)
        matches = df[df.name.isin(matches)]
        assert isinstance(matches, pd.DataFrame)
        if len(matches) == 0:
            return matches

        for key, func in filters.items():
            matches = cast(pd.DataFrame, matches[matches[key].apply(func)])

        return matches

    def _list_duplicates(
        self, parameters: Mapping, *, df: pd.DataFrame | None = None
    ) -> list[str]:
        """List the names (IDs) of simulations in the collection that have duplicate
        parameter values.

        Args:
            parameters: Parameter dictionary to check for duplicates. Keys are parameter
                names, values are the values to match against existing simulations.
            df: DataFrame to search in. If not provided, the DataFrame from the SQL
                database is used.

        Returns:
            list[str]: List of simulation names (IDs) that have the same parameter values
            as provided.
        """
        import pandas as pd

        if df is None:
            df = self._record.to_pandas()
        params = flatten_dict(parameters)

        class ComparableIterable:
            def __init__(self, ori):
                self.ori = np.asarray(ori)

            def __eq__(self, other):
                return np.array_equal(np.asarray(other), self.ori)

        # make all iterables comparable by converting them to ComparableIterable
        for k in params.keys():
            if isinstance(params[k], Iterable) and not isinstance(params[k], str):
                params[k] = ComparableIterable(params[k])

        # if any of the parameters is not in the dataframe, no duplicates
        for p in params:
            if p not in df.keys():
                return []

        # get matching rows where all values of the series are equal to the corresponding values in the dataframe
        s = pd.Series(params)
        match = df.loc[(df[s.keys()].apply(lambda row: (s == row).all(), axis=1))]
        return match.name.tolist()

    def _check_duplicate(self, parameters: Mapping) -> Literal[True]:
        """Check whether the given parameters dictionary already exists in the collection.
        Returns True if no duplicates are found. Raises `DuplicateSimulationError` if
        duplicates are found.

        Args:
            parameters (dict): Parameter dictionary to check for duplicates.
        """

        duplicates = self._list_duplicates(parameters)
        if len(duplicates) == 0:
            return True

        raise DuplicateSimulationError(duplicates=tuple(duplicates))
