"""Main builder orchestration for Mira."""

from __future__ import annotations

import fnmatch
import json
import os
import platform
import shutil
import struct
import sys
import uuid
from typing import Any

try:
    import tomllib  # Python 3.11+
except ModuleNotFoundError:  # pragma: no cover - fallback for 3.10
    try:
        import tomli as tomllib  # type: ignore
    except ModuleNotFoundError as exc:  # pragma: no cover - optional dependency
        raise ModuleNotFoundError(
            "tomli is required on Python < 3.11. Install it with: pip install tomli"
        ) from exc

from mira.builder.compiler import compile_directory
from mira.builder.collector import DependencyCollector
from mira.builder.manifest import ManifestGenerator
from mira.builder.packer import MiraPacker
from mira.keys import resolve_secret
from mira.signature import create_signature


class MiraBuilder:
    """Build a Python project into a .mira archive."""

    def __init__(self, project_dir: str, output_dir: str = "dist") -> None:
        """Create a builder.

        Args:
            project_dir: Path to the project directory containing pyproject.toml.
            output_dir: Output directory for the .mira archive.
        """
        self.project_dir = os.path.abspath(project_dir)
        if os.path.isabs(output_dir):
            self.output_dir = output_dir
        else:
            self.output_dir = os.path.join(self.project_dir, output_dir)

    def build(
        self,
        encryption_key: str | None = None,
        signing_key: str | None = None,
        signing_keyfile: str | None = None,
        embed_public_key: bool = False,
        cipher: str | None = None,
    ) -> str:
        """Build the project into a .mira archive.

        Args:
            encryption_key: Optional encryption key to protect the archive.
            signing_key: Optional PEM-encoded private key for signing.
            signing_keyfile: Optional path to a private key file for signing.
            embed_public_key: Whether to embed the public key in signature.json.
            cipher: Optional cipher name for encryption.

        Returns:
            Path to the generated .mira file.

        Raises:
            RuntimeError: If the build fails.
        """
        config = self._read_pyproject()
        project = config.get("project", {})
        tool = config.get("tool", {}).get("mira", {})

        name = self._require_str(project, "name")
        version = self._require_str(project, "version")
        description = project.get("description") or ""
        dependencies = list(project.get("dependencies") or [])

        include = tool.get("include") or name
        entry_point = tool.get("entry_point")
        exclude = list(tool.get("exclude") or [])
        copy_extra = list(tool.get("copy_extra") or [])
        encrypt_flag = bool(tool.get("encrypt", False))
        sign_flag = bool(tool.get("sign", False))
        cipher = cipher or tool.get("cipher") or os.environ.get("MIRA_CIPHER")
        hash_files = bool(tool.get("hash_files", True))
        signing_keyfile = signing_keyfile or tool.get("sign_keyfile")

        encryption_key = resolve_secret(
            encryption_key,
            None,
            "MIRA_KEY",
            "MIRA_KEY_FILE",
            prompt=False,
            label="encryption key",
        )
        if encrypt_flag or encryption_key:
            if not encryption_key:
                raise RuntimeError(
                    "Encryption enabled but no key provided. Use MIRA_KEY or --key."
                )
            if not cipher:
                cipher = "aes-256-gcm"

        signing_key = resolve_secret(
            signing_key,
            signing_keyfile,
            "MIRA_SIGNING_KEY",
            "MIRA_SIGNING_KEY_FILE",
            prompt=False,
            label="signing key",
        )
        if sign_flag or signing_key:
            if not signing_key:
                raise RuntimeError(
                    "Signing enabled but no key provided. Use MIRA_SIGNING_KEY or --sign-key."
                )

        project_src = os.path.join(self.project_dir, include)
        if not os.path.isdir(project_src):
            raise RuntimeError(f"Include directory not found: {project_src}")

        build_dir = self._make_build_dir()

        libs_dir = os.path.join(build_dir, "libs")
        dlls_dir = os.path.join(build_dir, "dlls")
        project_out = os.path.join(build_dir, include)

        self._log("Starting build")
        try:
            self._log("Compiling project", step=1, total=6)
            compile_directory(project_src, project_out, dlls_dir)
            if exclude:
                self._apply_exclude(project_out, exclude)

            self._log("Collecting dependencies", step=2, total=6)
            deps_dir = os.path.join(build_dir, ".deps")
            collector = DependencyCollector()
            collector.install_to(dependencies, deps_dir)
            collector.collect(deps_dir, libs_dir, dlls_dir)
            dependency_versions = self._build_dependency_versions(
                dependencies, collector.get_installed_versions(deps_dir)
            )
            self._safe_rmtree(deps_dir)

            self._log("Copying extra files", step=3, total=6)
            self._copy_extra(copy_extra, include, build_dir)

            self._log("Generating manifest", step=4, total=6)
            generator = ManifestGenerator(
                name=name,
                version=version,
                description=description,
                python_version=platform.python_version(),
                python_implementation=platform.python_implementation(),
                os_name=self._normalize_os(platform.system()),
                arch=self._normalize_arch(platform.machine()),
                pointer_size=struct.calcsize("P") * 8,
                include=include,
                entry_point=entry_point,
                dependencies=dependency_versions,
                hash_files=hash_files,
            )
            manifest = generator.generate(build_dir)
            generator.save(build_dir, manifest)

            if sign_flag or signing_key:
                signature = create_signature(
                    manifest,
                    signing_key,
                    embed_public_key=bool(tool.get("embed_public_key", embed_public_key)),
                )
                signature_path = os.path.join(build_dir, "signature.json")
                with open(signature_path, "w", encoding="utf-8") as handle:
                    json.dump(signature, handle, indent=2, ensure_ascii=False)

            self._log("Packing archive", step=5, total=6)
            packer = MiraPacker()
            archive_path = packer.pack(
                build_dir,
                self.output_dir,
                name,
                version,
                encryption_key=encryption_key if (encrypt_flag or encryption_key) else None,
                cipher=cipher,
            )

            self._log("Cleaning up", step=6, total=6)
            self._safe_rmtree(build_dir)

            self._log(f"Build completed: {archive_path}")
            return archive_path
        except Exception:
            self._safe_rmtree(build_dir)
            raise

    def _read_pyproject(self) -> dict[str, Any]:
        """Read and parse pyproject.toml from the project directory."""
        pyproject_path = os.path.join(self.project_dir, "pyproject.toml")
        if not os.path.isfile(pyproject_path):
            raise RuntimeError(f"pyproject.toml not found in {self.project_dir}")

        with open(pyproject_path, "rb") as handle:
            data = tomllib.load(handle)

        if not isinstance(data, dict):
            raise RuntimeError("pyproject.toml has invalid format")
        return data

    @staticmethod
    def _require_str(section: dict[str, Any], key: str) -> str:
        """Extract and validate a required string value from a config section."""
        value = section.get(key)
        if not isinstance(value, str) or not value.strip():
            raise RuntimeError(f"pyproject.toml missing or invalid [project].{key}")
        return value.strip()

    def _copy_extra(self, items: list[str], include: str, build_dir: str) -> None:
        """Copy extra files into the build directory.

        Extras are copied into the project package root to ensure they are
        distributed inside the archive.
        """
        if not items:
            return

        target_root = os.path.join(build_dir, include)
        for item in items:
            rel_item = item.strip("/\\")
            src_path = os.path.join(self.project_dir, rel_item)
            if not os.path.exists(src_path):
                raise RuntimeError(f"Extra path not found: {src_path}")

            dst_path = os.path.join(target_root, rel_item)
            if os.path.isdir(src_path):
                shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
            else:
                os.makedirs(os.path.dirname(dst_path), exist_ok=True)
                shutil.copy2(src_path, dst_path)

    def _apply_exclude(self, project_out: str, patterns: list[str]) -> None:
        """Remove files or directories that match exclude patterns."""
        normalized = [p.replace("\\", "/").rstrip("/") for p in patterns if p]
        if not normalized:
            return

        for root, dirs, files in os.walk(project_out, topdown=True):
            rel_root = os.path.relpath(root, project_out)
            rel_root_norm = rel_root.replace(os.sep, "/") if rel_root != "." else ""

            dirs_to_remove: list[str] = []
            for d in dirs:
                rel_dir = f"{rel_root_norm}/{d}" if rel_root_norm else d
                if self._match_any(rel_dir, normalized):
                    dirs_to_remove.append(d)
            for d in dirs_to_remove:
                dirs.remove(d)
                shutil.rmtree(os.path.join(root, d), ignore_errors=True)

            for filename in files:
                rel_file = f"{rel_root_norm}/{filename}" if rel_root_norm else filename
                if self._match_any(rel_file, normalized):
                    try:
                        os.remove(os.path.join(root, filename))
                    except OSError:
                        pass

    @staticmethod
    def _match_any(path: str, patterns: list[str]) -> bool:
        """Return True if a path matches any exclude pattern."""
        for pattern in patterns:
            if fnmatch.fnmatch(path, pattern):
                return True
            if path.startswith(pattern + "/"):
                return True
        return False

    @staticmethod
    def _build_dependency_versions(
        dependencies: list[str], installed: dict[str, str]
    ) -> dict[str, str]:
        """Combine dependency specs and installed versions into a manifest map."""
        result: dict[str, str] = {}
        installed_lower = {key.lower(): value for key, value in installed.items()}
        for dep in dependencies:
            name = MiraBuilder._parse_dependency_name(dep)
            if not name:
                continue
            version = installed_lower.get(name.lower())
            if not version:
                version = MiraBuilder._parse_dependency_version(dep) or "unknown"
            result[name] = version
        return result

    @staticmethod
    def _parse_dependency_name(dep: str) -> str:
        """Extract the distribution name from a dependency specifier."""
        for token in ["===", "==", ">=", "<=", "!=", "~=", ">", "<"]:
            if token in dep:
                return dep.split(token, 1)[0].strip()
        return dep.strip()

    @staticmethod
    def _parse_dependency_version(dep: str) -> str | None:
        """Extract a version constraint from a dependency specifier, if present."""
        for token in ["===", "==", ">=", "<=", "!=", "~=", ">", "<"]:
            if token in dep:
                return dep.split(token, 1)[1].strip()
        return None

    @staticmethod
    def _normalize_os(os_name: str) -> str:
        """Normalize OS names to stable identifiers for manifests."""
        os_name_lower = os_name.lower()
        if os_name_lower.startswith("win"):
            return "windows"
        if os_name_lower.startswith("darwin") or os_name_lower.startswith("mac"):
            return "macos"
        return "linux"

    @staticmethod
    def _normalize_arch(arch: str) -> str:
        """Normalize CPU architecture names to common identifiers."""
        arch_lower = arch.lower()
        if arch_lower in {"amd64", "x86_64"}:
            return "x86_64"
        if arch_lower in {"x86", "i386", "i686"}:
            return "x86"
        if arch_lower in {"arm64", "aarch64"}:
            return "arm64"
        return arch_lower

    @staticmethod
    def _safe_rmtree(path: str) -> None:
        """Remove a directory tree if it exists, ignoring errors."""
        if os.path.isdir(path):
            shutil.rmtree(path, ignore_errors=True)

    def _make_build_dir(self) -> str:
        """Create a writable build directory inside the output directory."""
        os.makedirs(self.output_dir, exist_ok=True)
        name = f"mira_build_{uuid.uuid4().hex}"
        path = os.path.join(self.output_dir, name)
        os.makedirs(path, exist_ok=False)
        return path

    @staticmethod
    def _log(message: str, step: int | None = None, total: int | None = None) -> None:
        """Print a progress message with optional step counters."""
        if step is not None and total is not None:
            print(f"[{step}/{total}] {message}")
        else:
            print(message)
