from __future__ import annotations

__lazy_modules__ = {
    "contextlib",
    f"{__spec__.parent}._compat.builtins",
    f"{__spec__.parent}._logging",
    f"{__spec__.parent}._shutil",
    f"{__spec__.parent}.builder.generator",
    f"{__spec__.parent}.errors",
    f"{__spec__.parent}.file_api.query",
    f"{__spec__.parent}.file_api.reply",
    f"{__spec__.parent}.program_search",
    "json",
    "packaging",
    "packaging.version",
    "shutil",
    "subprocess",
    "sysconfig",
    "textwrap",
    "typing",
}

import contextlib
import dataclasses
import json
import os
import shutil
import subprocess
import sys
import sysconfig
import textwrap
from pathlib import Path
from typing import Any

from packaging.version import Version

from . import __version__
from ._compat.builtins import ExceptionGroup
from ._logging import logger
from ._shutil import Run
from .builder.generator import parse_generator
from .errors import CMakeConfigError, CMakeNotFoundError, FailedLiveProcessError
from .file_api.query import stateless_query
from .file_api.reply import load_reply_dir
from .program_search import Program, best_program, get_cmake_program, get_cmake_programs

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Generator, Iterable, Mapping, Sequence

    from packaging.specifiers import SpecifierSet

    from ._compat.typing import Self
    from .file_api.model.index import Index

__all__ = ["CMake", "CMaker"]


def __dir__() -> list[str]:
    return __all__


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


@dataclasses.dataclass(frozen=True)
class CMake:
    version: Version
    cmake_path: Path

    @classmethod
    def default_search(
        cls,
        *,
        version: SpecifierSet | None = None,
        module: bool = True,
        env: Mapping[str, Any] | None = None,
    ) -> Self:
        env = env or {}
        cmake_executable = env.get("CMAKE_EXECUTABLE", "")
        candidates: Iterable[Program] = (
            [get_cmake_program(Path(cmake_executable))]
            if cmake_executable
            else get_cmake_programs(module=module)
        )
        cmake_program = best_program(candidates, version=version)

        if cmake_program is None:
            msg = f"Could not find CMake with version {version}"
            raise CMakeNotFoundError(msg)
        if cmake_program.version is None:
            msg = f"CMake version undetermined @ {cmake_program.path}"
            raise CMakeNotFoundError(msg)

        return cls(version=cmake_program.version, cmake_path=cmake_program.path)

    def __fspath__(self) -> str:
        return os.fspath(self.cmake_path)


@dataclasses.dataclass
class CMaker:
    cmake: CMake
    source_dir: Path
    build_dir: Path
    build_type: str
    module_dirs: list[Path] = dataclasses.field(default_factory=list)
    prefix_dirs: list[Path] = dataclasses.field(default_factory=list)
    prefix_roots: dict[str, list[Path]] = dataclasses.field(default_factory=dict)
    init_cache_file: Path = dataclasses.field(init=False, default=Path())
    env: dict[str, str] = dataclasses.field(init=False, default_factory=os.environ.copy)
    single_config: bool = not sysconfig.get_platform().startswith("win")
    file_api: Index | None = None
    _file_api_query: Path = dataclasses.field(init=False)

    def __post_init__(self) -> None:
        self.init_cache_file = self.build_dir / "CMakeInit.txt"
        source_dir = self.source_dir.resolve()

        if not self.source_dir.is_dir():
            msg = f"source directory {self.source_dir} does not exist"
            raise CMakeConfigError(msg)

        self.build_dir.mkdir(parents=True, exist_ok=True)
        if not self.build_dir.is_dir():
            msg = f"build directory {self.build_dir} must be a (creatable) directory"
            raise CMakeConfigError(msg)

        # TODO: This could be stateful instead
        self._file_api_query = stateless_query(self.build_dir)
        skbuild_info = self.build_dir / ".skbuild-info.json"
        stale = False

        info: dict[str, str] = {}
        with contextlib.suppress(FileNotFoundError), skbuild_info.open(
            "r", encoding="utf-8"
        ) as f:
            info = json.load(f)

        if info:
            # If building via SDist, this could be pre-filled
            cached_source_dir = Path(info["source_dir"])
            if cached_source_dir != source_dir:
                logger.warning(
                    "Original src {} != {}, clearing cache",
                    cached_source_dir,
                    source_dir,
                )
                stale = True

            # Isolated environments can cause this
            cached_skbuild_dir = Path(info["skbuild_path"])
            if cached_skbuild_dir != DIR:
                logger.info(
                    "New isolated environment {} -> {}, clearing cache",
                    cached_skbuild_dir,
                    DIR,
                )
                stale = True

        # Not using --fresh here, not just due to CMake 3.24+, but also just in
        # case it triggers an extra FetchContent pull in CMake 3.30+
        if stale:
            # Python 3.8+ can use missing_ok=True
            with contextlib.suppress(FileNotFoundError):
                self.build_dir.joinpath("CMakeCache.txt").unlink()
            shutil.rmtree(self.build_dir.joinpath("CMakeFiles"), ignore_errors=True)

        with skbuild_info.open("w", encoding="utf-8") as f:
            json.dump(self._info_dict(), f, indent=2)

    def _info_dict(self) -> dict[str, str]:
        """
        Produce an information dict about the current run that can be stored in a json file.
        """
        return {
            "source_dir": os.fspath(self.source_dir.resolve()),
            "build_dir": os.fspath(self.build_dir.resolve()),
            "cmake_path": os.fspath(self.cmake),
            "skbuild_path": os.fspath(DIR),
            "skbuild_version": __version__,
            "python_executable": sys.executable,
        }

    def init_cache(
        self, cache_settings: Mapping[str, str | os.PathLike[str] | bool]
    ) -> None:
        with self.init_cache_file.open("w", encoding="utf-8") as f:
            for key, value in cache_settings.items():
                if isinstance(value, bool):
                    str_value = "ON" if value else "OFF"
                    f.write(f'set({key} {str_value} CACHE BOOL "" FORCE)\n')
                elif isinstance(value, os.PathLike):
                    # Convert to CMake's internal path format
                    str_value = str(value).replace("\\", "/")
                    f.write(f'set({key} [===[{str_value}]===] CACHE PATH "" FORCE)\n')
                else:
                    f.write(f'set({key} [===[{value}]===] CACHE STRING "" FORCE)\n')

            if self.module_dirs:
                # Convert to CMake's internal path format, otherwise this breaks try_compile on Windows
                module_dirs_str = ";".join(map(str, self.module_dirs)).replace(
                    "\\", "/"
                )
                f.write(
                    f'set(CMAKE_MODULE_PATH [===[{module_dirs_str}]===] CACHE PATH "" FORCE)\n'
                )

            if self.prefix_dirs:
                prefix_dirs_str = ";".join(map(str, self.prefix_dirs)).replace(
                    "\\", "/"
                )
                f.write(
                    f'set(CMAKE_PREFIX_PATH [===[{prefix_dirs_str}]===] CACHE PATH "" FORCE)\n'
                )
                f.write('set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE "BOTH" CACHE PATH "")\n')

            if self.prefix_roots:
                for pkg, path_list in self.prefix_roots.items():
                    paths_str = ";".join(map(str, path_list)).replace("\\", "/")
                    f.write(
                        f'set({pkg}_ROOT [===[{paths_str}]===] CACHE PATH "" FORCE)\n'
                    )
                    # Available since CMake 3.27 with CMP0144
                    if pkg != pkg.upper():
                        f.write(
                            f'set({pkg.upper()}_ROOT [===[{paths_str}]===] CACHE PATH "" FORCE)\n'
                        )

        contents = self.init_cache_file.read_text(encoding="utf-8").strip()
        logger.debug(
            "{}:\n{}",
            self.init_cache_file,
            textwrap.indent(contents.strip(), "  "),
        )

    def _compute_cmake_args(
        self,
        defines: Mapping[str, str | os.PathLike[str] | bool],
        toolchain: os.PathLike[str] | None,
    ) -> Generator[str, None, None]:
        yield f"-S{self.source_dir}"
        yield f"-B{self.build_dir}"

        if toolchain is not None:
            toolchain_str = str(toolchain).replace("\\", "/")
            yield (
                f"--toolchain={toolchain_str}"
                if self.cmake.version >= Version("3.21")
                else f"-DCMAKE_TOOLCHAIN_FILE:PATH={toolchain_str}"
            )

        if self.init_cache_file.is_file():
            yield f"-C{self.init_cache_file}"

        for key, value in defines.items():
            if isinstance(value, bool):
                str_value = "ON" if value else "OFF"
                yield f"-D{key}:BOOL={str_value}"
            elif isinstance(value, os.PathLike):
                str_value = str(value).replace("\\", "/")
                yield f"-D{key}:PATH={str_value}"
            else:
                yield f"-D{key}={value}"

    def get_generator(
        self,
        *args: str,
        defines: Mapping[str, str | os.PathLike[str] | bool] | None = None,
    ) -> str | None:
        """
        Try to get the generator that will be used to build the project. If it's
        not set, return None (default generator will be used).
        """
        generator = parse_generator(args)
        if generator:
            return generator
        if defines and "CMAKE_GENERATOR" in defines:
            gen_value = defines["CMAKE_GENERATOR"]
            assert isinstance(gen_value, str)
            return gen_value
        return self.env.get("CMAKE_GENERATOR", None)

    def configure(
        self,
        *,
        defines: Mapping[str, str | os.PathLike[str] | bool] | None = None,
        cmake_args: Sequence[str] = (),
        toolchain: os.PathLike[str] | None = None,
    ) -> None:
        _cmake_args = self._compute_cmake_args(defines or {}, toolchain)
        all_args = [*_cmake_args, *cmake_args]

        gen = self.get_generator(*all_args, defines=defines or {})
        if gen:
            self.single_config = gen == "Ninja" or "Makefiles" in gen

        if self.single_config and self.build_type:
            all_args.insert(2, f"-DCMAKE_BUILD_TYPE:STRING={self.build_type}")

        try:
            Run(env=self.env).live(self.cmake, *all_args)
        except subprocess.CalledProcessError:
            msg = "CMake configuration failed"
            raise FailedLiveProcessError(msg) from None

        try:
            if self._file_api_query.exists():
                self.file_api = load_reply_dir(self._file_api_query)
        except ExceptionGroup as exc:
            logger.debug("Could not parse CMake file-api")
            logger.debug(str(exc))

    def _compute_build_args(
        self,
        *,
        verbose: bool,
        build_type: str | None = None,
    ) -> Generator[str, None, None]:
        build_type = self.build_type if build_type is None else build_type
        if verbose:
            yield "-v"
        if build_type and not self.single_config:
            yield "--config"
            yield build_type

    def build(
        self,
        build_args: Sequence[str] = (),
        *,
        targets: Sequence[str] = (),
        verbose: bool = False,
        build_type: str | None = None,
    ) -> None:
        local_args = list(
            self._compute_build_args(verbose=verbose, build_type=build_type)
        )
        if not targets:
            self._build(*local_args, *build_args)
            return

        for target in targets:
            self._build(*local_args, "--target", target, *build_args)

    def _build(self, *args: str) -> None:
        try:
            Run(env=self.env).live(self.cmake, "--build", self.build_dir, *args)
        except subprocess.CalledProcessError:
            msg = "CMake build failed"
            raise FailedLiveProcessError(msg) from None

    def install(
        self,
        prefix: Path | None,
        *,
        strip: bool = False,
        components: Sequence[str] = (),
        targets: Sequence[str] = (),
        build_type: str | None = None,
    ) -> None:
        build_type = self.build_type if build_type is None else build_type
        opts = ["--prefix", str(prefix)] if prefix else []
        if not self.single_config and build_type:
            opts += ["--config", build_type]
        if strip:
            opts.append("--strip")

        # These are "built", so --prefix/--strip/--component do not apply.
        for target in targets:
            logger.info("Installing target {}", target)
            build_args = list(
                self._compute_build_args(verbose=False, build_type=build_type)
            )
            self._build(*build_args, "--target", target)

        if not components:
            # With no components and no targets, run the default install.
            if not targets:
                self._install(opts)
            return

        for comp in components:
            opts_with_comp = [*opts, "--component", comp]
            logger.info("Installing component {}", comp)
            self._install(opts_with_comp)

    def _install(self, opts: Sequence[str]) -> None:
        try:
            Run(env=self.env).live(
                self.cmake,
                "--install",
                self.build_dir,
                *opts,
            )
        except subprocess.CalledProcessError:
            msg = "CMake install failed"
            raise FailedLiveProcessError(msg) from None
