# SPDX-FileCopyrightText: © 2025 DSLab - Fondazione Bruno Kessler
#
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import typing
from typing import Any
from warnings import warn

from requests import request
from requests.exceptions import JSONDecodeError

from digitalhub.stores.client._base.client import Client
from digitalhub.stores.client.dhcore.api_builder import ClientDHCoreApiBuilder
from digitalhub.stores.client.dhcore.configurator import ClientDHCoreConfigurator
from digitalhub.stores.client.dhcore.error_parser import ErrorParser
from digitalhub.stores.client.dhcore.key_builder import ClientDHCoreKeyBuilder
from digitalhub.stores.client.dhcore.params_builder import ClientDHCoreParametersBuilder
from digitalhub.utils.exceptions import BackendError, ClientError
from digitalhub.utils.generic_utils import dump_json

if typing.TYPE_CHECKING:
    from requests import Response


# API levels that are supported
MAX_API_LEVEL = 20
MIN_API_LEVEL = 14
LIB_VERSION = 14


class ClientDHCore(Client):
    """
    DHCore client for remote DigitalHub Core backend communication.

    Provides REST API communication with DigitalHub Core backend supporting
    multiple authentication methods: Basic (username/password), OAuth2 (token
    with refresh), and Personal Access Token exchange. Automatically handles
    API version compatibility, pagination, token refresh, error parsing, and
    JSON serialization. Supports API versions {MIN_API_LEVEL} to {MAX_API_LEVEL}.

    Parameters
    ----------
    config : dict, optional
        DHCore environment configuration. If None, loads from environment
        variables and configuration files.

    Attributes
    ----------
    _api_builder : ClientDHCoreApiBuilder
        Builds API endpoint URLs for different operations.
    _key_builder : ClientDHCoreKeyBuilder
        Builds storage keys for entities.
    _params_builder : ClientDHCoreParametersBuilder
        Builds request parameters for API calls.
    _error_parser : ErrorParser
        Parses backend responses and raises appropriate exceptions.
    _configurator : ClientDHCoreConfigurator
        Manages client configuration and authentication.

    Examples
    --------
    >>> from digitalhub.stores.client.api import (
    ...     get_client,
    ... )
    >>> client = get_client(
    ...     local=False
    ... )
    >>> # Client is now ready for API operations
    """

    def __init__(self, config: dict | None = None) -> None:
        """
        Initialize DHCore client with API builders and configurators.

        Parameters
        ----------
        config : dict, optional
            DHCore environment configuration. If None, loads from environment
            variables and configuration files.

        Returns
        -------
        None
        """
        super().__init__()

        # API builder
        self._api_builder = ClientDHCoreApiBuilder()

        # Key builder
        self._key_builder = ClientDHCoreKeyBuilder()

        # Parameters builder
        self._params_builder = ClientDHCoreParametersBuilder()

        # Error parser
        self._error_parser = ErrorParser()

        # Client Configurator
        self._configurator = ClientDHCoreConfigurator()

    ##############################
    # CRUD methods
    ##############################

    def create_object(self, api: str, obj: Any, **kwargs) -> dict:
        """
        Create an object in DHCore via POST request.

        Automatically sets Content-Type header and serializes object to JSON.

        Parameters
        ----------
        api : str
            API endpoint path for creating the object.
        obj : Any
            Object to create. Will be serialized to JSON.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Created object as returned by the backend.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        ClientError
            If there are client-side configuration issues.
        """
        if "headers" not in kwargs:
            kwargs["headers"] = {}
        kwargs["headers"]["Content-Type"] = "application/json"
        kwargs["data"] = dump_json(obj)
        return self._prepare_call("POST", api, **kwargs)

    def read_object(self, api: str, **kwargs) -> dict:
        """
        Get an object from DHCore.

        Sends a GET request to the DHCore backend to retrieve an existing object.

        Parameters
        ----------
        api : str
            API endpoint path for reading the object.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Retrieved object as returned by the backend.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        EntityNotExistsError
            If the requested object does not exist.
        """
        return self._prepare_call("GET", api, **kwargs)

    def update_object(self, api: str, obj: Any, **kwargs) -> dict:
        """
        Update an object in DHCore via PUT request.

        Automatically sets Content-Type header and serializes object to JSON.

        Parameters
        ----------
        api : str
            API endpoint path for updating the object.
        obj : Any
            Updated object data. Will be serialized to JSON.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Updated object as returned by the backend.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        EntityNotExistsError
            If the object to update does not exist.
        """
        if "headers" not in kwargs:
            kwargs["headers"] = {}
        kwargs["headers"]["Content-Type"] = "application/json"
        kwargs["data"] = dump_json(obj)
        return self._prepare_call("PUT", api, **kwargs)

    def delete_object(self, api: str, **kwargs) -> dict:
        """
        Delete an object from DHCore.

        Sends DELETE request to remove an object. Wraps boolean responses
        in {"deleted": True/False} dictionary.

        Parameters
        ----------
        api : str
            API endpoint path for deleting the object.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Deletion result from backend or {"deleted": bool} wrapper.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        EntityNotExistsError
            If the object to delete does not exist.
        """
        resp = self._prepare_call("DELETE", api, **kwargs)
        if isinstance(resp, bool):
            resp = {"deleted": resp}
        return resp

    def list_objects(self, api: str, **kwargs) -> list[dict]:
        """
        List objects from DHCore with automatic pagination.

        Sends GET requests to retrieve paginated objects, automatically handling
        pagination (starting from page 0) until all objects are retrieved.

        Parameters
        ----------
        api : str
            API endpoint path for listing objects.
        **kwargs : dict
            Additional HTTP request arguments. Can include 'params' dict
            with pagination parameters.

        Returns
        -------
        list[dict]
            List containing all objects from all pages.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        """
        if "params" not in kwargs:
            kwargs["params"] = {}

        start_page = 0
        if "page" not in kwargs["params"]:
            kwargs["params"]["page"] = start_page

        objects = []
        while True:
            resp = self._prepare_call("GET", api, **kwargs)
            contents = resp["content"]
            total_pages = resp["totalPages"]
            if not contents or kwargs["params"]["page"] >= total_pages:
                break
            objects.extend(contents)
            kwargs["params"]["page"] += 1

        return objects

    def list_first_object(self, api: str, **kwargs) -> dict:
        """
        Get the first object from a DHCore list.

        Retrieves the first object by calling list_objects and returning
        the first item.

        Parameters
        ----------
        api : str
            API endpoint path for listing objects.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            First object from the list.

        Raises
        ------
        BackendError
            If no objects found or backend returns an error.
        """
        try:
            return self.list_objects(api, **kwargs)[0]
        except IndexError:
            raise BackendError("No object found.")

    def search_objects(self, api: str, **kwargs) -> list[dict]:
        """
        Search objects from DHCore using Solr capabilities.

        Performs search query with pagination and removes search highlights.
        Sets default pagination (page=0, size=10) and sorting (metadata.updated DESC)
        if not provided.

        Parameters
        ----------
        api : str
            API endpoint path for searching objects (usually Solr search).
        **kwargs : dict
            Additional HTTP request arguments including search parameters,
            filters, and pagination options.

        Returns
        -------
        list[dict]
            List of matching objects with search highlights removed.

        Raises
        ------
        BackendError
            If the backend returns an error response.
        """
        if "params" not in kwargs:
            kwargs["params"] = {}

        start_page = 0
        if "page" not in kwargs["params"]:
            kwargs["params"]["page"] = start_page

        if "size" not in kwargs["params"]:
            kwargs["params"]["size"] = 10

        # Add sorting
        if "sort" not in kwargs["params"]:
            kwargs["params"]["sort"] = "metadata.updated,DESC"

        objects_with_highlights: list[dict] = []
        while True:
            resp = self._prepare_call("GET", api, **kwargs)
            contents = resp["content"]
            total_pages = resp["totalPages"]
            if not contents or kwargs["params"]["page"] >= total_pages:
                break
            objects_with_highlights.extend(contents)
            kwargs["params"]["page"] += 1

        objects = []
        for obj in objects_with_highlights:
            obj.pop("highlights", None)
            objects.append(obj)

        return objects

    ##############################
    # Call methods
    ##############################

    def _prepare_call(self, call_type: str, api: str, **kwargs) -> dict:
        """
        Prepare DHCore API call with configuration and authentication.

        Checks configuration, builds URL, and adds authentication parameters.

        Parameters
        ----------
        call_type : str
            HTTP method type (GET, POST, PUT, DELETE, etc.).
        api : str
            API endpoint path to call.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Response from the API call.

        Raises
        ------
        ClientError
            If client configuration is invalid.
        BackendError
            If backend returns an error response.
        """
        self._configurator.check_config()
        url = self._build_url(api)
        full_kwargs = self._configurator.get_auth_parameters(kwargs)
        return self._make_call(call_type, url, **full_kwargs)

    def _build_url(self, api: str) -> str:
        """
        Build the complete URL for an API call.

        Combines the configured endpoint with the API path to create
        the full URL for the HTTP request. Automatically removes leading
        slashes from the API path to ensure proper URL construction.

        Parameters
        ----------
        api : str
            The API endpoint path. Leading slashes are automatically handled.

        Returns
        -------
        str
            The complete URL for the API call.
        """

    def _build_url(self, api: str) -> str:
        """
        Build complete URL for API call.

        Combines configured endpoint with API path, automatically removing
        leading slashes for proper URL construction.

        Parameters
        ----------
        api : str
            API endpoint path. Leading slashes are automatically handled.

        Returns
        -------
        str
            Complete URL for the API call.
        """
        endpoint = self._configurator.get_endpoint()
        return f"{endpoint}/{api.removeprefix('/')}"

    def _make_call(self, call_type: str, url: str, refresh: bool = True, **kwargs) -> dict:
        """
        Execute HTTP request to DHCore API with automatic handling.

        Handles API version checking, token refresh on 401 errors, response parsing,
        and error handling with 60-second timeout.

        Parameters
        ----------
        call_type : str
            HTTP method type (GET, POST, PUT, DELETE, etc.).
        url : str
            Complete URL to call.
        refresh : bool, default True
            Whether to attempt token refresh on authentication errors.
            Set to False to prevent infinite recursion during refresh.
        **kwargs : dict
            Additional HTTP request arguments.

        Returns
        -------
        dict
            Parsed response from backend as dictionary.

        Raises
        ------
        ClientError
            If backend API version is not supported.
        BackendError
            If backend returns error response or response parsing fails.
        UnauthorizedError
            If authentication fails and token refresh not possible.
        """
        # Call the API
        response = request(call_type, url, timeout=60, **kwargs)

        # Evaluate DHCore API version
        self._check_core_version(response)

        # Handle token refresh (redo call)
        if (response.status_code in [401]) and (refresh) and self._configurator.refreshable_auth_types():
            self._configurator.refresh_credentials(change_origin=True)
            kwargs = self._configurator.get_auth_parameters(kwargs)
            return self._make_call(call_type, url, refresh=False, **kwargs)

        self._error_parser.parse(response)
        return self._dictify_response(response)

    def _check_core_version(self, response: Response) -> None:
        """
        Validate DHCore API version compatibility.

        Checks backend API version against supported range and warns if backend
        version is newer than library. Supported: {MIN_API_LEVEL} to {MAX_API_LEVEL}.

        Parameters
        ----------
        response : Response
            HTTP response containing X-Api-Level header.

        Returns
        -------
        None

        Raises
        ------
        ClientError
            If backend API level is not supported by this client.
        """
        if "X-Api-Level" in response.headers:
            core_api_level = int(response.headers["X-Api-Level"])
            if not (MIN_API_LEVEL <= core_api_level <= MAX_API_LEVEL):
                raise ClientError("Backend API level not supported.")
            if LIB_VERSION < core_api_level:
                warn("Backend API level is higher than library version. You should consider updating the library.")

    def _dictify_response(self, response: Response) -> dict:
        """
        Parse HTTP response body to dictionary.

        Converts JSON response to Python dictionary, treating empty responses
        as valid and returning empty dict.

        Parameters
        ----------
        response : Response
            HTTP response object to parse.

        Returns
        -------
        dict
            Parsed response body as dictionary, or empty dict if body is empty.

        Raises
        ------
        BackendError
            If response cannot be parsed as JSON.
        """
        try:
            return response.json()
        except JSONDecodeError:
            if response.text == "":
                return {}
            raise BackendError("Backend response could not be parsed.")

    ##############################
    # Interface methods
    ##############################

    @staticmethod
    def is_local() -> bool:
        """
        Check if this client operates locally.

        Used to distinguish between ClientDHCore (remote) and ClientLocal
        implementations.

        Returns
        -------
        bool
            False, indicating this client communicates with remote DHCore backend.
        """
        return False
