"""Runtime interface for loading .mira packages."""

from __future__ import annotations

import json
import os
import sys
import zipfile
import importlib

from mira.crypto import decrypt_to_temp_zip, is_encrypted_mira, open_mira_zip
from mira.keys import resolve_secret
from mira.library.repository import MiraRepository
from mira.library.resolver import PackageResolver
from mira.manifest_schema import validate_manifest
from mira.temp import make_temp_dir
from mira.runtime.finder import MiraFinder
from mira.runtime.registry import MiraRegistry, MiraPackageInfo


class MiraRuntime:
    """Load and unload .mira packages at runtime."""

    def __init__(self) -> None:
        """Create a runtime manager."""
        self.registry = MiraRegistry()

    def load(self, mira_path: str, key: str | None = None) -> None:
        """Load a .mira package into the current Python process.

        Args:
            mira_path: Path to the .mira archive.
            key: Optional encryption key for protected archives.

        Raises:
            RuntimeError: If the package cannot be loaded or is incompatible.
        """
        if not os.path.isfile(mira_path):
            raise RuntimeError(f".mira file not found: {mira_path}")

        key = resolve_secret(key, None, "MIRA_KEY", "MIRA_KEY_FILE")
        temp_zip_path = None
        if is_encrypted_mira(mira_path):
            if not key:
                raise RuntimeError(
                    "Encryption key required to load this .mira archive"
                )
            temp_zip_path = decrypt_to_temp_zip(mira_path, key)
            zip_path = temp_zip_path
        else:
            zip_path = mira_path

        zip_file = zipfile.ZipFile(zip_path, "r")
        try:
            manifest = self._read_manifest(zip_file)
        except Exception:
            zip_file.close()
            if temp_zip_path and os.path.exists(temp_zip_path):
                os.remove(temp_zip_path)
            raise

        resolver = PackageResolver()
        compatible, warnings = resolver.check_compatibility(manifest)
        if not compatible:
            zip_file.close()
            if temp_zip_path and os.path.exists(temp_zip_path):
                os.remove(temp_zip_path)
            details = "; ".join(warnings) if warnings else "incompatible package"
            raise RuntimeError(f"Package not compatible: {details}")

        name = manifest.get("name")
        version = manifest.get("version")
        include = manifest.get("include") or name
        if not name or not version:
            zip_file.close()
            if temp_zip_path and os.path.exists(temp_zip_path):
                os.remove(temp_zip_path)
            raise RuntimeError("manifest.json missing name or version")

        files = set(zip_file.namelist())
        temp_dir = None
        if self._has_dlls(files):
            temp_dir = make_temp_dir(f"mira_{name}_")
            self._extract_dlls(zip_file, temp_dir)
            self._inject_native_path(temp_dir)

        info = MiraPackageInfo(
            name=name,
            version=version,
            zip_file=zip_file,
            manifest=manifest,
            temp_dir=temp_dir,
            temp_zip_path=temp_zip_path,
            include=include,
            files=files,
        )

        try:
            self.registry.register(info)
        except Exception:
            zip_file.close()
            if info.temp_dir:
                self._remove_native_path(info.temp_dir)
                self._safe_rmtree(info.temp_dir)
            if info.temp_zip_path and os.path.exists(info.temp_zip_path):
                os.remove(info.temp_zip_path)
            raise

        self._ensure_finder()

    def run(self, mira_path: str, key: str | None = None) -> object:
        """Load a .mira package and run its entry point.

        Args:
            mira_path: Path to the .mira archive.
            key: Optional encryption key for protected archives.

        Returns:
            The result returned by the entry point callable.

        Raises:
            RuntimeError: If the package is a library or entry point cannot be executed.
        """
        key = resolve_secret(key, None, "MIRA_KEY", "MIRA_KEY_FILE")
        manifest = self._read_manifest_from_path(mira_path, key)
        entry_point = manifest.get("entry_point")
        if not entry_point:
            raise RuntimeError("Package has no entry_point and cannot be run as an app")

        name = manifest.get("name")
        if not name:
            raise RuntimeError("manifest.json missing name")

        self.load(mira_path, key=key)
        return self.run_loaded(name)

    def run_loaded(self, name: str) -> object:
        """Run the entry point of a loaded package.

        Args:
            name: Package name.

        Returns:
            The result returned by the entry point callable.

        Raises:
            RuntimeError: If the package is not loaded or is a library.
        """
        info = self.registry.get(name)
        if not info:
            raise RuntimeError(f"Package not loaded: {name}")
        entry_point = info.manifest.get("entry_point")
        if not entry_point:
            raise RuntimeError(f"Package {name} has no entry_point (library)")
        return self._execute_entry_point(entry_point)

    def unload(self, name: str) -> None:
        """Unload a previously loaded package.

        Args:
            name: Package name.
        """
        try:
            info = self.registry.unregister(name)
        except KeyError as exc:
            raise RuntimeError(f"Package not loaded: {name}") from exc
        self._purge_modules(info)

        if info.temp_dir:
            self._remove_native_path(info.temp_dir)
            self._safe_rmtree(info.temp_dir)

        info.zip_file.close()
        if info.temp_zip_path and os.path.exists(info.temp_zip_path):
            os.remove(info.temp_zip_path)

    def list(self) -> list[str]:
        """Return the list of loaded package names."""
        return [pkg.name for pkg in self.registry.list_all()]

    def load_from_repo(
        self,
        name: str,
        version: str | None = None,
        key: str | None = None,
        repository: MiraRepository | None = None,
    ) -> str:
        """Load a .mira package from the local repository.

        Args:
            name: Package name.
            version: Optional package version.
            key: Optional encryption key for protected archives.
            repository: Optional repository override.

        Returns:
            Path to the loaded .mira archive.
        """
        repo = repository or MiraRepository()
        path = repo.find(name, version=version, key=key)
        self.load(path, key=key)
        return path

    def get_info(self, name: str) -> dict:
        """Return the manifest for a loaded package."""
        info = self.registry.get(name)
        if not info:
            raise RuntimeError(f"Package not loaded: {name}")
        return info.manifest

    @staticmethod
    def _read_manifest(zip_file: zipfile.ZipFile) -> dict:
        """Read and parse manifest.json from an open ZipFile."""
        try:
            data = zip_file.read("manifest.json")
        except KeyError as exc:
            raise RuntimeError("manifest.json not found in archive") from exc
        manifest = json.loads(data.decode("utf-8"))
        validate_manifest(manifest)
        return manifest

    def _read_manifest_from_path(self, mira_path: str, key: str | None) -> dict:
        """Read manifest.json from a .mira archive path."""
        with open_mira_zip(mira_path, key) as zip_file:
            return self._read_manifest(zip_file)

    @staticmethod
    def _parse_entry_point(entry_point: str) -> tuple[str, str]:
        """Parse entry point string in module:function format."""
        if ":" not in entry_point:
            raise RuntimeError(
                "Invalid entry_point format: expected module:function"
            )
        module_name, func_name = entry_point.split(":", 1)
        module_name = module_name.strip()
        func_name = func_name.strip()
        if not module_name or not func_name:
            raise RuntimeError(
                "Invalid entry_point format: expected module:function"
            )
        return module_name, func_name

    def _execute_entry_point(self, entry_point: str) -> object:
        """Import and execute the entry point callable."""
        module_name, func_name = self._parse_entry_point(entry_point)
        module = importlib.import_module(module_name)
        func = getattr(module, func_name, None)
        if func is None:
            raise RuntimeError(f"Entry point not found: {module_name}:{func_name}")
        if not callable(func):
            raise RuntimeError(f"Entry point is not callable: {module_name}:{func_name}")
        return func()

    def _ensure_finder(self) -> None:
        """Ensure that MiraFinder is registered in sys.meta_path."""
        for finder in sys.meta_path:
            if isinstance(finder, MiraFinder):
                return
        sys.meta_path.insert(0, MiraFinder(self.registry))

    @staticmethod
    def _has_dlls(files: set[str]) -> bool:
        """Return True if the archive contains native binaries."""
        return any(name.startswith("dlls/") for name in files)

    @staticmethod
    def _extract_dlls(zip_file: zipfile.ZipFile, temp_dir: str) -> None:
        """Extract native binaries from the archive into a temp directory."""
        for name in zip_file.namelist():
            if not name.startswith("dlls/"):
                continue
            if name.endswith("/"):
                continue
            relative = name.replace("dlls/", "", 1)
            dest = os.path.join(temp_dir, relative)
            os.makedirs(os.path.dirname(dest), exist_ok=True)
            with open(dest, "wb") as handle:
                handle.write(zip_file.read(name))

    @staticmethod
    def _inject_native_path(temp_dir: str) -> None:
        """Add a temp directory to PATH and sys.path for native imports."""
        if temp_dir not in sys.path:
            sys.path.insert(0, temp_dir)
        path_env = os.environ.get("PATH", "")
        parts = path_env.split(os.pathsep) if path_env else []
        if temp_dir not in parts:
            os.environ["PATH"] = temp_dir + os.pathsep + path_env

    @staticmethod
    def _remove_native_path(temp_dir: str) -> None:
        """Remove a temp directory from PATH and sys.path."""
        if temp_dir in sys.path:
            sys.path.remove(temp_dir)
        path_env = os.environ.get("PATH", "")
        if not path_env:
            return
        parts = [p for p in path_env.split(os.pathsep) if p and p != temp_dir]
        os.environ["PATH"] = os.pathsep.join(parts)

    @staticmethod
    def _purge_modules(info: MiraPackageInfo) -> None:
        """Remove modules belonging to a package from sys.modules."""
        to_remove = []
        for name, module in list(sys.modules.items()):
            if module is None:
                continue
            if name == info.name or name.startswith(info.name + "."):
                to_remove.append(name)
                continue
            loader = getattr(module, "__loader__", None)
            if loader is not None and getattr(loader, "package", None) is info:
                to_remove.append(name)

        for name in to_remove:
            sys.modules.pop(name, None)

    @staticmethod
    def _safe_rmtree(path: str) -> None:
        """Recursively remove a directory, ignoring errors."""
        if not os.path.isdir(path):
            return
        for root, dirs, files in os.walk(path, topdown=False):
            for file in files:
                try:
                    os.remove(os.path.join(root, file))
                except OSError:
                    pass
            for d in dirs:
                try:
                    os.rmdir(os.path.join(root, d))
                except OSError:
                    pass
        try:
            os.rmdir(path)
        except OSError:
            pass
