"""
osidb-bindings session
"""

import asyncio
import importlib
import re
import types
from collections import defaultdict
from types import ModuleType
from typing import Any, Callable, get_args, get_origin

import aiohttp
import requests
from requests_gssapi import HTTPSPNEGOAuth

from .bindings.python_client import AuthenticatedClient, models
from .bindings.python_client.api.auth import (
    auth_token_create,
    auth_token_refresh_create,
    auth_token_retrieve,
)
from .constants import (
    ALL_SESSION_OPERATIONS,
    DEFAULT_LIMIT,
    OSIDB_BINDINGS_API_PATH,
    OSIDB_BINDINGS_PLACEHOLDER_FIELD,
    OSIDB_BINDINGS_USERAGENT,
    USERAGENT_PLACEHOLDER,
)
from .exceptions import (
    MissingEndpointMethod,
    MissingEndpointSection,
    OperationUnsupported,
    OSIDBBindingsException,
    UndefinedRequestBody,
)
from .helpers import get_env, parse_version_key
from .iterators import Paginator

MAX_CONCURRENCY = get_env("OSIDB_BINDINGS_MAX_CONCURRENCY", "10", is_int=True)


def file_trackers(
    self, form_data: dict[str, Any], *args, api_version: str | None = None, **kwargs
):
    """Shortcut for POST /trackers/api/file"""

    method_module = self._get_method_module(
        resource_name="file",
        method="create",
        api_section="trackers",
        api_version=api_version,
    )
    model = getattr(method_module, "REQUEST_BODY_TYPE", None)
    if model is None:
        raise UndefinedRequestBody(
            'The request body for the resource "trackers" '
            'and the operation "file" is not defined.'
        )

    transformed_data = serialize_data(form_data, model)
    sync_fn = get_sync_function(method_module)
    return sync_fn(
        *args,
        client=self.session.get_client_with_new_access_token(),
        body=transformed_data,
        **kwargs,
    )


def promote_flaw(self, id, *args, api_version: str | None = None, **kwargs):
    method_module = self._get_method_module(
        resource_name="flaws",
        method="promote_create",
        api_version=api_version,
    )
    sync_fn = get_sync_function(method_module)
    return sync_fn(
        id,
        *args,
        client=self.session.get_client_with_new_access_token(),
        **kwargs,
    )


def reject_flaw(
    self,
    id: str,
    form_data: dict[str, Any],
    *args,
    api_version: str | None = None,
    **kwargs,
):
    method_module = self._get_method_module(
        resource_name="flaws",
        method="reject_create",
        api_version=api_version,
    )
    sync_fn = get_sync_function(method_module)
    model = getattr(method_module, "REQUEST_BODY_TYPE", None)
    if model is None:
        raise UndefinedRequestBody(
            'The request body for the resource "flaw" '
            'and the operation "reject" is not defined.'
        )
    transformed_data = serialize_data(form_data, model)
    return sync_fn(
        id,
        *args,
        client=self.session.get_client_with_new_access_token(),
        body=transformed_data,
        **kwargs,
    )


def reset_flaw(self, id, *args, api_version: str | None = None, **kwargs):
    method_module = self._get_method_module(
        resource_name="flaws",
        method="reset_create",
        api_version=api_version,
    )
    sync_fn = get_sync_function(method_module)
    return sync_fn(
        id,
        *args,
        client=self.session.get_client_with_new_access_token(),
        **kwargs,
    )


def revert_flaw(self, id, *args, api_version: str | None = None, **kwargs):
    method_module = self._get_method_module(
        resource_name="flaws",
        method="revert_create",
        api_version=api_version,
    )
    sync_fn = get_sync_function(method_module)
    return sync_fn(
        id,
        *args,
        client=self.session.get_client_with_new_access_token(),
        **kwargs,
    )


def double_underscores_to_single_underscores(fn):
    """
    Function decorator which changes all the keyword arguments which include
    double underscore to use single underscore instead.

    eg. affect__affectedness -> affect_affectedness

    This change is needed because in OpenAPI schema query parameters might contain
    double underscore, howerver OpenAPI Python Client package converts all these
    double underscores into single ones and thus this creates a confusion as user
    tries to use query parameter from the OpenAPI schema specification unaware of
    the underscore change.
    """

    def inner(*args, **kwargs):
        new_kwargs = {name.replace("__", "_"): value for name, value in kwargs.items()}
        return fn(*args, **new_kwargs)

    return inner


def get_sync_function(api_module: ModuleType) -> Callable:
    """
    Get 'sync' function from API module if available (response example is defined in schema)
    or get basic 'sync_detailed' function (response example is not defined in schema)
    """
    return double_underscores_to_single_underscores(
        getattr(api_module, "sync", getattr(api_module, "sync_detailed"))
    )


def get_asyncio_function(api_module: ModuleType) -> Callable:
    """
    Get 'asyncio' function from API module if available (response example is defined in schema)
    or get basic 'asyncio_detailed' function (response example is not defined in schema)
    """
    return double_underscores_to_single_underscores(
        getattr(api_module, "asyncio", getattr(api_module, "asyncio_detailed"))
    )


def serialize_data(data, model):
    """
    Serialize data into bindings model or list of bindings models
    """
    if hasattr(model, "from_dict"):
        return model.from_dict(data)
    elif get_origin(model) is list:
        inner_model_name = get_args(model)[0]
        inner_model = getattr(models, inner_model_name)
        return [serialize_data(item, inner_model) for item in data]
    else:
        raise OSIDBBindingsException(f"Unserializable model '{model}'")


def new_session(
    osidb_server_uri,
    password=None,
    username=None,
    verify_ssl=True,
    refresh_token=None,
    user_agent=None,
):
    """
    Create a new session for selected OSIDB URI

    Provide an existing refresh token to skip
    the initial authentication & authorization
    """

    if username and password:
        # OSIDB instances with the username/password auth for token acquirement
        auth = (username, password)
    else:
        # OSIDB instances with the kerberos auth for token acquirement
        auth = "kerberos"

    return Session(
        base_url=osidb_server_uri.strip("/"),
        auth=auth,
        verify_ssl=verify_ssl,
        refresh_token=refresh_token,
        user_agent=user_agent,
    )


class Session:
    """Simple session wrapper which encapsulates the client"""

    def __init__(
        self,
        base_url,
        auth=None,
        verify_ssl=True,
        refresh_token=None,
        user_agent: str | None = None,
    ):
        # Store auth for the refresh token acquirement
        self.auth = auth
        self.refresh_token = refresh_token
        self.endpoints = self.__cache_endpoints()

        if user_agent is not None:
            if USERAGENT_PLACEHOLDER in user_agent:
                resolved_user_agent = user_agent.replace(
                    USERAGENT_PLACEHOLDER, OSIDB_BINDINGS_USERAGENT
                )
            else:
                resolved_user_agent = f"{user_agent} {OSIDB_BINDINGS_USERAGENT}"
        else:
            resolved_user_agent = OSIDB_BINDINGS_USERAGENT

        self.__client = AuthenticatedClient(
            base_url=base_url,
            headers={
                "User-Agent": resolved_user_agent,
                "Bugzilla-Api-Key": get_env("BUGZILLA_API_KEY", ""),
                "Jira-Api-Key": get_env("JIRA_ACCESS_TOKEN", ""),
                "Jira-Api-Email": get_env("JIRA_API_EMAIL", ""),
            },
            verify_ssl=verify_ssl,
        )

        self.flaws = SessionOperationsGroup(
            self,
            "flaws",
            allowed_operations=(
                "retrieve",
                "update",
                "list",
                "create",
                "search",
            ),
            subresources={
                "acknowledgments": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
                "comments": {"allowed_operations": ["retrieve", "list", "create"]},
                "cvss_scores": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
                "labels": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
                "package_versions": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
                "references": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
            },
            extra_operations=[
                ("promote", promote_flaw),
                ("reject", reject_flaw),
                ("reset", reset_flaw),
                ("revert", revert_flaw),
            ],
        )
        self.affects = SessionOperationsGroup(
            self,
            "affects",
            allowed_operations=(
                "retrieve",
                "update",
                "list",
                "create",
                "destroy",
                "bulk_create",
                "bulk_update",
                # TODO: currently blocked by OSIDB-2996
                # "bulk_delete",
            ),
            subresources={
                "cvss_scores": {
                    "allowed_operations": [
                        "retrieve",
                        "update",
                        "list",
                        "create",
                        "destroy",
                    ]
                },
            },
        )
        self.trackers = SessionOperationsGroup(
            self,
            "trackers",
            allowed_operations=(
                "retrieve",
                "list",
                "update",
                "create",
            ),
            extra_operations=[("file", file_trackers)],
        )

        self.labels = SessionOperationsGroup(
            self,
            "labels",
            allowed_operations=("retrieve", "list"),
        )

        if self.refresh_token is None:
            self.refresh_token = self.__get_refresh_token()

    def get_latest_endpoint_version(
        self, api_section: str, resource_name: str, method: str
    ) -> str:
        method_name = f"{resource_name}_{method}"
        section = self.endpoints.get(f"{api_section}_api")
        if section is not None:
            if method_name in section:
                return max(section[method_name], key=parse_version_key)
            else:
                raise MissingEndpointMethod(
                    f"Endpoint method '{method}' for resource '{resource_name}' does not exist."
                )
        else:
            raise MissingEndpointSection(
                f"Endpoint section {api_section} does not exist."
            )

    @staticmethod
    def __cache_endpoints():
        from osidb_bindings.bindings.python_client.api import osidb, trackers

        sections = {"osidb": osidb, "trackers": trackers}
        endpoints = {}
        for section_name, module in sections.items():
            pattern = re.compile(rf"^{section_name}_api_(v[^_]+)_(.+)$")
            endpoints[f"{section_name}_api"] = defaultdict(set)
            for endpoint_name in module.ENDPOINT_NAMES:
                match = pattern.match(endpoint_name)
                if match:
                    endpoints[f"{section_name}_api"][match.group(2)].add(match.group(1))

        return endpoints

    def status(self):
        api_version = self.get_latest_endpoint_version("osidb", "status", "retrieve")
        status_module = importlib.import_module(
            f"{OSIDB_BINDINGS_API_PATH}.osidb.osidb_api_{api_version}_status_retrieve",
            package="osidb_bindings",
        )
        status_fn = get_sync_function(status_module)
        return status_fn(client=self.get_client_with_new_access_token())

    def __get_refresh_token(self) -> str:
        """Get resfresh token based on the auth type"""

        if isinstance(self.auth, tuple):
            response = auth_token_create.sync(
                client=self.__client,
                body=models.TokenObtainPairRequest.from_dict(
                    {"username": self.auth[0], "password": self.auth[1]}
                ),
            )
        else:
            response = auth_token_retrieve.sync(
                client=self.__client.with_auth(HTTPSPNEGOAuth())
            )
        return response.refresh

    def __get_access_token(self) -> str:
        """Get new access token using the refresh token"""

        try:
            response = auth_token_refresh_create.sync(
                client=self.__client,
                body=models.TokenRefreshRequest.from_dict(
                    {"refresh": self.refresh_token}
                ),
            )
        except requests.HTTPError:
            # expired refresh token, renew it and try again
            self.refresh_token = self.__get_refresh_token()
            response = auth_token_refresh_create.sync(
                client=self.__client,
                body=models.TokenRefreshRequest.from_dict(
                    {"refresh": self.refresh_token}
                ),
            )

        return response.access

    def get_client_with_new_access_token(self) -> AuthenticatedClient:
        """Create a new client with refreshed access token"""

        return self.__client.with_headers(
            {"Authorization": f"Bearer {self.__get_access_token()}"}
        )


class SessionOperationsGroup:
    """
    Group of the CRUD and extra operations for one specific resource
    """

    def __init__(
        self,
        session: Session,
        resource_name: str,
        allowed_operations: list[str] = ALL_SESSION_OPERATIONS,
        extra_operations: list[tuple[str, Callable]] = None,
        subresources: dict[str, dict] = None,
    ):
        self.session = session
        self.resource_name = resource_name
        self.allowed_operations = allowed_operations

        if subresources is None:
            subresources = {}

        # Create a session operation group for each subresource with respective
        # allowed session operations
        for subresource_name, subresource_metadata in subresources.items():
            setattr(
                self,
                subresource_name,
                SessionOperationsGroup(
                    self.session,
                    resource_name=f"{resource_name}_{subresource_name}",
                    **subresource_metadata,
                ),
            )

        if extra_operations is None:
            extra_operations = []

        for operation_name, operation_fn in extra_operations:
            setattr(self, operation_name, types.MethodType(operation_fn, self))

    def __raise_operation_unsupported(self, operation_name: str):
        """Operation unsupported exception shortcut"""

        raise OperationUnsupported(
            f'Operation "{operation_name}" is not supported for the '
            f'"{self.resource_name}" resource.'
        )

    def __raise_undefined_request_body(self, operation_name: str):
        """Undefined request body shortcut"""

        raise UndefinedRequestBody(
            f'The request body for the resource "{self.resource_name}" '
            f'and the operation "{operation_name}" is not defined.'
        )

    def _get_method_module(
        self,
        resource_name: str,
        method: str,
        api_section: str = "osidb",
        api_version: str | None = None,
    ) -> ModuleType:
        latest_api_version = self.session.get_latest_endpoint_version(
            api_section, resource_name, method
        )
        if api_version is None:
            selected_method_endpoint = (
                f"{OSIDB_BINDINGS_API_PATH}.{api_section}."
                f"{api_section}_api_{latest_api_version}_{resource_name}_{method}"
            )
        else:
            if api_version != latest_api_version:
                print(
                    (
                        f"WARNING: A non-latest API version ({api_version}) was used for {resource_name}::{method}. "
                        f"Please consider upgrading to the latest version: {latest_api_version}."
                    )
                )

            selected_method_endpoint = (
                f"{OSIDB_BINDINGS_API_PATH}.{api_section}."
                f"{api_section}_api_{api_version}_{resource_name}_{method}"
            )

        # import endpoint module based on a method and version
        return importlib.import_module(
            selected_method_endpoint,
            package="osidb_bindings",
        )

    # CRUD operations

    def retrieve(self, id, *args, api_version: str | None = None, **kwargs):
        if "retrieve" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="retrieve",
                api_version=api_version,
            )
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                id,
                *args,
                client=self.session.get_client_with_new_access_token(),
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("retrieve")

    def retrieve_list(self, *args, api_version: str | None = None, **kwargs):
        if "list" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name, method="list", api_version=api_version
            )
            sync_fn = get_sync_function(method_module)
            response = sync_fn(
                *args, client=self.session.get_client_with_new_access_token(), **kwargs
            )
            return Paginator.make_response_iterable(
                response, self.retrieve_list, *args, api_version=api_version, **kwargs
            )
        else:
            self.__raise_operation_unsupported("retrieve_list")

    def create(
        self, form_data: dict[str, Any], *args, api_version: str | None = None, **kwargs
    ):
        if "create" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="create",
                api_version=api_version,
            )
            model = getattr(method_module, "REQUEST_BODY_TYPE", None)
            if model is None:
                self.__raise_undefined_request_body("create")

            serialized_data = serialize_data(form_data, model)
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                *args,
                client=self.session.get_client_with_new_access_token(),
                body=serialized_data,
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("create")

    def bulk_create(
        self, form_data: dict[str, Any], *args, api_version: str | None = None, **kwargs
    ):
        if "bulk_create" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="bulk_create",
                api_version=api_version,
            )
            model = getattr(method_module, "REQUEST_BODY_TYPE", None)
            if model is None:
                self.__raise_undefined_request_body("bulk_create")

            serialized_data = serialize_data(form_data, model)
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                *args,
                client=self.session.get_client_with_new_access_token(),
                body=serialized_data,
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("bulk_create")

    def update(
        self,
        id,
        form_data: dict[str, Any],
        *args,
        api_version: str | None = None,
        **kwargs,
    ):
        if "update" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="update",
                api_version=api_version,
            )
            model = getattr(method_module, "REQUEST_BODY_TYPE", None)
            if model is None:
                self.__raise_undefined_request_body("update")

            serialized_data = serialize_data(form_data, model)
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                id,
                *args,
                client=self.session.get_client_with_new_access_token(),
                body=serialized_data,
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("update")

    def bulk_update(
        self, form_data: dict[str, Any], *args, api_version: str | None = None, **kwargs
    ):
        if "bulk_update" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="bulk_update",
                api_version=api_version,
            )
            model = getattr(method_module, "REQUEST_BODY_TYPE", None)
            if model is None:
                self.__raise_undefined_request_body("bulk_update")

            serialized_data = serialize_data(form_data, model)
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                *args,
                client=self.session.get_client_with_new_access_token(),
                body=serialized_data,
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("bulk_update")

    def delete(self, id, *args, api_version: str | None = None, **kwargs):
        if "destroy" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="destroy",
                api_version=api_version,
            )
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                id,
                *args,
                client=self.session.get_client_with_new_access_token(),
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("delete")

    def bulk_delete(
        self, form_data: dict[str, Any], *args, api_version: str | None = None, **kwargs
    ):
        if "bulk_delete" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name,
                method="bulk_destroy",
                api_version=api_version,
            )
            model = getattr(method_module, "REQUEST_BODY_TYPE", None)
            if model is None:
                self.__raise_undefined_request_body("bulk_delete")

            serialized_data = serialize_data(form_data, model)
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                *args,
                client=self.session.get_client_with_new_access_token(),
                body=serialized_data,
                **kwargs,
            )
        else:
            self.__raise_operation_unsupported("bulk_delete")

    # Extra operations

    def count(self, *args, api_version: str | None = None, **kwargs):
        if "list" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name, method="list", api_version=api_version
            )
            sync_fn = get_sync_function(method_module)
            kwargs.pop("offset", None)
            kwargs["limit"] = 1
            kwargs["include_fields"] = OSIDB_BINDINGS_PLACEHOLDER_FIELD

            response = sync_fn(
                *args, client=self.session.get_client_with_new_access_token(), **kwargs
            )
            return response.count
        else:
            self.__raise_operation_unsupported("retrieve_list")

    def search(self, searched_text: str, api_version: str | None = None):
        if "search" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name, method="list", api_version=api_version
            )
            sync_fn = get_sync_function(method_module)
            return sync_fn(
                client=self.session.get_client_with_new_access_token(),
                search=searched_text,
            )
        else:
            self.__raise_operation_unsupported("search")

    def retrieve_list_iterator(self, *args, api_version: str | None = None, **kwargs):
        if "list" in self.allowed_operations:
            paginator = Paginator(
                *args,
                retrieve_list_fn=self.retrieve_list,
                api_version=api_version,
                **kwargs,
            )

            for page in paginator:
                for resource in page.results:
                    yield resource
        else:
            self.__raise_operation_unsupported("retrieve_list")

    def retrieve_list_iterator_async(
        self,
        *args,
        max_results: int | None = None,
        api_version: str | None = None,
        **kwargs,
    ):
        if "list" in self.allowed_operations:
            for page in asyncio.run(
                self.__retrieve_list_async(
                    *args, max_results=max_results, api_version=api_version, **kwargs
                )
            ):
                for resource in page.results:
                    yield resource
        else:
            self.__raise_operation_unsupported("retrieve_list")

    async def __retrieve_list_async(
        self,
        *args,
        max_results: int | None = None,
        api_version: str | None = None,
        **kwargs,
    ):
        if "list" in self.allowed_operations:
            method_module = self._get_method_module(
                resource_name=self.resource_name, method="list", api_version=api_version
            )
            async_fn = get_asyncio_function(method_module)

            kwargs.pop("offset", None)
            limit = kwargs.pop("limit", None) or DEFAULT_LIMIT

            results = []
            connector = aiohttp.TCPConnector(limit=MAX_CONCURRENCY)
            async with aiohttp.ClientSession(connector=connector) as async_session:
                client = (
                    self.session.get_client_with_new_access_token().with_async_session(
                        async_session
                    )
                )

                # await first response page
                first_response = await async_fn(
                    *args, limit=limit, client=client, **kwargs
                )
                results.append(first_response)

                # no additional pages
                if first_response.next_ is None:
                    return results

                results_count = (
                    min(max_results, first_response.count)
                    if max_results is not None
                    else first_response.count
                )

                # retrieve rest of the pages
                tasks = [
                    async_fn(*args, limit=limit, offset=offset, client=client, **kwargs)
                    for offset in range(limit, results_count, limit)
                ]
                results.extend(await asyncio.gather(*tasks))

            return results
        else:
            self.__raise_operation_unsupported("retrieve_list")
