# Copyright 2023 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.

"""Unit tests for the DebusineHttpClient."""

import json
from collections import deque
from itertools import count
from typing import Any
from unittest import TestCase, mock

import requests

import responses

from debusine.client import exceptions
from debusine.client.debusine_http_client import DebusineHttpClient
from debusine.client.models import StrictBaseModel
from debusine.test import TestHelpersMixin


class TestResponse(StrictBaseModel):
    """A dummy model for test cases."""

    __test__ = False

    id: int
    page: int | None = None


class DebusineHttpClientTests(TestHelpersMixin, TestCase):
    """Tests for the DebusineHttpClient."""

    def setUp(self):
        """Initialize member variables for the tests."""
        self.api_url = "https://debusine.debian.org/api/1.0"
        self.token = "token-for-server"

    def responses_add_test_endpoint(self, status):
        """
        Add a response (requests.get() will receive this response).

        Used for testing the debusine API client low level methods.
        """
        responses.add(
            responses.GET,
            self.api_url,
            json={'id': 12},
            status=status,
        )

    @staticmethod
    def responses_add_problem(
        method,
        url,
        title,
        detail=None,
        validation_errors=None,
        status=requests.codes.bad_request,
    ):
        """Add a debusine problem+json to responses."""
        response = {
            "title": title,
            "detail": detail,
            "validation_errors": validation_errors,
        }

        responses.add(
            method,
            url,
            json=response,
            content_type="application/problem+json",
            status=status,
        )

    @staticmethod
    def responses_add_paginated_responses(
        base_url: str, *, length: int = 4, page_size: int = 3
    ):
        """Add a set of responses for paginated listing requests."""
        objects = deque(TestResponse(id=i) for i in range(length))

        url = base_url
        for pageno in count(1):
            body: dict[str, Any] = {
                "count": length,
                "results": [],
            }
            for i in range(page_size):
                if not objects:
                    break
                obj = objects.popleft()
                obj.page = pageno
                body["results"].append(obj.dict())
            if objects:
                body["next"] = base_url + f"?page={pageno + 1}"
            if pageno > 1:
                body["previous"] = base_url + f"?page={pageno - 1}"

            from responses.matchers import query_string_matcher  # type: ignore # noqa: E501

            url_without_query_string, _, query_string = url.partition("?")

            responses.add(
                responses.GET,
                url_without_query_string,
                json=body,
                match=[query_string_matcher(query_string)],
            )

            if not objects:
                break
            url = body["next"]
        else:  # pragma: no cover
            pass

    def test_api_request_get_with_body_raise_value_error(self):
        """Raise ValueError if passing a body for the GET method."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            ValueError, "^data argument not allowed with HTTP GET$"
        ):
            debusine_client._api_request(
                "GET", self.api_url, TestResponse, data={}
            )

    @responses.activate
    def test_api_request_expected_class_none_response_content_raise_error(self):
        """Raise UnexpectedResponseError: response was not empty."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)

        method = "PUT"
        content = "test"

        responses.add(method, self.api_url, body=content)

        expected_regexp = (
            fr"^Server \({self.api_url}\) expected to return "
            f"an empty body. Returned:\nb'{content}'$"
        )

        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError, expected_regexp
        ):
            debusine_client._api_request(method, self.api_url, None, data=None)

    def test_api_request_only_valid_methods(self):
        """Raise ValueError if the method is not recognized."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            ValueError, "^Method must be one of: GET, POST, PUT, PATCH$"
        ):
            debusine_client._api_request("BAD-METHOD", "", TestResponse)

    @responses.activate
    def test_api_request_not_found_raise_exception(self):
        """Raise NotFoundError if the request returns 404."""
        self.responses_add_test_endpoint(status=requests.codes.not_found)
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaises(exceptions.NotFoundError):
            debusine_client._api_request("GET", self.api_url, requests.codes.ok)

        self.assert_token_key_included_in_all_requests(self.token)

    @responses.activate
    def test_api_request_raise_debusine_error(self):
        """Raise DebusineError: server returned HTTP 400 and error message."""
        title = "Cannot update task"
        detail = "Invalid task_name"
        errors = {"field": "invalid value"}

        self.responses_add_problem(
            responses.GET, self.api_url, title, detail, errors
        )

        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaises(exceptions.DebusineError) as debusine_error:
            debusine_client._api_request("GET", self.api_url, TestResponse)

        exception = debusine_error.exception

        self.assertEqual(
            exception.problem,
            {"title": title, "detail": detail, "validation_errors": errors},
        )

    @responses.activate
    def test_api_request_raise_token_disabled_error(self):
        """Raise TokenDisabledError: server returned HTTP 403."""
        responses.add(
            responses.GET,
            self.api_url,
            status=requests.codes.forbidden,
        )

        token = "b3ecf243c43"
        debusine_client = DebusineHttpClient(self.api_url, token)
        with self.assertRaisesRegex(
            exceptions.ClientForbiddenError,
            rf"^HTTP 403. Token \({token}\) is invalid or disabled$",
        ):
            debusine_client._api_request("GET", self.api_url, TestResponse)

    @responses.activate
    def test_api_request_raise_403_error_with_detail(self):
        """Raise TokenDisabledError: server returned HTTP 403 with detail."""
        title = "Error"
        detail = "You do not have permission to perform this action."

        self.responses_add_problem(
            responses.GET,
            self.api_url,
            title,
            detail=detail,
            status=requests.codes.forbidden,
        )

        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaises(exceptions.DebusineError) as debusine_error:
            debusine_client._api_request("GET", self.api_url, TestResponse)

        exception = debusine_error.exception

        self.assertEqual(
            exception.problem,
            {"title": title, "detail": detail, "validation_errors": None},
        )

    @responses.activate
    def test_api_request_raise_unexpected_error(self):
        """Raise UnexpectedResponseError for unexpected status code."""
        self.responses_add_test_endpoint(status=requests.codes.teapot)
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            r"^Server returned unexpected status code: 418 "
            rf"\({self.api_url}\)$",
        ):
            debusine_client._api_request("GET", self.api_url, TestResponse)

        self.assert_token_key_included_in_all_requests(self.token)

    @responses.activate
    def test_api_request_raise_unexpected_response_400_no_error_msg(self):
        """Raise UnexpectedError: server returned HTTP 400 and no error msg."""
        responses.add(
            responses.GET,
            self.api_url,
            json={'foo': 'bar'},
            status=requests.codes.bad_request,
        )
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            "^Server returned unexpected status code: 400 "
            fr"\({self.api_url}\)$",
        ):
            debusine_client._api_request("GET", self.api_url, TestResponse)

    @responses.activate
    def test_api_request_raise_unexpected_response_400_no_json(self):
        """Raise UnexpectedError: server returned HTTP 400 and no JSON."""
        responses.add(
            responses.GET,
            self.api_url,
            body="Not including JSON",
            status=requests.codes.bad_request,
        )
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            "^Server returned unexpected status code: 400 "
            fr"\({self.api_url}\)$",
        ):
            debusine_client._api_request("GET", self.api_url, TestResponse)

    @responses.activate
    def test_api_request_returns_not_json_raise_exception(self):
        """Raise UnexpectedResponseError if body is not a valid JSON."""
        responses.add(
            responses.GET,
            self.api_url,
            body='Something that is not JSON as client expects',
        )
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            fr"^Server \({self.api_url}\) did not return valid object. Error: ",
        ):
            debusine_client._api_request("GET", self.api_url, TestResponse)

        self.assert_token_key_included_in_all_requests(self.token)

    def test_api_request_create_and_use_session(self):
        """_api_request method create a session, uses and re-uses it."""
        session_patch = mock.patch("requests.Session", autospec=True)
        session_mock = session_patch.start()
        self.addCleanup(session_patch.stop)

        token = "token"
        debusine_client = DebusineHttpClient(self.api_url, token)

        # Lazy creation of the session: not created yet
        self.assertIsNone(debusine_client._session)

        # Make a call (the HTTP GET is done, but it failed)
        with self.assertRaises(exceptions.UnexpectedResponseError):
            debusine_client._api_request("GET", self.api_url, TestResponse)

        session_mock.assert_called_once()
        session_mock.return_value.get.assert_called_once_with(
            self.api_url, headers={"Token": token}
        )

        session = debusine_client._session
        # Make a call (the HTTP GET is done, but it failed)
        with self.assertRaises(exceptions.UnexpectedResponseError):
            debusine_client._api_request("GET", self.api_url, TestResponse)

        # Same session re-used
        self.assertIs(debusine_client._session, session)

    def test_api_request_no_token(self):
        """_api_request sends no Token header if the client has no token."""
        session_patch = mock.patch("requests.Session", autospec=True)
        session_mock = session_patch.start()
        self.addCleanup(session_patch.stop)

        debusine_client = DebusineHttpClient(self.api_url, None)

        # Make a call (the HTTP GET is done, but it failed)
        with self.assertRaises(exceptions.UnexpectedResponseError):
            debusine_client._api_request("GET", self.api_url, TestResponse)

        session_mock.assert_called_once()
        session_mock.return_value.get.assert_called_once_with(
            self.api_url, headers={}
        )

    @responses.activate
    def test_api_request_submit_data_different_status_codes(self):
        """_api_request() return requests.codes.ok or created."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        data = {"foo": "bar"}

        for status_code in [requests.codes.ok, requests.codes.created]:
            responses.reset()
            responses.add(
                responses.POST,
                self.api_url,
                status=status_code,
                json={'id': 1},
            )

            if status_code == requests.codes.ok:
                expected_statuses = None
            else:
                expected_statuses = [status_code]

            debusine_client._api_request(
                "POST",
                self.api_url,
                TestResponse,
                data=data,
                expected_statuses=expected_statuses,
            )

            self.assert_token_key_included_in_all_requests(self.token)
            self.assertEqual(json.loads(responses.calls[0].request.body), data)

    @responses.activate
    def test_debusine_error_details_combinations_everything(self):
        """Test combinations of status and body for _debusine_error_details."""
        json_with_error_message = {'title': 'error message'}
        no_json = 'There is no JSON'

        content_type_problem = 'application/problem+json'
        content_type_not_problem = 'application/json'

        all_tests = [
            {
                'body': json_with_error_message,
                'expected': json_with_error_message,
                'content_type': content_type_problem,
            },
            {
                'body': json_with_error_message,
                'expected': None,
                'content_type': content_type_not_problem,
            },
            {
                'body': no_json,
                'expected': None,
                'content_type': content_type_problem,
            },
            {
                'body': json_with_error_message,
                'expected': None,
                'content_type': content_type_not_problem,
            },
            {
                'body': no_json,
                'expected': None,
                'content_type': content_type_problem,
            },
        ]

        for test in all_tests:
            with self.subTest(test=test):
                responses.reset()
                kwargs = {'content_type': test['content_type']}

                if isinstance(test['body'], dict):
                    kwargs['json'] = test['body']
                else:
                    kwargs['body'] = test['body']

                responses.add(responses.GET, self.api_url, **kwargs)
                response = requests.get(self.api_url)

                error = DebusineHttpClient._debusine_problem(response)

                self.assertEqual(error, test['expected'])

    @responses.activate
    def test_api_request_raise_client_connection_error(self):
        """Raise ClientConnectionError for RequestException/ConnectionError."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)

        for exception in (
            requests.exceptions.RequestException,
            ConnectionError,
        ):
            with self.subTest(exception=exception):
                responses.add(
                    responses.GET,
                    self.api_url,
                    body=exception(),
                )

                with self.assertRaisesRegex(
                    exceptions.ClientConnectionError,
                    f"^Cannot connect to {self.api_url}. Error: $",
                ):
                    debusine_client._api_request(
                        "GET", self.api_url, TestResponse
                    )

    @responses.activate
    def test_iter_paginated_get_iterates(self):
        """iter_paginated_get() iterates across pages."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        self.responses_add_paginated_responses(
            self.api_url, length=4, page_size=3
        )

        iter_ = debusine_client.iter_paginated_get("", TestResponse)
        for i in range(4):
            obj = next(iter_)
            self.assertEqual(obj.id, i)
        self.assertEqual(obj.page, 2)

    @responses.activate
    def test_iter_paginated_get_terminates(self):
        """iter_paginated_get() terminates at the end."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        self.responses_add_paginated_responses(
            self.api_url, length=4, page_size=3
        )

        response = list(debusine_client.iter_paginated_get("", TestResponse))
        self.assertEqual(
            response,
            [
                TestResponse(id=0, page=1),
                TestResponse(id=1, page=1),
                TestResponse(id=2, page=1),
                TestResponse(id=3, page=2),
            ],
        )

    @responses.activate
    def test_iter_paginated_get_handles_empty_set(self):
        """iter_paginated_get() handles an empty resultset."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        self.responses_add_paginated_responses(
            self.api_url, length=0, page_size=3
        )

        response = list(debusine_client.iter_paginated_get("", TestResponse))
        self.assertEqual(response, [])

    @responses.activate
    def test_iter_paginated_get_invalid_pagination_structure(self):
        """iter_paginated_get() handles an invalid pagination response."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        responses.add(
            responses.GET,
            self.api_url,
            json={"foo": "bar"},
        )
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            fr"^Server \({self.api_url}\) did not return valid object. Error: ",
        ):
            next(debusine_client.iter_paginated_get("", TestResponse))

    @responses.activate
    def test_iter_paginated_get_invalid_response(self):
        """iter_paginated_get() handles an invalid object in responses."""
        debusine_client = DebusineHttpClient(self.api_url, self.token)
        responses.add(
            responses.GET,
            self.api_url,
            json={
                "count": 12,
                "results": [{"foo": "bar"}],
            },
        )
        with self.assertRaisesRegex(
            exceptions.UnexpectedResponseError,
            fr"^Server \({self.api_url}\) did not return valid object. Error: ",
        ):
            next(debusine_client.iter_paginated_get("", TestResponse))
