from __future__ import annotations

import base64
import datetime
import hashlib
import hmac
import json
import re
from collections.abc import Iterator, Mapping, MutableMapping, Sequence
from typing import Any
from urllib import parse

from meilisearch._httprequests import HttpRequests
from meilisearch.config import Config
from meilisearch.errors import (
    MeilisearchCommunicationError,
    MeilisearchError,
)
from meilisearch.index import Index
from meilisearch.models.index import SizeFormat
from meilisearch.models.key import Key, KeysResults
from meilisearch.models.search_rule import SearchRule, SearchRulesResults
from meilisearch.models.task import Batch, BatchResults, Task, TaskInfo, TaskResults
from meilisearch.models.webhook import Webhook, WebhooksResults
from meilisearch.task import TaskHandler


class Client:
    """
    A client for the Meilisearch API

    A client instance is needed for every Meilisearch API method to know the location of
    Meilisearch and its permissions.
    """

    def __init__(
        self,
        url: str,
        api_key: str | None = None,
        timeout: int | None = None,
        client_agents: tuple[str, ...] | None = None,
        custom_headers: Mapping[str, str] | None = None,
    ) -> None:
        """
        Parameters
        ----------
        url:
            The url to the Meilisearch API (ex: http://localhost:7700)
        api_key:
            The optional API key for Meilisearch
        timeout (optional):
            The amount of time in seconds that the client will wait for a response before timing
            out.
        client_agents (optional):
            Used to send additional client agent information for clients extending the functionality
            of this client.
        custom_headers (optional):
            Custom headers to add when sending data to Meilisearch.
        """

        self.config = Config(url, api_key, timeout=timeout, client_agents=client_agents)

        # Store custom headers so they can be propagated to sub-clients (Index, TaskHandler, etc.)
        self._custom_headers = custom_headers

        self.http = HttpRequests(self.config, custom_headers)

        self.task_handler = TaskHandler(self.config, custom_headers)

    def create_index(
        self,
        uid: str,
        options: Mapping[str, Any] | None = None,
        *,
        metadata: str | None = None,
    ) -> TaskInfo:
        """Create an index.

        Parameters
        ----------
        uid: str
            UID of the index.
        options (optional): dict
            Options passed during index creation (ex: primaryKey).
        metadata (optional):
            Custom metadata string to attach to the task.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return Index.create(
            self.config, uid, options, custom_headers=self._custom_headers, metadata=metadata
        )

    def delete_index(self, uid: str, *, metadata: str | None = None) -> TaskInfo:
        """Deletes an index

        Parameters
        ----------
        uid:
            UID of the index.
        metadata (optional):
            Custom metadata string to attach to the task.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """

        url = f"{self.config.paths.index}/{uid}"
        if metadata is not None:
            url += f"?{parse.urlencode({'customMetadata': metadata})}"
        task = self.http.delete(url)

        return TaskInfo(**task)

    def get_indexes(self, parameters: Mapping[str, Any] | None = None) -> dict[str, list[Index]]:
        """Get all indexes.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the get indexes route: https://www.meilisearch.com/docs/reference/api/indexes#list-all-indexes

        Returns
        -------
        indexes:
            Dictionary with limit, offset, total and results a list of Index instances.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        if parameters is None:
            parameters = {}
        response = self.http.get(f"{self.config.paths.index}?{parse.urlencode(parameters)}")
        response["results"] = [
            Index(
                self.config,
                index["uid"],
                index["primaryKey"],
                index["createdAt"],
                index["updatedAt"],
                custom_headers=self._custom_headers,
            )
            for index in response["results"]
        ]
        return response

    def get_raw_indexes(self, parameters: Mapping[str, Any] | None = None) -> list[dict[str, Any]]:
        """Get all indexes in dictionary format.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the get indexes route: https://www.meilisearch.com/docs/reference/api/indexes#list-all-indexes

        Returns
        -------
        indexes:
            Dictionary with limit, offset, total and results a list of indexes in dictionary format. (e.g [{ 'uid': 'movies' 'primaryKey': 'objectID' }])

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        if parameters is None:
            parameters = {}
        return self.http.get(f"{self.config.paths.index}?{parse.urlencode(parameters)}")

    def get_index(self, uid: str) -> Index:
        """Get the index.
        This index should already exist.

        Parameters
        ----------
        uid:
            UID of the index.

        Returns
        -------
        index:
            An Index instance containing the information of the fetched index.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return Index(self.config, uid, custom_headers=self._custom_headers).fetch_info()

    def get_raw_index(self, uid: str) -> dict[str, Any]:
        """Get the index as a dictionary.
        This index should already exist.

        Parameters
        ----------
        uid:
            UID of the index.

        Returns
        -------
        index:
            An index in dictionary format. (e.g { 'uid': 'movies' 'primaryKey': 'objectID' })

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.get(f"{self.config.paths.index}/{uid}")

    def index(self, uid: str) -> Index:
        """Create a local reference to an index identified by UID, without doing an HTTP call.
        Calling this method doesn't create an index in the Meilisearch instance, but grants access to all the other methods in the Index class.

        Parameters
        ----------
        uid:
            UID of the index.

        Returns
        -------
        index:
            An Index instance.
        """
        if uid is not None:
            return Index(self.config, uid=uid, custom_headers=self._custom_headers)
        raise ValueError("The index UID should not be None")

    def multi_search(
        self, queries: Sequence[Mapping[str, Any]], federation: dict[str, Any] | None = None
    ) -> dict[str, list[dict[str, Any]]]:
        """Multi-index search.

        Parameters
        ----------
        queries:
            List of dictionaries containing the specified indexes and their search queries
            https://www.meilisearch.com/docs/reference/api/search#search-in-an-index
            It can also include remote options in federationOptions for each query
            https://www.meilisearch.com/docs/reference/api/network
            Each query may include showPerformanceDetails to request a performance
            trace in the response (results[].performanceDetails, returned as raw data).
        federation: (optional):
            Dictionary containing offset, limit, and optionally showPerformanceDetails
            for federated search (top-level performanceDetails in the response).
            https://www.meilisearch.com/docs/reference/api/multi_search

        Returns
        -------
        results:
            Dictionary of results for each search query

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.post(
            f"{self.config.paths.multi_search}",
            body={"queries": queries, "federation": federation},
        )

    def render_template(
        self,
        template: Mapping[str, Any],
        input: Mapping[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Render a template against an input document.

        This is an experimental route. Enable the ``renderRoute`` feature on your
        Meilisearch instance before calling it, for example
        ``client.update_experimental_features({"renderRoute": True})``.

        https://www.meilisearch.com/docs/reference/api/template

        Parameters
        ----------
        template:
            Dictionary describing the template or fragment to render, for example
            {"kind": "inlineDocumentTemplate", "inline": "Rendered on {{doc.id}}"}.
        input (optional):
            Dictionary describing what to render the template with, for example
            {"kind": "inlineDocument", "inline": {"id": "this document"}}. When omitted
            the route returns the unrendered template and ``rendered`` is null.

        Returns
        -------
        rendered:
            Dictionary with the unrendered ``template`` and the ``rendered`` result.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        body: dict[str, Any] = {"template": template}
        if input is not None:
            body["input"] = input

        return self.http.post(self.config.paths.render_template, body=body)

    def update_documents_by_function(
        self, index_uid: str, queries: dict[str, list[dict[str, Any]]]
    ) -> dict[str, Any]:
        """Update Documents by function
        Parameters
        ----------
        index_uid:
            The index_uid where you want to update documents of.
        queries:
            List of dictionaries containing functions with or without filters that you want to use to update documents.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.post(
            path=f"{self.config.paths.index}/{index_uid}/{self.config.paths.document}/{self.config.paths.edit}",
            body=dict(queries),
        )

    def get_all_stats(
        self,
        *,
        show_internal_database_sizes: bool | None = None,
        size_format: SizeFormat | str | None = None,
    ) -> dict[str, Any]:
        """Get all stats of Meilisearch

        Get information about database size and all indexes
        https://www.meilisearch.com/docs/reference/api/stats

        Parameters
        ----------
        show_internal_database_sizes (optional):
            When true, index stat objects contain an additional internalDatabaseSizes key
            with the size of each internal database. Defaults to false.
        size_format (optional):
            When set to "human", database sizes are returned as strings with units (e.g. "1.5 GiB").
            When set to "raw" or omitted, sizes are returned as numbers in bytes.

        Returns
        -------
        stats:
            Dictionary containing stats about your Meilisearch instance.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        params: dict[str, Any] = {}
        if show_internal_database_sizes is not None:
            params["showInternalDatabaseSizes"] = str(show_internal_database_sizes).lower()
        if size_format is not None:
            params["sizeFormat"] = (
                size_format.value if isinstance(size_format, SizeFormat) else size_format
            )

        path = self.config.paths.stat
        if params:
            path = f"{path}?{parse.urlencode(params)}"
        return self.http.get(path)

    def health(self) -> dict[str, str]:
        """Get health of the Meilisearch server.

        Returns
        -------
        health:
            Dictionary containing the status of the Meilisearch instance.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.get(self.config.paths.health)

    def is_healthy(self) -> bool:
        """Get health of the Meilisearch server."""
        try:
            self.health()
        except MeilisearchError:
            return False
        return True

    def get_key(self, key_or_uid: str) -> Key:
        """Gets information about a specific API key.

        Parameters
        ----------
        key_or_uid:
            The key or the uid for which to retrieve the information.

        Returns
        -------
        key:
            The API key.
            https://www.meilisearch.com/docs/reference/api/keys#get-key

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        key = self.http.get(f"{self.config.paths.keys}/{key_or_uid}")

        return Key(**key)

    def get_keys(self, parameters: Mapping[str, Any] | None = None) -> KeysResults:
        """Gets the Meilisearch API keys.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the get keys route: https://www.meilisearch.com/docs/reference/api/keys#get-all-keys

        Returns
        -------
        keys:
            API keys.
            https://www.meilisearch.com/docs/reference/api/keys#get-keys

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        if parameters is None:
            parameters = {}
        keys = self.http.get(f"{self.config.paths.keys}?{parse.urlencode(parameters)}")

        return KeysResults(**keys)

    def create_key(self, options: Mapping[str, Any]) -> Key:
        """Creates a new API key.

        Parameters
        ----------
        options:
            Options, the information to use in creating the key (ex: { 'actions': ['*'], 'indexes': ['movies'], 'description': 'Search Key', 'expiresAt': '22-01-01' }).
            An `actions`, an `indexes` and a `expiresAt` fields are mandatory,`None` should be specified for no expiration date.
            `actions`: A list of actions permitted for the key. ["*"] for all actions.
            `indexes`: A list of indexes permitted for the key. ["*"] for all indexes.
            Note that if an expires_at value is included it should be in UTC time.

        Returns
        -------
        key:
            The new API key.
            https://www.meilisearch.com/docs/reference/api/keys#get-keys

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        task = self.http.post(f"{self.config.paths.keys}", options)

        return Key(**task)

    def update_key(self, key_or_uid: str, options: Mapping[str, Any]) -> Key:
        """Update an API key.

        Parameters
        ----------
        key_or_uid:
            The key or the uid of the key for which to update the information.
        options:
            The information to use in creating the key (ex: { 'description': 'Search Key', 'expiresAt': '22-01-01' }). Note that if an
            expires_at value is included it should be in UTC time.

        Returns
        -------
        key:
            The updated API key.
            https://www.meilisearch.com/docs/reference/api/keys#get-keys

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        url = f"{self.config.paths.keys}/{key_or_uid}"
        key = self.http.patch(url, options)

        return Key(**key)

    def delete_key(self, key_or_uid: str) -> int:
        """Deletes an API key.

        Parameters
        ----------
        key:
            The key or the uid of the key to delete.

        Returns
        -------
        keys:
            The Response status code. 204 signifies a successful delete.
            https://www.meilisearch.com/docs/reference/api/keys#get-keys

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        response = self.http.delete(f"{self.config.paths.keys}/{key_or_uid}")

        return response.status_code

    # WEBHOOKS ROUTES

    def get_webhooks(self) -> WebhooksResults:
        """Get all webhooks.

        Returns
        -------
        webhooks:
            WebhooksResults instance containing list of webhooks and pagination info.
            https://www.meilisearch.com/docs/reference/api/webhooks

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        webhooks = self.http.get(f"{self.config.paths.webhooks}")
        return WebhooksResults(**webhooks)

    def get_webhook(self, webhook_uuid: str) -> Webhook:
        """Get information about a specific webhook.

        Parameters
        ----------
        webhook_uuid:
            The uuid of the webhook to retrieve.

        Returns
        -------
        webhook:
            The webhook information.
            https://www.meilisearch.com/docs/reference/api/webhooks#get-one-webhook

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        webhook = self.http.get(f"{self.config.paths.webhooks}/{webhook_uuid}")
        return Webhook(**webhook)

    def create_webhook(self, options: Mapping[str, Any]) -> Webhook:
        """Create a new webhook.

        Parameters
        ----------
        options:
            The webhook configuration. Can include:
            - url: The URL to send the webhook to
            - headers: Dictionary of HTTP headers to include in webhook requests

        Returns
        -------
        webhook:
            The newly created webhook.
            https://www.meilisearch.com/docs/reference/api/webhooks#create-a-webhook

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        webhook = self.http.post(self.config.paths.webhooks, options)
        return Webhook(**webhook)

    def update_webhook(self, webhook_uuid: str, options: Mapping[str, Any]) -> Webhook:
        """Update an existing webhook.

        Parameters
        ----------
        webhook_uuid:
            The uuid of the webhook to update.
        options:
            The webhook fields to update. Can include:
            - url: The URL to send the webhook to
            - headers: Dictionary of HTTP headers to include in webhook requests

        Returns
        -------
        webhook:
            The updated webhook.
            https://www.meilisearch.com/docs/reference/api/webhooks#update-a-webhook

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        webhook = self.http.patch(f"{self.config.paths.webhooks}/{webhook_uuid}", options)
        return Webhook(**webhook)

    def delete_webhook(self, webhook_uuid: str) -> int:
        """Delete a webhook.

        Parameters
        ----------
        webhook_uuid:
            The uuid of the webhook to delete.

        Returns
        -------
        status_code:
            The Response status code. 204 signifies a successful delete.
            https://www.meilisearch.com/docs/reference/api/webhooks#delete-a-webhook

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        response = self.http.delete(f"{self.config.paths.webhooks}/{webhook_uuid}")
        return response.status_code

    # DYNAMIC SEARCH RULES ROUTES

    def get_dynamic_search_rules(
        self, parameters: Mapping[str, Any] | None = None
    ) -> SearchRulesResults:
        """Get dynamic search rules with optional pagination and filtering.

        Parameters
        ----------
        parameters (optional):
            Pagination and filter parameters accepted by the list dynamic search rules route.

        Returns
        -------
        search_rules:
            SearchRulesResults containing the matching rules and pagination information.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process the request.
        """
        if parameters is None:
            parameters = {}

        search_rules = self.http.post(self.config.paths.dynamic_search_rules, parameters)
        return SearchRulesResults(**search_rules)

    def get_dynamic_search_rule(self, uid: str) -> SearchRule:
        """Get a dynamic search rule by UID.

        Parameters
        ----------
        uid:
            UID of the dynamic search rule.

        Returns
        -------
        search_rule:
            The requested dynamic search rule.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process the request.
        """
        search_rule = self.http.get(f"{self.config.paths.dynamic_search_rules}/{uid}")
        return SearchRule(**search_rule)

    def update_dynamic_search_rule(
        self,
        uid: str,
        options: Mapping[str, Any],
        *,
        metadata: str | None = None,
    ) -> TaskInfo:
        """Update a dynamic search rule, or create it when it doesn't exist.

        Parameters
        ----------
        uid:
            UID of the dynamic search rule.
        options:
            Fields to update on the dynamic search rule.
        metadata (optional):
            Custom metadata string to attach to the task.

        Returns
        -------
        task_info:
            Information about the enqueued update task.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process the request.
        """
        url = f"{self.config.paths.dynamic_search_rules}/{uid}"
        if metadata is not None:
            url += f"?{parse.urlencode({'customMetadata': metadata})}"

        task = self.http.patch(url, options)
        return TaskInfo(**task)

    def delete_dynamic_search_rule(self, uid: str, *, metadata: str | None = None) -> TaskInfo:
        """Delete a dynamic search rule.

        Parameters
        ----------
        uid:
            UID of the dynamic search rule.
        metadata (optional):
            Custom metadata string to attach to the task.

        Returns
        -------
        task_info:
            Information about the enqueued deletion task.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process the request.
        """
        url = f"{self.config.paths.dynamic_search_rules}/{uid}"
        if metadata is not None:
            url += f"?{parse.urlencode({'customMetadata': metadata})}"

        task = self.http.delete(url)
        return TaskInfo(**task)

    def get_version(self) -> dict[str, str]:
        """Get version Meilisearch

        Returns
        -------
        version:
            Information about the version of Meilisearch.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.get(self.config.paths.version)

    def version(self) -> dict[str, str]:
        """Alias for get_version

        Returns
        -------
        version:
            Information about the version of Meilisearch.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.get_version()

    def create_dump(self) -> TaskInfo:
        """Trigger the creation of a Meilisearch dump.

        Returns
        -------
        Dump:
            Information about the dump.
            https://www.meilisearch.com/docs/reference/api/dump#create-a-dump

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        task = self.http.post(self.config.paths.dumps)

        return TaskInfo(**task)

    def export(
        self,
        url: str,
        api_key: str | None = None,
        payload_size: str | None = None,
        indexes: Mapping[str, Any] | None = None,
    ) -> TaskInfo:
        """Trigger the creation of a Meilisearch export.

        Parameters
        ----------
        url:
            A string pointing to a remote Meilisearch instance, including its port if necessary.

        api_key:
            A security key with index.create, settings.update, and documents.add permissions
             to a secured Meilisearch instance.

        payload_size:
            The maximum size of each single data payload in a human-readable format such as "100 MiB".
             Larger payloads are generally more efficient, but require significantly more powerful machines.

        indexes:
            A set of objects whose keys correspond to patterns matching the indexes you want to export.
             By default, Meilisearch exports all documents across all indexes.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/export#create-an-export

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request.
            Meilisearch error codes are described
            here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        payload: dict[str, Any] = {"url": url}
        if api_key is not None:
            payload["apiKey"] = api_key
        if payload_size is not None:
            payload["payloadSize"] = payload_size
        if indexes is not None:
            payload["indexes"] = indexes

        task = self.http.post(self.config.paths.export, body=payload)

        return TaskInfo(**task)

    def create_snapshot(self) -> TaskInfo:
        """Trigger the creation of a Meilisearch snapshot.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        task = self.http.post(self.config.paths.snapshots)

        return TaskInfo(**task)

    def swap_indexes(self, parameters: list[Mapping[str, list[str] | bool]]) -> TaskInfo:
        """Swap two indexes.

        Parameters
        ----------
        indexes:
            List of indexes to swap ex:
             1: {"indexes": ["indexA", "indexB"]}  # default rename to false
            2: {"indexes": ["indexA", "indexB"], "rename": false}
            3: {"indexes": ["indexA", "indexB"], "rename": true}

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return TaskInfo(**self.http.post(self.config.paths.swap, parameters))

    def get_tasks(self, parameters: MutableMapping[str, Any] | None = None) -> TaskResults:
        """Get all tasks.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the get tasks route: https://www.meilisearch.com/docs/reference/api/tasks#get-tasks.

        Returns
        -------
        task:
            TaskResult instance containing limit, from, next and results containing a list of all
            enqueued, processing, succeeded or failed tasks.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.get_tasks(parameters=parameters)

    def get_task(self, uid: int) -> Task:
        """Get one task.

        Parameters
        ----------
        uid:
            Identifier of the task.

        Returns
        -------
        task:
            Task instance containing information about the processed asynchronous task.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.get_task(uid)

    def cancel_tasks(
        self, parameters: MutableMapping[str, Any], *, metadata: str | None = None
    ) -> TaskInfo:
        """Cancel a list of enqueued or processing tasks.

        Parameters
        ----------
        parameters:
            parameters accepted by the cancel tasks route:https://www.meilisearch.com/docs/reference/api/tasks#cancel-tasks.
        metadata (optional):
            Custom metadata string to attach to the task.

        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.cancel_tasks(parameters=parameters, metadata=metadata)

    def delete_tasks(
        self, parameters: MutableMapping[str, Any], *, metadata: str | None = None
    ) -> TaskInfo:
        """Delete a list of finished tasks.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the delete tasks route:https://www.meilisearch.com/docs/reference/api/tasks#delete-task.
        metadata (optional):
            Custom metadata string to attach to the task.
        Returns
        -------
        task_info:
            TaskInfo instance containing information about a task to track the progress of an asynchronous process.
            https://www.meilisearch.com/docs/reference/api/tasks#get-one-task
        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.delete_tasks(parameters=parameters, metadata=metadata)

    def wait_for_task(
        self,
        uid: int,
        timeout_in_ms: int = 5000,
        interval_in_ms: int = 50,
    ) -> Task:
        """Wait until Meilisearch processes a task until it fails or succeeds.

        Parameters
        ----------
        uid:
            Identifier of the task to wait for being processed.
        timeout_in_ms (optional):
            Time the method should wait before raising a MeilisearchTimeoutError
        interval_in_ms (optional):
            Time interval the method should wait (sleep) between requests

        Returns
        -------
        task:
            Task instance containing information about the processed asynchronous task.

        Raises
        ------
        MeilisearchTimeoutError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.wait_for_task(uid, timeout_in_ms, interval_in_ms)

    def get_batches(self, parameters: MutableMapping[str, Any] | None = None) -> BatchResults:
        """Get all batches.

        Parameters
        ----------
        parameters (optional):
            parameters accepted by the get batches route: https://www.meilisearch.com/docs/reference/api/batches#get-batches.

        Returns
        -------
        batch:
            BatchResult instance containing limit, from, next and results containing a list of all batches.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.get_batches(parameters=parameters)

    def get_batch(self, uid: int) -> Batch:
        """Get one tasks batch.

        Parameters
        ----------
        uid:
            Identifier of the batch.

        Returns
        -------
        batch:
            Batch instance containing information about the progress of the asynchronous batch.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.task_handler.get_batch(uid)

    def generate_tenant_token(
        self,
        api_key_uid: str,
        search_rules: Mapping[str, Any] | Sequence[str],
        *,
        expires_at: datetime.datetime | None = None,
        api_key: str | None = None,
    ) -> str:
        """Generate a JWT token for the use of multitenancy.

        Parameters
        ----------
        api_key_uid:
            The uid of the API key used as issuer of the token.
        search_rules:
            A Dictionary or list of string which contains the rules to be enforced at search time for all or specific
            accessible indexes for the signing API Key.
            In the specific case where you do not want to have any restrictions you can also use a list ["*"].
        expires_at (optional):
            Date and time when the key will expire. Note that if an expires_at value is included it should be in UTC time.
        api_key (optional):
            The API key parent of the token. If you leave it empty the client API Key will be used.

        Returns
        -------
        jwt_token:
           A string containing the jwt tenant token.
           Note: If your token does not work remember that the search_rules is mandatory and should be well formatted.
           `exp` must be a `datetime` in the future. It's not possible to create a token from the master key.
        """
        # Validate all fields
        if api_key == "" or api_key is None and self.config.api_key is None:
            raise ValueError(
                "An api key is required in the client or should be passed as an argument."
            )
        if api_key_uid == "" or api_key_uid is None or self._valid_uuid(api_key_uid) is False:
            raise ValueError("An uid is required and must comply to the uuid4 format.")
        if not search_rules or search_rules == [""]:
            raise ValueError("The search_rules field is mandatory and should be defined.")
        if expires_at and expires_at < datetime.datetime.now(tz=datetime.timezone.utc):
            raise ValueError("The date expires_at should be in the future.")

        # Standard JWT header for encryption with SHA256/HS256 algorithm
        header = {"typ": "JWT", "alg": "HS256"}

        api_key = str(self.config.api_key) if api_key is None else api_key

        # Add the required fields to the payload
        payload = {
            "apiKeyUid": api_key_uid,
            "searchRules": search_rules,
            "exp": int(datetime.datetime.timestamp(expires_at)) if expires_at is not None else None,
        }

        # Serialize the header and the payload
        json_header = json.dumps(header, separators=(",", ":")).encode()
        json_payload = json.dumps(payload, separators=(",", ":")).encode()

        # Encode the header and the payload to Base64Url String
        header_encode = self._base64url_encode(json_header)
        payload_encode = self._base64url_encode(json_payload)

        secret_encoded = api_key.encode()
        # Create Signature Hash
        signature = hmac.new(
            secret_encoded,
            (header_encode + "." + payload_encode).encode(),
            hashlib.sha256,
        ).digest()
        # Create JWT
        jwt_token = header_encode + "." + payload_encode + "." + self._base64url_encode(signature)

        return jwt_token

    def add_or_update_networks(self, body: MutableMapping[str, Any] | None) -> dict[str, str]:
        """Configure the network topology

        Parameters
        ----------
        body:
            The network configuration dictionary. must contain either:
            - 'remotes': A dictionary of instances in the network
            - 'leader': The leader instance (should be a key in the `remotes` dictionary)

        Returns
        -------
        remote networks:
            Remote Networks containing information about the networks allowed/present.
            https://www.meilisearch.com/docs/reference/api/network

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """

        return self.http.patch(path=f"{self.config.paths.network}", body=body)

    def get_all_networks(self) -> dict[str, str]:
        """Fetches all the remote-networks present

        Returns
        -------
        remote networks:
            All remote networks containing information about each remote and their respective remote-name and searchApi key

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.get(path=f"{self.config.paths.network}")

    def create_chat_completion(
        self,
        workspace_uid: str,
        messages: list[dict[str, str]],
        model: str = "gpt-3.5-turbo",
        stream: bool = True,
    ) -> Iterator[dict[str, Any]]:
        """Streams a chat completion from the Meilisearch chat API.

        Parameters
        ----------
        workspace_uid:
            Unique identifier of the chat workspace to use.
        messages:
            List of message dicts (e.g. {"role": "user", "content": "..."}) comprising the chat history.
        model:
            The model name to use for completion (should correspond to the LLM in workspace settings).
        stream:
            Whether to stream the response. Must be True for now (only streaming is supported).

        Returns
        -------
        chunks:
            Parsed chunks of the completion as Python dicts. Each chunk is a partial response (in OpenAI format).
            Iteration ends when the completion is done.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        MeilisearchCommunicationError
            If a network error occurs.
        ValueError
            If stream=False is passed (not currently supported), or if workspace_uid is empty or contains path separators.
        """
        if not stream:
            # The API currently only supports streaming responses:
            raise ValueError("Non-streaming chat completions are not supported. Use stream=True.")

        # Basic security validation (only what's needed)
        if not workspace_uid:
            raise ValueError("workspace_uid is required and cannot be empty")
        if "/" in workspace_uid or "\\" in workspace_uid:
            raise ValueError("Invalid workspace_uid: must not contain path separators")

        payload = {"model": model, "messages": messages, "stream": True}

        # Construct the URL for the chat completions route.
        endpoint = f"chats/{workspace_uid}/chat/completions"

        # Initiate the HTTP POST request in streaming mode.
        response = self.http.post_stream(endpoint, body=payload)

        try:
            # Iterate over the streaming response lines
            for raw_line in response.iter_lines():
                if raw_line is None or raw_line == b"":
                    continue

                line = raw_line.decode("utf-8")
                if line.startswith("data: "):
                    data = line[len("data: ") :]
                    if data.strip() == "[DONE]":
                        break

                    try:
                        chunk = json.loads(data)
                        yield chunk
                    except json.JSONDecodeError as e:
                        raise MeilisearchCommunicationError(
                            f"Failed to parse chat chunk: {e}"
                        ) from e
        finally:
            response.close()

    def get_chat_workspaces(
        self,
        *,
        offset: int | None = None,
        limit: int | None = None,
    ) -> dict[str, Any]:
        """Get all chat workspaces.

        Parameters
        ----------
        offset (optional):
            Number of workspaces to skip.
        limit (optional):
            Maximum number of workspaces to return.

        Returns
        -------
        workspaces
            Dictionary containing the list of chat workspaces and pagination information.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        params = {}
        if offset is not None:
            params["offset"] = offset
        if limit is not None:
            params["limit"] = limit
        path = "chats" + ("?" + parse.urlencode(params) if params else "")
        return self.http.get(path)

    def get_chat_workspace_settings(self, workspace_uid: str) -> dict[str, Any]:
        """Get the settings for a specific chat workspace.

        Parameters
        ----------
        workspace_uid:
            Unique identifier of the chat workspace.

        Returns
        -------
        settings:
            Dictionary containing the workspace settings.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        ValueError
            If workspace_uid is empty or contains path separators.
        """
        # Basic security validation (only what's needed)
        if not workspace_uid:
            raise ValueError("workspace_uid is required and cannot be empty")
        if "/" in workspace_uid or "\\" in workspace_uid:
            raise ValueError("Invalid workspace_uid: must not contain path separators")

        return self.http.get(f"chats/{workspace_uid}/settings")

    def update_chat_workspace_settings(
        self, workspace_uid: str, settings: Mapping[str, Any]
    ) -> dict[str, Any]:
        """Update the settings for a specific chat workspace.

        Parameters
        ----------
        workspace_uid:
            Unique identifier of the chat workspace.
        settings:
            Dictionary containing the settings to update.

        Returns
        -------
        settings:
            Dictionary containing the updated workspace settings.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        ValueError
            If workspace_uid is empty or contains path separators, or if settings is empty.
        """
        # Basic security validation (only what's needed)
        if not workspace_uid:
            raise ValueError("workspace_uid is required and cannot be empty")
        if "/" in workspace_uid or "\\" in workspace_uid:
            raise ValueError("Invalid workspace_uid: must not contain path separators")

        if not settings:
            raise ValueError("settings cannot be empty")

        return self.http.patch(f"chats/{workspace_uid}/settings", body=settings)

    @staticmethod
    def _base64url_encode(data: bytes) -> str:
        return base64.urlsafe_b64encode(data).decode("utf-8").replace("=", "")

    @staticmethod
    def _valid_uuid(uuid: str) -> bool:
        uuid4hex = re.compile(
            r"^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}",
            re.I,
        )
        match = uuid4hex.match(uuid)
        return bool(match)

    def get_experimental_features(self) -> dict[str, Any]:
        """Retrieve the current settings for all experimental features.

        Returns
        -------
        features:
            A dictionary mapping feature names to their enabled/disabled state.
            For example: {"multimodal": True, "vectorStore": False}

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.get(self.config.paths.experimental_features)

    def update_experimental_features(self, features: dict[str, bool]) -> dict[str, Any]:
        """Update one or more experimental features.

        Parameters
        ----------
        features:
            A dictionary mapping feature names to booleans.
            For example, {"multimodal": True} to enable multimodal,
            or {"multimodal": True, "vectorStore": False} to update multiple features.

        Returns
        -------
        features:
            The updated experimental features settings as a dictionary.

        Raises
        ------
        MeilisearchApiError
            An error containing details about why Meilisearch can't process your request. Meilisearch error codes are described here: https://www.meilisearch.com/docs/reference/errors/error_codes#meilisearch-errors
        """
        return self.http.patch(self.config.paths.experimental_features, body=features)
