from __future__ import annotations

import os
import shutil
import sysconfig
from pathlib import Path
from textwrap import dedent

import pytest
from packaging.specifiers import SpecifierSet
from packaging.version import Version

from scikit_build_core.builder.builder import Builder
from scikit_build_core.cmake import CMake, CMaker
from scikit_build_core.errors import CMakeNotFoundError
from scikit_build_core.settings.skbuild_read_settings import SettingsReader

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Generator

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


def single_config(param: None | str) -> bool:
    if param is None:
        return not sysconfig.get_platform().startswith("win")

    return param in {"Ninja", "Makefiles"}


@pytest.fixture(
    params=[
        pytest.param(None, id="default"),
        pytest.param("Ninja", id="ninja"),
        pytest.param(
            "Makefiles",
            id="makefiles",
            marks=pytest.mark.skipif(
                sysconfig.get_platform().startswith("win"), reason="run on Windows only"
            ),
        ),
        pytest.param(
            "Others",
            id="others",
            marks=pytest.mark.skipif(
                sysconfig.get_platform().startswith("win"), reason="run on Windows only"
            ),
        ),
    ]
)
def generator(
    request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
) -> str | None:
    if request.param is None:
        monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
    else:
        monkeypatch.setenv("CMAKE_GENERATOR", request.param)

    return request.param  # type: ignore[no-any-return]


def configure_args(
    config: CMaker, *, init: bool = False, single_config: bool = False
) -> Generator[str, None, None]:
    yield f"-S{config.source_dir}"
    yield f"-B{config.build_dir}"

    if single_config:
        yield f"-DCMAKE_BUILD_TYPE:STRING={config.build_type}"

    if init:
        cmake_init = config.build_dir / "CMakeInit.txt"
        yield f"-C{cmake_init}"


@pytest.mark.configure
def test_init_cache(
    generator: str,
    tmp_path: Path,
    fp,
):
    fp.register(
        [fp.program("cmake"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.14.0"}}',
    )
    fp.register(
        [fp.program("cmake3"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.14.0"}}',
    )

    config = CMaker(
        CMake.default_search(),
        source_dir=DIR / "packages/simple_pure",
        build_dir=tmp_path / "build",
        build_type="Release",
    )
    config.init_cache(
        {"SKBUILD": True, "SKBUILD_VERSION": "1.0.0", "SKBUILD_PATH": config.source_dir}
    )

    cmd = list(
        configure_args(config, init=True, single_config=single_config(generator))
    )
    print("Registering: cmake", *cmd)
    fp.register([fp.program("cmake"), *cmd])
    fp.register([fp.program("cmake3"), *cmd])
    config.configure()

    cmake_init = config.build_dir / "CMakeInit.txt"
    source_dir_str = str(config.source_dir).replace("\\", "/")
    assert (
        cmake_init.read_text(encoding="utf-8")
        == f"""\
set(SKBUILD ON CACHE BOOL "" FORCE)
set(SKBUILD_VERSION [===[1.0.0]===] CACHE STRING "" FORCE)
set(SKBUILD_PATH [===[{source_dir_str}]===] CACHE PATH "" FORCE)
"""
    )


@pytest.mark.configure
def test_too_old(fp, monkeypatch):
    monkeypatch.setattr(shutil, "which", lambda _: None)
    fp.register(
        [fp.program("cmake"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.14.0"}}',
    )
    fp.register(
        [fp.program("cmake3"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.14.0"}}',
    )

    with pytest.raises(CMakeNotFoundError) as excinfo:
        CMake.default_search(version=SpecifierSet(">=3.15"))
    assert "Could not find CMake with version >=3.15" in excinfo.value.args[0]


@pytest.mark.configure
def test_cmake_args(
    generator: str,
    tmp_path: Path,
    fp,
):
    fp.register(
        [fp.program("cmake"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )
    fp.register(
        [fp.program("cmake3"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )

    config = CMaker(
        CMake.default_search(),
        source_dir=DIR / "packages" / "simple_pure",
        build_dir=tmp_path / "build",
        build_type="Release",
    )

    cmd = list(configure_args(config, single_config=single_config(generator)))
    cmd.append("-DSOMETHING=one")
    print("Registering: cmake", *cmd)
    fp.register([fp.program("cmake"), *cmd])
    fp.register([fp.program("cmake3"), *cmd])

    config.configure(cmake_args=["-DSOMETHING=one"])
    # config.configure might mutate config.single_config
    assert config.single_config == single_config(generator)
    assert len(fp.calls) == 2


@pytest.mark.configure
def test_cmake_multi_config_generator_via_define(
    tmp_path: Path,
    fp,
    monkeypatch: pytest.MonkeyPatch,
):
    # A multi-config generator set via ``cmake.define.CMAKE_GENERATOR`` must be
    # detected so that single_config is False (no -DCMAKE_BUILD_TYPE, --config
    # is used for build/install).
    monkeypatch.delenv("CMAKE_GENERATOR", raising=False)
    fp.register(
        [fp.program("cmake"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )
    fp.register(
        [fp.program("cmake3"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )

    config = CMaker(
        CMake.default_search(),
        source_dir=DIR / "packages" / "simple_pure",
        build_dir=tmp_path / "build",
        build_type="Release",
    )

    # Register any configure call; we only care about the resulting state.
    fp.register([fp.program("cmake"), fp.any()])
    fp.register([fp.program("cmake3"), fp.any()])

    config.configure(defines={"CMAKE_GENERATOR": "Ninja Multi-Config"})

    assert config.single_config is False
    configure_call = next(c for c in fp.calls if "-E" not in c)
    assert not any("CMAKE_BUILD_TYPE" in arg for arg in configure_call)


@pytest.mark.configure
def test_cmake_paths(
    generator: str,
    tmp_path: Path,
    fp,
):
    fp.register(
        [fp.program("cmake"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )
    fp.register(
        [fp.program("cmake3"), "-E", "capabilities"],
        stdout='{"version":{"string":"3.15.0"}}',
    )

    config = CMaker(
        CMake.default_search(),
        source_dir=DIR / "packages/simple_pure",
        build_dir=tmp_path / "build",
        build_type="Release",
        prefix_dirs=[tmp_path / "prefix"],
        module_dirs=[tmp_path / "module"],
    )

    cmd = list(configure_args(config, single_config=single_config(generator)))
    print("Registering: cmake", *cmd)
    fp.register([fp.program("cmake"), *cmd])
    fp.register([fp.program("cmake3"), *cmd])

    config.configure()

    assert len(fp.calls) == 2


@pytest.mark.configure
def test_cmake_defines(
    tmp_path: Path,
):
    source_dir = DIR / "packages" / "cmake_defines"
    binary_dir = tmp_path / "build"

    config = CMaker(
        CMake.default_search(),
        source_dir=source_dir,
        build_dir=binary_dir,
        build_type="Release",
    )

    reader = SettingsReader.from_file(source_dir / "pyproject.toml")

    builder = Builder(reader.settings, config)
    builder.configure(defines={})

    configure_log = Path.read_text(binary_dir / "log.txt")
    assert configure_log == dedent(
        """\
        ONE_LEVEL_LIST.LENGTH = 4
        Foo
        Bar
        ExceptionallyLargeListEntryThatWouldOverflowTheLine
        Baz
        NESTED_LIST.LENGTH = 3
        Apple
        Lemon;Lime
        Banana
        """
    )


def test_install_targets(tmp_path: Path, fp):
    source_dir = tmp_path / "src"
    source_dir.mkdir()
    build_dir = tmp_path / "build"

    config = CMaker(
        CMake(Version("3.30"), Path("cmake")),
        source_dir=source_dir,
        build_dir=build_dir,
        build_type="Release",
        single_config=True,
    )

    fp.register([fp.program("cmake"), fp.any()], occurrences=10)

    config.install(tmp_path / "prefix", strip=False, targets=["install-distribution"])

    build_calls = [c for c in fp.calls if "--build" in c]
    assert len(build_calls) == 1
    call = [os.fspath(arg) for arg in build_calls[0]]
    assert "--target" in call
    assert call[call.index("--target") + 1] == "install-distribution"
    assert os.fspath(build_dir) in call
    # Target installs are emitted via `cmake --build`, not `cmake --install`.
    assert not any("--install" in c for c in fp.calls)


def test_install_targets_and_components(tmp_path: Path, fp):
    source_dir = tmp_path / "src"
    source_dir.mkdir()
    build_dir = tmp_path / "build"

    config = CMaker(
        CMake(Version("3.30"), Path("cmake")),
        source_dir=source_dir,
        build_dir=build_dir,
        build_type="Release",
        single_config=True,
    )

    fp.register([fp.program("cmake"), fp.any()], occurrences=10)

    config.install(
        tmp_path / "prefix",
        strip=False,
        components=["runtime"],
        targets=["install-distribution"],
    )

    assert any("--install" in c and "--component" in c for c in fp.calls)
    assert any("--build" in c and "--target" in c for c in fp.calls)


def test_get_cmake_via_envvar(monkeypatch: pytest.MonkeyPatch, fp):
    monkeypatch.setattr("shutil.which", lambda x: x)
    cmake_path = Path("some-prog")
    fp.register(
        [cmake_path, "-E", "capabilities"], stdout='{"version":{"string":"3.20.0"}}'
    )
    monkeypatch.setenv("CMAKE_EXECUTABLE", str(cmake_path))
    result = CMake.default_search(env=os.environ)
    assert result.cmake_path == cmake_path
    assert result.version == Version("3.20.0")
