"""Custom build_py command that writes version files to the build directory.

This module provides a custom build_py command that writes version files
to the build directory (self.build_lib) instead of the source tree.
This supports read-only source installations (e.g., Bazel builds).
"""

from __future__ import annotations

import logging

from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from setuptools.command.build_py import build_py as _build_py

if TYPE_CHECKING:
    from setuptools import Distribution
    from vcs_versioning import Configuration
    from vcs_versioning import ScmVersion
    from vcs_versioning._backends._scm_workdir import ScmWorkdir
    from vcs_versioning._fallback_workdir import FallbackWorkdir

log = logging.getLogger(__name__)


def _sanitize_relative_path(relative_path: str) -> Path:
    """Validate and return a safe relative path for writing under ``build_lib``.

    Defense-in-depth: these values come from the project's own
    ``pyproject.toml`` / ``setup.py`` (not a trust boundary), but we
    reject obvious mistakes that could escape the build directory.

    Raises:
        ValueError: If the path is absolute or contains ``..`` segments.
    """
    p = Path(relative_path)
    if p.is_absolute():
        raise ValueError(
            f"Version file path must be relative, got absolute: {relative_path}"
        )
    if ".." in p.parts:
        raise ValueError(
            f"Version file path must not contain '..' traversal: {relative_path}"
        )
    return p


def _is_inside_package(relative_path: str, packages: list[str] | None) -> bool:
    """Check if a version file path is inside one of the distribution's packages.

    Files at the root level (e.g., ``VERSION``) or outside any declared
    package (e.g., ``version.h``) should NOT be written to ``build_lib``
    because that would include them in the wheel.
    """
    if not packages:
        return False

    path = _sanitize_relative_path(relative_path)
    if len(path.parts) < 2:
        return False

    for pkg in packages:
        pkg_path = Path(pkg.replace(".", "/"))
        try:
            path.relative_to(pkg_path)
            return True
        except ValueError:
            continue

    return False


def _transform_version_file_path(
    version_file: str, package_dir: dict[str, str] | None
) -> str:
    """Transform version_file path based on package_dir mapping.

    For src/ layouts, strips the source directory prefix so the path
    is relative to the package root in the build directory.

    Examples:
        version_file='src/mypackage/_version.py' + package_dir={'': 'src'}
        -> 'mypackage/_version.py'

        version_file='mypackage/_version.py' + package_dir=None
        -> 'mypackage/_version.py' (unchanged)

    Args:
        version_file: The configured version file path (relative to project root)
        package_dir: The package_dir mapping from setuptools configuration

    Returns:
        The transformed path suitable for the build directory
    """
    if not package_dir:
        return version_file

    version_path = Path(version_file)

    # Check the root package_dir mapping (empty string key)
    # This handles the common case: package_dir = {"": "src"}
    root_dir = package_dir.get("", "")
    if root_dir:
        root_path = Path(root_dir)
        try:
            relative = version_path.relative_to(root_path)
            log.debug(
                "Transformed version file path: %s -> %s (stripped %s)",
                version_file,
                relative,
                root_dir,
            )
            return str(relative)
        except ValueError:
            pass  # Not relative to root_dir

    # Check other package mappings (e.g., {"mypackage": "lib"})
    for pkg_name, pkg_dir in package_dir.items():
        if pkg_name == "":
            continue
        pkg_path = Path(pkg_dir)
        try:
            relative = version_path.relative_to(pkg_path)
            # Replace pkg_dir prefix with pkg_name
            result = str(Path(pkg_name.replace(".", "/")) / relative)
            log.debug(
                "Transformed version file path: %s -> %s (pkg %s -> %s)",
                version_file,
                result,
                pkg_dir,
                pkg_name,
            )
            return result
        except ValueError:
            pass

    # No transformation needed
    return version_file


@dataclass(frozen=True)
class VersionInferenceData:
    """Data from version inference stored on the distribution.

    Contains the Configuration and ScmVersion objects needed by
    the build_py command to write version files to the build directory.
    """

    version: str
    """The computed version string."""

    config: Configuration
    """The full Configuration object."""

    scm_version: ScmVersion | None
    """The ScmVersion object (may be None if from fallback/pretend)."""

    workdir: ScmWorkdir | FallbackWorkdir | None = None
    """The discovered workdir, if any.  Carried here so the egg_info mixin
    can write metadata files and provide file-finder data without a ContextVar."""


def get_version_inference_data(dist: Distribution) -> VersionInferenceData | None:
    """Get the version inference data from the distribution.

    Returns None if no data was stored.
    """
    return getattr(dist, "_setuptools_scm_version_inference_data", None)


def set_version_inference_data(dist: Distribution, data: VersionInferenceData) -> None:
    """Store the version inference data on the distribution."""
    dist._setuptools_scm_version_inference_data = data  # type: ignore[attr-defined]


class ScmVersionFileMixin(_build_py):
    """Mixin that writes version files to build_lib and registers them as outputs.

    Place at the front of the MRO so its methods run first, then delegate
    to the next class via super(). Works with any build_py implementation.

    For editable installs (strict mode), version files are registered in
    get_outputs() so setuptools copies them to the persistent auxiliary
    directory where the editable finder can serve them.
    """

    _scm_version_file_outputs: list[str]

    def initialize_options(self) -> None:
        super().initialize_options()
        self._scm_version_file_outputs = []

    def run(self) -> None:
        super().run()
        self._scm_version_file_outputs = self._write_version_files()

    def get_outputs(self, include_bytecode: bool = True) -> list[str]:
        outputs = super().get_outputs(include_bytecode)
        outputs.extend(self._scm_version_file_outputs)
        return outputs

    def _write_version_files(self) -> list[str]:
        """Write version files to the build directory.

        Only writes files that are inside one of the distribution's packages.
        Root-level files (e.g., ``VERSION``) or files outside any package
        (e.g., ``version.h``) are skipped to avoid polluting the wheel with
        files that were never meant to be distributed.

        Returns a list of absolute paths to the files written, for use
        in get_outputs() so editable wheels include them.
        """
        data = get_version_inference_data(self.distribution)
        if data is None:
            log.debug("No version inference data found, skipping version file writing")
            return []

        config = data.config
        if config.write_to is None and config.version_file is None:
            log.debug("No version file paths configured, skipping")
            return []

        build_lib = Path(self.build_lib)
        log.info("Writing version files to build directory: %s", build_lib)

        package_dir = getattr(self.distribution, "package_dir", None)
        packages: list[str] | None = getattr(self.distribution, "packages", None)
        written: list[str] = []

        if config.write_to:
            transformed_path = _transform_version_file_path(
                str(config.write_to), package_dir
            )
            if not _is_inside_package(transformed_path, packages):
                log.debug(
                    "Skipping write_to=%s (transformed=%s): "
                    "not inside any distribution package",
                    config.write_to,
                    transformed_path,
                )
            else:
                target = self._write_single_version_file(
                    build_lib=build_lib,
                    relative_path=transformed_path,
                    template=config.write_to_template,
                    version=data.version,
                    scm_version=data.scm_version,
                )
                if target is not None:
                    written.append(target)

        if config.version_file:
            transformed_path = _transform_version_file_path(
                str(config.version_file), package_dir
            )
            if not _is_inside_package(transformed_path, packages):
                log.debug(
                    "Skipping version_file=%s (transformed=%s): "
                    "not inside any distribution package",
                    config.version_file,
                    transformed_path,
                )
            else:
                target = self._write_single_version_file(
                    build_lib=build_lib,
                    relative_path=transformed_path,
                    template=config.version_file_template,
                    version=data.version,
                    scm_version=data.scm_version,
                )
                if target is not None:
                    written.append(target)

        return written

    def _write_single_version_file(
        self,
        build_lib: Path,
        relative_path: str,
        template: str | None,
        version: str,
        scm_version: ScmVersion | None,
    ) -> str | None:
        """Write a single version file to the build directory.

        Returns the absolute path of the written file, or None on failure.
        """
        from vcs_versioning._dump_version import DummyScmVersion
        from vcs_versioning._dump_version import _validate_template
        from vcs_versioning._version_cls import _version_as_tuple

        try:
            _sanitize_relative_path(relative_path)
        except ValueError as e:
            log.warning("Refusing to write version file: %s", e)
            return None

        target = build_lib / relative_path
        log.debug("Writing version file: %s", target)

        try:
            final_template = _validate_template(target, template)
        except ValueError as e:
            log.warning("Skipping version file %s: %s", target, e)
            return None

        version_tuple = _version_as_tuple(version)
        content = final_template.format(
            version=version,
            version_tuple=version_tuple,
            scm_version=scm_version or DummyScmVersion(),
        )

        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        log.info("Wrote version file: %s", target)
        return str(target)


class build_py(ScmVersionFileMixin, _build_py):
    """Default build_py with version file writing.

    Used when no project-specific build_py is registered in cmdclass.
    """
