# test based on aioresponse

import asyncio
import re
from collections.abc import Coroutine, Generator
from random import uniform
from unittest.mock import patch
import pytest

from aiohttp import hdrs, http
from http import HTTPStatus
from aiohttp.client import ClientSession
from aiohttp.client_reqrep import ClientResponse
from ddt import data, ddt, unpack  # type: ignore[import-untyped]
from yarl import URL

try:
    from aiohttp.errors import (  # type: ignore[import-not-found]
        ClientConnectionError,
        ClientResponseError,
    )
except ImportError:
    from aiohttp.client_exceptions import (  # type: ignore[import-not-found]
        ClientConnectionError,
        ClientResponseError,
    )

from aiointercept import CallbackResult, aiointercept
from tests.conftest import network_retry

from .base import AsyncTestCase


@ddt
class AIOResponsesTestCase(AsyncTestCase):
    async def setup(self):
        self.url = "http://example.com/api?foo=bar"  # removed fragment
        self.session = ClientSession()

    async def teardown(self):
        close_result = self.session.close()
        if close_result is not None:
            await close_result

    def run_async(self, coroutine: Coroutine | Generator):
        if self.loop.is_running():
            return self.loop.create_task(coroutine)
        return self.loop.run_until_complete(coroutine)

    async def request(self, url: str):
        return await self.session.get(url)

    @data(
        hdrs.METH_HEAD,
        hdrs.METH_GET,
        hdrs.METH_POST,
        hdrs.METH_PUT,
        hdrs.METH_PATCH,
        hdrs.METH_DELETE,
        hdrs.METH_OPTIONS,
    )
    @patch("aiointercept.aiointercept.add")
    async def test_shortcut_method(self, http_method, mocked):
        async with aiointercept(True) as m:
            getattr(m, http_method.lower())(self.url)
            mocked.assert_called_once_with(self.url, method=http_method)

    @aiointercept(True)
    async def test_returned_instance(self, m):
        m.get(self.url)
        response = await self.session.get(self.url)
        self.assertIsInstance(response, ClientResponse)

    @aiointercept(True)
    async def test_returned_instance_and_status_code(self, m):
        m.get(self.url, status=204)
        response = await self.session.get(self.url)
        self.assertIsInstance(response, ClientResponse)
        self.assertEqual(response.status, 204)

    @unpack
    @data(
        ("http://example.com", "/api?foo=bar#fragment"),
        ("http://example.com/", "/api?foo=bar#fragment"),
    )
    @aiointercept(True)
    async def test_base_url(self, base_url, relative_url, m):
        m.get(self.url, status=200)
        await self.session.close()  # replace the session created  setup
        self.session = ClientSession(base_url=base_url)
        response = await self.session.get(relative_url)
        self.assertEqual(response.status, 200)

    @aiointercept(True)
    async def test_session_headers(self, m):
        m.get(self.url)
        await self.session.close()  # replace the session created in setup
        self.session = ClientSession(headers={"Authorization": "Bearer foobar"})
        response = await self.session.get(self.url)

        self.assertEqual(response.status, 200)

        # Check that the headers from the ClientSession are within the request
        key = ("GET", URL(self.url))
        request = m.requests[key][0]
        self.assertEqual(request.kwargs["headers"]["Authorization"], "Bearer foobar")

    @aiointercept(True)
    async def test_returned_response_headers(self, m):
        m.get(self.url, content_type="text/html", headers={"Connection": "keep-alive"})
        response = await self.session.get(self.url)

        self.assertEqual(response.headers["Connection"], "keep-alive")
        self.assertEqual(response.headers[hdrs.CONTENT_TYPE], "text/html")

    @aiointercept(True)
    async def test_returned_response_cookies(self, m):
        m.get(self.url, headers={"Set-Cookie": "cookie=value"})
        response = await self.session.get(self.url)

        self.assertEqual(response.cookies["cookie"].value, "value")

    @aiointercept(True)
    async def test_returned_response_raw_headers(self, m):
        m.get(self.url, content_type="text/html", headers={"Connection": "keep-alive"})
        response = await self.session.get(self.url)
        # we assert that response raw headers contains the expected headers, but we do not assert that they
        # are the only ones, since aiohttp could add some extra headers, such as content-length
        expected_raw_headers = (
            (b"Connection", b"keep-alive"),
            (hdrs.CONTENT_TYPE.encode(), b"text/html"),
        )
        self.assertTrue(
            all(header in response.raw_headers for header in expected_raw_headers)
        )

    @aiointercept(True)
    async def test_raise_for_status(self, m):
        m.get(self.url, status=400)
        with self.assertRaises(ClientResponseError) as cm:
            response = await self.session.get(self.url)
            response.raise_for_status()
        self.assertEqual(cm.exception.message, HTTPStatus(400).phrase)

    @aiointercept(True)
    async def test_request_raise_for_status(self, m):
        m.get(self.url, status=400)
        with self.assertRaises(ClientResponseError) as cm:
            await self.session.get(self.url, raise_for_status=True)
        self.assertEqual(cm.exception.message, HTTPStatus(400).phrase)

    @aiointercept(True)
    async def test_returned_instance_and_params_handling(self, m):
        expected_url = "http://example.com/api?foo=bar&x=42#fragment"
        m.get(expected_url)
        response = await self.session.get(self.url, params={"x": 42})
        self.assertIsInstance(response, ClientResponse)
        self.assertEqual(response.status, 200)

        expected_url = "http://example.com/api?x=42#fragment"
        m.get(expected_url)
        response = await self.session.get(
            "http://example.com/api#fragment", params={"x": 42}
        )
        self.assertIsInstance(response, ClientResponse)
        self.assertEqual(response.status, 200)
        self.assertEqual(len(m.requests), 2)
        with self.assertRaises(AssertionError):
            m.assert_called_once()

    @aiointercept(True)
    async def test_method_dont_match(self, m):
        m.get(self.url)
        with self.assertRaises(ClientConnectionError):
            await self.session.post(self.url)

    @aiointercept(True)
    async def test_method_match_case_insensitive(self, m):
        m.get(self.url)
        response = await self.session.request("get", self.url)
        self.assertEqual(response.status, 200)
        m.assert_any_call(self.url)
        m.assert_called_with(self.url)

    @aiointercept(True)
    async def test_post_with_data(self, m: aiointercept):
        body = {"foo": "bar"}
        payload = {"spam": "eggs"}
        user_agent = {"User-Agent": "aiointercept"}
        m.post(
            self.url,
            payload=payload,
            headers=dict(connection="keep-alive"),
            body=body,
        )
        response = await self.session.post(self.url, data=payload, headers=user_agent)
        self.assertIsInstance(response, ClientResponse)
        self.assertEqual(response.status, 200)
        response_data = await response.json()
        self.assertEqual(response_data, payload)
        m.assert_called_once_with(
            self.url,
            method="POST",
            data=payload,
            headers={"User-Agent": "aiointercept"},
        )
        # Wrong data
        with self.assertRaises(AssertionError):
            m.assert_called_once_with(
                self.url,
                method="POST",
                data=body,
                headers={"User-Agent": "aiointercept"},
            )
        # Wrong url
        with self.assertRaises(AssertionError):
            m.assert_called_once_with(
                "http://httpbingo.org/",
                method="POST",
                data=payload,
                headers={"User-Agent": "aiointercept"},
            )
        # Wrong headers
        with self.assertRaises(AssertionError):
            m.assert_called_once_with(
                self.url,
                method="POST",
                data=payload,
                headers={"User-Agent": "aiorequest"},
            )

    @aiointercept(True)
    async def test_streaming(self, m):
        m.get(self.url, body="Test")
        resp = await self.session.get(self.url)
        content = await resp.content.read()
        self.assertEqual(content, b"Test")

    @aiointercept(True)
    async def test_streaming_up_to(self, m):
        m.get(self.url, body="Test")
        resp = await self.session.get(self.url)
        content = await resp.content.read(2)
        self.assertEqual(content, b"Te")
        content = await resp.content.read(2)
        self.assertEqual(content, b"st")

    @aiointercept(True)
    async def test_binary_body(self, m):
        body = b"Invalid utf-8: \x95\x00\x85"
        m.get(self.url, body=body)
        resp = await self.session.get(self.url)
        content = await resp.read()
        self.assertEqual(content, body)

    @aiointercept(True)
    async def test_binary_body_via_callback(self, m):
        body = b"\x00\x01\x02\x80\x81\x82\x83\x84\x85"

        def callback(url, **kwargs):
            return CallbackResult(body=body)

        m.get(self.url, callback=callback)
        resp = await self.session.get(self.url)
        content = await resp.read()
        self.assertEqual(content, body)

    async def test_mocking_as_context_manager(self):
        async with aiointercept(True) as aiomock:
            aiomock.add(self.url, payload={"foo": "bar"})
            resp = await self.session.get(self.url)
            self.assertEqual(resp.status, 200)
            payload = await resp.json()
            self.assertDictEqual(payload, {"foo": "bar"})

    async def test_mocking_as_decorator(self):
        @aiointercept(True)
        async def foo(loop, m):
            m.add(self.url, payload={"foo": "bar"})

            resp = await self.session.get(self.url)
            self.assertEqual(resp.status, 200)
            payload = await resp.json()
            self.assertDictEqual(payload, {"foo": "bar"})

        await foo(self.loop)

    async def test_passing_argument(self):
        @aiointercept(True, param="mocked")
        async def foo(mocked):
            mocked.add(self.url, payload={"foo": "bar"})
            resp = await self.session.get(self.url)
            self.assertEqual(resp.status, 200)

        await foo()

    async def test_mocking_as_decorator_wrong_mocked_arg_name(self):
        @aiointercept(True, param="foo")
        async def foo(bar):
            # no matter what is here it should raise an error
            pass

        with self.assertRaises(TypeError) as cm:
            await foo()
        exc = cm.exception
        self.assertIn("foo() got an unexpected keyword argument 'foo'", str(exc))

    async def test_unknown_request(self):
        async with aiointercept(True) as aiomock:
            aiomock.add(self.url, payload={"foo": "bar"})
            with self.assertRaises(ClientConnectionError):
                await self.session.get("http://example.com/foo")

    async def test_multiple_requests(self):
        """Ensure that requests are saved the way they would have been sent."""
        async with aiointercept(True) as m:
            m.get(self.url, status=200)
            m.get(self.url, status=201)
            m.get(self.url, status=202)
            json_content_as_ref = [1]
            resp = await self.session.get(self.url, json=json_content_as_ref)
            self.assertEqual(resp.status, 200)
            json_content_as_ref[:] = [2]
            resp = await self.session.get(self.url, json=json_content_as_ref)
            self.assertEqual(resp.status, 201)
            json_content_as_ref[:] = [3]
            resp = await self.session.get(self.url, json=json_content_as_ref)
            self.assertEqual(resp.status, 202)

            key = ("GET", URL(self.url))
            self.assertIn(key, m.requests)
            self.assertEqual(len(m.requests[key]), 3)

    async def test_request_retrieval_in_case_no_response(self):
        async with aiointercept(True) as m:
            with self.assertRaises(ClientConnectionError):
                await self.session.get(self.url)
            key = ("GET", URL(self.url))
            self.assertIn(key, m.requests)
            # self.assertEqual(len(m.requests[key]), 1) aiohttp could retry
            # self.assertEqual(m.requests[key][0].args, tuple())
            # self.assertEqual(m.requests[key][0].kwargs, {"allow_redirects": True})

    async def test_request_failure_in_case_session_is_closed(self):
        async def do_request(session):
            return await session.get(self.url)

        async with aiointercept(True):
            coro = do_request(self.session)
            await self.session.close()

            with self.assertRaises(RuntimeError) as exception_info:
                await coro
            assert str(exception_info.exception) == "Session is closed"

    @network_retry
    async def test_address_as_instance_of_url_combined_with_pass_through(self):
        external_api = "http://httpbingo.org/status/201"

        async def doit():
            api_resp = await self.session.get(self.url)
            # we have to hit actual url,
            # otherwise we do not test pass through option properly
            ext_rep = await self.session.get(URL(external_api))
            return api_resp, ext_rep

        async with aiointercept(True, passthrough=[external_api]) as m:
            m.get(self.url, status=200)
            api, ext = await doit()

            self.assertEqual(api.status, 200)
            self.assertEqual(ext.status, 201)

    @network_retry
    async def test_pass_through_with_origin_params(self):
        external_api = "http://httpbingo.org/get"

        async def doit(params):
            # we have to hit actual url,
            # otherwise we do not test pass through option properly
            ext_rep = await self.session.get(URL(external_api), params=params)
            return ext_rep

        async with aiointercept(True, passthrough=[external_api]) as m:  #  noqa: F841
            params = {"foo": "bar"}
            ext = await doit(params=params)
            self.assertEqual(ext.status, 200)
            self.assertEqual(str(ext.url), "http://httpbingo.org/get?foo=bar")

    @aiointercept(True)
    async def test_custom_response_class(self, m):
        class CustomClientResponse(ClientResponse):
            pass

        m.get(self.url, body="Test")
        old_class = (
            self.session._response_class
        )  # NOTE: now is not necessary to mock the behaviour in
        # aioresponses, will inherit from aiohttp
        self.session._response_class = CustomClientResponse
        resp = await self.session.get(self.url)
        self.session._response_class = old_class
        self.assertTrue(isinstance(resp, CustomClientResponse))

    @aiointercept(True)
    async def test_request_should_match_regexp(self, mocked):
        mocked.get(
            re.compile(r"^http://example\.com/api\?foo=.*$"), payload={}, status=200
        )

        response = await self.request(self.url)
        self.assertEqual(response.status, 200)

    @aiointercept(True)
    async def test_request_does_not_match_regexp(self, mocked):
        mocked.get(
            re.compile(r"^http://exampleexample\.com/api\?foo=.*$"),
            payload={},
            status=200,
        )
        with self.assertRaises(ClientConnectionError):
            await self.request(self.url)

    @aiointercept(True)
    async def test_callback(self, m):
        body = b"New body"

        def callback(url, **kwargs):
            self.assertEqual(str(url), self.url)
            return CallbackResult(body=body)

        m.get(self.url, callback=callback)
        response = await self.request(self.url)
        data = await response.read()
        assert data == body

    @aiointercept(True)
    async def test_callback_coroutine(self, m):
        body = b"New body"
        event = asyncio.Event()

        async def callback(url, **kwargs):
            await event.wait()
            self.assertEqual(str(url), self.url)
            return CallbackResult(body=body)

        m.get(self.url, callback=callback)
        future = asyncio.ensure_future(self.request(self.url))
        await asyncio.wait([future], timeout=1)
        assert not future.done()
        event.set()
        await asyncio.wait([future], timeout=1)
        assert future.done()
        response = future.result()
        data = await response.read()
        assert data == body

    @aiointercept(True)
    async def test_assert_not_called(self, m: aiointercept):
        m.get(self.url)
        m.assert_not_called()
        await self.session.get(self.url)
        with self.assertRaises(AssertionError):
            m.assert_not_called()

    @aiointercept(True)
    async def test_assert_called(self, m: aiointercept):
        m.get(self.url)
        with self.assertRaises(AssertionError):
            m.assert_called()
        await self.session.get(self.url)

        m.assert_called_once()
        m.assert_called_once_with(self.url)
        m.assert_called_with(self.url)
        with self.assertRaises(AssertionError):
            m.assert_not_called()

        with self.assertRaises(AssertionError):
            m.assert_called_with("http://foo.bar")

    @aiointercept(True)
    async def test_assert_called_twice(self, m: aiointercept):
        m.get(self.url, repeat=True)
        m.assert_not_called()
        await self.session.get(self.url)
        await self.session.get(self.url)
        with self.assertRaises(AssertionError):
            m.assert_called_once()

    @aiointercept(True)
    async def test_integer_repeat_once(self, m: aiointercept):
        m.get(self.url, repeat=1)
        m.assert_not_called()
        await self.session.get(self.url)
        with self.assertRaises(ClientConnectionError):
            await self.session.get(self.url)

    @aiointercept(True)
    async def test_integer_repeat_twice(self, m: aiointercept):
        m.get(self.url, repeat=2)
        m.assert_not_called()
        await self.session.get(self.url)
        await self.session.get(self.url)
        with self.assertRaises(ClientConnectionError):
            await self.session.get(self.url)

    @aiointercept(True)
    async def test_assert_any_call(self, m: aiointercept):
        http_bin_url = "http://httpbingo.org"
        m.get(self.url)
        m.get(http_bin_url)
        await self.session.get(self.url)
        response = await self.session.get(http_bin_url)
        self.assertEqual(response.status, 200)
        m.assert_any_call(self.url)
        m.assert_any_call(http_bin_url)

    @aiointercept(True)
    async def test_assert_any_call_not_called(self, m: aiointercept):
        http_bin_url = "http://httpbingo.org"
        m.get(self.url)
        response = await self.session.get(self.url)
        self.assertEqual(response.status, 200)
        m.assert_any_call(self.url)
        with self.assertRaises(AssertionError):
            m.assert_any_call(http_bin_url)

    async def test_possible_race_condition(self):
        async def random_sleep_cb(url, **kwargs):
            await asyncio.sleep(uniform(0.1, 1))
            return CallbackResult(body="test")

        async with aiointercept(True) as mocked:
            for i in range(20):
                mocked.get(f"http://example.org/id-{i}", callback=random_sleep_cb)

            tasks = [self.session.get(f"http://example.org/id-{i}") for i in range(20)]
            await asyncio.gather(*tasks)


class AIOResponsesRaiseForStatusSessionTestCase(AsyncTestCase):
    """Test case for sessions with raise_for_status=True.

    This flag, introduced in aiohttp v2.0.0, automatically calls
    `raise_for_status()`.
    It is overridden by the `raise_for_status` argument of the request since
    aiohttp v3.4.a0.

    """

    async def setup(self):
        self.url = "http://example.com/api?foo=bar#fragment"
        self.session = ClientSession(raise_for_status=True)

    async def teardown(self):
        close_result = self.session.close()
        if close_result is not None:
            await close_result

    @aiointercept(True)
    async def test_raise_for_status(self, m):
        m.get(self.url, status=400)
        with self.assertRaises(ClientResponseError) as cm:
            await self.session.get(self.url)
        self.assertEqual(cm.exception.message, HTTPStatus(400).phrase)

    @aiointercept(True)
    async def test_do_not_raise_for_status(self, m):
        m.get(self.url, status=400)
        response = await self.session.get(self.url, raise_for_status=False)

        self.assertEqual(response.status, 400)

    @aiointercept(True)
    async def test_callable_raise_for_status(self, m):
        async def raise_for_status(response: ClientResponse):
            if response.status >= 400:
                raise Exception("callable raise_for_status")

        m.get(self.url, status=400)
        with self.assertRaises(Exception) as cm:
            await self.session.get(self.url, raise_for_status=raise_for_status)
        self.assertEqual(str(cm.exception), "callable raise_for_status")


class AIOResponseRedirectTest(AsyncTestCase):
    async def setup(self):
        self.url = "http://example.com:8080/redirect"
        self.session = ClientSession()

    async def teardown(self):
        close_result = self.session.close()
        if close_result is not None:
            await close_result

    @aiointercept(True)
    async def test_redirect_followed(self, rsps):
        rsps.get(
            self.url,
            status=307,
            headers={"Location": "https://httpbingo.org"},
        )
        rsps.get("https://httpbingo.org")
        response = await self.session.get(self.url, allow_redirects=True)
        self.assertEqual(response.status, 200)
        self.assertEqual(str(response.url), "https://httpbingo.org")
        self.assertEqual(len(response.history), 1)
        self.assertEqual(str(response.history[0].url), self.url)

    @aiointercept(True)
    async def test_post_redirect_followed(self, rsps):
        rsps.post(
            self.url,
            status=302,
            headers={"Location": "https://httpbingo.org"},
        )
        rsps.get("https://httpbingo.org")
        response = await self.session.post(self.url, allow_redirects=True)
        self.assertEqual(response.status, 200)
        self.assertEqual(str(response.url), "https://httpbingo.org")
        self.assertEqual(response.method, "GET")
        self.assertEqual(len(response.history), 1)
        self.assertEqual(str(response.history[0].url), self.url)

    @aiointercept(True)
    async def test_redirect_missing_mocked_match(self, rsps):
        rsps.get(
            self.url,
            status=307,
            headers={"Location": "https://httpbingo.org"},
        )
        with self.assertRaises(ClientConnectionError):
            await self.session.get(self.url, allow_redirects=True)

    @aiointercept(True)
    async def test_redirect_missing_location_header(self, rsps):
        rsps.get(self.url, status=307)
        response = await self.session.get(self.url, allow_redirects=True)
        self.assertEqual(str(response.url), self.url)

    @aiointercept(True)
    async def test_request_info(self, rsps):
        rsps.get(self.url, status=200)

        response = await self.session.get(self.url)

        request_info = response.request_info
        assert str(request_info.url) == self.url

    @aiointercept(True)
    async def test_request_info_with_original_request_headers(self, rsps):
        headers = {"Authorization": "Bearer access-token"}
        rsps.get(self.url, status=200)

        response = await self.session.get(self.url, headers=headers)

        request_info = response.request_info
        assert str(request_info.url) == self.url
        assert request_info.headers["Authorization"] == headers["Authorization"]

    @aiointercept(True)
    async def test_relative_url_redirect_followed(self, rsps):
        base_url = "https://httpbingo.org"
        url = f"{base_url}/foo/bar"
        rsps.get(
            url,
            status=307,
            headers={"Location": "../baz"},
        )
        rsps.get(f"{base_url}/baz")

        response = await self.session.get(url, allow_redirects=True)

        self.assertEqual(response.status, 200)
        self.assertEqual(str(response.url), f"{base_url}/baz")
        self.assertEqual(len(response.history), 1)
        self.assertEqual(str(response.history[0].url), url)

    @network_retry
    async def test_pass_through_unmatched_requests(self):
        matched_url = "https://matched_example.org"
        unmatched_url = "https://httpbingo.org/get"
        params_unmatched = {"foo": "bar"}

        async with aiointercept(True, passthrough_unmatched=True) as m:
            m.post(URL(matched_url), status=200)
            mocked_response = await self.session.post(URL(matched_url))
            response = await self.session.get(
                URL(unmatched_url), params=params_unmatched
            )
            self.assertEqual(response.status, 200)
            self.assertEqual(str(response.url), "https://httpbingo.org/get?foo=bar")
            self.assertEqual(mocked_response.status, 200)


class ApiClient:
    def __init__(self, session: ClientSession):
        self.session = session

    async def get(self, url: str, session) -> dict:
        async with session.get(url) as response:
            response.raise_for_status()
            return await response.json()


@pytest.mark.asyncio
async def test_get_activates_mock_after_session_created():
    """aiohttp session is created first; aioresponses mock is activated afterwards."""
    url = "https://fake-server.com/api/data"
    expected = {"key": "value"}

    async with ClientSession() as session:
        # Session is already open here — mock is applied after the fact.
        async with aiointercept(True) as mock:
            mock.get(url, payload={"key": "value"}, status=200)

            client = ApiClient(session)
            result = await client.get(url, session)

    assert result == expected
