"""systemd-nspawn container runtime implementation."""

from __future__ import annotations

import contextlib
import json
import os
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from gaol.models.container import ContainerState, ContainerStatus, NetworkMode
from gaol.profiles.applicator import MergedProfile, get_profile_applicator
from gaol.runtimes.base import ContainerRuntime

if TYPE_CHECKING:
    from gaol.checkpoint.models import CheckpointResult, RestoreResult
    from gaol.models.container import ContainerConfig, ContainerStats, Snapshot

# Base directory for nspawn machines
MACHINES_DIR = Path("/var/lib/machines")

# Label file for gaol metadata
METADATA_FILE = ".gaol.json"


class NspawnRuntime(ContainerRuntime):
    """systemd-nspawn container runtime using machinectl."""

    @property
    def name(self) -> str:
        """Return the name of this runtime."""
        return "nspawn"

    def is_available(self) -> bool:
        """Check if systemd-nspawn is available on the current system."""
        # Check for Linux
        if os.uname().sysname != "Linux":
            return False

        # Check for systemd-nspawn binary
        if not shutil.which("systemd-nspawn"):
            return False

        # Check for machinectl
        return bool(shutil.which("machinectl"))

    def _run_cmd(
        self,
        cmd: list[str],
        *,
        check: bool = True,
        capture_output: bool = True,
        input: str | None = None,
    ) -> subprocess.CompletedProcess[str]:
        """Run a command and return the result."""
        try:
            result = subprocess.run(
                cmd,
                check=check,
                capture_output=capture_output,
                text=True,
                input=input,
            )
            return result
        except subprocess.CalledProcessError as e:
            raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{e.stderr}") from e

    def _get_machine_path(self, name: str) -> Path:
        """Get the path to a machine's root filesystem."""
        return MACHINES_DIR / name

    def _get_metadata_path(self, name: str) -> Path:
        """Get the path to a machine's metadata file."""
        return self._get_machine_path(name) / METADATA_FILE

    def _save_metadata(self, name: str, metadata: dict) -> None:
        """Save metadata for a machine."""
        metadata_path = self._get_metadata_path(name)
        metadata_path.write_text(json.dumps(metadata, indent=2, default=str))

    def _load_metadata(self, name: str) -> dict:
        """Load metadata for a machine."""
        metadata_path = self._get_metadata_path(name)
        if metadata_path.exists():
            return json.loads(metadata_path.read_text())
        return {}

    def _get_machine_status(self, name: str) -> ContainerStatus:
        """Get the status of a machine using machinectl."""
        result = self._run_cmd(["machinectl", "show", name, "--property=State"], check=False)
        if result.returncode != 0:
            # Machine doesn't exist or isn't running
            machine_path = self._get_machine_path(name)
            if machine_path.exists():
                return ContainerStatus.STOPPED
            return ContainerStatus.UNKNOWN

        state = result.stdout.strip().split("=")[1] if "=" in result.stdout else ""
        state_map = {
            "running": ContainerStatus.RUNNING,
            "degraded": ContainerStatus.RUNNING,
            "starting": ContainerStatus.RUNNING,
            "stopping": ContainerStatus.STOPPED,
            "": ContainerStatus.STOPPED,
        }
        return state_map.get(state.lower(), ContainerStatus.UNKNOWN)

    def _resolve_environment(self, env: dict[str, str]) -> dict[str, str]:
        """Resolve ${secret:NAME} references in environment variables.

        Args:
            env: Dictionary of environment variables

        Returns:
            New dictionary with secrets resolved
        """
        from gaol.secrets import has_secrets
        from gaol.secrets.resolver import get_secret_resolver

        # Check if any values have secret references
        has_refs = any(has_secrets(v) for v in env.values())
        if not has_refs:
            return env

        resolver = get_secret_resolver()
        return resolver.resolve_environment(env)

    def _bootstrap_rootfs(self, name: str, distro: str) -> None:
        """Bootstrap a root filesystem for a machine."""
        from gaol.distro import get_debootstrap_cmd, get_mirror, list_supported_distros

        machine_path = self._get_machine_path(name)
        machine_path.mkdir(parents=True, exist_ok=True)

        try:
            bootstrap_cmd = get_debootstrap_cmd(distro)
        except KeyError as e:
            # Only Debian/Ubuntu distros support debootstrap
            debian_distros = [d for d in list_supported_distros() if get_mirror(d) is not None]
            raise RuntimeError(
                f"Unsupported distro for nspawn bootstrap: {distro}. "
                f"Supported: {', '.join(debian_distros)}"
            ) from e

        # Use debootstrap for Debian/Ubuntu
        cmd_parts = bootstrap_cmd.split()

        # Get mirror from distro registry
        mirror = get_mirror(distro) or "http://deb.debian.org/debian"

        full_cmd = ["sudo"] + cmd_parts + [str(machine_path), mirror]

        try:
            self._run_cmd(full_cmd, capture_output=False)
        except RuntimeError as e:
            # Clean up on failure
            if machine_path.exists():
                shutil.rmtree(machine_path)
            raise RuntimeError(f"Failed to bootstrap {distro}: {e}") from e

    def _setup_bootstrap_dns(self, name: str) -> None:
        """Set up static DNS resolvers for the bootstrap phase.

        During debootstrap and initial apt-get installs, systemd-resolved
        isn't running yet. This writes a static resolv.conf with public
        resolvers so apt can reach the internet.
        """
        machine_path = self._get_machine_path(name)
        resolv_conf = machine_path / "etc" / "resolv.conf"

        # DNS content with public resolvers
        dns_content = "nameserver 8.8.8.8\nnameserver 1.1.1.1\n"

        # Remove existing file/symlink and create a real file
        self._run_cmd(["sudo", "rm", "-f", str(resolv_conf)], check=False)
        subprocess.run(
            ["sudo", "tee", str(resolv_conf)],
            input=dns_content,
            text=True,
            capture_output=True,
        )

    def _setup_runtime_dns(self, name: str) -> None:
        """Set up DNS for runtime via systemd-resolved stub.

        After systemd-resolved is installed and enabled, symlink resolv.conf
        to the resolved stub so DNS works properly when the container boots.
        """
        resolv_conf = self._get_machine_path(name) / "etc" / "resolv.conf"
        self._run_cmd(["sudo", "rm", "-f", str(resolv_conf)], check=False)
        self._run_cmd([
            "sudo", "ln", "-sf",
            "/run/systemd/resolve/stub-resolv.conf",
            str(resolv_conf),
        ])

    def _install_base_packages(self, name: str) -> None:
        """Install base packages required for bootable containers.

        Installs systemd, dbus, and systemd-resolved to enable proper init system
        functionality and DNS resolution in nspawn containers.
        """
        machine_path = self._get_machine_path(name)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {name}")

        # Install systemd, dbus, and systemd-resolved
        # Use systemd-nspawn without boot to run apt in the new rootfs
        packages = ["systemd", "systemd-sysv", "dbus", "systemd-resolved"]

        # Set up DNS before running apt-get
        # This is needed because debootstrap's resolv.conf might not work with
        # the host's systemd-resolved stub resolver
        self._setup_bootstrap_dns(name)

        # Temporarily move the nspawn config to use host networking during setup
        # The nspawn config may set up bridged networking which requires DHCP, but
        # systemd-nspawn -D mode doesn't run systemd services like networkd
        nspawn_config = Path(f"/etc/systemd/nspawn/{name}.nspawn")
        nspawn_backup = Path(f"/tmp/{name}.nspawn.bak")
        if nspawn_config.exists():
            self._run_cmd(["sudo", "mv", str(nspawn_config), str(nspawn_backup)], check=False)

        try:
            # First update package lists
            # Use --resolv-conf=off to prevent nspawn from overriding our DNS config
            self._run_cmd(
                [
                    "sudo", "systemd-nspawn", "-D", str(machine_path),
                    "--resolv-conf=off",
                    "apt-get", "update",
                ],
                capture_output=False,
            )

            # Install packages non-interactively
            self._run_cmd(
                [
                    "sudo", "systemd-nspawn", "-D", str(machine_path),
                    "--resolv-conf=off",
                    "env", "DEBIAN_FRONTEND=noninteractive",
                    "apt-get", "install", "-y", *packages,
                ],
                capture_output=False,
            )
        finally:
            # Restore the nspawn config
            if nspawn_backup.exists():
                self._run_cmd(["sudo", "mv", str(nspawn_backup), str(nspawn_config)], check=False)

        # Enable systemd-resolved
        self._run_cmd(
            [
                "sudo", "systemd-nspawn", "-D", str(machine_path),
                "systemctl", "enable", "systemd-resolved",
            ],
            check=False,  # May fail if already enabled
        )

        # Enable systemd-networkd so the container gets DHCP on its veth
        self._run_cmd(
            [
                "sudo", "systemd-nspawn", "-D", str(machine_path),
                "systemctl", "enable", "systemd-networkd",
            ],
            check=False,
        )

        # Symlink resolv.conf to resolved stub for runtime DNS
        self._setup_runtime_dns(name)

    def _create_users(self, container_id: str, users: list[dict]) -> tuple[int, str, str]:
        """Create users in a running container.

        Args:
            container_id: ID of the container
            users: List of user configurations (as dicts)

        Returns:
            Tuple of (exit_code, stdout, stderr)
        """
        if not users:
            return 0, "", ""

        script_lines = ["#!/bin/bash", "set -e"]

        for user in users:
            name = user["name"]
            uid = user.get("uid")
            gid = user.get("gid")
            groups = user.get("groups", [])
            home = user.get("home")
            shell = user.get("shell", "/bin/bash")
            create_home = user.get("create_home", True)

            # Build useradd command
            cmd = ["useradd"]
            if uid is not None:
                cmd.extend(["-u", str(uid)])
            if gid is not None:
                cmd.extend(["-g", str(gid)])
            if home:
                cmd.extend(["-d", home])
            if create_home:
                cmd.append("-m")
            cmd.extend(["-s", shell])
            cmd.append(name)

            script_lines.append(f"if ! id {name} &>/dev/null; then")
            script_lines.append(f"    {' '.join(cmd)}")
            script_lines.append("fi")

            # Add to additional groups
            for group in groups:
                script_lines.append(f"usermod -aG {group} {name} 2>/dev/null || true")

        script = "\n".join(script_lines)

        result = self._run_cmd(
            ["sudo", "machinectl", "shell", f"root@{container_id}", "/bin/bash", "-c", script],
            check=False,
        )
        return result.returncode, result.stdout, result.stderr

    def _create_services(self, container_id: str, services: list[dict]) -> tuple[int, str, str]:
        """Create systemd services in a running container.

        Args:
            container_id: ID of the container
            services: List of service configurations (as dicts)

        Returns:
            Tuple of (exit_code, stdout, stderr)
        """
        if not services:
            return 0, "", ""

        all_stdout = []
        all_stderr = []
        final_code = 0

        for svc in services:
            name = svc["name"]
            command = svc["command"]
            user = svc.get("user", "root")
            workdir = svc.get("workdir")
            description = svc.get("description") or f"{name} service"
            restart = svc.get("restart", "on-failure")
            environment = svc.get("environment", {})
            enabled = svc.get("enabled", True)

            # Build service unit content
            service_content = f"""[Unit]
Description={description}
After=network.target

[Service]
Type=simple
User={user}
"""
            if workdir:
                service_content += f"WorkingDirectory={workdir}\n"

            for key, value in environment.items():
                service_content += f"Environment={key}={value}\n"

            service_content += f"""ExecStart={command}
Restart={restart}
RestartSec=5

[Install]
WantedBy=multi-user.target
"""

            # Create service file
            service_path = f"/etc/systemd/system/{name}.service"
            setup_script = f"""cat > {service_path} << 'SERVICEEOF'
{service_content}
SERVICEEOF
systemctl daemon-reload
"""
            if enabled:
                setup_script += f"systemctl enable {name}.service\n"

            result = self._run_cmd(
                ["sudo", "machinectl", "shell", f"root@{container_id}", "/bin/bash", "-c", setup_script],
                check=False,
            )

            all_stdout.append(result.stdout)
            all_stderr.append(result.stderr)
            if result.returncode != 0:
                final_code = result.returncode

        return final_code, "\n".join(all_stdout), "\n".join(all_stderr)

    def create(self, config: ContainerConfig) -> str:
        """Create a new nspawn container."""
        # Apply profiles if specified
        merged_profile: MergedProfile | None = None
        if config.profiles:
            applicator = get_profile_applicator()
            merged_profile = applicator.resolve_and_merge(config.profiles)
            config = applicator.apply_to_config(config, merged_profile)

        machine_path = self._get_machine_path(config.name)

        if machine_path.exists():
            raise RuntimeError(f"Machine already exists: {config.name}")

        # Bootstrap the root filesystem
        self._bootstrap_rootfs(config.name, config.distro)

        # Install base packages (systemd, dbus, systemd-resolved)
        self._install_base_packages(config.name)

        # Save metadata including merged profile info
        metadata = {
            "name": config.name,
            "runtime": "nspawn",
            "distro": config.distro,
            "profiles": config.profiles,
            "created_at": datetime.now().isoformat(),
            "network_mode": config.network.mode.value,
            "environment": config.environment,
            "volumes": config.volumes,
            "privileged": config.privileged,
            "users": [u.model_dump() for u in config.users],
            "services": [s.model_dump() for s in config.services],
            "packages": config.packages,
            "setup_commands": config.setup_commands,
        }
        self._save_metadata(config.name, metadata)

        # Create nspawn configuration file
        nspawn_config = self._generate_nspawn_config(config, merged_profile)
        nspawn_file = Path(f"/etc/systemd/nspawn/{config.name}.nspawn")
        nspawn_file.parent.mkdir(parents=True, exist_ok=True)

        # Write config using sudo
        self._run_cmd(
            ["sudo", "tee", str(nspawn_file)],
            input=nspawn_config,
            capture_output=True,
        )

        return config.name

    def _expand_path(self, path: str) -> str:
        """Expand ~ and environment variables in a path.

        Args:
            path: Path that may contain ~ or $VAR

        Returns:
            Expanded absolute path
        """
        import pwd

        # Get the original user when running with sudo
        sudo_user = os.environ.get("SUDO_USER")
        sudo_uid = os.environ.get("SUDO_UID")

        # Expand ~ to home directory
        if path.startswith("~"):
            if sudo_user:
                try:
                    user_home = pwd.getpwnam(sudo_user).pw_dir
                    path = path.replace("~", user_home, 1)
                except KeyError:
                    path = os.path.expanduser(path)
            else:
                path = os.path.expanduser(path)

        # For sudo, set common GUI environment variables if not already set
        # These are needed for expanding profile volume paths
        env_overrides: dict[str, str] = {}
        if sudo_uid and "${XDG_RUNTIME_DIR}" in path:
            env_overrides["XDG_RUNTIME_DIR"] = f"/run/user/{sudo_uid}"
        if "${WAYLAND_DISPLAY}" in path:
            # Default to wayland-0 if not set
            env_overrides["WAYLAND_DISPLAY"] = os.environ.get("WAYLAND_DISPLAY", "wayland-0")
        if "${DISPLAY}" in path:
            env_overrides["DISPLAY"] = os.environ.get("DISPLAY", ":0")

        # Apply overrides temporarily and expand
        original_env = {}
        for key, value in env_overrides.items():
            if key not in os.environ:
                original_env[key] = None
                os.environ[key] = value

        try:
            path = os.path.expandvars(path)
        finally:
            # Restore original environment
            for key, value in original_env.items():
                if value is None:
                    del os.environ[key]

        return path

    def _generate_nspawn_config(
        self, config: ContainerConfig, merged_profile: MergedProfile | None = None
    ) -> str:
        """Generate a .nspawn configuration file."""
        lines = ["[Exec]"]

        # Boot mode - boot the init system
        lines.append("Boot=yes")

        # User namespace - disable for GUI containers that need host socket access
        if not config.private_users:
            lines.append("PrivateUsers=no")

        # Resolve secrets in environment variables
        resolved_env = self._resolve_environment(config.environment)

        # Environment variables
        for key, value in resolved_env.items():
            lines.append(f"Environment={key}={value}")

        # Add capabilities from profiles
        if merged_profile:
            for cap in merged_profile.capabilities_add:
                lines.append(f"Capability={cap}")
            for cap in merged_profile.capabilities_drop:
                lines.append(f"DropCapability={cap}")

        # Network section
        lines.append("")
        lines.append("[Network]")

        if config.network.mode == NetworkMode.BRIDGED:
            lines.append("VirtualEthernet=yes")
            lines.append("Bridge=br0")
        elif config.network.mode == NetworkMode.HOST:
            lines.append("Private=no")
        elif config.network.mode == NetworkMode.NONE:
            lines.append("Private=yes")
        else:  # SHARED (NAT)
            lines.append("VirtualEthernet=yes")
            lines.append("Private=yes")

        # Port mappings
        for host_port, container_port in config.network.ports.items():
            lines.append(f"Port=tcp:{host_port}:{container_port}")

        # Files section for bind mounts and device passthrough
        files_lines: list[str] = []

        # Volume mounts - expand ~ and environment variables in both paths
        for host_path, container_path in config.volumes.items():
            expanded_host = self._expand_path(host_path)
            expanded_container = self._expand_path(container_path)
            files_lines.append(f"Bind={expanded_host}:{expanded_container}")

        # GPU device passthrough
        gpu_env: dict[str, str] = {}
        if config.gpu and config.gpu.enabled:
            from gaol.gpu import GPUDetector, GPUVendor
            from gaol.gpu.nvidia import NvidiaGPU
            from gaol.gpu.amd import AmdGPU
            from gaol.gpu.intel import IntelGPU

            vendor = config.gpu.vendor
            if vendor == GPUVendor.AUTO:
                if GPUDetector.detect_nvidia():
                    vendor = GPUVendor.NVIDIA
                elif GPUDetector.detect_amd():
                    vendor = GPUVendor.AMD
                elif GPUDetector.detect_intel():
                    vendor = GPUVendor.INTEL

            if vendor == GPUVendor.NVIDIA:
                gpu_config = NvidiaGPU.get_nspawn_config(config.gpu)
                for device in gpu_config["binds"]:
                    files_lines.append(f"Bind={device}")
                gpu_env.update(gpu_config["environment"])
            elif vendor == GPUVendor.AMD:
                gpu_config = AmdGPU.get_nspawn_config(config.gpu)
                for device in gpu_config["binds"]:
                    files_lines.append(f"Bind={device}")
                gpu_env.update(gpu_config["environment"])
            elif vendor == GPUVendor.INTEL:
                gpu_config = IntelGPU.get_nspawn_config(config.gpu)
                for device in gpu_config["binds"]:
                    files_lines.append(f"Bind={device}")
                gpu_env.update(gpu_config["environment"])

        # Custom device mappings
        for device in config.devices:
            container_path = device.container_path or device.host_path
            files_lines.append(f"Bind={device.host_path}:{container_path}")

        # Add Files section if we have any binds
        if files_lines:
            lines.append("")
            lines.append("[Files]")
            lines.extend(files_lines)

        # Add GPU environment variables to Exec section
        if gpu_env:
            # Insert GPU env vars after other environment variables
            insert_idx = next(
                (i for i, line in enumerate(lines) if line.startswith("[Network]")),
                len(lines),
            )
            for key, value in gpu_env.items():
                lines.insert(insert_idx, f"Environment={key}={value}")
                insert_idx += 1

        return "\n".join(lines) + "\n"

    def setup(self, container_id: str) -> tuple[int, str, str]:
        """Run profile setup script in a container.

        This should be called after start() to install packages and run setup commands.
        Also creates any users and services defined in the container config.

        Args:
            container_id: ID of the container

        Returns:
            Tuple of (exit_code, stdout, stderr)
        """
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        metadata = self._load_metadata(container_id)
        profiles = metadata.get("profiles", [])
        distro = metadata.get("distro", "trixie")
        users = metadata.get("users", [])
        services = metadata.get("services", [])

        config_packages = metadata.get("packages", [])

        all_stdout = []
        all_stderr = []
        final_code = 0

        # Build merged profile from profiles + config-level packages
        merged = MergedProfile()
        if profiles:
            applicator = get_profile_applicator()
            merged = applicator.resolve_and_merge(profiles)

        # Merge config-level setup commands (run before profile commands)
        config_setup_commands = metadata.get("setup_commands", [])
        if config_setup_commands:
            merged.setup_commands = config_setup_commands + merged.setup_commands

        # Merge config-level packages into the profile
        for pkg in config_packages:
            if pkg not in merged.packages:
                merged.packages.append(pkg)

        # Run setup script if there are packages or setup commands to apply
        if merged.packages or merged.setup_commands or profiles:
            setup_script = merged.generate_setup_script(distro)

            # Write setup script to container's /root (not /tmp which is tmpfs)
            setup_path = machine_path / "root" / "gaol-setup.sh"
            self._run_cmd(
                ["sudo", "tee", str(setup_path)],
                input=setup_script,
                capture_output=True,
            )
            self._run_cmd(["sudo", "chmod", "+x", str(setup_path)])

            # Run setup script in container
            result = self._run_cmd(
                ["sudo", "machinectl", "shell", f"root@{container_id}", "/root/gaol-setup.sh"],
                check=False,
            )
            all_stdout.append(result.stdout)
            all_stderr.append(result.stderr)
            if result.returncode != 0:
                final_code = result.returncode

        # Create users
        if users:
            code, stdout, stderr = self._create_users(container_id, users)
            all_stdout.append(stdout)
            all_stderr.append(stderr)
            if code != 0:
                final_code = code

        # Create services
        if services:
            code, stdout, stderr = self._create_services(container_id, services)
            all_stdout.append(stdout)
            all_stderr.append(stderr)
            if code != 0:
                final_code = code

        if not profiles and not config_packages and not users and not services:
            return 0, "No setup required\n", ""

        return final_code, "\n".join(all_stdout), "\n".join(all_stderr)

    def start(self, container_id: str) -> None:
        """Start a stopped container."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        status = self._get_machine_status(container_id)
        if status == ContainerStatus.RUNNING:
            return  # Already running

        # Start the machine using machinectl
        self._run_cmd(["sudo", "machinectl", "start", container_id])

    def stop(self, container_id: str, timeout: int = 10) -> None:
        """Stop a running container."""
        status = self._get_machine_status(container_id)
        if status != ContainerStatus.RUNNING:
            return  # Not running

        # Try graceful poweroff first
        result = self._run_cmd(
            ["sudo", "machinectl", "poweroff", container_id],
            check=False,
        )

        if result.returncode != 0:
            # Force terminate after timeout
            import time

            time.sleep(min(timeout, 2))  # Brief wait before force terminate
            self._run_cmd(["sudo", "machinectl", "terminate", container_id], check=False)

    def destroy(self, container_id: str, force: bool = False) -> None:
        """Remove a container."""
        machine_path = self._get_machine_path(container_id)

        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        # Stop if running
        status = self._get_machine_status(container_id)
        if status == ContainerStatus.RUNNING:
            if not force:
                raise RuntimeError(f"Machine is running: {container_id}. Use force=True to remove.")
            self.stop(container_id)

        # Remove the machine directory
        self._run_cmd(["sudo", "rm", "-rf", str(machine_path)])

        # Remove nspawn config if exists
        nspawn_file = Path(f"/etc/systemd/nspawn/{container_id}.nspawn")
        if nspawn_file.exists():
            self._run_cmd(["sudo", "rm", "-f", str(nspawn_file)])

    def exec(
        self,
        container_id: str,
        command: list[str],
        *,
        interactive: bool = False,
        tty: bool = False,  # noqa: ARG002 - not supported by systemd-run
        user: str | None = None,
        workdir: str | None = None,
        environment: dict[str, str] | None = None,
    ) -> tuple[int, str, str]:
        """Execute a command in a running container.

        Uses systemd-run --pipe for non-interactive execution (correct exit codes,
        PATH lookup, proper stdout/stderr capture). Falls back to machinectl shell
        for interactive use.
        """
        status = self._get_machine_status(container_id)
        if status != ContainerStatus.RUNNING:
            raise RuntimeError(f"Machine is not running: {container_id}")

        if interactive:
            return self._exec_interactive(container_id, command, user=user, workdir=workdir,
                                          environment=environment)

        # systemd-run --pipe: returns inner command's exit code, resolves PATH
        cmd = ["sudo", "systemd-run", "--machine", container_id,
               "--pipe", "--wait", "--quiet"]

        if user:
            cmd.extend(["--uid", user])

        if workdir:
            cmd.extend(["--working-directory", workdir])

        if environment:
            for key, value in environment.items():
                cmd.extend(["--setenv", f"{key}={value}"])

        cmd.append("--")
        cmd.extend(command)

        result = self._run_cmd(cmd, check=False)
        return result.returncode, result.stdout, result.stderr

    def _exec_interactive(
        self,
        container_id: str,
        command: list[str],
        *,
        user: str | None = None,
        workdir: str | None = None,
        environment: dict[str, str] | None = None,
    ) -> tuple[int, str, str]:
        """Interactive exec via machinectl shell (TTY passthrough)."""
        cmd = ["sudo", "machinectl", "shell"]

        if user:
            cmd.append(f"{user}@{container_id}")
        else:
            cmd.append(f"root@{container_id}")

        if environment:
            for key, value in environment.items():
                cmd.extend(["--setenv", f"{key}={value}"])

        if workdir:
            command = ["sh", "-c", f"cd {workdir} && " + " ".join(command)]

        cmd.extend(command)

        result = self._run_cmd(cmd, check=False)
        return result.returncode, result.stdout, result.stderr

    def list(self, all: bool = False) -> list[ContainerState]:
        """List containers."""
        containers = []

        if not MACHINES_DIR.exists():
            return containers

        for machine_path in MACHINES_DIR.iterdir():
            if not machine_path.is_dir():
                continue

            metadata_path = machine_path / METADATA_FILE
            if not metadata_path.exists():
                # Not a gaol-managed machine
                continue

            name = machine_path.name
            status = self._get_machine_status(name)

            # Skip stopped containers if not showing all
            if not all and status != ContainerStatus.RUNNING:
                continue

            metadata = self._load_metadata(name)
            containers.append(
                ContainerState(
                    id=name,
                    name=name,
                    runtime="nspawn",
                    status=status,
                    image=f"nspawn:{metadata.get('distro', 'unknown')}",
                    created_at=datetime.fromisoformat(metadata["created_at"])
                    if "created_at" in metadata
                    else None,
                    profiles=metadata.get("profiles", []),
                )
            )

        return containers

    def status(self, container_id: str) -> ContainerState:
        """Get the status of a container."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        metadata = self._load_metadata(container_id)
        status = self._get_machine_status(container_id)

        # Get IP address if running
        ip_address = None
        if status == ContainerStatus.RUNNING:
            result = self._run_cmd(
                ["machinectl", "show", container_id, "--property=IPAddress"],
                check=False,
            )
            if result.returncode == 0 and "=" in result.stdout:
                ip_address = result.stdout.strip().split("=")[1] or None

        return ContainerState(
            id=container_id,
            name=container_id,
            runtime="nspawn",
            status=status,
            image=f"nspawn:{metadata.get('distro', 'unknown')}",
            created_at=datetime.fromisoformat(metadata["created_at"])
            if "created_at" in metadata
            else None,
            ip_address=ip_address,
            profiles=metadata.get("profiles", []),
        )

    def logs(
        self,
        container_id: str,
        *,
        follow: bool = False,
        tail: int | None = None,
        since: str | None = None,
    ) -> str:
        """Get container logs using journalctl."""
        cmd = ["journalctl", "-M", container_id]

        if follow:
            cmd.append("-f")
        if tail:
            cmd.extend(["-n", str(tail)])
        if since:
            cmd.extend(["--since", since])

        result = self._run_cmd(cmd, check=False)
        return result.stdout

    def copy_to(self, container_id: str, src: str, dest: str) -> None:
        """Copy a file or directory into a container."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        dest_path = machine_path / dest.lstrip("/")
        self._run_cmd(["sudo", "cp", "-r", src, str(dest_path)])

    def copy_from(self, container_id: str, src: str, dest: str) -> None:
        """Copy a file or directory from a container."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        src_path = machine_path / src.lstrip("/")
        self._run_cmd(["sudo", "cp", "-r", str(src_path), dest])

    def _get_snapshots_dir(self, container_id: str) -> Path:
        """Get the snapshots directory for a container."""
        return MACHINES_DIR / f".gaol-snapshots-{container_id}"

    def _get_snapshot_path(self, container_id: str, snapshot_name: str) -> Path:
        """Get the path for a specific snapshot."""
        return self._get_snapshots_dir(container_id) / snapshot_name

    def snapshot_create(
        self, container_id: str, name: str, description: str | None = None
    ) -> Snapshot:
        """Create a snapshot by copying the machine directory."""
        from gaol.models.container import Snapshot

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        # Stop the container if running for consistent snapshot
        status = self._get_machine_status(container_id)
        was_running = status == ContainerStatus.RUNNING
        if was_running:
            self.stop(container_id)

        try:
            # Create snapshots directory
            snapshots_dir = self._get_snapshots_dir(container_id)
            self._run_cmd(["sudo", "mkdir", "-p", str(snapshots_dir)])

            # Create snapshot directory
            snapshot_path = self._get_snapshot_path(container_id, name)
            if snapshot_path.exists():
                raise RuntimeError(f"Snapshot already exists: {name}")

            # Copy machine directory to snapshot
            self._run_cmd(["sudo", "cp", "-a", str(machine_path), str(snapshot_path)])

            # Save snapshot metadata
            metadata = {
                "name": name,
                "container_id": container_id,
                "created_at": datetime.now().isoformat(),
                "description": description,
            }
            metadata_path = snapshot_path / ".snapshot-metadata.json"
            self._run_cmd(
                ["sudo", "tee", str(metadata_path)],
                input=json.dumps(metadata, indent=2),
                capture_output=True,
            )

            # Get directory size
            result = self._run_cmd(
                ["sudo", "du", "-sb", str(snapshot_path)],
                check=False,
            )
            size_bytes = None
            if result.returncode == 0:
                with contextlib.suppress(ValueError, IndexError):
                    size_bytes = int(result.stdout.split()[0])

            return Snapshot(
                id=f"{container_id}-{name}",
                name=name,
                container_id=container_id,
                container_name=container_id,
                created_at=datetime.now(),
                size_bytes=size_bytes,
                description=description,
            )
        finally:
            # Restart container if it was running
            if was_running:
                self.start(container_id)

    def snapshot_list(self, container_id: str) -> list[Snapshot]:
        """List snapshots for a container."""
        from gaol.models.container import Snapshot

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        snapshots_dir = self._get_snapshots_dir(container_id)
        if not snapshots_dir.exists():
            return []

        snapshots = []
        for snapshot_path in snapshots_dir.iterdir():
            if not snapshot_path.is_dir():
                continue

            # Read metadata
            metadata_path = snapshot_path / ".snapshot-metadata.json"
            if metadata_path.exists():
                result = self._run_cmd(
                    ["sudo", "cat", str(metadata_path)],
                    check=False,
                )
                if result.returncode == 0:
                    try:
                        metadata = json.loads(result.stdout)
                        created_at = datetime.fromisoformat(metadata.get("created_at", ""))
                    except (json.JSONDecodeError, ValueError):
                        created_at = datetime.now()
                        metadata = {}
                else:
                    created_at = datetime.now()
                    metadata = {}
            else:
                created_at = datetime.now()
                metadata = {}

            # Get directory size
            result = self._run_cmd(
                ["sudo", "du", "-sb", str(snapshot_path)],
                check=False,
            )
            size_bytes = None
            if result.returncode == 0:
                with contextlib.suppress(ValueError, IndexError):
                    size_bytes = int(result.stdout.split()[0])

            snapshots.append(
                Snapshot(
                    id=f"{container_id}-{snapshot_path.name}",
                    name=snapshot_path.name,
                    container_id=container_id,
                    container_name=container_id,
                    created_at=created_at,
                    size_bytes=size_bytes,
                    description=metadata.get("description"),
                )
            )

        return sorted(snapshots, key=lambda s: s.created_at, reverse=True)

    def snapshot_restore(self, container_id: str, snapshot_name: str) -> None:
        """Restore a container from a snapshot."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        snapshot_path = self._get_snapshot_path(container_id, snapshot_name)
        if not snapshot_path.exists():
            raise RuntimeError(f"Snapshot not found: {snapshot_name}")

        # Stop the container if running
        status = self._get_machine_status(container_id)
        if status == ContainerStatus.RUNNING:
            self.stop(container_id)

        # Remove current machine directory contents and restore from snapshot
        self._run_cmd(["sudo", "rm", "-rf", str(machine_path)])
        self._run_cmd(["sudo", "cp", "-a", str(snapshot_path), str(machine_path)])

        # Remove snapshot metadata file from restored machine
        restored_metadata = machine_path / ".snapshot-metadata.json"
        if restored_metadata.exists():
            self._run_cmd(["sudo", "rm", "-f", str(restored_metadata)])

    def snapshot_delete(self, container_id: str, snapshot_name: str) -> None:
        """Delete a snapshot."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        snapshot_path = self._get_snapshot_path(container_id, snapshot_name)
        if not snapshot_path.exists():
            raise RuntimeError(f"Snapshot not found: {snapshot_name}")

        self._run_cmd(["sudo", "rm", "-rf", str(snapshot_path)])

    def stats(self, container_id: str) -> ContainerStats:
        """Get resource usage statistics for a container using cgroups."""
        from gaol.models.container import ContainerStats

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        status = self._get_machine_status(container_id)
        if status != ContainerStatus.RUNNING:
            raise RuntimeError(f"Container not running: {container_id}")

        # Find cgroup path for the machine (systemd cgroup v2)
        # Machine services are at /sys/fs/cgroup/machine.slice/machine-{name}.scope
        cgroup_base = Path("/sys/fs/cgroup")
        machine_cgroup = cgroup_base / "machine.slice" / f"machine-{container_id}.scope"

        # Alternative path for systemd-nspawn
        if not machine_cgroup.exists():
            machine_cgroup = cgroup_base / "machine.slice" / f"systemd-nspawn@{container_id}.service"

        if not machine_cgroup.exists():
            # Fallback: try to find via machinectl
            result = self._run_cmd(
                ["machinectl", "show", container_id, "--property=ControlGroup"],
                check=False,
            )
            if result.returncode == 0 and "=" in result.stdout:
                cgroup_path = result.stdout.strip().split("=", 1)[1]
                machine_cgroup = cgroup_base / cgroup_path.lstrip("/")

        # Read cgroup stats
        cpu_percent = 0.0
        memory_used = 0
        memory_limit = None
        pids = 0

        if machine_cgroup.exists():
            # CPU stats (cgroup v2)
            cpu_stat_path = machine_cgroup / "cpu.stat"
            if cpu_stat_path.exists():
                # Read CPU usage - usage_usec gives total CPU time used
                cpu_stat = cpu_stat_path.read_text()
                for line in cpu_stat.splitlines():
                    if line.startswith("usage_usec"):
                        # Convert microseconds to a percentage estimate
                        # This is a snapshot, so we'd need two readings for accurate %
                        # For now, just report that we have CPU data
                        pass

            # Memory stats (cgroup v2)
            memory_current = machine_cgroup / "memory.current"
            if memory_current.exists():
                memory_used = int(memory_current.read_text().strip())

            memory_max = machine_cgroup / "memory.max"
            if memory_max.exists():
                max_val = memory_max.read_text().strip()
                if max_val != "max":
                    memory_limit = int(max_val)

            # PID count
            pids_current = machine_cgroup / "pids.current"
            if pids_current.exists():
                pids = int(pids_current.read_text().strip())

        # Calculate memory percentage
        memory_percent = 0.0
        if memory_limit and memory_limit > 0:
            memory_percent = (memory_used / memory_limit) * 100.0

        # Network stats - read from /proc inside the container
        network_rx = 0
        network_tx = 0

        # Try to get network stats via nsenter
        result = self._run_cmd(
            [
                "sudo", "nsenter", "-t", str(self._get_leader_pid(container_id)),
                "-n", "cat", "/proc/net/dev",
            ],
            check=False,
        )
        if result.returncode == 0:
            for line in result.stdout.splitlines():
                line = line.strip()
                if ":" in line and not line.startswith("lo:"):
                    parts = line.split()
                    if len(parts) >= 10:
                        # Format: iface: rx_bytes rx_packets ... tx_bytes tx_packets ...
                        with contextlib.suppress(ValueError):
                            network_rx += int(parts[1])
                            network_tx += int(parts[9])

        # Block I/O stats (cgroup v2)
        block_read = 0
        block_write = 0
        if machine_cgroup.exists():
            io_stat = machine_cgroup / "io.stat"
            if io_stat.exists():
                io_data = io_stat.read_text()
                for line in io_data.splitlines():
                    parts = line.split()
                    for part in parts:
                        if part.startswith("rbytes="):
                            block_read += int(part.split("=")[1])
                        elif part.startswith("wbytes="):
                            block_write += int(part.split("=")[1])

        # Get CPU count from host
        cpu_count = os.cpu_count() or 1

        return ContainerStats(
            container_id=container_id,
            container_name=container_id,
            cpu_percent=round(cpu_percent, 2),
            cpu_count=cpu_count,
            memory_used_bytes=memory_used,
            memory_limit_bytes=memory_limit,
            memory_percent=round(memory_percent, 2),
            network_rx_bytes=network_rx,
            network_tx_bytes=network_tx,
            block_read_bytes=block_read,
            block_write_bytes=block_write,
            pids=pids,
        )

    def _get_leader_pid(self, container_id: str) -> int:
        """Get the leader PID for a running machine."""
        result = self._run_cmd(
            ["machinectl", "show", container_id, "--property=Leader"],
            check=False,
        )
        if result.returncode == 0 and "=" in result.stdout:
            pid_str = result.stdout.strip().split("=", 1)[1]
            return int(pid_str)
        raise RuntimeError(f"Could not get leader PID for: {container_id}")

    def export_filesystem(
        self,
        container_id: str,
        dest_path: str,
        *,
        compression: str | None = "gzip",
    ) -> str:
        """Export nspawn machine filesystem to a tarball."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        # Stop if running for consistent export
        status = self._get_machine_status(container_id)
        was_running = status == ContainerStatus.RUNNING
        if was_running:
            self.stop(container_id)

        try:
            # Determine output filename and compression flag
            if compression == "gzip":
                ext = ".tar.gz"
                compress_flag = "-z"
            elif compression == "xz":
                ext = ".tar.xz"
                compress_flag = "-J"
            else:
                ext = ".tar"
                compress_flag = ""

            output_path = Path(dest_path) / f"{container_id}-export{ext}"

            # Create tarball from machine directory
            cmd = ["sudo", "tar"]
            if compress_flag:
                cmd.append(compress_flag)
            cmd.extend(["-cf", str(output_path), "-C", str(machine_path), "."])

            self._run_cmd(cmd)

            # Make output readable by current user
            self._run_cmd(["sudo", "chmod", "644", str(output_path)])

            return str(output_path)

        finally:
            if was_running:
                self.start(container_id)

    def import_filesystem(
        self,
        config: ContainerConfig,
        source_path: str,
    ) -> str:
        """Create nspawn machine by extracting tarball."""
        machine_path = self._get_machine_path(config.name)
        if machine_path.exists():
            raise RuntimeError(f"Machine already exists: {config.name}")

        source = Path(source_path)
        if not source.exists():
            raise RuntimeError(f"Source file not found: {source_path}")

        try:
            # Create machine directory
            self._run_cmd(["sudo", "mkdir", "-p", str(machine_path)])

            # Detect compression
            if str(source).endswith(".gz"):
                compress_flag = "-z"
            elif str(source).endswith(".xz"):
                compress_flag = "-J"
            else:
                compress_flag = ""

            # Extract tarball
            cmd = ["sudo", "tar"]
            if compress_flag:
                cmd.append(compress_flag)
            cmd.extend(["-xf", str(source), "-C", str(machine_path)])

            self._run_cmd(cmd)

            # Save metadata
            from datetime import datetime

            metadata = {
                "name": config.name,
                "runtime": "nspawn",
                "distro": config.distro,
                "profiles": config.profiles,
                "created_at": datetime.now().isoformat(),
                "migrated_from": "import",
            }
            self._save_metadata(config.name, metadata)

            # Generate nspawn config file
            # Apply profiles to get merged config
            from gaol.profiles import get_profile_applicator

            applicator = get_profile_applicator()
            merged_profile = applicator.resolve_and_merge(config.profiles)

            nspawn_config = self._generate_nspawn_config(config, merged_profile)
            nspawn_file = Path(f"/etc/systemd/nspawn/{config.name}.nspawn")
            self._run_cmd(
                ["sudo", "tee", str(nspawn_file)],
                input=nspawn_config,
            )

            return config.name

        except Exception as e:
            # Clean up on failure
            if machine_path.exists():
                self._run_cmd(["sudo", "rm", "-rf", str(machine_path)], check=False)
            raise RuntimeError(f"Failed to import filesystem: {e}") from e

    def get_filesystem_size(self, container_id: str) -> int:
        """Get approximate filesystem size in bytes."""
        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        # Use du to get directory size
        result = self._run_cmd(
            ["sudo", "du", "-sb", str(machine_path)],
            check=False,
        )
        if result.returncode == 0:
            try:
                size_str = result.stdout.strip().split()[0]
                return int(size_str)
            except (IndexError, ValueError):
                pass

        raise RuntimeError(f"Could not determine filesystem size for: {container_id}")

    # -------------------------------------------------------------------------
    # Checkpoint/Restore (CRIU) methods
    # -------------------------------------------------------------------------

    def checkpoint_create(
        self,
        container_id: str,
        checkpoint_name: str,
        image_dir: str | None = None,
        *,
        leave_running: bool = False,
        tcp_established: bool = False,
    ) -> "CheckpointResult":
        """Create a CRIU checkpoint of a running nspawn container.

        Uses CRIU to checkpoint the container's running state including
        memory, processes, and optionally network connections.

        Args:
            container_id: ID of the container to checkpoint
            checkpoint_name: Name for the checkpoint
            image_dir: Directory to store checkpoint images (optional)
            leave_running: Keep container running after checkpoint
            tcp_established: Checkpoint established TCP connections

        Returns:
            CheckpointResult with operation status
        """
        from gaol.checkpoint.executor import NspawnCheckpointExecutor
        from gaol.checkpoint.store import get_checkpoint_store

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        status = self._get_machine_status(container_id)
        if status != ContainerStatus.RUNNING:
            raise RuntimeError(f"Container not running: {container_id}")

        # Use provided image_dir or create one in checkpoint store
        if image_dir:
            checkpoint_image_dir = Path(image_dir) / checkpoint_name
        else:
            store = get_checkpoint_store()
            checkpoint_image_dir = store.create_image_dir(container_id, checkpoint_name)

        executor = NspawnCheckpointExecutor()
        return executor.checkpoint_create(
            machine_name=container_id,
            image_dir=checkpoint_image_dir,
            checkpoint_name=checkpoint_name,
            leave_running=leave_running,
            tcp_established=tcp_established,
        )

    def checkpoint_restore(
        self,
        container_id: str,
        checkpoint_name: str,
        image_dir: str | None = None,
        *,
        tcp_established: bool = False,
    ) -> "RestoreResult":
        """Restore an nspawn container from a CRIU checkpoint.

        Args:
            container_id: ID of the container to restore
            checkpoint_name: Name of the checkpoint to restore from
            image_dir: Directory containing checkpoint images
            tcp_established: Restore TCP connections

        Returns:
            RestoreResult with operation status
        """
        from gaol.checkpoint.executor import NspawnCheckpointExecutor
        from gaol.checkpoint.store import get_checkpoint_store

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        # Get image directory
        if image_dir:
            checkpoint_image_dir = Path(image_dir) / checkpoint_name
        else:
            store = get_checkpoint_store()
            checkpoint_image_dir = store.get_image_dir(container_id, checkpoint_name)

        if not checkpoint_image_dir.exists():
            raise RuntimeError(f"Checkpoint not found: {checkpoint_name}")

        executor = NspawnCheckpointExecutor()
        return executor.restore(
            machine_name=container_id,
            image_dir=checkpoint_image_dir,
            checkpoint_name=checkpoint_name,
            tcp_established=tcp_established,
        )

    def checkpoint_list(self, container_id: str) -> list[str]:
        """List available checkpoints for an nspawn container.

        Args:
            container_id: ID of the container

        Returns:
            List of checkpoint names
        """
        from gaol.checkpoint.store import get_checkpoint_store

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        store = get_checkpoint_store()
        return store.list_image_dirs(container_id)

    def checkpoint_delete(
        self,
        container_id: str,
        checkpoint_name: str,
        image_dir: str | None = None,
    ) -> bool:
        """Delete an nspawn checkpoint.

        Args:
            container_id: ID of the container
            checkpoint_name: Name of the checkpoint to delete
            image_dir: Directory containing checkpoint images

        Returns:
            True if checkpoint was deleted
        """
        from gaol.checkpoint.store import get_checkpoint_store

        machine_path = self._get_machine_path(container_id)
        if not machine_path.exists():
            raise RuntimeError(f"Machine not found: {container_id}")

        if image_dir:
            checkpoint_image_dir = Path(image_dir) / checkpoint_name
            if checkpoint_image_dir.exists():
                shutil.rmtree(checkpoint_image_dir)
                return True
            return False

        store = get_checkpoint_store()
        return store.delete_image_dir(container_id, checkpoint_name)

    def checkpoint_capabilities(self) -> dict[str, bool]:
        """Get CRIU checkpoint capabilities for nspawn.

        Returns:
            Dictionary with capability names and availability
        """
        from gaol.checkpoint.executor import CRIUExecutor

        caps = CRIUExecutor.check_capabilities()
        return {
            "available": caps.available,
            "kernel_checkpoint_restore": caps.kernel_checkpoint_restore,
            "kernel_ns_last_pid": caps.kernel_ns_last_pid,
            "tcp_established": caps.tcp_established,
            "external_unix_sockets": caps.external_unix_sockets,
            "file_locks": caps.file_locks,
            "lazy_pages": caps.lazy_pages,
            "has_cap_sys_admin": caps.has_cap_sys_admin,
            "leave_running": caps.available,
        }
