from __future__ import annotations

import socket
import typing
from unittest import mock

import pytest

from urllib3.connection import (  # type: ignore[attr-defined]
    CertificateError,
    HTTPConnection,
    HTTPSConnection,
    _match_hostname,
    _url_from_connection,
    _wrap_proxy_error,
)
from urllib3._async.connection import AsyncHTTPConnection
from urllib3.exceptions import HTTPError, ProxyError, ResponseNotReady, SSLError
from urllib3.util import ssl_
from urllib3.util.ssl_match_hostname import (
    CertificateError as ImplementationCertificateError,
)
from urllib3.util.ssl_match_hostname import _dnsname_match, match_hostname

if typing.TYPE_CHECKING:
    from urllib3._typing import _TYPE_PEER_CERT_RET_DICT


class TestConnection:
    """
    Tests in this suite should not make any network requests or connections.
    """

    def test_new_conn_receives_ech_config(self) -> None:
        ech_config = b"ech-config"
        sock = mock.MagicMock(type=socket.SOCK_STREAM)
        resolver = mock.MagicMock()

        def create_connection(*args: typing.Any, **kwargs: typing.Any) -> object:
            kwargs["ech_config_hook"](ech_config)
            return sock

        resolver.create_connection.side_effect = create_connection
        conn = HTTPConnection("example.com", resolver=resolver)

        assert conn._new_conn() is sock
        assert conn._ech_config == ech_config

    @pytest.mark.asyncio
    async def test_async_new_conn_receives_ech_config(self) -> None:
        ech_config = b"ech-config"
        sock = mock.MagicMock(type=socket.SOCK_STREAM)
        resolver = mock.MagicMock()

        async def create_connection(*args: typing.Any, **kwargs: typing.Any) -> object:
            kwargs["ech_config_hook"](ech_config)
            return sock

        resolver.create_connection.side_effect = create_connection
        conn = AsyncHTTPConnection("example.com", resolver=resolver)

        assert await conn._new_conn() is sock
        assert conn._ech_config == ech_config

    def test_match_hostname_no_cert(self) -> None:
        cert = None
        asserted_hostname = "foo"
        with pytest.raises(ValueError):
            _match_hostname(cert, asserted_hostname)

    def test_match_hostname_empty_cert(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {}
        asserted_hostname = "foo"
        with pytest.raises(ValueError):
            _match_hostname(cert, asserted_hostname)

    def test_match_hostname_match(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("DNS", "foo"),)}
        asserted_hostname = "foo"
        _match_hostname(cert, asserted_hostname)

    def test_match_hostname_mismatch(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("DNS", "foo"),)}
        asserted_hostname = "bar"
        try:
            with mock.patch("urllib3.connection.log.warning") as mock_log:
                _match_hostname(cert, asserted_hostname)
        except CertificateError as e:
            assert "hostname 'bar' doesn't match 'foo'" in str(e)
            mock_log.assert_called_once_with(
                "Certificate did not match expected hostname: %s. Certificate: %s",
                "bar",
                {"subjectAltName": (("DNS", "foo"),)},
            )
            assert e._peer_cert == cert  # type: ignore[attr-defined]

    def test_match_hostname_no_dns(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("DNS", ""),)}
        asserted_hostname = "bar"
        try:
            with mock.patch("urllib3.connection.log.warning") as mock_log:
                _match_hostname(cert, asserted_hostname)
        except CertificateError as e:
            assert "hostname 'bar' doesn't match ''" in str(e)
            mock_log.assert_called_once_with(
                "Certificate did not match expected hostname: %s. Certificate: %s",
                "bar",
                {"subjectAltName": (("DNS", ""),)},
            )
            assert e._peer_cert == cert  # type: ignore[attr-defined]

    def test_match_hostname_startwith_wildcard(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("DNS", "*"),)}
        asserted_hostname = "foo"
        _match_hostname(cert, asserted_hostname)

    def test_match_hostname_dnsname(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (("DNS", "xn--p1b6ci4b4b3a*.xn--11b5bs8d"),)
        }
        asserted_hostname = "xn--p1b6ci4b4b3a*.xn--11b5bs8d"
        _match_hostname(cert, asserted_hostname)

    def test_match_hostname_include_wildcard(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("DNS", "foo*"),)}
        asserted_hostname = "foobar"
        _match_hostname(cert, asserted_hostname)

    def test_match_hostname_more_than_one_dnsname_error(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (("DNS", "foo*"), ("DNS", "fo*"))
        }
        asserted_hostname = "bar"
        with pytest.raises(CertificateError, match="doesn't match either of"):
            _match_hostname(cert, asserted_hostname)

    def test_dnsname_match_include_more_than_one_wildcard_error(self) -> None:
        with pytest.raises(CertificateError, match="too many wildcards in certificate"):
            _dnsname_match("foo**", "foobar")

    def test_match_hostname_ignore_common_name(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subject": ((("commonName", "foo"),),)}
        asserted_hostname = "foo"
        with pytest.raises(
            ImplementationCertificateError,
            match="no appropriate subjectAltName fields were found",
        ):
            match_hostname(cert, asserted_hostname)

    def test_match_hostname_check_common_name(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {"subject": ((("commonName", "foo"),),)}
        asserted_hostname = "foo"
        match_hostname(cert, asserted_hostname, True)

    def test_match_hostname_ip_address(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (("IP Address", "1.1.1.1"),)
        }
        asserted_hostname = "1.1.1.2"
        try:
            with mock.patch("urllib3.connection.log.warning") as mock_log:
                _match_hostname(cert, asserted_hostname)
        except CertificateError as e:
            assert "hostname '1.1.1.2' doesn't match '1.1.1.1'" in str(e)
            mock_log.assert_called_once_with(
                "Certificate did not match expected hostname: %s. Certificate: %s",
                "1.1.1.2",
                {"subjectAltName": (("IP Address", "1.1.1.1"),)},
            )
            assert e._peer_cert == cert  # type: ignore[attr-defined]

    @pytest.mark.parametrize(
        ["asserted_hostname", "san_ip"],
        [
            ("1:2::3:4", "1:2:0:0:0:0:3:4"),
            ("1:2:0:0::3:4", "1:2:0:0:0:0:3:4"),
            ("::0.1.0.2", "0:0:0:0:0:0:1:2"),
            ("::1%42", "0:0:0:0:0:0:0:1"),
            ("::2%iface", "0:0:0:0:0:0:0:2"),
        ],
    )
    def test_match_hostname_ip_address_ipv6(
        self, asserted_hostname: str, san_ip: str
    ) -> None:
        """Check that hostname matches follow RFC 9110 rules for IPv6."""
        cert: _TYPE_PEER_CERT_RET_DICT = {"subjectAltName": (("IP Address", san_ip),)}
        match_hostname(cert, asserted_hostname)

    def test_match_hostname_ip_address_ipv6_doesnt_match(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (("IP Address", "1:2::2:1"),)
        }
        asserted_hostname = "1:2::2:2"
        try:
            with mock.patch("urllib3.connection.log.warning") as mock_log:
                _match_hostname(cert, asserted_hostname)
        except CertificateError as e:
            assert "hostname '1:2::2:2' doesn't match '1:2::2:1'" in str(e)
            mock_log.assert_called_once_with(
                "Certificate did not match expected hostname: %s. Certificate: %s",
                "1:2::2:2",
                {"subjectAltName": (("IP Address", "1:2::2:1"),)},
            )
            assert e._peer_cert == cert  # type: ignore[attr-defined]

    def test_match_hostname_dns_with_brackets_doesnt_match(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (
                ("DNS", "localhost"),
                ("IP Address", "localhost"),
            )
        }
        asserted_hostname = "[localhost]"
        with pytest.raises(CertificateError) as e:
            _match_hostname(cert, asserted_hostname)
        assert (
            "hostname '[localhost]' doesn't match either of 'localhost', 'localhost'"
            in str(e.value)
        )

    def test_match_hostname_ip_address_ipv6_brackets(self) -> None:
        cert: _TYPE_PEER_CERT_RET_DICT = {
            "subjectAltName": (("IP Address", "1:2::2:1"),)
        }
        asserted_hostname = "[1:2::2:1]"
        # Assert no error is raised
        _match_hostname(cert, asserted_hostname)

    def test_HTTPSConnection_default_socket_options(self) -> None:
        conn = HTTPSConnection("not.a.real.host", port=443)
        assert conn.socket_options == [
            (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1, "tcp")
        ]

    @pytest.mark.parametrize(
        "proxy_scheme, err_part",
        [
            ("http", "Unable to connect to proxy"),
            (
                "https",
                "Unable to connect to proxy. Your proxy appears to only use HTTP and not HTTPS",
            ),
        ],
    )
    def test_wrap_proxy_error(self, proxy_scheme: str, err_part: str) -> None:
        new_err = _wrap_proxy_error(HTTPError("unknown protocol"), proxy_scheme)
        assert isinstance(new_err, ProxyError) is True
        assert err_part in new_err.args[0]

    def test_url_from_pool(self) -> None:
        conn = HTTPConnection("google.com", port=80)

        path = "path?query=foo"
        assert f"http://google.com:80/{path}" == _url_from_connection(conn, path)

    def test_getresponse_requires_reponseoptions(self) -> None:
        conn = HTTPConnection("google.com", port=80)

        # Should error if a request has not been sent
        with pytest.raises(ResponseNotReady):
            conn.getresponse()

    def test_assert_fingerprint_closes_socket(self) -> None:
        context = mock.create_autospec(ssl_.SSLContext)
        context.wrap_socket.return_value.getpeercert.return_value = b"fake cert"
        conn = HTTPSConnection(
            "google.com",
            port=443,
            assert_fingerprint="AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA",
            ssl_context=context,
        )
        with mock.patch.object(conn, "_new_conn"):
            with pytest.raises(SSLError):
                conn.connect()

        context.wrap_socket.return_value.close.assert_called_once_with()

    def test_assert_hostname_closes_socket(self) -> None:
        context = mock.create_autospec(ssl_.SSLContext)
        context.wrap_socket.return_value.getpeercert.return_value = {
            "subjectAltName": (("DNS", "google.com"),)
        }
        context.wrap_socket.return_value.context = context
        conn = HTTPSConnection(
            "google.com", port=443, assert_hostname="example.com", ssl_context=context
        )
        with mock.patch.object(conn, "_new_conn"):
            with pytest.raises(ImplementationCertificateError):
                conn.connect()

        context.wrap_socket.return_value.close.assert_called_once_with()
