from __future__ import annotations

import contextlib
import importlib
import os
import re
import shutil
import subprocess
import sys
import sysconfig
import tarfile
import zipfile
from collections.abc import Generator
from pathlib import Path

import pytest

# These tests must be run explicitly

DIR = Path(__file__).parent.resolve()
MAIN_DIR = DIR.parent.parent

FILENAME_VERSION = re.compile(r"[-_]((\d+\.\d+\.\d+)(?:[a-z]+\d*)?)(?:-|\.tar\.gz$)")

# Newer pytest has global path setting, but keeping old pytest for now
sys.path.append(str(MAIN_DIR / "tools"))

from make_global import get_global

HAS_UV = shutil.which("uv") is not None
UV_ARGS = ["--installer=uv"] if HAS_UV else []

PKGCONFIG = """\
prefix=${{pcfiledir}}/../../
includedir=${{prefix}}/include

Name: pybind11
Description: Seamless operability between C++11 and Python
Version: {VERSION}
Cflags: -I${{includedir}}
"""


main_headers = {
    "include/pybind11/attr.h",
    "include/pybind11/buffer_info.h",
    "include/pybind11/cast.h",
    "include/pybind11/chrono.h",
    "include/pybind11/common.h",
    "include/pybind11/complex.h",
    "include/pybind11/critical_section.h",
    "include/pybind11/eigen.h",
    "include/pybind11/embed.h",
    "include/pybind11/eval.h",
    "include/pybind11/functional.h",
    "include/pybind11/gil.h",
    "include/pybind11/gil_safe_call_once.h",
    "include/pybind11/gil_simple.h",
    "include/pybind11/iostream.h",
    "include/pybind11/native_enum.h",
    "include/pybind11/numpy.h",
    "include/pybind11/operators.h",
    "include/pybind11/options.h",
    "include/pybind11/pybind11.h",
    "include/pybind11/pytypes.h",
    "include/pybind11/subinterpreter.h",
    "include/pybind11/stl.h",
    "include/pybind11/stl_bind.h",
    "include/pybind11/trampoline_self_life_support.h",
    "include/pybind11/type_caster_pyobject_ptr.h",
    "include/pybind11/typing.h",
    "include/pybind11/warnings.h",
}

conduit_headers = {
    "include/pybind11/conduit/README.txt",
    "include/pybind11/conduit/pybind11_conduit_v1.h",
    "include/pybind11/conduit/pybind11_platform_abi_id.h",
    "include/pybind11/conduit/wrap_include_python_h.h",
}

detail_headers = {
    "include/pybind11/detail/argument_vector.h",
    "include/pybind11/detail/class.h",
    "include/pybind11/detail/common.h",
    "include/pybind11/detail/cpp_conduit.h",
    "include/pybind11/detail/descr.h",
    "include/pybind11/detail/dynamic_raw_ptr_cast_if_possible.h",
    "include/pybind11/detail/function_record_pyobject.h",
    "include/pybind11/detail/function_ref.h",
    "include/pybind11/detail/holder_caster_foreign_helpers.h",
    "include/pybind11/detail/init.h",
    "include/pybind11/detail/internals.h",
    "include/pybind11/detail/native_enum_data.h",
    "include/pybind11/detail/pybind11_namespace_macros.h",
    "include/pybind11/detail/struct_smart_holder.h",
    "include/pybind11/detail/type_caster_base.h",
    "include/pybind11/detail/typeid.h",
    "include/pybind11/detail/using_smart_holder.h",
    "include/pybind11/detail/value_and_holder.h",
    "include/pybind11/detail/exception_translation.h",
}

eigen_headers = {
    "include/pybind11/eigen/common.h",
    "include/pybind11/eigen/matrix.h",
    "include/pybind11/eigen/tensor.h",
}

stl_headers = {
    "include/pybind11/stl/filesystem.h",
}

cmake_files = {
    "share/cmake/pybind11/FindPythonLibsNew.cmake",
    "share/cmake/pybind11/pybind11Common.cmake",
    "share/cmake/pybind11/pybind11Config.cmake",
    "share/cmake/pybind11/pybind11ConfigVersion.cmake",
    "share/cmake/pybind11/pybind11GuessPythonExtSuffix.cmake",
    "share/cmake/pybind11/pybind11NewTools.cmake",
    "share/cmake/pybind11/pybind11Targets.cmake",
    "share/cmake/pybind11/pybind11Tools.cmake",
}

pkgconfig_files = {
    "share/pkgconfig/pybind11.pc",
}

py_files = {
    "__init__.py",
    "__main__.py",
    "_version.py",
    "commands.py",
    "py.typed",
    "setup_helpers.py",
    "share/__init__.py",
    "share/pkgconfig/__init__.py",
}

headers = main_headers | conduit_headers | detail_headers | eigen_headers | stl_headers
generated_files = cmake_files | pkgconfig_files
all_files = headers | generated_files | py_files

sdist_files = {
    "pyproject.toml",
    "LICENSE",
    "README.rst",
    "PKG-INFO",
    "SECURITY.md",
}


@contextlib.contextmanager
def preserve_file(filename: Path) -> Generator[str, None, None]:
    old_stat = filename.stat()
    old_file = filename.read_text(encoding="utf-8")
    try:
        yield old_file
    finally:
        filename.write_text(old_file, encoding="utf-8")
        os.utime(filename, (old_stat.st_atime, old_stat.st_mtime))


@contextlib.contextmanager
def build_global() -> Generator[None, None, None]:
    """
    Build global SDist and wheel.
    """

    pyproject = MAIN_DIR / "pyproject.toml"
    with preserve_file(pyproject):
        newer_txt = get_global()
        pyproject.write_text(newer_txt, encoding="utf-8")
        yield


def read_tz_file(tar: tarfile.TarFile, name: str) -> bytes:
    start = tar.getnames()[0].split("/")[0] + "/"
    inner_file = tar.extractfile(tar.getmember(f"{start}{name}"))
    assert inner_file
    with contextlib.closing(inner_file) as f:
        return f.read()


def normalize_line_endings(value: bytes) -> bytes:
    return value.replace(os.linesep.encode("utf-8"), b"\n")


def test_build_sdist(monkeypatch, tmpdir):
    monkeypatch.chdir(MAIN_DIR)

    subprocess.run(
        [sys.executable, "-m", "build", "--sdist", f"--outdir={tmpdir}", *UV_ARGS],
        check=True,
    )

    (sdist,) = tmpdir.visit("*.tar.gz")
    version = FILENAME_VERSION.search(sdist.basename).group(1)

    with tarfile.open(str(sdist), "r:gz") as tar:
        simpler = {n.split("/", 1)[-1] for n in tar.getnames()[1:]}
        (pkg_info_path,) = (n for n in simpler if n.endswith("PKG-INFO"))

        pyproject_toml = read_tz_file(tar, "pyproject.toml")
        pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")

    files = headers | sdist_files
    assert files <= simpler

    assert b'name = "pybind11"' in pyproject_toml
    assert f"Version: {version}" in pkg_info
    assert "License-Expression: BSD-3-Clause" in pkg_info
    assert "License-File: LICENSE" in pkg_info
    assert "Provides-Extra: global" in pkg_info
    assert f'Requires-Dist: pybind11-global=={version}; extra == "global"' in pkg_info


def test_build_global_dist(monkeypatch, tmpdir):
    monkeypatch.chdir(MAIN_DIR)
    with build_global():
        subprocess.run(
            [
                sys.executable,
                "-m",
                "build",
                "--sdist",
                "--outdir",
                str(tmpdir),
                *UV_ARGS,
            ],
            check=True,
        )

    (sdist,) = tmpdir.visit("*.tar.gz")
    version = FILENAME_VERSION.search(sdist.basename).group(2)

    with tarfile.open(str(sdist), "r:gz") as tar:
        simpler = {n.split("/", 1)[-1] for n in tar.getnames()[1:]}
        (pkg_info_path,) = (n for n in simpler if n.endswith("PKG-INFO"))

        pyproject_toml = read_tz_file(tar, "pyproject.toml")
        pkg_info = read_tz_file(tar, pkg_info_path).decode("utf-8")

    files = headers | sdist_files
    assert files <= simpler

    assert b'name = "pybind11-global"' in pyproject_toml
    assert f"Version: {version}" in pkg_info
    assert "License-Expression: BSD-3-Clause" in pkg_info
    assert "License-File: LICENSE" in pkg_info
    assert "Provides-Extra: global" not in pkg_info
    assert 'Requires-Dist: pybind11-global; extra == "global"' not in pkg_info


def tests_build_wheel(monkeypatch, tmpdir):
    monkeypatch.chdir(MAIN_DIR)

    subprocess.run(
        [sys.executable, "-m", "build", "--wheel", "--outdir", str(tmpdir), *UV_ARGS],
        check=True,
    )

    (wheel,) = tmpdir.visit("*.whl")
    version, simple_version = FILENAME_VERSION.search(wheel.basename).groups()

    files = {f"pybind11/{n}" for n in all_files}
    files |= {
        "dist-info/licenses/LICENSE",
        "dist-info/METADATA",
        "dist-info/RECORD",
        "dist-info/WHEEL",
        "dist-info/entry_points.txt",
    }

    with zipfile.ZipFile(str(wheel)) as z:
        names = z.namelist()
        share = zipfile.Path(z, "pybind11/share")
        pkgconfig = (share / "pkgconfig/pybind11.pc").read_text(encoding="utf-8")
        cmakeconfig = (share / "cmake/pybind11/pybind11Config.cmake").read_text(
            encoding="utf-8"
        )
        (pkg_info_path,) = (n for n in names if n.endswith("METADATA"))
        pkg_info = zipfile.Path(z, pkg_info_path).read_text(encoding="utf-8")

    trimmed = {n for n in names if "dist-info" not in n}
    trimmed |= {f"dist-info/{n.split('/', 1)[-1]}" for n in names if "dist-info" in n}

    assert files == trimmed

    assert 'set(pybind11_INCLUDE_DIR "${PACKAGE_PREFIX_DIR}/include")' in cmakeconfig

    pkgconfig_expected = PKGCONFIG.format(VERSION=simple_version)
    assert pkgconfig_expected == pkgconfig

    assert f"Version: {version}" in pkg_info
    assert "License-Expression: BSD-3-Clause" in pkg_info
    assert "License-File: LICENSE" in pkg_info
    assert "Provides-Extra: global" in pkg_info
    assert f'Requires-Dist: pybind11-global=={version}; extra == "global"' in pkg_info


def tests_build_global_wheel(monkeypatch, tmpdir):
    monkeypatch.chdir(MAIN_DIR)
    with build_global():
        subprocess.run(
            [
                sys.executable,
                "-m",
                "build",
                "--wheel",
                "--outdir",
                str(tmpdir),
                *UV_ARGS,
            ],
            check=True,
        )

    (wheel,) = tmpdir.visit("*.whl")
    version, simple_version = FILENAME_VERSION.search(wheel.basename).groups()

    files = {f"data/data/{n}" for n in headers}
    files |= {f"data/headers/{n[8:]}" for n in headers}
    files |= {f"data/data/{n}" for n in generated_files}
    files |= {
        "dist-info/licenses/LICENSE",
        "dist-info/METADATA",
        "dist-info/WHEEL",
        "dist-info/RECORD",
    }

    with zipfile.ZipFile(str(wheel)) as z:
        names = z.namelist()
        beginning = names[0].split("/", 1)[0].rsplit(".", 1)[0]

        share = zipfile.Path(z, f"{beginning}.data/data/share")
        pkgconfig = (share / "pkgconfig/pybind11.pc").read_text(encoding="utf-8")
        cmakeconfig = (share / "cmake/pybind11/pybind11Config.cmake").read_text(
            encoding="utf-8"
        )

        (pkg_info_path,) = (n for n in names if n.endswith("METADATA"))
        pkg_info = zipfile.Path(z, pkg_info_path).read_text(encoding="utf-8")

    assert f"Version: {version}" in pkg_info
    assert "License-Expression: BSD-3-Clause" in pkg_info
    assert "License-File: LICENSE" in pkg_info
    assert "Provides-Extra: global" not in pkg_info
    assert 'Requires-Dist: pybind11-global; extra == "global"' not in pkg_info

    trimmed = {n[len(beginning) + 1 :] for n in names}

    assert files == trimmed

    assert 'set(pybind11_INCLUDE_DIR "${PACKAGE_PREFIX_DIR}/include")' in cmakeconfig

    pkgconfig_expected = PKGCONFIG.format(VERSION=simple_version)
    assert pkgconfig_expected == pkgconfig


def test_version_matches():
    header = MAIN_DIR / "include/pybind11/detail/common.h"
    text = header.read_text()

    # Extract the relevant macro values
    regex_prefix = r"#\s*define\s+PYBIND11_VERSION_"
    micro = re.search(rf"{regex_prefix}MICRO\s+(\d+)\b", text).group(1)
    release_level = re.search(rf"{regex_prefix}RELEASE_LEVEL\s+(\w+)\b", text).group(1)
    release_serial = re.search(
        rf"{regex_prefix}RELEASE_SERIAL\s+(\d+)\b",
        text,
    ).group(1)
    patch = re.search(rf"{regex_prefix}PATCH\s+([\w.-]+)\b", text).group(1)

    # Map release level macro to string
    level_map = {
        "PY_RELEASE_LEVEL_ALPHA": "a",
        "PY_RELEASE_LEVEL_BETA": "b",
        "PY_RELEASE_LEVEL_GAMMA": "rc",
        "PY_RELEASE_LEVEL_FINAL": "",
    }
    level_str = level_map[release_level]

    if release_level == "PY_RELEASE_LEVEL_FINAL":
        assert level_str == ""
        assert release_serial == "0"
        expected_patch = micro
    else:
        expected_patch = f"{micro}{level_str}{release_serial}"

    assert patch == expected_patch


def run_command_line(*args: str) -> str:
    env = os.environ.copy()
    env["PYTHONPATH"] = str(MAIN_DIR)
    result = subprocess.run(
        [sys.executable, "-m", "pybind11", *args],
        capture_output=True,
        text=True,
        check=True,
        env=env,
    )
    return result.stdout


def test_cli_cflags():
    out = run_command_line("--cflags")
    assert "-std=c++17" in out
    assert f"-I{sysconfig.get_path('include')}" in out


def test_cli_ldflags_embed():
    out = run_command_line("--ldflags", "--embed")
    assert "-lpython" in out
    if sysconfig.get_config_var("LIBDIR"):
        assert "-L" in out


@pytest.mark.skipif(os.name != "posix", reason="quote style is platform-specific")
def test_cflags_quotes_paths_with_spaces(monkeypatch):
    monkeypatch.syspath_prepend(str(MAIN_DIR))
    commands = importlib.import_module("pybind11.commands")
    monkeypatch.setattr(sysconfig, "get_path", lambda name: f"/spa ced/{name}")
    assert "'-I/spa ced/include'" in commands.get_cflags()


@pytest.mark.skipif(os.name != "posix", reason="Unix link flags only")
def test_ldflags_other_unix(monkeypatch):
    monkeypatch.syspath_prepend(str(MAIN_DIR))
    commands = importlib.import_module("pybind11.commands")
    monkeypatch.setattr(sys, "platform", "freebsd14")
    monkeypatch.setattr(commands, "_config", lambda name: "")  # noqa: ARG005
    out = commands.get_ldflags()
    assert "-shared" in out
    assert "-fPIC" in out


def test_cli_file():
    out = run_command_line("--file", "example.cpp").rstrip()
    ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
    assert "-std=c++17" in out
    assert out.index("-std=c++17") < out.index("example.cpp")
    assert out.endswith(f"-o example{ext_suffix}")
    if sys.platform.startswith(("linux", "darwin")):
        assert out.index("example.cpp") < out.index("-shared")


def test_cli_file_embed():
    out = run_command_line("--file", "example.cpp", "--embed").rstrip()
    assert out.endswith("-o example")
