import ast
import io
import os
import shutil
import tarfile
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import List, Optional, Tuple

from conan.api.conan_api import ConanAPI
from conan.cli.cli import Cli
from conan.tools.env import VirtualBuildEnv
from distlib.wheel import Wheel

from conan_py_build.wheel_deploy import move_deploy_to_wheel, patch_rpath
from packaging.tags import sys_tags
from packaging.utils import canonicalize_name
from pyproject_metadata import StandardMetadata


try:
    import tomllib
except ImportError:
    import tomli as tomllib


def _get_wheel_tags() -> dict:
    """
    Get wheel tags for the target platform.

    If WHEEL_ARCH environment variable is set (e.g., from a Conan profile's [buildenv]),
    uses the environment variables:
        - WHEEL_PYVER: Python version tag (e.g., "cp312", "py3")
        - WHEEL_ABI: ABI tag (e.g., "cp312", "abi3", "none")
        - WHEEL_ARCH: Platform tag (e.g., "manylinux_2_28_x86_64", "win_amd64")

    Otherwise, auto-detects tags from the current platform using packaging library.
    """
    # Check for cross-compile env vars (typically set by Conan profile [buildenv])
    wheel_arch = os.environ.get("WHEEL_ARCH")
    if wheel_arch:
        tags = {
            "pyver": [os.environ.get("WHEEL_PYVER", "py3")],
            "abi": [os.environ.get("WHEEL_ABI", "none")],
            "arch": [wheel_arch],
        }
        print(f"  Using wheel tags from environment: {tags}")
        return tags

    # Default: auto-detect from current platform
    tag = next(sys_tags())
    return {
        "pyver": [tag.interpreter],
        "abi": [tag.abi],
        "arch": [tag.platform],
    }


def _read_pyproject(project_dir: Path) -> dict:
    """Read and parse pyproject.toml from project_dir."""
    pyproject_path = project_dir / "pyproject.toml"
    if not pyproject_path.exists():
        raise FileNotFoundError(f"pyproject.toml not found in {project_dir}")

    with open(pyproject_path, "rb") as f:
        return tomllib.load(f)


def _get_project_metadata(project_dir: Path) -> dict:
    """Extract project metadata from pyproject.toml."""
    return _read_pyproject(project_dir).get("project", {})


def _get_tool_config(project_dir: Path) -> dict:
    """Read [tool.conan-py-build] from pyproject.toml."""
    return _read_pyproject(project_dir).get("tool", {}).get("conan-py-build", {})


def _read_version_from_file(path: Path) -> Optional[str]:
    """Read __version__ from a Python file (module-level string literal only)."""
    try:
        tree = ast.parse(path.read_text(encoding="utf-8"))
    except (OSError, SyntaxError):
        return None
    for node in tree.body:
        if isinstance(node, ast.Assign) and len(node.targets) == 1:
            target = node.targets[0]
            value = node.value
        elif isinstance(node, ast.AnnAssign) and node.value is not None:
            target = node.target
            value = node.value
        else:
            continue
        if isinstance(target, ast.Name) and target.id == "__version__":
            if isinstance(value, ast.Constant) and isinstance(value.value, str):
                return value.value
            return None
    return None


def _get_sdist_config(project_dir: Path) -> dict:
    """Read [tool.conan-py-build].sdist (merged with defaults)."""
    tool = _get_tool_config(project_dir)
    sdist = tool.get("sdist", {})
    if not isinstance(sdist, dict):
        return {"include": [], "exclude": []}
    include = sdist.get("include", [])
    exclude = sdist.get("exclude", [])
    return {
        "include": include if isinstance(include, list) else [],
        "exclude": exclude if isinstance(exclude, list) else [],
    }


def _resolve_conanfile_path(conanfile_path: str, source_dir: Path) -> Path:
    # Using py=True will raise an exception if the path is not a .py file.
    conan_api = ConanAPI()
    full_path = conan_api.local.get_conanfile_path(conanfile_path, source_dir, py=True)
    return Path(full_path)


def _validate_version_config(source_dir: Path) -> None:
    """Validate [tool.conan-py-build.version] settings."""
    version_cfg = _get_tool_config(source_dir).get("version", {})
    provider = version_cfg.get("provider")
    has_file = bool(version_cfg.get("file"))

    if provider is not None and provider != "setuptools_scm":
        raise RuntimeError(
            f"[tool.conan-py-build.version].provider must be 'setuptools_scm', "
            f"got {provider!r}. To read version from a file, use version.file instead."
        )

    if has_file and provider:
        raise RuntimeError(
            "[tool.conan-py-build.version].file and provider = 'setuptools_scm' "
            "are mutually exclusive. Use one or the other."
        )

    if not has_file and not provider:
        raise RuntimeError(
            "[tool.conan-py-build.version] must define 'file' or 'provider'."
        )


def _uses_setuptools_scm(source_dir: Path) -> bool:
    """True when [tool.conan-py-build.version].provider is 'setuptools_scm'."""
    return _get_tool_config(source_dir).get("version", {}).get("provider") == "setuptools_scm"


def _get_version_from_file(source_dir: Path) -> Optional[str]:
    """Read __version__ from the path in [tool.conan-py-build.version].file."""
    version_file = _get_tool_config(source_dir).get("version", {}).get("file")
    if not version_file:
        return None
    resolved = (source_dir / version_file).resolve()
    try:
        resolved.relative_to(source_dir.resolve())
    except ValueError:
        raise RuntimeError(
            f"[tool.conan-py-build.version].file must be inside project: {version_file!r}"
        )
    return _read_version_from_file(resolved)


def _get_version_from_scm(source_dir: Path) -> str:
    """Get version from setuptools-scm."""
    try:
        from setuptools_scm import Configuration, _get_version
    except ImportError:
        raise RuntimeError(
            "setuptools-scm is required when provider = 'setuptools_scm'. "
            "Add 'setuptools-scm' to [build-system].requires."
        )

    config = Configuration.from_file(str(source_dir / "pyproject.toml"))
    try:
        version = _get_version(config, force_write_version_files=True)
    except TypeError:
        version = _get_version(config)
    if version is None:
        raise LookupError(
            "setuptools-scm could not detect a version. "
            "Build from a git repo with tags, or from an sdist."
        )
    return version


def _resolve_version(project_metadata: dict, source_dir: Path) -> str:
    version = project_metadata.get("version")
    dynamic = project_metadata.get("dynamic")
    version_is_dynamic = isinstance(dynamic, list) and "version" in dynamic

    if not version and version_is_dynamic:
        _validate_version_config(source_dir)
        if _uses_setuptools_scm(source_dir):
            version = _get_version_from_scm(source_dir)
        else:
            version = _get_version_from_file(source_dir)
        if not version:
            raise RuntimeError(
                "dynamic = [\"version\"] but version could not be resolved. "
                "Set [tool.conan-py-build.version].file or provider = 'setuptools_scm'."
            )

    version = version or "0.0.0"
    project_metadata["version"] = version
    return version


def _normalize_name(name: str) -> str:
    canonical = canonicalize_name(name)
    return canonical.replace("-", "_")


def _parse_config(config_settings: Optional[dict]) -> dict:
    """
    Parse settings from config_settings.

    Supported config_settings:
        host-profile: Conan profile for the host context (default: "default")
        build-profile: Conan profile for the build context (default: "default")
        build-dir: Directory for build artifacts (default: temp directory, deleted after build)
    """
    config = config_settings or {}

    return {
        "host_profile": config.get("host-profile") or "default",
        "build_profile": config.get("build-profile") or "default",
        "build_dir": config.get("build-dir"),
    }


def _autodetect_profile() -> bool:
    """True if CONAN_PY_BUILD_PROFILE_AUTODETECT is set (1, true, yes). Use local profile instead of Conan default."""
    env_val = os.environ.get("CONAN_PY_BUILD_PROFILE_AUTODETECT", "").strip().lower()
    return env_val in ("1", "true", "yes")


def _resolve_default_profiles(conan_api, source_dir: Path, host_profile: str, build_profile: str) -> Tuple[str, str]:
    if host_profile != "default" and build_profile != "default":
        return host_profile, build_profile
    use_local_auto_profile = _autodetect_profile()
    if use_local_auto_profile:
        path = (source_dir / "conan-py-build.profile").resolve()
        print(f"Autodetect Conan profile: Using local profile: {path}", flush=True)
        host_profile = build_profile = str(path)
    else:
        path = Path(conan_api.config.home()) / "profiles" / "default"
    if use_local_auto_profile or not path.is_file():
        detected = conan_api.profiles.detect()
        if (detected.settings or {}).get("compiler") is None:
            raise RuntimeError(
                "No compiler detected. Install a C/C++ toolchain (e.g. Visual Studio on Windows, "
                "Xcode on macOS, gcc/clang on Linux) and try again."
            )
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(detected.dumps())
    return host_profile, build_profile


@contextmanager
def _build_directory(build_dir: Optional[str]):
    """Context manager for build directory - persistent or temporary."""
    if build_dir:
        path = Path(build_dir)
        path.mkdir(parents=True, exist_ok=True)
        print(f"Using persistent build directory: {path}")
        yield path
    else:
        with tempfile.TemporaryDirectory() as tmp_dir:
            yield Path(tmp_dir)


def _get_standard_metadata(metadata: dict, project_dir: Path):
    """Build StandardMetadata from [project] section (for METADATA and PKG-INFO).
    metadata: [project] section from pyproject.toml.
    project_dir: project root for resolving readme/license/dynamic paths.
    """
    project = dict(metadata)
    dynamic = project.get("dynamic")
    if isinstance(dynamic, list) and "version" in dynamic:
        project["dynamic"] = [f for f in dynamic if f != "version"]
    pyproject = {"project": project}
    return StandardMetadata.from_pyproject(pyproject, project_dir=project_dir)


def _copy_license_files_from_paths(
    dist_info_dir: Path, project_dir: Path, license_paths: List[str]
) -> None:
    """Copy listed license files into dist-info/licenses/ (paths relative to project_dir)."""
    if not license_paths:
        return
    project_resolved = project_dir.resolve()
    licenses_dir = dist_info_dir / "licenses"
    for rel_path_str in license_paths:
        src = project_dir / rel_path_str.replace("/", os.sep)
        if not src.is_file():
            raise FileNotFoundError(f"license file not found: {rel_path_str!r}")
        rel = src.resolve().relative_to(project_resolved)
        dest = licenses_dir / rel
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src.resolve(), dest)


def _write_metadata_file(dist_info_dir: Path, metadata: dict, project_dir: Path):
    """Write the METADATA file to dist-info directory.
    project_dir is used to resolve readme/license/dynamic paths.
    Use newline='\\n' so METADATA has Unix line endings on all platforms (matches sdist PKG-INFO).
    """
    std_metadata = _get_standard_metadata(metadata, project_dir)
    content = str(std_metadata.as_rfc822())
    license_paths = [p.as_posix() for p in (std_metadata.license_files or [])]
    _copy_license_files_from_paths(dist_info_dir, project_dir, license_paths)
    with (dist_info_dir / "METADATA").open("w", encoding="utf-8", newline="\n") as f:
        f.write(content)


def _create_dist_info(staging_dir: Path, metadata: dict, project_dir: Path) -> Path:
    """Create .dist-info directory with metadata files."""
    name = _normalize_name(metadata.get("name", "unknown"))
    version = metadata.get("version", "0.0.0")

    dist_info_dir = staging_dir / f"{name}-{version}.dist-info"
    dist_info_dir.mkdir(parents=True, exist_ok=True)

    _write_metadata_file(dist_info_dir, metadata, project_dir)

    return dist_info_dir


# PEP 517 Hooks

def get_requires_for_build_wheel(config_settings: Optional[dict] = None) -> list:
    """PEP 517 hook: Return additional dependencies needed to build a wheel."""
    if _uses_setuptools_scm(Path.cwd()):
        return ["setuptools-scm"]
    return []


def get_requires_for_build_sdist(config_settings: Optional[dict] = None) -> list:
    """PEP 517 hook: Return additional dependencies needed to build an sdist."""
    if _uses_setuptools_scm(Path.cwd()):
        return ["setuptools-scm"]
    return []


def build_wheel(
    wheel_directory: str,
    config_settings: Optional[dict] = None,
    metadata_directory: Optional[str] = None,
) -> str:
    """
    PEP 517 hook: Build a wheel from the source tree.

    Note: prepare_metadata_for_build_wheel is not implemented, so
    metadata_directory is ignored if provided.
    """

    if metadata_directory is not None:
        print(f"WARNING: metadata_directory provided: '{metadata_directory}' - " \
               "backend will ignore/recreate dist-info.")

    wheel_dir = Path(wheel_directory)
    wheel_dir.mkdir(parents=True, exist_ok=True)

    source_dir = Path.cwd()
    project_metadata = _get_project_metadata(source_dir)
    version = _resolve_version(project_metadata, source_dir)
    name = _normalize_name(project_metadata.get("name", "unknown"))

    print(f"Building wheel for {name}...")

    config = _parse_config(config_settings)

    with _build_directory(config["build_dir"]) as base_dir:
        return _do_build_wheel(
            source_dir,
            base_dir,
            wheel_dir,
            name,
            version,
            project_metadata,
            config,
        )


def build_editable(
    wheel_directory: str,
    config_settings: Optional[dict] = None,
    metadata_directory: Optional[str] = None,
) -> str:
    """
    PEP 660 hook: Build an editable wheel.
    """
    print("WARNING: Editable install: building a normal wheel (no live reload of C++ extensions).")
    return build_wheel(wheel_directory, config_settings, metadata_directory)


def _check_wheel_package_path(source_dir: Path, wheel_package: str) -> Path:
    source_resolved = source_dir.resolve()
    package_dir = (source_dir / wheel_package).resolve()
    try:
        package_dir.relative_to(source_resolved)
    except ValueError:
        raise RuntimeError(
            f"Package '{wheel_package}' must be inside source path '{source_dir}'."
        )
    if not package_dir.is_dir():
        raise FileNotFoundError(
            f"Package path does not exist or is not a directory: '{wheel_package}' (resolved: {package_dir})"
        )
    if not (package_dir / "__init__.py").is_file():
        raise FileNotFoundError(
            f"Python package must contain __init__.py: '{wheel_package}' (resolved: {package_dir})"
        )
    return package_dir


def _get_wheel_packages(
    source_dir: Path,
    name: str
) -> List[Path]:
    """Internal function to collect all python packages that need to be included in the final wheel."""
    tool = _get_tool_config(source_dir)
    wheel_packages = tool.get("wheel", {}).get("packages")
    if wheel_packages and isinstance(wheel_packages, list):
        return [_check_wheel_package_path(source_dir, p) for p in wheel_packages]
    # Default: src/<name>; validate like explicit packages (must exist and have __init__.py).
    return [_check_wheel_package_path(source_dir, f"src/{name}")]


def _do_build_wheel(
    source_dir: Path,
    base_dir: Path,
    wheel_dir: Path,
    name: str,
    version: str,
    project_metadata: dict,
    config: dict,
) -> str:
    """Internal function that performs the actual wheel build."""
    
    # Staging = wheel platlib; build tree stays outside via cmake_layout.
    staging_dir = base_dir / "package"
    for python_package_dir in _get_wheel_packages(source_dir, name):
        package_dir = base_dir / "package" / python_package_dir.name
        package_dir.mkdir(parents=True, exist_ok=True)
        shutil.copytree(python_package_dir, package_dir, dirs_exist_ok=True)

    build_folder_conf = f"tools.cmake.cmake_layout:build_folder={(base_dir / 'build').resolve()}"
    user_presets_conf = "tools.cmake.cmaketoolchain:user_presets="  # empty = disable CMakeUserPresets.json

    runtime_deploy_dir = (base_dir / "runtime_deploy").resolve()

    conan_api = ConanAPI()
    cli = Cli(conan_api)
    cli.add_commands()

    host_profile, build_profile = _resolve_default_profiles(
        conan_api, source_dir, config["host_profile"], config["build_profile"]
    )

    profile_args = [
        "--profile:host",
        host_profile,
        "--profile:build",
        build_profile,
    ]

    tool = _get_tool_config(source_dir)

    for key, val in tool.items():
        if not key.startswith("extra-profile") or not val or not isinstance(val, str):
            continue
        p = (source_dir / val).resolve()
        if not p.is_file():
            continue
        arg = key[len("extra-"):].replace("-", ":", 1)  # profile-host -> profile:host
        profile_args.extend([f"--{arg}", str(p)])

    conanfile_path = tool.get("conanfile-path") or "."
    resolved_conanfile = str(_resolve_conanfile_path(conanfile_path, source_dir))

    source_cmd = ["source", resolved_conanfile]
    print("Running conan source...", flush=True)
    try:
        conan_api.command.run(source_cmd)
    except Exception as e:
        raise RuntimeError(f"Conan source failed: {e}") from e

    build_cmd = [
        "build",
        resolved_conanfile,
        "-of",
        str(staging_dir),
        "-d",
        "runtime_deploy",
        "--deployer-folder",
        str(runtime_deploy_dir),
        "-c",
        build_folder_conf,
        "-c",
        user_presets_conf,
        "--build=missing",
    ]
    build_cmd.extend(profile_args)

    print(
        f"Running conan build (profiles: host={host_profile}, build={build_profile})...",
        flush=True,
    )
    try:
        build_result = conan_api.command.run(build_cmd)
    except Exception as e:
        raise RuntimeError(f"Conan build failed: {e}") from e

    print("Running conan export-pkg...", flush=True)
    
    deps_graph = build_result.get("graph")
    conanfile = deps_graph.root.conanfile
    
    export_pkg_cmd = [
        "export-pkg",
        resolved_conanfile,
        "-of",
        str(staging_dir),
        "-tf",
        "",
        "-c",
        build_folder_conf,
        "-c",
        user_presets_conf,
    ]
    export_pkg_cmd.extend(profile_args)
    try:
        export_result = conan_api.command.run(export_pkg_cmd)
    except Exception as e:
        raise RuntimeError(f"Conan export-pkg failed: {e}") from e

    pkg_path = Path(export_result["graph"].serialize()["nodes"]["0"]["package_folder"])
    shutil.copytree(
        pkg_path, staging_dir,
        ignore=lambda _, names: [n for n in names if n in ("conaninfo.txt", "conanmanifest.txt")],
        dirs_exist_ok=True,
    )

    # Add rpath to extension modules only, we are not touching shared libs from Conan.
    patch_rpath(staging_dir)

    # Copy shared libs from Conan's runtime_deploy to the wheel layout.
    move_deploy_to_wheel(runtime_deploy_dir, staging_dir)

    # Create dist-info
    _create_dist_info(staging_dir, project_metadata, source_dir)

    # Build wheel using distlib. Apply Conan's buildenv to get cross-compile
    # wheel tags from [buildenv]
    buildenv = VirtualBuildEnv(conanfile)
    env_vars = buildenv.environment().vars(conanfile)

    with env_vars.apply():
        tags = _get_wheel_tags()
        wheel_name = _build_wheel_with_tags(wheel_dir, staging_dir, name, version, tags)

    return wheel_name


def _build_wheel_with_tags(
    wheel_dir: Path, staging_dir: Path, name: str, version: str, tags: dict
) -> str:
    """Build the wheel file with the given tags."""
    whl = Wheel()
    whl.name = name
    whl.version = version
    whl.dirname = str(wheel_dir)

    paths = {"platlib": str(staging_dir)}
    wheel_path = whl.build(paths, tags=tags, wheel_version=(1, 0))

    wheel_name = Path(wheel_path).name
    print(f"Built wheel: {wheel_name}")
    return wheel_name


# FIXME: This implementation is too basic and needs improvement.
def build_sdist(sdist_directory: str, config_settings: Optional[dict] = None) -> str:
    """
    PEP 517 hook: Build a source distribution (sdist).
    """
    sdist_dir = Path(sdist_directory)
    sdist_dir.mkdir(parents=True, exist_ok=True)

    source_dir = Path.cwd()
    project_metadata = _get_project_metadata(source_dir)
    version = _resolve_version(project_metadata, source_dir)
    name = project_metadata.get("name", "unknown")
    sdist_name = f"{name}-{version}"
    sdist_filename = f"{sdist_name}.tar.gz"

    print(f"Building sdist: {sdist_filename}")

    sdist_config = _get_sdist_config(source_dir)
    default_include = [
        "pyproject.toml",
        "CMakeLists.txt",
        "cmake",
        "src",
        "include",
        "README.md",
        "README.rst",
        "LICENSE",
    ]

    tool = _get_tool_config(source_dir)
    conanfile_path = tool.get("conanfile-path") or "."
    resolved_conanfile = _resolve_conanfile_path(conanfile_path, source_dir)
    default_include.append(resolved_conanfile.relative_to(source_dir).as_posix())

    if _uses_setuptools_scm(source_dir):
        version_file = _read_pyproject(source_dir).get("tool", {}).get("setuptools_scm", {}).get("version_file")
        if version_file:
            default_include.append(version_file)

    default_exclude = [
        "__pycache__",
        "*.pyc",
        "*.pyo",
        ".git",
        ".gitignore",
        "build",
        "dist",
        "*.egg-info",
        ".eggs",
    ]
    sdist_md = dict(project_metadata)
    sdist_md["name"] = name
    sdist_md["version"] = version
    std_meta_sdist = _get_standard_metadata(sdist_md, source_dir)
    pkg_info_content = str(std_meta_sdist.as_rfc822())
    license_paths_sdist = [p.as_posix() for p in (std_meta_sdist.license_files or [])]
    include_patterns = default_include + sdist_config["include"]
    exclude_patterns = default_exclude + sdist_config["exclude"]

    def should_exclude(path: Path) -> bool:
        try:
            rel = path.relative_to(source_dir)
        except ValueError:
            rel = path
        name = path.name
        parts = rel.parts
        for pattern in exclude_patterns:
            if pattern.startswith("*"):
                if name.endswith(pattern[1:]):
                    return True
            elif name == pattern or pattern in parts:
                return True
        return False

    sdist_path = sdist_dir / sdist_filename

    added_arcnames = set()

    with tarfile.open(sdist_path, "w:gz", format=tarfile.PAX_FORMAT) as tar:
        for pattern in include_patterns:
            source_path = source_dir / pattern
            if source_path.exists():
                if source_path.is_file():
                    arcname = f"{sdist_name}/{Path(pattern).as_posix()}"
                    if arcname not in added_arcnames:
                        added_arcnames.add(arcname)
                        tar.add(source_path, arcname=arcname)
                elif source_path.is_dir():
                    for file_path in source_path.rglob("*"):
                        if file_path.is_file() and not should_exclude(file_path):
                            rel_path = file_path.relative_to(source_dir)
                            arcname = f"{sdist_name}/{rel_path.as_posix()}"
                            if arcname in added_arcnames:
                                continue
                            added_arcnames.add(arcname)
                            tar.add(file_path, arcname=arcname)

        for rel_path_str in license_paths_sdist:
            src = source_dir / rel_path_str.replace("/", os.sep)
            if not src.is_file():
                raise FileNotFoundError(f"license file not found for sdist: {rel_path_str!r}")
            arcname = f"{sdist_name}/{rel_path_str}"
            if arcname not in added_arcnames:
                added_arcnames.add(arcname)
                tar.add(src.resolve(), arcname=arcname)

        pkg_info_data = pkg_info_content.encode("utf-8")
        pkg_info_file = tarfile.TarInfo(name=f"{sdist_name}/PKG-INFO")
        pkg_info_file.size = len(pkg_info_data)
        tar.addfile(pkg_info_file, io.BytesIO(pkg_info_data))

    print(f"Built sdist: {sdist_filename}")
    return sdist_filename
