# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Common infrastructure for server view tests."""

import logging
from collections.abc import Callable
from functools import partial
from typing import Any, overload
from unittest import mock

from django.test import Client
from pydantic import BaseModel
from requests.structures import CaseInsensitiveDict
from rest_framework import status

from debusine.client import exceptions
from debusine.client.debusine import Debusine
from debusine.client.debusine_http_client import DebusineHttpClient
from debusine.db.playground import scenarios
from debusine.test.django import TestCase as BaseTestCase
from debusine.test.django import TestResponseType
from debusine.utils.urls import DEFAULT_REQUEST_TIMEOUT


class DjangoDebusineHttpClient(DebusineHttpClient):
    """DebusineHttpClient using the Django test client."""

    def __init__(
        self,
        client: Client,
        api_url: str,
        token: str | None,
        scope: str | None = None,
        timeout: int | tuple[int, int] = DEFAULT_REQUEST_TIMEOUT,
    ) -> None:
        """Initialize DebusineHttpClient."""
        super().__init__(api_url, token, scope, timeout=timeout)
        self.client = client

    def _django_method(self, method: str) -> Callable[..., TestResponseType]:
        method_to_func: dict[str, Callable[..., TestResponseType]] = {
            'GET': self.client.get,
            'POST': self.client.post,
            'PUT': self.client.put,
            'PATCH': self.client.patch,
            'DELETE': self.client.delete,
        }
        return method_to_func[method]

    @overload
    def _api_request(
        self,
        method: str,
        url: str,
        expected_class: None,
        data: dict[str, Any] | None = None,
        *,
        expected_statuses: list[int] | None = None,
        if_match: str | None = None,
    ) -> tuple[None, CaseInsensitiveDict[str]]: ...

    @overload
    def _api_request[ClientModel: BaseModel](
        self,
        method: str,
        url: str,
        expected_class: type[ClientModel],
        data: dict[str, Any] | None = None,
        *,
        expected_statuses: list[int] | None = None,
        if_match: str | None = None,
    ) -> tuple[ClientModel, CaseInsensitiveDict[str]]: ...

    def _api_request[ClientModel: BaseModel](
        self,
        method: str,
        url: str,
        expected_class: type[ClientModel] | None,
        data: dict[str, Any] | None = None,
        *,
        if_match: str | None = None,
        expected_statuses: list[int] | None = None,
    ) -> tuple[ClientModel | None, CaseInsensitiveDict[str]]:
        """
        Request to the server.

        :param method: HTTP method (GET, POST, ...).
        :param url: The complete URL to request.
        :param expected_class: expected object class that the server.
          will return. Used to deserialize the response and return an object.
          If None expects an empty response body.
        :param if_match: if not None, set it to the header "If-Match"
        :param expected_statuses: defaults to expect HTTP 200. List of HTTP
          status codes that might be return by the call. If it receives
          an unexpected one it raises UnexpectedResponseError.
        :raises exceptions.UnexpectedResponseError: the server didn't return
          a valid JSON or returned an unexpected HTTP status code.
        :raises exceptions.WorkRequestNotFound: the server could not find the
          work request.
        :raises exceptions.ClientConnectionError: the client could not connect
          to the server.
        :raises ValueError: invalid options passed.
        :raises DebusineError: the server returned an error with
          application/problem+json. Contains the detail message.
        :raises exceptions.ClientForbiddenError: the server returned HTTP 403.
        :return: an object of the expected_class or None if expected_class
          was None.
        """
        # FIXME: there is extensive use of pragma: no cover to keep this
        # aligned with the base implementation. As more Debusine methods are
        # tested with thiswill, most of the pragmas will probably be dropped
        if data is not None and method == 'GET':  # pragma: no cover
            raise ValueError('data argument not allowed with HTTP GET')

        if expected_statuses is None:  # pragma: no cover
            expected_statuses = [status.HTTP_200_OK]

        optional_kwargs = {}

        if data is not None:
            optional_kwargs = {"data": data, "content_type": "application/json"}

        try:
            headers = {}
            if self._scope is not None:  # pragma: no cover
                headers["X-Debusine-Scope"] = self._scope
            if self._token is not None:  # pragma: no cover
                headers["Token"] = self._token
            if if_match is not None:
                headers["If-Match"] = if_match
            response = self._django_method(method)(
                url, headers=headers, **optional_kwargs
            )
        except ConnectionError as exc:  # pragma: no cover
            raise exceptions.ClientConnectionError(
                f'Cannot connect to {url}. Error: {exc}'
            ) from exc

        if error := self._django_debusine_problem(response):
            raise exceptions.DebusineError(**error)
        elif response.status_code in expected_statuses:
            return (
                self._handle_response_content(
                    response.content, expected_class, url
                ),
                CaseInsensitiveDict[str](response.headers),
            )
        elif (
            response.status_code == status.HTTP_404_NOT_FOUND
        ):  # pragma: no cover
            raise exceptions.NotFoundError(f'Not found ({url})')
        elif (
            response.status_code == status.HTTP_403_FORBIDDEN
        ):  # pragma: no cover
            raise exceptions.ClientForbiddenError(
                f"HTTP 403. Token ({self._token}) is invalid or disabled"
            )
        else:  # pragma: no cover
            raise exceptions.UnexpectedResponseError(
                f'Server returned unexpected status '
                f'code: {response.status_code} ({url})'
            )

    @staticmethod
    def _django_debusine_problem(
        response: TestResponseType,
    ) -> dict[Any, Any] | None:
        """If response is an application/problem+json returns the body JSON."""
        if response.headers.get("content-type") == "application/problem+json":
            content = response.json()
            assert isinstance(content, dict)
            return content

        return None


class TestCase(BaseTestCase):
    """Base test case for server views."""

    scenario: scenarios.DefaultContextAPI

    def get_debusine(self) -> Debusine:
        """Get a Debusine client that can query the test server."""
        with mock.patch(
            "debusine.client.debusine.DebusineHttpClient",
            partial(DjangoDebusineHttpClient, self.client),
        ):
            return Debusine(
                "/api",
                self.scenario.user_token.key,
                self.scenario.scope.name,
                logger=logging.getLogger("debusine"),
            )
