# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (C) 2026 YueGuobin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
Web Wireshark - Session Manager

This module handles xpra session management for Web Wireshark containers.
"""

import asyncio
import logging
import re
import socket
from typing import Optional
from urllib.parse import urlparse

from gns3server.config import Config
from gns3server.utils.port_allocator import link_id_to_display, link_id_to_port
from .docker_client import DockerHTTPClient

logger = logging.getLogger(__name__)

# Default GNS3 server address
DEFAULT_GNS3_URL = "http://127.0.0.1:3080"


class WebWiresharkManager:
    """Manages Web Wireshark sessions and containers."""

    # Container command execution timeout (seconds)
    CONTAINER_EXEC_TIMEOUT = 10

    def __init__(self):
        self.docker = DockerHTTPClient()
        self.network_name = "gns3-wireshark"

    async def close(self):
        """Close Docker connection."""
        await self.docker.close()

    async def _is_container_healthy(self, container_id: str) -> bool:
        """Check if container is responsive.

        Args:
            container_id: Container ID

        Returns:
            True if healthy, False otherwise
        """
        try:
            proc = await asyncio.create_subprocess_exec(
                "docker", "exec", container_id,
                "bash", "-c", "echo 'ping'",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await asyncio.wait_for(
                proc.communicate(),
                timeout=self.CONTAINER_EXEC_TIMEOUT
            )
            return proc.returncode == 0 and b"ping" in stdout
        except asyncio.TimeoutError:
            logger.warning(f"Container {container_id[:12]} health check timeout")
            return False
        except FileNotFoundError:
            logger.warning(f"Container {container_id[:12]} docker command not found")
            return False
        except Exception as e:
            logger.warning(f"Container {container_id[:12]} health check failed: {e}")
            return False

    async def _exec_in_container(self, container_id: str, command: str, timeout: int = None) -> tuple:
        """Execute command in container with timeout.

        Args:
            container_id: Container ID
            command: Command to execute
            timeout: Timeout in seconds (default: CONTAINER_EXEC_TIMEOUT)

        Returns:
            (returncode, stdout, stderr)
        """
        if timeout is None:
            timeout = self.CONTAINER_EXEC_TIMEOUT

        try:
            proc = await asyncio.create_subprocess_exec(
                "docker", "exec", container_id,
                "bash", "-c", command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            try:
                stdout, stderr = await asyncio.wait_for(
                    proc.communicate(),
                    timeout=timeout
                )
                return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
            except asyncio.TimeoutError:
                logger.error(f"Command timeout after {timeout}s: {command}")
                proc.terminate()
                try:
                    await asyncio.wait_for(proc.wait(), timeout=2)
                except asyncio.TimeoutError:
                    proc.kill()
                return (-1, "", f"timeout after {timeout}s")
        except Exception as e:
            logger.error(f"Command execution failed: {e}")
            return (-1, "", str(e))

    async def _kill_process_tree(self, container_id: str, pattern: str) -> None:
        """Kill all processes matching pattern inside the container.

        Uses pgrep inside the container to get container-local PIDs, then kills them.
        This avoids the issue where Docker API returns host PIDs instead of container PIDs.

        Args:
            container_id: Container ID
            pattern: Process pattern to match (regex applied to COMMAND field)
        """
        try:
            # Get container-local PIDs using pgrep inside the container
            pgrep_cmd = f"sh -c 'pgrep -f \"{pattern}\" || true'"
            returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd)
            pids_str = stdout.strip()

            if not pids_str:
                logger.debug(f"No processes found matching pattern '{pattern}'")
                return

            pids = pids_str.replace('\n', ' ')
            logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")

            # Kill the processes using their container-local PIDs
            kill_cmd = f"kill -9 {pids} 2>/dev/null || true"
            returncode, stdout, stderr = await self._exec_in_container(container_id, kill_cmd)
            logger.debug(f"Kill result: returncode={returncode}")

        except Exception as e:
            logger.error(f"Error killing processes matching '{pattern}': {e}")

    async def _check_residuals_exist(self, container_id: str, display: int) -> tuple[bool, bool]:
        """Check if there are residual processes or files for a display.

        Uses host perspective for fast checking (~20ms vs ~850ms for docker exec).
        Returns two booleans: (has_process_residuals, has_socket_residuals).
        This allows selective cleanup when only one type needs attention.

        Args:
            container_id: Container ID
            display: Display number (e.g., 10210)

        Returns:
            Tuple of (has_process_residuals, has_socket_residuals)
        """
        try:
            # Get container init PID from host perspective
            proc = await asyncio.create_subprocess_exec(
                "docker", "inspect", container_id,
                "--format", "{{.State.Pid}}",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await proc.communicate()
            container_init_pid = stdout.decode().strip()

            if not container_init_pid:
                # Can't determine, assume cleanup needed
                return (True, True)

            has_process_residuals = False
            has_socket_residuals = False

            # Check for residual processes from host perspective
            proc = await asyncio.create_subprocess_exec(
                "ps", "-eo", "pid,ppid,args",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await proc.communicate()

            # Build parent->children mapping
            children_map = {}
            for line in stdout.decode().strip().split('\n'):
                parts = line.split(None, 2)
                if len(parts) < 3:
                    continue
                try:
                    pid, ppid = int(parts[0]), int(parts[1])
                    children_map.setdefault(ppid, []).append(pid)
                except ValueError:
                    continue

            # Recursively find all descendant PIDs of container init
            descendant_pids = set()

            def find_descendants(ppid):
                children = children_map.get(ppid, [])
                for child_pid in children:
                    descendant_pids.add(child_pid)
                    find_descendants(child_pid)

            find_descendants(int(container_init_pid))

            # Check if any descendant processes match display patterns
            import re
            patterns = [
                f'xpra.*:{display}',
                f'Xvfb.*:{display}',
                f'wireshark.*:{display}',
                f'pulseaudio.*display=:{display}'
            ]

            for line in stdout.decode().strip().split('\n'):
                parts = line.split(None, 2)
                if len(parts) < 3:
                    continue
                try:
                    pid, ppid = int(parts[0]), int(parts[1])
                    command = parts[2]

                    if pid not in descendant_pids:
                        continue

                    for pattern in patterns:
                        if re.search(pattern, command):
                            logger.debug(f"Found residual process: PID={pid}, COMMAND={command[:80]}")
                            has_process_residuals = True
                            break
                except ValueError:
                    continue

            # Check for X lock files from host perspective
            # /proc/<pid>/root points to container filesystem
            lock_paths = [
                f"/proc/{container_init_pid}/root/tmp/.X{display}-lock",
                f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}"
            ]
            for lock_path in lock_paths:
                proc = await asyncio.create_subprocess_exec(
                    "test", "-e", lock_path,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE
                )
                await proc.communicate()
                if proc.returncode == 0:
                    logger.debug(f"Found residual lock file: {lock_path}")
                    has_socket_residuals = True

            # Check for xpra socket files
            socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
            proc = await asyncio.create_subprocess_exec(
                "test", "-e", socket_path,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            await proc.communicate()
            if proc.returncode == 0:
                logger.debug(f"Found residual socket: {socket_path}")
                has_socket_residuals = True

            return (has_process_residuals, has_socket_residuals)

        except Exception as e:
            logger.warning(f"Error checking residuals (assuming cleanup needed): {e}")
            return (True, True)

    async def _kill_process_tree_batch(self, container_id: str, patterns: list) -> None:
        """Kill all processes matching multiple patterns inside the container.

        Uses host perspective to find and kill container processes much faster
        than docker exec (37x faster: ~23ms vs ~850ms).

        This method finds ALL processes in the container (including grandchildren)
        by walking the process tree from container init, not just direct children.

        Args:
            container_id: Container ID
            patterns: List of process patterns to match (regex applied to COMMAND field)
        """
        if not patterns:
            return

        try:
            # Get container init PID from host perspective
            proc = await asyncio.create_subprocess_exec(
                "docker", "inspect", container_id,
                "--format", "{{.State.Pid}}",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await proc.communicate()
            container_init_pid = stdout.decode().strip()

            if not container_init_pid:
                logger.warning("Could not get container init PID, falling back to docker exec")
                await self._kill_process_tree_batch_via_exec(container_id, patterns)
                return

            # List all processes from host perspective with PID, PPID, and command
            # This is faster than docker exec and allows us to walk the process tree
            proc = await asyncio.create_subprocess_exec(
                "ps", "-eo", "pid,ppid,args",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            )
            stdout, _ = await proc.communicate()

            if not stdout.strip():
                logger.debug("No processes found from host perspective")
                return

            # Build parent->children mapping and collect process info
            children_map = {}  # ppid -> [pid]
            process_info = {}  # pid -> (ppid, command)

            for line in stdout.decode().strip().split('\n'):
                parts = line.split(None, 2)
                if len(parts) < 3:
                    continue
                try:
                    pid, ppid = int(parts[0]), int(parts[1])
                    command = parts[2]

                    children_map.setdefault(ppid, []).append(pid)
                    process_info[pid] = (ppid, command)
                except ValueError:
                    continue

            # Recursively find all descendant processes of container init
            descendant_pids = set()

            def find_descendants(ppid):
                """Recursively find all descendant PIDs."""
                children = children_map.get(ppid, [])
                for child_pid in children:
                    descendant_pids.add(child_pid)
                    find_descendants(child_pid)  # Recursively find grandchildren

            find_descendants(int(container_init_pid))

            # Filter descendant processes by pattern and extract PIDs
            matching_pids = []
            for pid in descendant_pids:
                if pid not in process_info:
                    continue
                _, command = process_info[pid]

                # Check if command matches any pattern
                for pattern in patterns:
                    if re.search(pattern, command):
                        matching_pids.append(str(pid))
                        logger.debug(f"Found process: PID={pid}, COMMAND={command[:100]}")
                        break

            if matching_pids:
                logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
                # Kill all matching PIDs from host perspective
                proc = await asyncio.create_subprocess_exec(
                    "kill", "-9", *matching_pids,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE
                )
                await proc.communicate()
                logger.debug("Kill completed from host perspective")
            else:
                logger.debug(f"No processes found matching patterns: {patterns}")

        except Exception as e:
            logger.warning(f"Error using host perspective for killing (fallback to docker exec): {e}")
            # Fallback to docker exec method
            await self._kill_process_tree_batch_via_exec(container_id, patterns)

    async def _kill_process_tree_batch_via_exec(self, container_id: str, patterns: list) -> None:
        """Kill processes using docker exec (fallback method).

        This is slower (~850ms) but more reliable in some edge cases.

        Args:
            container_id: Container ID
            patterns: List of process patterns to match
        """
        try:
            # Combine all patterns into a single regex using OR operator
            combined_pattern = '|'.join(f'({pattern})' for pattern in patterns)

            # Single pgrep to find all matching processes
            pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi'

            returncode, stdout, stderr = await self._exec_in_container(
                container_id,
                pgrep_cmd,
                timeout=5
            )

            if "Found processes:" in stdout:
                logger.info(stdout.strip())

        except Exception as e:
            logger.error(f"Error in batch kill via docker exec: {e}")

    async def _cleanup_x_lock(self, container_id: str, display: int) -> None:
        """Remove X lock file and xpra socket files for a display.

        Args:
            container_id: Container ID
            display: Display number (e.g., 10210)
        """
        # Clean up X lock files and xpra socket files in a single docker exec
        # This reduces docker exec calls from 2 to 1, improving stop performance
        cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
               f"/run/user/1000/xpra/{display}/socket "
               f"/run/user/1000/xpra/*-{display} "
               f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
        returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
        logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")

    async def _cleanup_socket_files(self, container_id: str, display: int) -> None:
        """Remove only xpra socket files for a display (faster, single docker exec).

        Args:
            container_id: Container ID
            display: Display number (e.g., 10210)
        """
        cmd = (f"rm -f /run/user/1000/xpra/{display}/socket "
               f"/run/user/1000/xpra/*-{display} "
               f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
        returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
        logger.debug(f"Cleanup socket files: returncode={returncode}")

    @staticmethod
    def _parse_memory(memory_str: str) -> int:
        """Parse memory string to bytes.

        Args:
            memory_str: Memory string (e.g., "2g", "512m")

        Returns:
            Memory in bytes
        """
        memory_str = memory_str.lower().strip()
        if not memory_str:
            return 0

        # Extract number and unit
        match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str)
        if not match:
            raise ValueError(f"Invalid memory format: {memory_str}")

        value = float(match.group(1))
        unit = match.group(2) or 'b'

        # Convert to bytes
        multipliers = {
            'b': 1,
            'k': 1024,
            'm': 1024 * 1024,
            'g': 1024 * 1024 * 1024
        }

        unit = unit[0]  # Take first character
        return int(value * multipliers.get(unit, 1))

    def _get_gns3_url_from_config(self) -> Optional[str]:
        """Get GNS3 server URL from config file."""
        try:
            server_config = Config.instance().settings.Server
            url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
            logger.info(f"Got GNS3 URL from Config: {url}")
            return url
        except Exception as e:
            logger.debug(f"Cannot get URL from Config: {e}")
        return None

    async def _get_container_gateway_ip(self, container_id: str = None) -> Optional[str]:
        """Get the Docker bridge gateway IP for container to access host.

        Args:
            container_id: Container ID (optional, only used for fallback methods)

        Returns:
            Gateway IP (e.g., 172.31.0.1) or None if not detectable
        """
        # Method 1: Get from Docker Network API (fastest, no container access needed)
        try:
            network = await self.docker.get_network(self.network_name)
            if network and "IPAM" in network:
                for config in network["IPAM"].get("Config", []):
                    if "Gateway" in config:
                        gateway_ip = config["Gateway"]
                        logger.info(f"Got gateway IP from Docker network API: {gateway_ip}")
                        return gateway_ip
        except Exception as e:
            logger.debug(f"Cannot get gateway from Docker network API: {e}")

        # Fallback: Read from /proc/net/route inside container (slower)
        if container_id:
            try:
                returncode, stdout, stderr = await self._exec_in_container(
                    container_id,
                    "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
                )
                logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'")
                if returncode == 0 and stdout.strip():
                    gateway_hex = stdout.strip()
                    # Convert hex to dotted decimal
                    gateway_ip = socket.inet_ntoa(bytes.fromhex(gateway_hex)[::-1])
                    logger.info(f"Detected container gateway IP from route: {gateway_ip}")
                    return gateway_ip
            except Exception as e:
                logger.debug(f"Cannot get gateway from /proc/net/route: {e}")

        return None

    async def get_container_ip(self, container_name: str, container_id: str = None) -> Optional[str]:
        """Get the container IP address in the wireshark network.

        Args:
            container_name: Container name (e.g., "gns3-wireshark-project_id")
            container_id: Container ID (optional, only used for fallback method)

        Returns:
            Container IP address (e.g., 172.31.0.2) or None if not found
        """
        # Method 1: Get from Docker Container API (fastest)
        try:
            container = await self.docker.get_container(container_name)
            if container:
                networks = container.get("NetworkSettings", {}).get("Networks", {})
                # Find gns3-wireshark network
                for network_name, network_config in networks.items():
                    if "wireshark" in network_name.lower():
                        container_ip = network_config.get("IPAddress")
                        if container_ip:
                            logger.info(f"Got container IP from Docker API: {container_ip}")
                            return container_ip
        except Exception as e:
            logger.debug(f"Cannot get container IP from Docker API: {e}")

        # Fallback: Execute command inside container to get IP (slower)
        if container_id:
            try:
                # Use 'hostname -I' to get all IP addresses and take the first one
                # This works on most Linux systems and is more portable than 'ip' command
                returncode, stdout, stderr = await self._exec_in_container(
                    container_id,
                    "hostname -I 2>/dev/null | awk '{print $1}'"
                )
                if returncode == 0 and stdout.strip():
                    container_ip = stdout.strip()
                    logger.info(f"Got container IP from container command: {container_ip}")
                    return container_ip
            except Exception as e:
                logger.debug(f"Cannot get container IP from container command: {e}")

        logger.warning(f"Failed to get container IP for {container_name}")
        return None

    async def _fix_localhost_url(self, url: str, container_id: str) -> str:
        """Fix URL with localhost/127.0.0.1 for container access.

        If the URL points to localhost, replace it with the container's
        gateway IP so the container can reach the host.

        Args:
            url: Original URL
            container_id: Container ID

        Returns:
            Fixed URL with accessible host IP
        """
        try:
            parsed = urlparse(url)
            if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'):
                gateway = await self._get_container_gateway_ip(container_id)
                if gateway:
                    fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
                    if parsed.query:
                        fixed_url += f"?{parsed.query}"
                    logger.info(f"Fixed localhost URL for container: {url} -> {fixed_url}")
                    return fixed_url
                else:
                    logger.warning(f"Cannot detect gateway IP, keeping original URL: {url}")
        except Exception as e:
            logger.warning(f"Error fixing localhost URL: {e}")
        return url

    def detect_gns3_url(self) -> str:
        """Detect GNS3 server URL from config or use default.

        Note: This returns the raw URL (may contain 127.0.0.1).
        The URL will be fixed for container access later using _fix_localhost_url.

        Returns:
            GNS3 server URL
        """
        # Strategy 1: Get from Config
        url = self._get_gns3_url_from_config()
        if url:
            return url

        # Strategy 2: Use default
        logger.warning(f"Using fallback default URL: {DEFAULT_GNS3_URL}")
        return DEFAULT_GNS3_URL

    async def ensure_network(self):
        """Ensure Docker network exists."""
        network = await self.docker.get_network(self.network_name)
        if network:
            logger.info(f"Network {self.network_name} already exists")
        else:
            # Get subnet from config or use default
            config = Config.instance()
            subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
            logger.info(f"Creating network {self.network_name} with subnet {subnet}")
            await self.docker.create_network(
                self.network_name,
                driver="bridge",
                subnet=subnet
            )

    async def get_or_create_container(
        self,
        project_id: str,
        image: str = "gns3/web-wireshark:latest",
        memory: str = "2g",
        memory_swap: str = None,
        cpus: float = 1.0,
        pids_limit: int = 1000
    ) -> str:
        """Get or create project's Web Wireshark container.

        Args:
            project_id: Project ID
            image: Docker image name
            memory: Memory limit (default: 2g)
            memory_swap: Memory swap limit (default: same as memory)
            cpus: CPU cores (default: 1.0)
            pids_limit: Process limit (default: 1000)

        Returns:
            Container ID
        """
        container_name = f"gns3-wireshark-{project_id}"

        logger.info(f"Checking container {container_name}")
        container = await self.docker.get_container(container_name)
        logger.info(f"Container found: {container is not None}")

        if container:
            logger.info(f"Container state: {container['State']}")
            if container["State"]["Running"]:
                # Use Docker's built-in health check status instead of manual ping
                health_status = container.get("Health", {}).get("Status", None)

                if health_status == "unhealthy":
                    # Only unhealthy containers need further verification
                    logger.warning(f"Container {container_name} reports unhealthy status")
                    if not await self._is_container_healthy(container["Id"]):
                        # Container is truly unresponsive, force remove
                        logger.warning(f"Container {container_name} is not responding, force removing...")
                        try:
                            await self.docker.remove_container(container["Id"], force=True)
                            logger.info(f"Container {container_name} force removed")
                        except Exception as e:
                            logger.warning(f"Failed to remove unhealthy container: {e}")
                        container = None  # Trigger recreation
                    else:
                        # Health check passed despite unhealthy status, use it
                        logger.info(f"Container {container_name} is responsive despite unhealthy status")
                        return container["Id"]
                else:
                    # healthy, starting, or no health check configured - trust Docker
                    logger.info(f"Container {container_name} is running (health: {health_status})")
                    return container["Id"]
            else:
                logger.info(f"Starting existing container {container_name}")
                await self.docker.start_container(container["Id"])
                if await self._is_container_healthy(container["Id"]):
                    return container["Id"]
                # Still unhealthy after start, remove and recreate
                logger.error(f"Container {container_name} failed to become healthy, force removing...")
                try:
                    await self.docker.remove_container(container["Id"], force=True)
                except Exception:
                    pass
                container = None

        # Need to create new container
        if container is None:
            logger.info(f"Creating new container {container_name} with image {image}")
            logger.info(f"Resources: memory={memory}, cpus={cpus}, pids_limit={pids_limit}")

            # Calculate CPU quota (NanoCpus uses nanoseconds: 1 CPU = 1000000000)
            cpu_quota = int(cpus * 1000000000)

            # Configure host settings
            host_config = {
                "Memory": self._parse_memory(memory),
                "MemorySwap": self._parse_memory(memory_swap) if memory_swap else 0,
                "NanoCpus": cpu_quota,
                "PidsLimit": pids_limit,
                "RestartPolicy": {"Name": "unless-stopped"},
                "Init": True,  # Use init system (tini) as PID 1 to reap zombie processes
                "LogConfig": {
                    "Type": "json-file",
                    "Config": {
                        "max-size": "10m",
                        "max-file": "3"
                    }
                }
            }

            # Health check configuration
            health_config = {
                "Test": ["CMD-SHELL", "xpra list"],
                "Interval": 30000000000,  # 30 seconds (nanoseconds)
                "Timeout": 10000000000,   # 10 seconds
                "Retries": 3
            }

            try:
                container_id = await self.docker.create_container(
                    name=container_name,
                    image=image,
                    network=self.network_name,
                    host_config=host_config,
                    health_config=health_config,
                    environment={
                        "XDG_RUNTIME_DIR": "/run/user/1000",
                        "LANG": "C.UTF-8",
                        "LC_ALL": "C.UTF-8"
                    }
                )
            except RuntimeError as e:
                error_msg = str(e)
                if "404" in error_msg or "not found" in error_msg.lower():
                    raise RuntimeError(
                        f"Docker image '{image}' not found. "
                        f"Please pull the image from Docker Hub: docker pull {image} "
                        f"Or build it locally using the Dockerfile in the GNS3 repository."
                    ) from e
                raise

            # Start container
            await self.docker.start_container(container_id)
            logger.info(f"Container {container_name} created and started")
            return container_id

        return container["Id"]

    async def start_wireshark_session(
        self,
        project_id: str,
        link_id: str,
        jwt_token: str,
        capture_stream_url: Optional[str] = None,
        image: str = "gns3/web-wireshark:latest",
        memory: str = "2g",
        memory_swap: Optional[str] = None,
        cpus: float = 1.0,
        pids_limit: int = 1000
    ) -> dict:
        """Start Web Wireshark session.

        Args:
            project_id: Project ID
            link_id: Link ID
            jwt_token: JWT authentication token
            capture_stream_url: Capture stream URL (optional, auto-detected if not provided)
            image: Docker image name
            memory: Memory limit
            memory_swap: Memory swap limit
            cpus: CPU cores
            pids_limit: Process limit

        Returns:
            Session info dict
        """
        logger.info(f"Starting Web Wireshark session for link {link_id}")

        # Ensure Docker network exists before creating container
        await self.ensure_network()

        # Auto-detect capture_stream_url if not provided
        if not capture_stream_url:
            gns3_url = self.detect_gns3_url()
            capture_stream_url = (
                f"{gns3_url}/v3/projects/{project_id}/links/"
                f"{link_id}/capture/stream"
            )
            logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")

        container_id = await self.get_or_create_container(
            project_id,
            image,
            memory,
            memory_swap,
            cpus,
            pids_limit
        )
        container_name = f"gns3-wireshark-{project_id}"

        # Fix localhost URL after we have container_id
        capture_stream_url = await self._fix_localhost_url(capture_stream_url, container_id)
        logger.info(f"Final capture stream URL: {capture_stream_url}")

        # Allocate display and port using deterministic hash
        display = link_id_to_display(link_id)
        port = link_id_to_port(link_id)

        # Check for residual processes/files from host perspective (fast ~20ms)
        # Only execute docker exec cleanup (~850ms) if residuals are found
        has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display)

        if has_process_residuals or has_socket_residuals:
            logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...")
            if has_process_residuals:
                patterns = [
                    f'xpra.*:{display}',
                    f'Xvfb.*:{display}',
                    f'wireshark.*:{display}',
                    f'pulseaudio.*display=:{display}'
                ]
                await self._kill_process_tree_batch(container_id, patterns)
            if has_socket_residuals:
                await self._cleanup_x_lock(container_id, display)
        else:
            logger.debug(f"No residual processes/files found on display :{display}")

        # Prepare xpra command
        session_name = f"link-{link_id}"
        logger.info(f"Starting xpra session {session_name} on display :{display}")

        xpra_cmd = [
            "XPRA_CLIENT_CAN_SHUTDOWN=false",
            "xpra", "start", f":{display}",
            '--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
            "--html=off",
            f"--bind-ws=0.0.0.0:{port}",
            f"--session-name={session_name}",
            "--daemon=yes",
            "--dbus-launch=no",
            "--resize-display=yes"
        ]

        # Parallel execution: get container info + start xpra
        container_info_task = asyncio.create_task(
            self.docker.get_container(container_name)
        )
        xpra_start_task = asyncio.create_task(
            self._exec_in_container(container_id, " ".join(xpra_cmd))
        )

        # Wait for both tasks to complete
        container, (returncode, stdout, stderr) = await asyncio.gather(
            container_info_task,
            xpra_start_task
        )

        # Check xpra start result
        if returncode != 0:
            logger.error(f"xpra start failed (code {returncode}): {stdout} {stderr}")
            raise RuntimeError(f"xpra start failed: {stdout} {stderr}")

        logger.info(f"xpra session {session_name} started successfully on display :{display}")

        # Extract container IP
        networks = container["NetworkSettings"]["Networks"]
        container_ip = networks.get(self.network_name, {}).get("IPAddress", "")

        if not container_ip:
            logger.error(f"Container {container_name} has no IP in network {self.network_name}")
            raise RuntimeError("Container has no IP address")

        # Start Wireshark in background (fire-and-forget, no waiting)
        # Use -k for HTTPS to skip certificate validation (for self-signed certs)
        curl_insecure_flag = "-k" if capture_stream_url.startswith("https://") else ""
        wireshark_cmd = (
            f"curl {curl_insecure_flag} -N -H 'Authorization: Bearer {jwt_token}' "
            f"'{capture_stream_url}' | "
            f"wireshark -i - -k --fullscreen -display :{display} &"
        )

        logger.info(f"[Web Wireshark] Starting Wireshark with capture stream URL: {capture_stream_url}")
        logger.info(f"[Web Wireshark] Display: :{display}, Container IP: {container_ip}, Port: {port}")

        # Execute Wireshark command without waiting for completion
        await asyncio.create_subprocess_exec(
            "docker", "exec", container_id,
            "bash", "-c", wireshark_cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE
        )

        # Don't wait - Wireshark runs in background
        logger.info(f"[Web Wireshark] Wireshark start command executed for link {link_id}")

        result = {
            "link_id": link_id,
            "display": display,
            "port": port,
            "ws_url": f"ws://{container_ip}:{port}",
            "container_name": container_name,
            "container_id": container_id,
            "session_name": session_name,
            "capture_stream_url": capture_stream_url
        }

        logger.info(f"Web Wireshark session started successfully: {result['ws_url']}")
        return result

    async def stop_wireshark_session(self, project_id: str, link_id: str):
        """Stop Web Wireshark session.

        Args:
            project_id: Project ID
            link_id: Link ID
        """
        logger.info(f"Stopping Web Wireshark session for link {link_id}")
        try:
            container_name = f"gns3-wireshark-{project_id}"
            container = await self.docker.get_container(container_name)

            if not container:
                logger.info(f"Container {container_name} not found, nothing to stop")
                return

            # Get display number
            display = link_id_to_display(link_id)

            # Kill all processes associated with this display using process tree kill
            # This ensures child processes are properly terminated, not left as zombies
            logger.info(f"Stopping all processes on display :{display}")
            patterns = [
                f'xpra.*:{display}',
                f'Xvfb.*:{display}',
                f'Xvfb-for-Xpra-{display}',
                f'wireshark.*:{display}',
                f'pulseaudio.*display=:{display}'
            ]
            await self._kill_process_tree_batch(container["Id"], patterns)
            await self._cleanup_x_lock(container["Id"], display)

            logger.info("Web Wireshark session stopped successfully")

        except Exception as e:
            logger.error(f"Error stopping session: {e}")

    async def restart_wireshark_session(
        self,
        project_id: str,
        link_id: str,
        jwt_token: str,
        capture_stream_url: str = None,
        image: str = "gns3/web-wireshark:latest",
        memory: str = "2g",
        memory_swap: str = None,
        cpus: float = 1.0,
        pids_limit: int = 1000
    ):
        """Restart Web Wireshark session.

        This simply calls start_wireshark_session since it already handles
        cleanup of existing processes.

        Args:
            project_id: Project ID
            link_id: Link ID
            jwt_token: JWT authentication token
            capture_stream_url: Capture stream URL
            image: Docker image name
            memory: Memory limit
            memory_swap: Memory swap limit
            cpus: CPU cores
            pids_limit: Process limit

        Returns:
            Session info dict
        """
        return await self.start_wireshark_session(
            project_id=project_id,
            link_id=link_id,
            jwt_token=jwt_token,
            capture_stream_url=capture_stream_url,
            image=image,
            memory=memory,
            memory_swap=memory_swap,
            cpus=cpus,
            pids_limit=pids_limit
        )

    async def stop_all_sessions(self, project_id: str):
        """Stop all Web Wireshark sessions for a project.

        Args:
            project_id: Project ID
        """
        logger.info(f"Stopping all Web Wireshark sessions for project {project_id}")
        try:
            container_name = f"gns3-wireshark-{project_id}"
            container = await self.docker.get_container(container_name)

            if not container:
                logger.info(f"Container {container_name} not found, nothing to stop")
                return

            # Kill all wireshark, xpra and Xvfb processes for link sessions
            # This ensures clean removal of all session processes
            patterns = [
                "xpra.*--session-name=link-",
                "Xvfb-for-Xpra-",
                "wireshark.*display :",
                "pulseaudio.*display :"
            ]
            await self._kill_process_tree_batch(container["Id"], patterns)

            logger.info(f"All Web Wireshark sessions stopped for project {project_id}")

        except Exception as e:
            # Don't fail if container is already stopped or doesn't exist
            logger.warning(f"Error stopping all sessions: {e}")

    async def stop_container(self, project_id: str):
        """Stop Web Wireshark container.

        Args:
            project_id: Project ID
        """
        logger.info(f"Stopping Web Wireshark container for project {project_id}")
        try:
            container_name = f"gns3-wireshark-{project_id}"
            container = await self.docker.get_container(container_name)

            if not container:
                logger.info(f"Container {container_name} not found, nothing to stop")
                return

            if not container["State"]["Running"]:
                logger.info(f"Container {container_name} is not running")
                return

            await self.docker.stop_container(container["Id"])
            logger.info(f"Container {container_name} stopped")

        except Exception as e:
            logger.error(f"Error stopping container: {e}")

    async def delete_container(self, project_id: str):
        """Delete Web Wireshark container.

        Args:
            project_id: Project ID
        """
        logger.info(f"Deleting Web Wireshark container for project {project_id}")
        try:
            container_name = f"gns3-wireshark-{project_id}"
            container = await self.docker.get_container(container_name)

            if not container:
                logger.info(f"Container {container_name} not found, nothing to delete")
                return

            # Stop first if running
            if container["State"]["Running"]:
                await self.docker.stop_container(container["Id"])

            # Remove container
            await self.docker.remove_container(container["Id"], force=True)
            logger.info(f"Container {container_name} deleted")

        except Exception as e:
            logger.error(f"Error deleting container: {e}")
