"""OpenSearch client module.

This module provides a client wrapper for OpenSearch connections with
configuration management, connection testing, and health monitoring.
"""

from contextlib import contextmanager, asynccontextmanager
from typing import Any, AsyncGenerator, Generator

from opensearchpy import AsyncHttpConnection, AsyncOpenSearch, OpenSearch, RequestsHttpConnection

from ..core.exception import exception_to_dict
from ..log import footprint
from .config import OpenSearchConfig

__all__ = ("OpenSearchClient",)


class OpenSearchClient:
    """Manage synchronous OpenSearch connections.

    Provides access to OpenSearch client with configuration-based initialization,
    connection testing, health monitoring, and context managers for safe
    resource handling.

    Attributes:
        config: OpenSearchConfig object containing connection parameters.

    Example:
        >>> from dtpyfw.opensearch import OpenSearchConfig, OpenSearchClient
        >>> config = (
        ...     OpenSearchConfig()
        ...     .set_url("https://localhost:9200")
        ...     .set_username("admin")
        ...     .set_password("admin")
        ...     .set_verify_certs(False)
        ... )
        >>> client = OpenSearchClient(config)
        >>>
        >>> # Usage with context manager
        >>> with client.get_client() as opensearch:
        ...     info = opensearch.info()
        >>>
        >>> # Direct usage
        >>> if client.is_healthy():
        ...     info = client.get_cluster_info()
    """

    def __init__(self, config: OpenSearchConfig) -> None:
        """Initialize OpenSearch client with provided configuration.

        Args:
            config: OpenSearchConfig instance containing connection parameters,
                authentication, SSL settings, and other client options.

        Raises:
            ValueError: If required configuration parameters are missing.

        Example:
            >>> config = OpenSearchConfig().set_url("https://localhost:9200")
            >>> client = OpenSearchClient(config)
        """
        self.config = config
        self._client: OpenSearch | None = None
        self._async_client: AsyncOpenSearch | None = None

    def _build_client_config(self) -> dict[str, Any]:
        """Build common client configuration from OpenSearchConfig.

        Returns:
            dict[str, Any]: Dictionary containing OpenSearch client configuration
                parameters suitable for initializing both sync and async clients.

        Raises:
            ValueError: If neither 'hosts' nor 'url' is provided in configuration.
        """
        hosts = self.config.get("hosts")
        url = self.config.get("url")
        username = self.config.get("username")
        password = self.config.get("password")
        use_ssl = self.config.get("use_ssl", True)
        verify_certs = self.config.get("verify_certs", True)
        ssl_show_warn = self.config.get("ssl_show_warn", True)
        timeout = self.config.get("timeout", 30)
        max_retries = self.config.get("max_retries", 3)
        retry_on_timeout = self.config.get("retry_on_timeout", True)
        ca_certs = self.config.get("ca_certs")
        client_cert = self.config.get("client_cert")
        client_key = self.config.get("client_key")

        # Determine hosts configuration
        if hosts:
            opensearch_hosts = hosts
        elif url:
            opensearch_hosts = [url]
        else:
            raise ValueError(
                "Either 'hosts' or 'url' must be provided in configuration"
            )

        # Configure authentication
        auth = None
        if username and password:
            auth = (username, password)

        # Build client configuration
        client_config: dict[str, Any] = {
            "hosts": opensearch_hosts,
            "use_ssl": use_ssl,
            "verify_certs": verify_certs,
            "ssl_show_warn": ssl_show_warn,
            "timeout": timeout,
            "max_retries": max_retries,
            "retry_on_timeout": retry_on_timeout,
        }

        # Add optional configuration
        if auth:
            client_config["http_auth"] = auth
        if ca_certs:
            client_config["ca_certs"] = ca_certs
        if client_cert and client_key:
            client_config["client_cert"] = client_cert
            client_config["client_key"] = client_key

        return client_config

    def _get_sync_client(self) -> OpenSearch:
        """Get or create a synchronous OpenSearch client.

        Creates a client on first access and reuses it for subsequent calls.
        Uses RequestsHttpConnection for synchronous operations.

        Returns:
            OpenSearch: A synchronous OpenSearch client instance.

        Raises:
            ConnectionError: If client initialization fails.
        """
        if self._client is None:
            controller = f"{__name__}.OpenSearchClient._get_sync_client"
            try:
                client_config = self._build_client_config()
                connection_class = self.config.get(
                    "connection_class", RequestsHttpConnection
                )
                client_config["connection_class"] = connection_class
                self._client = OpenSearch(**client_config)
            except Exception as e:
                footprint.leave(
                    log_type="error",
                    controller=controller,
                    subject="OpenSearch client initialization failed",
                    message=f"Failed to initialize OpenSearch client: {str(e)}",
                    payload={"error": exception_to_dict(e)},
                )
                raise ConnectionError(f"OpenSearch connection failed: {str(e)}") from e
        return self._client

    def _get_async_client(self) -> AsyncOpenSearch:
        """Get or create an asynchronous OpenSearch client.

        Creates a client on first access and reuses it for subsequent calls.
        Uses AsyncHttpConnection for asynchronous operations.

        Returns:
            AsyncOpenSearch: An asynchronous OpenSearch client instance.

        Raises:
            ConnectionError: If client initialization fails.
        """
        if self._async_client is None:
            controller = f"{__name__}.OpenSearchClient._get_async_client"
            try:
                client_config = self._build_client_config()
                connection_class = self.config.get(
                    "async_connection_class", AsyncHttpConnection
                )
                client_config["connection_class"] = connection_class
                self._async_client = AsyncOpenSearch(**client_config)
            except Exception as e:
                footprint.leave(
                    log_type="error",
                    controller=controller,
                    subject="Async OpenSearch client initialization failed",
                    message=f"Failed to initialize async OpenSearch client: {str(e)}",
                    payload={"error": exception_to_dict(e)},
                )
                raise ConnectionError(
                    f"Async OpenSearch connection failed: {str(e)}"
                ) from e
        return self._async_client

    @contextmanager
    def get_client(self) -> Generator[OpenSearch, None, None]:
        """Get the synchronous OpenSearch client as a context manager.

        Provides a context manager for safe client access. The client is
        initialized lazily on first access and reused for subsequent calls.

        Yields:
            OpenSearch: The configured opensearch-py client instance.

        Raises:
            ConnectionError: If client initialization or connection fails.

        Example:
            >>> client = OpenSearchClient(config)
            >>> with client.get_client() as opensearch:
            ...     result = opensearch.search(index="my-index", body={"query": {"match_all": {}}})
        """
        try:
            yield self._get_sync_client()
        finally:
            pass  # Connection cleanup handled by client internally

    @asynccontextmanager
    async def get_async_client(self) -> AsyncGenerator[AsyncOpenSearch, None]:
        """Get the asynchronous OpenSearch client as a context manager.

        Provides an async context manager for safe client access. The client is
        initialized lazily on first access and reused for subsequent calls.
        The client remains open for connection pooling - use close_async() to
        explicitly close when done.

        Yields:
            AsyncOpenSearch: The configured async opensearch-py client instance.

        Raises:
            ConnectionError: If client initialization or connection fails.

        Example:
            >>> client = OpenSearchClient(config)
            >>> async with client.get_async_client() as opensearch:
            ...     result = await opensearch.search(index="my-index", body={"query": {"match_all": {}}})
        """
        try:
            yield self._get_async_client()
        finally:
            # Do NOT close the client here - it should be reused across requests
            # Call close_async() explicitly when completely done with the client
            pass

    def is_healthy(self) -> bool:
        """Check if OpenSearch connection is healthy.

        Performs a ping operation to verify that the OpenSearch cluster
        is reachable and responding to requests.

        Returns:
            bool: True if connection is healthy and cluster responds to ping,
                False if connection is down or cluster is unreachable.

        Example:
            >>> client = OpenSearchClient(config)
            >>> if client.is_healthy():
            ...     print("OpenSearch cluster is responding")
            ... else:
            ...     print("OpenSearch cluster is unreachable")
        """
        try:
            with self.get_client() as opensearch:
                return bool(opensearch.ping())
        except Exception:
            return False

    async def is_healthy_async(self) -> bool:
        """Check if async OpenSearch connection is healthy.

        Performs an async ping operation to verify that the OpenSearch cluster
        is reachable and responding to requests.

        Returns:
            bool: True if connection is healthy and cluster responds to ping,
                False if connection is down or cluster is unreachable.

        Example:
            >>> client = OpenSearchClient(config)
            >>> if await client.is_healthy_async():
            ...     print("OpenSearch cluster is responding")
            ... else:
            ...     print("OpenSearch cluster is unreachable")
        """
        try:
            async with self.get_async_client() as opensearch:
                return bool(await opensearch.ping())
        except Exception:
            return False

    def get_cluster_info(self) -> dict[str, Any]:
        """Get OpenSearch cluster information and status.

        Retrieves comprehensive information about the OpenSearch cluster
        including version, name, and cluster UUID.

        Returns:
            dict[str, Any]: Dictionary containing cluster information with keys
                such as 'name', 'cluster_name', 'cluster_uuid', 'version', etc.

        Raises:
            ConnectionError: If unable to retrieve cluster info due to
                connection issues or cluster unavailability.

        Example:
            >>> client = OpenSearchClient(config)
            >>> info = client.get_cluster_info()
            >>> print(f"Cluster: {info['cluster_name']}")
            >>> print(f"Version: {info['version']['number']}")
        """
        try:
            with self.get_client() as opensearch:
                info: dict[str, Any] = opensearch.info()
                return info
        except Exception as e:
            raise ConnectionError(f"Failed to retrieve cluster info: {str(e)}") from e

    async def get_cluster_info_async(self) -> dict[str, Any]:
        """Get OpenSearch cluster information and status asynchronously.

        Retrieves comprehensive information about the OpenSearch cluster
        including version, name, and cluster UUID using async operations.

        Returns:
            dict[str, Any]: Dictionary containing cluster information with keys
                such as 'name', 'cluster_name', 'cluster_uuid', 'version', etc.

        Raises:
            ConnectionError: If unable to retrieve cluster info due to
                connection issues or cluster unavailability.

        Example:
            >>> client = OpenSearchClient(config)
            >>> info = await client.get_cluster_info_async()
            >>> print(f"Cluster: {info['cluster_name']}")
            >>> print(f"Version: {info['version']['number']}")
        """
        try:
            async with self.get_async_client() as opensearch:
                info: dict[str, Any] = await opensearch.info()
                return info
        except Exception as e:
            raise ConnectionError(f"Failed to retrieve cluster info: {str(e)}") from e

    async def close_async(self) -> None:
        """Close the async OpenSearch client and release resources.

        Explicitly closes the async client connection and cleans up the
        underlying aiohttp session. Call this when completely done with
        the client (e.g., on application shutdown).

        Example:
            >>> client = OpenSearchClient(config)
            >>> # ... use client for multiple operations ...
            >>> await client.close_async()  # Clean shutdown
        """
        if self._async_client is not None:
            try:
                await self._async_client.close()
            except Exception as e:
                footprint.leave(
                    log_type="warning",
                    controller=f"{__name__}.OpenSearchClient.close_async",
                    subject="Failed to close async OpenSearch client",
                    message=f"Error during client closure: {str(e)}",
                    payload={"error": exception_to_dict(e)},
                )
            finally:
                self._async_client = None

    def close(self) -> None:
        """Close the sync OpenSearch client and release resources.

        Closes the synchronous client connection. Call this when completely
        done with the client (e.g., on application shutdown).

        Example:
            >>> client = OpenSearchClient(config)
            >>> # ... use client for multiple operations ...
            >>> client.close()  # Clean shutdown
        """
        if self._client is not None:
            try:
                self._client.close()
            except Exception as e:
                footprint.leave(
                    log_type="warning",
                    controller=f"{__name__}.OpenSearchClient.close",
                    subject="Failed to close OpenSearch client",
                    message=f"Error during client closure: {str(e)}",
                    payload={"error": exception_to_dict(e)},
                )
            finally:
                self._client = None
