from __future__ import annotations

import contextlib
import os
import platform
import random
import shutil
import string
import subprocess
import sys
import time
import typing
from http.client import RemoteDisconnected
from pathlib import Path
from socket import timeout as SocketTimeout
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

import nox


_IS_GIL_DISABLED = hasattr(sys, "_is_gil_enabled") and sys._is_gil_enabled() is False


@contextlib.contextmanager
def traefik_boot(
    session: nox.Session, *args: str, stop_on_exit: bool = True
) -> typing.Generator[None, None, None]:
    """
    Start a server to reliably test HTTP/1.1, HTTP/2 and HTTP/3 over QUIC.
    """
    # we may want to avoid starting the traefik server...
    if os.environ.get("TRAEFIK_HTTPBIN_ENABLE", "true") != "true":
        yield
        return

    # nox allows us to specify pos args
    # if we detect any of them, we should check
    # if the target tests requires Traefik or not.
    detect_specific_traefik: bool | None = None
    for arg in args:
        if arg.startswith("test/"):
            detect_specific_traefik = False
        if arg.startswith("test/with_traefik") or arg == "test/":
            detect_specific_traefik = True
            break
    if detect_specific_traefik is False:
        yield
        return

    external_stack_started = False
    is_windows = platform.system() == "Windows"
    dc_v1_legacy = is_windows is False and shutil.which("docker-compose") is not None
    traefik_ipv4 = os.environ.get("TRAEFIK_HTTPBIN_IPV4", "127.0.0.1")

    if dc_v1_legacy:
        dc_v2_probe = subprocess.Popen(["docker", "compose", "ps"])

        dc_v2_probe.wait()
        dc_v1_legacy = dc_v2_probe.returncode != 0

    if not os.path.exists("./traefik/httpbin.local.pem"):
        session.log("Prepare fake certificates for our Traefik server...")

        session.run(
            *[
                "python",
                "-m",
                "trustme",
                "-i",
                "httpbin.local",
                "alt.httpbin.local",
                "-d",
                "./traefik",
            ]
        )

        shutil.move("./traefik/server.pem", "./traefik/httpbin.local.pem")

        if os.path.exists("./traefik/httpbin.local.pem.key"):
            os.unlink("./traefik/httpbin.local.pem.key")

        shutil.move("./traefik/server.key", "./traefik/httpbin.local.pem.key")

        if os.path.exists("./rootCA.pem"):
            os.unlink("./rootCA.pem")

        shutil.move("./traefik/client.pem", "./rootCA.pem")

    try:
        session.log("Attempt to start Traefik with go-httpbin[...]")

        if is_windows:
            if not os.path.exists("./go-httpbin"):
                clone_proc = subprocess.Popen(
                    ["git", "clone", "https://github.com/mccutchen/go-httpbin.git"]
                )

                clone_proc.wait()

            shutil.copyfile(
                "./traefik/patched.Dockerfile", "./go-httpbin/patched.Dockerfile"
            )

            dc_cmd = [
                "docker",
                "compose",
                "-f",
                "docker-compose.win.yaml",
                "up",
                "-d",
            ]
        else:
            if dc_v1_legacy:
                dc_cmd = ["docker-compose", "up", "-d"]
            else:
                dc_cmd = ["docker", "compose", "up", "-d"]

        dc_process = subprocess.Popen(dc_cmd)
        dc_process.wait()

        if dc_process.returncode != 0:
            session.warn(
                f"docker compose exited with code {dc_process.returncode}, "
                "retrying in 30s..."
            )
            time.sleep(30)
            dc_process = subprocess.Popen(dc_cmd)
            dc_process.wait()

        if dc_process.returncode != 0:
            session.warn("docker compose failed after retry, disabling Traefik tests")
            # Clean up any partially-started containers.
            subprocess.Popen(dc_cmd[:-2] + ["stop"]).wait()
            os.environ["TRAEFIK_HTTPBIN_ENABLE"] = "false"
            yield
            return

    except OSError as e:
        session.warn(
            f"Traefik server cannot be run due to an error with containers: {e}"
        )
    else:
        session.log("Traefik server is starting[...]")

        i = 0

        while True:
            if i >= 120:
                if not dc_v1_legacy:
                    subprocess.Popen(
                        [
                            "docker",
                            "compose",
                            "-f",
                            "docker-compose.win.yaml",
                            "logs",
                            "--tail=128",
                        ]
                    )

                raise TimeoutError(
                    "Error while waiting for the Traefik server (timeout/readiness)"
                )

            try:
                r = urlopen(
                    Request(
                        f"http://{traefik_ipv4}:8888/get",
                        headers={"Host": "httpbin.local"},
                    ),
                    timeout=1.0,
                )
            except (
                HTTPError,
                URLError,
                RemoteDisconnected,
                TimeoutError,
                SocketTimeout,
                ConnectionError,
            ) as e:
                i += 1
                time.sleep(1)
                session.log(f"Waiting for the Traefik server: {e}...")
                continue

            if int(r.status) == 200:
                break

        session.log("Traefik server is ready to accept connections[...]")
        external_stack_started = True

    yield

    if external_stack_started and stop_on_exit:
        dc_process = subprocess.Popen(dc_cmd[:-2] + ["stop"])
        dc_process.wait()


@nox.session(name="wasi_traefik", python="3.14")
def wasi_traefik(session: nox.Session) -> None:
    """Start and health-check the Compose Traefik stack for WASI tests."""
    session.install("trustme==1.2.1")
    with traefik_boot(session, stop_on_exit=False):
        pass


def tests_impl(
    session: nox.Session,
    extras: str = "socks,brotli,zstd,ws",
    byte_string_comparisons: bool = False,
    tracemalloc_enable: bool = False,
    xdist_enable: bool = True,
) -> None:
    # Install deps and the package itself.
    session.install("-U", "pip", "setuptools", silent=False)
    session.install("-r", "dev-requirements.txt", "--require-hashes", silent=False)

    if "FIPS_MODE" in os.environ:
        # we must ensure cryptography is using the system OpenSSL build
        session.run("pip", "uninstall", "cryptography", "-y")
        session.run("pip", "install", "cryptography", "--no-binary", "cryptography")

    if "URLLIB3_NO_OVERRIDE" in os.environ:
        session.run("pip", "uninstall", "-y", "urllib3")

    with traefik_boot(session, *session.posargs):
        session.install(f".[{extras}]", silent=False)

        # Show the pip version.
        session.run("pip", "--version")
        # Print the Python version and bytesize.
        session.run("python", "--version")
        session.run("python", "-c", "import struct; print(struct.calcsize('P') * 8)")

        session.run(
            "python",
            "-c",
            "from urllib3.contrib.anytls import ssl, BACKEND; "
            "print(BACKEND, ssl.OPENSSL_VERSION)",
        )

        # Inspired from https://hynek.me/articles/ditch-codecov-python/
        # We use parallel mode and then combine in a later CI step
        session.run(
            "python",
            *(("-bb",) if byte_string_comparisons else ()),
            "-m",
            "pytest",
            *(
                ("-n", "2" if os.environ.get("CI") else "4")
                if xdist_enable
                else ("-n", "0")
            ),
            "--cov",
            "urllib3",
            "-v",
            "-ra",
            f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
            "--tb=native",
            "--durations=10",
            "--strict-config",
            "--strict-markers",
            *(session.posargs or ("test/",)),
            env={
                "PYTHONWARNINGS": "always::DeprecationWarning",
                "COVERAGE_CORE": "sysmon",
                "PYTHONTRACEMALLOC": "25" if tracemalloc_enable else "",
                "PYTHON_GIL": "0" if _IS_GIL_DISABLED else "",
            },
        )

    suffix = "".join(
        random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits)
        for _ in range(8)
    )
    os.rename(".coverage", f".coverage.{suffix}")


@nox.session(
    python=[
        "3.7",
        "3.8",
        "3.9",
        "3.10",
        "3.11",
        "3.12",
        "3.13",
        "3.13t",
        "3.14",
        "3.14t",
        "3.15",
        "3.15t",
        "pypy",
    ]
)
def test(session: nox.Session) -> None:
    tests_impl(session)


@nox.session(
    python=[
        "3.7",
        "3.8",
        "3.9",
        "3.10",
        "3.11",
        "3.12",
        "3.13",
        "3.13t",
        "3.14",
        "3.14t",
    ]
)
def test_rtls(session: nox.Session) -> None:
    tests_impl(session, extras="socks,brotli,zstd,ws,rtls")


@nox.session(
    python=[
        "3.7",
        "3.8",
        "3.9",
        "3.10",
        "3.11",
        "3.12",
        "3.13",
        "3.13t",
        "3.14",
        "3.14t",
    ]
)
def test_utls(session: nox.Session) -> None:
    tests_impl(session, extras="socks,brotli,zstd,ws,utls")


@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"])
def tracemalloc(session: nox.Session) -> None:
    tests_impl(session, tracemalloc_enable=True, xdist_enable=False)


@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"])
def test_ssl_large_resources(session: nox.Session) -> None:
    # Install deps and the package itself.
    session.install("-U", "pip", "setuptools", silent=False)
    session.install("-r", "dev-requirements.txt", "--require-hashes", silent=False)
    session.install(".", silent=False)

    # Show the pip version.
    session.run("pip", "--version")
    # Print the Python version and bytesize.
    session.run("python", "--version")
    session.run("python", "-c", "import struct; print(struct.calcsize('P') * 8)")
    session.run("python", "-c", "import ssl; print(ssl.OPENSSL_VERSION)")

    session.run(
        "python",
        "-m",
        "coverage",
        "run",
        "--parallel-mode",
        "-m",
        "pytest",
        "-v",
        "-ra",
        f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
        "--tb=native",
        "--strict-config",
        "--strict-markers",
        "test/with_dummyserver/test_socketlevel.py::TestSSL::test_requesting_large_resources_via_ssl",
        env={
            "PYTHONWARNINGS": "always::DeprecationWarning",
            "COVERAGE_CORE": "sysmon",
            "CI": None,
        },
    )


@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"])
def test_pysocks(session: nox.Session) -> None:
    # Install deps and the package itself.
    session.install("-U", "pip", "setuptools", silent=False)
    session.install("-r", "dev-requirements.txt", "--require-hashes", silent=False)
    session.install(".", silent=False)
    session.run("pip", "uninstall", "-y", "python-socks")
    session.install("pysocks")

    # Show the pip version.
    session.run("pip", "--version")
    # Print the Python version and bytesize.
    session.run("python", "--version")
    session.run("python", "-c", "import struct; print(struct.calcsize('P') * 8)")
    session.run("python", "-c", "import ssl; print(ssl.OPENSSL_VERSION)")

    session.run(
        "python",
        "-m",
        "coverage",
        "run",
        "--parallel-mode",
        "-m",
        "pytest",
        "-v",
        "-ra",
        f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
        "--tb=native",
        "--strict-config",
        "--strict-markers",
        "test/contrib/test_socks.py",
        env={
            "PYTHONWARNINGS": "always::DeprecationWarning",
            "COVERAGE_CORE": "sysmon",
        },
    )


@nox.session(python=["3"])
def test_brotlipy(session: nox.Session) -> None:
    """Check that if 'brotlipy' is installed instead of 'brotli' or
    'brotlicffi' that we still don't blow up.
    """
    session.install("brotlipy")
    tests_impl(session, extras="socks", byte_string_comparisons=False)


def git_clone(session: nox.Session, git_url: str) -> None:
    """We either clone the target repository or if already exist
    simply reset the state and pull.
    """
    expected_directory = git_url.split("/")[-1]

    if expected_directory.endswith(".git"):
        expected_directory = expected_directory[:-4]

    if not os.path.isdir(expected_directory):
        session.run("git", "clone", "--depth", "1", git_url, external=True)
    else:
        session.run(
            "git", "-C", expected_directory, "reset", "--hard", "HEAD", external=True
        )
        session.run("git", "-C", expected_directory, "pull", external=True)


@nox.session()
def downstream_botocore(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/boto/botocore")
    session.chdir("botocore")
    for patch in [
        "0001-Mark-100-Continue-tests-as-failing.patch",
        "0003-Mark-HttpConn-bypass-internals-as-xfail.patch",
    ]:
        session.run("git", "apply", f"{root}/ci/{patch}", external=True)
    session.run("git", "rev-parse", "HEAD", external=True)
    session.run("python", "scripts/ci/install")

    session.cd(root)

    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/botocore")

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")
    session.run("python", "scripts/ci/run-tests")


@nox.session()
def downstream_niquests(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/jawah/niquests")
    session.chdir("niquests")

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install("nox")

    constraint = Path(root) / tmp_dir / "urllib3-future-constraint.txt"
    constraint.write_text(
        f"urllib3-future @ {Path(root).resolve().as_uri()}\n", encoding="utf-8"
    )
    nested_session = f"test-{sys.version_info.major}.{sys.version_info.minor}"
    session.run(
        "nox",
        "-s",
        nested_session,
        *(("--", *session.posargs) if session.posargs else ()),
        env={
            "PIP_CONSTRAINT": str(constraint),
        },
    )


@nox.session()
def downstream_requests(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/psf/requests")
    session.chdir("requests")

    for patch in [
        "0004-Requests-ChunkedEncodingError.patch",
    ]:
        session.run("git", "apply", f"{root}/ci/{patch}", external=True)

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install(".[socks]", silent=False)
    session.install("-r", "requirements-dev.txt", silent=False)

    session.cd(root)
    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/requests")

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")
    session.run(
        "python",
        "-m",
        "pytest",
        "-v",
        f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
        *(session.posargs or ("tests/",)),
    )


@nox.session()
def downstream_boto3(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/boto/boto3")
    session.chdir("boto3")

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install(".", silent=False)
    session.install("-r", "requirements-dev.txt", silent=False)

    session.cd(root)
    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/boto3")

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")
    session.run(
        "python",
        "scripts/ci/run-tests",
    )


@nox.session()
def downstream_clickhouse_connect(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/ClickHouse/clickhouse-connect")
    session.chdir("clickhouse-connect")

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install("-r", "tests/test_requirements.txt", silent=False)
    session.run("python", "setup.py", "build_ext", "--inplace")
    session.run("python", "setup.py", "develop")

    session.cd(root)
    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/clickhouse-connect")

    dc_v2_probe = subprocess.Popen(
        ["docker", "compose", "up", "-d", "clickhouse"],
        env={"CLICKHOUSE_CONNECT_TEST_CH_VERSION": "latest"},
    )

    dc_v2_probe.wait()

    assert dc_v2_probe.returncode == 0

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")

    # the test tests/integration_tests/test_streaming.py::test_stream_failure
    # is faulty due to chunk_size = 1024 * 1024
    # and connection closed without full response
    # the lib expect stream to yield bytes even if not
    # chunk size respected.
    session.run("rm", "-f", "tests/integration_tests/test_streaming.py")
    session.run("rm", "-f", "tests/integration_tests/test_mid_stream_exception.py")

    try:
        session.run(
            "pytest",
            "tests/integration_tests",
        )
    finally:
        dc_v2_probe = subprocess.Popen(
            ["docker", "compose", "stop"],
            env={"CLICKHOUSE_CONNECT_TEST_CH_VERSION": "latest"},
        )

        dc_v2_probe.wait()


@nox.session()
def downstream_sphinx(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/sphinx-doc/sphinx")
    session.chdir("sphinx")

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install("-U", "pip")  # ensure we can use dependency groups
    session.install(".", "--group", "test", silent=False)
    # Pygments 2.21 changed HTML escaping; remove after sphinx-doc/sphinx#14611.
    session.install("Pygments<2.21")

    session.cd(root)
    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/sphinx")

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")
    session.run(
        "python",
        "-m",
        "pytest",
        "-v",
        f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
        *(session.posargs or ("tests/",)),
    )


@nox.session()
def downstream_docker(session: nox.Session) -> None:
    root = os.getcwd()
    tmp_dir = session.create_tmp()

    session.cd(tmp_dir)
    git_clone(session, "https://github.com/docker/docker-py")
    session.chdir("docker-py")

    for patch in [
        "0005-DockerPy-FixBadChunk.patch",
        "0006-DockerPy-xfail-flaky-attach-no-stream.patch",
    ]:
        session.run("git", "apply", f"{root}/ci/{patch}", external=True)

    session.run("git", "rev-parse", "HEAD", external=True)
    session.install(".[ssh,dev]", silent=False)

    session.cd(root)
    session.install(".", silent=False)
    session.cd(f"{tmp_dir}/docker-py")

    session.run("python", "-c", "import urllib3; print(urllib3.__version__)")
    session.run(
        "python",
        "-m",
        "pytest",
        "-v",
        f"--color={'yes' if 'GITHUB_ACTIONS' in os.environ else 'auto'}",
        *(
            session.posargs
            or (
                "tests/unit",
                "tests/integration",
                "--ignore=tests/integration/credentials/store_test.py",
                "--deselect=tests/integration/api_container_test.py::AttachContainerTest::test_run_container_reading_socket_ws",
            )
        ),
    )


@nox.session()
def format(session: nox.Session) -> None:
    """Run code formatters."""
    lint(session)


@nox.session
def lint(session: nox.Session) -> None:
    session.install("pre-commit")
    session.run("pre-commit", "run", "--all-files")

    mypy(session)


@nox.session
def mypy(session: nox.Session) -> None:
    """Run mypy."""
    session.install("-r", "mypy-requirements.txt", "--require-hashes")
    session.run("mypy", "--version")
    session.run(
        "mypy",
        "dummyserver",
        "noxfile.py",
        "src/urllib3",
        "test",
    )


@nox.session
def docs(session: nox.Session) -> None:
    session.install("-r", "docs/requirements.txt")
    session.install(".[socks,brotli,zstd,ws]")

    session.chdir("docs")
    if os.path.exists("_build"):
        shutil.rmtree("_build")
    session.run("sphinx-build", "-b", "html", "-W", ".", "_build/html")
