import atexit
import logging
import os
import re
import stat
import subprocess
import tempfile
import threading
import time
from typing import Optional

import requests

from envied.core import binaries
from envied.core.proxies.proxy import Proxy
from envied.core.utilities import get_country_code, get_country_name, get_debug_logger, get_ip_info

# Global registry for cleanup on exit
_gluetun_instances: list["Gluetun"] = []
_cleanup_lock = threading.Lock()
_cleanup_registered = False


def _cleanup_all_gluetun_containers():
    """Cleanup all Gluetun containers on exit."""
    # Get instances without holding the lock during cleanup
    with _cleanup_lock:
        instances = list(_gluetun_instances)
        _gluetun_instances.clear()

    # Cleanup each instance (no lock held, so no deadlock possible)
    for instance in instances:
        try:
            instance.cleanup()
        except Exception:
            pass


def _register_cleanup():
    """Register cleanup handlers (only once)."""
    global _cleanup_registered
    with _cleanup_lock:
        if not _cleanup_registered:
            # Only use atexit for cleanup - don't override signal handlers
            # This allows Ctrl+C to work normally while still cleaning up on exit
            atexit.register(_cleanup_all_gluetun_containers)
            _cleanup_registered = True


class Gluetun(Proxy):
    """
    Dynamic Gluetun VPN-to-HTTP Proxy Provider with multi-provider support.

    Automatically manages Docker containers running Gluetun for WireGuard/OpenVPN VPN connections.
    Supports multiple VPN providers in a single configuration using query format: provider:region

    Supported VPN providers: windscribe, expressvpn, nordvpn, surfshark, protonvpn, mullvad,
    privateinternetaccess, cyberghost, vyprvpn, torguard, and 50+ more.

    Configuration example in envied.yaml:
        proxy_providers:
          gluetun:
            providers:
              windscribe:
                vpn_type: wireguard
                credentials:
                  private_key: YOUR_KEY
                  addresses: YOUR_ADDRESS
                server_countries:
                  us: US
                  uk: GB
              nordvpn:
                vpn_type: wireguard
                credentials:
                  private_key: YOUR_KEY
                  addresses: YOUR_ADDRESS
                server_countries:
                  us: US
                  de: DE
            # Global settings (optional)
            base_port: 8888
            auto_cleanup: true
            container_prefix: "unshackle-gluetun"

    Usage:
        --proxy gluetun:windscribe:us
        --proxy gluetun:nordvpn:de
    """

    # Mapping of common VPN provider names to Gluetun identifiers
    PROVIDER_MAPPING = {
        "windscribe": "windscribe",
        "expressvpn": "expressvpn",
        "nordvpn": "nordvpn",
        "surfshark": "surfshark",
        "protonvpn": "protonvpn",
        "mullvad": "mullvad",
        "pia": "private internet access",
        "privateinternetaccess": "private internet access",
        "cyberghost": "cyberghost",
        "vyprvpn": "vyprvpn",
        "torguard": "torguard",
        "ipvanish": "ipvanish",
        "purevpn": "purevpn",
    }

    # Windscribe uses specific region names instead of country codes
    # See: https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/windscribe.md
    WINDSCRIBE_REGION_MAP = {
        # Country codes to Windscribe region names
        "us": "US East",
        "us-east": "US East",
        "us-west": "US West",
        "us-central": "US Central",
        "ca": "Canada East",
        "ca-east": "Canada East",
        "ca-west": "Canada West",
        "uk": "United Kingdom",
        "gb": "United Kingdom",
        "de": "Germany",
        "fr": "France",
        "nl": "Netherlands",
        "au": "Australia",
        "jp": "Japan",
        "sg": "Singapore",
        "hk": "Hong Kong",
        "kr": "South Korea",
        "in": "India",
        "it": "Italy",
        "es": "Spain",
        "ch": "Switzerland",
        "se": "Sweden",
        "no": "Norway",
        "dk": "Denmark",
        "fi": "Finland",
        "at": "Austria",
        "be": "Belgium",
        "ie": "Ireland",
        "pl": "Poland",
        "pt": "Portugal",
        "cz": "Czech Republic",
        "ro": "Romania",
        "hu": "Hungary",
        "gr": "Greece",
        "tr": "Turkey",
        "ru": "Russia",
        "ua": "Ukraine",
        "br": "Brazil",
        "mx": "Mexico",
        "ar": "Argentina",
        "za": "South Africa",
        "nz": "New Zealand",
        "th": "Thailand",
        "ph": "Philippines",
        "id": "Indonesia",
        "my": "Malaysia",
        "vn": "Vietnam",
        "tw": "Taiwan",
        "ae": "United Arab Emirates",
        "il": "Israel",
    }

    def __init__(
        self,
        providers: Optional[dict] = None,
        base_port: int = 8888,
        auto_cleanup: bool = True,
        container_prefix: str = "unshackle-gluetun",
        auth_user: Optional[str] = None,
        auth_password: Optional[str] = None,
        verify_ip: bool = True,
        **kwargs,
    ):
        """
        Initialize Gluetun proxy provider with multi-provider support.

        Args:
            providers: Dict of VPN provider configurations
                Format: {
                    "windscribe": {
                        "vpn_type": "wireguard",
                        "credentials": {"private_key": "...", "addresses": "..."},
                        "server_countries": {"us": "US", "uk": "GB"}
                    },
                    "nordvpn": {...}
                }
            base_port: Starting port for HTTP proxies (default: 8888)
            auto_cleanup: Automatically remove stopped containers (default: True)
            container_prefix: Docker container name prefix (default: "unshackle-gluetun")
            auth_user: Optional HTTP proxy authentication username
            auth_password: Optional HTTP proxy authentication password
            verify_ip: Automatically verify IP and region after connection (default: True)
        """
        # Check Docker availability using binaries module
        if not binaries.Docker:
            raise RuntimeError(
                "Docker is not available. Please install Docker to use Gluetun proxy.\n"
                "Visit: https://docs.docker.com/engine/install/"
            )

        self.providers = providers or {}
        self.base_port = base_port
        self.auto_cleanup = auto_cleanup
        self.container_prefix = container_prefix
        self.auth_user = auth_user
        self.auth_password = auth_password
        self.verify_ip = verify_ip

        # Track active containers: {query_key: {"container_name": ..., "port": ..., ...}}
        self.active_containers = {}

        # Lock for thread-safe port allocation
        self._port_lock = threading.Lock()

        # Validate provider configurations
        for provider_name, config in self.providers.items():
            self._validate_provider_config(provider_name, config)

        # Register this instance for cleanup on exit
        _register_cleanup()
        with _cleanup_lock:
            _gluetun_instances.append(self)

        # Log initialization
        debug_logger = get_debug_logger()
        if debug_logger:
            debug_logger.log(
                level="INFO",
                operation="gluetun_init",
                message=f"Gluetun proxy provider initialized with {len(self.providers)} provider(s)",
                context={
                    "providers": list(self.providers.keys()),
                    "base_port": base_port,
                    "auto_cleanup": auto_cleanup,
                    "verify_ip": verify_ip,
                    "container_prefix": container_prefix,
                },
            )

    def __repr__(self) -> str:
        provider_count = len(self.providers)
        return f"Gluetun ({provider_count} provider{['s', ''][provider_count == 1]})"

    def get_proxy(self, query: str) -> Optional[str]:
        """
        Get an HTTP proxy URI for a Gluetun VPN connection.

        Args:
            query: Query format: "provider:region" (e.g., "windscribe:us", "nordvpn:uk")

        Returns:
            HTTP proxy URI or None if unavailable
        """
        # Parse query
        parts = query.split(":")
        if len(parts) != 2:
            raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')")

        provider_name = parts[0].lower()
        region = parts[1].lower()

        # Check if provider is configured
        if provider_name not in self.providers:
            available = ", ".join(self.providers.keys())
            raise ValueError(f"VPN provider '{provider_name}' not configured. Available providers: {available}")

        # Create query key for tracking
        query_key = f"{provider_name}:{region}"
        container_name = f"{self.container_prefix}-{provider_name}-{region}"

        debug_logger = get_debug_logger()

        # Check if container already exists (in memory OR in Docker)
        # This handles multiple concurrent Unshackle sessions
        if query_key in self.active_containers:
            container = self.active_containers[query_key]
            if self._is_container_running(container["container_name"]):
                if debug_logger:
                    debug_logger.log(
                        level="DEBUG",
                        operation="gluetun_container_reuse",
                        message=f"Reusing existing container (in-memory): {query_key}",
                        context={
                            "query_key": query_key,
                            "container_name": container["container_name"],
                            "port": container["port"],
                        },
                    )
                # Re-verify if needed
                if self.verify_ip:
                    self._verify_container(query_key)
                return self._build_proxy_uri(container["port"])
        else:
            # Not in memory, but might exist in Docker (from another session)
            existing_info = self._get_existing_container_info(container_name)
            if existing_info:
                # Container exists in Docker, reuse it
                self.active_containers[query_key] = existing_info
                if debug_logger:
                    debug_logger.log(
                        level="INFO",
                        operation="gluetun_container_reuse_docker",
                        message=f"Reusing existing Docker container: {query_key}",
                        context={
                            "query_key": query_key,
                            "container_name": container_name,
                            "port": existing_info["port"],
                        },
                    )
                # Re-verify if needed
                if self.verify_ip:
                    self._verify_container(query_key)
                return self._build_proxy_uri(existing_info["port"])

        # Get provider configuration
        provider_config = self.providers[provider_name]

        # Determine server location
        server_countries = provider_config.get("server_countries", {})
        server_cities = provider_config.get("server_cities", {})
        server_hostnames = provider_config.get("server_hostnames", {})

        country = server_countries.get(region)
        city = server_cities.get(region)
        hostname = server_hostnames.get(region)

        # Check if region is a specific server pattern (e.g., us1239, uk5678)
        # Format: 2-letter country code + number
        specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE)

        if specific_server_match and not country and not city and not hostname:
            # Specific server requested (e.g., us1239)
            country_code = specific_server_match.group(1).upper()
            server_num = specific_server_match.group(2)

            # Build hostname based on provider
            hostname = self._build_server_hostname(provider_name, country_code, server_num)
            country = country_code  # Set country for verification

        # If not explicitly mapped and not a specific server, try to use query as country code
        elif not country and not city and not hostname:
            if re.match(r"^[a-z]{2}$", region):
                # Convert country code to full name for Gluetun
                country = get_country_name(region)
                if not country:
                    raise ValueError(
                        f"Country code '{region}' not recognized. "
                        f"Configure it in server_countries or use a valid ISO 3166-1 alpha-2 code."
                    )
            else:
                raise ValueError(
                    f"Region '{region}' not recognized for provider '{provider_name}'. "
                    f"Configure it in server_countries or server_cities, or use a 2-letter country code."
                )

        # Remove any stopped container with the same name
        self._remove_stopped_container(container_name)

        # Find available port
        port = self._get_available_port()

        # Create container (name already set above)
        try:
            self._create_container(
                container_name=container_name,
                port=port,
                provider_name=provider_name,
                provider_config=provider_config,
                country=country,
                city=city,
                hostname=hostname,
            )

            # Store container info
            self.active_containers[query_key] = {
                "container_name": container_name,
                "port": port,
                "provider": provider_name,
                "region": region,
                "country": country,
                "city": city,
                "hostname": hostname,
            }

            # Wait for container to be ready (60s timeout for VPN connection)
            if not self._wait_for_container(container_name, timeout=60):
                # Get container logs for better error message
                logs = self._get_container_logs(container_name, tail=30)
                error_msg = f"Gluetun container '{container_name}' failed to start"
                if hasattr(self, "_last_wait_error") and self._last_wait_error:
                    error_msg += f": {self._last_wait_error}"
                if logs:
                    # Extract last few relevant lines
                    log_lines = [line for line in logs.strip().split("\n") if line.strip()][-5:]
                    error_msg += "\nRecent logs:\n" + "\n".join(log_lines)
                raise RuntimeError(error_msg)

            # Verify IP and region if enabled
            if self.verify_ip:
                self._verify_container(query_key)

            return self._build_proxy_uri(port)

        except Exception as e:
            # Cleanup on failure
            self._remove_container(container_name)
            if query_key in self.active_containers:
                del self.active_containers[query_key]
            raise RuntimeError(f"Failed to create Gluetun container: {e}")

    def cleanup(self):
        """Stop and remove all managed Gluetun containers."""
        debug_logger = get_debug_logger()
        container_count = len(self.active_containers)

        if container_count > 0 and debug_logger:
            debug_logger.log(
                level="DEBUG",
                operation="gluetun_cleanup_start",
                message=f"Cleaning up {container_count} Gluetun container(s)",
                context={
                    "container_count": container_count,
                    "containers": list(self.active_containers.keys()),
                },
            )

        for query_key, container_info in list(self.active_containers.items()):
            container_name = container_info["container_name"]
            self._remove_container(container_name)

            if debug_logger:
                debug_logger.log(
                    level="DEBUG",
                    operation="gluetun_container_removed",
                    message=f"Removed Gluetun container: {container_name}",
                    context={
                        "query_key": query_key,
                        "container_name": container_name,
                    },
                )

        self.active_containers.clear()

        if container_count > 0 and debug_logger:
            debug_logger.log(
                level="INFO",
                operation="gluetun_cleanup_complete",
                message=f"Cleanup complete: removed {container_count} container(s)",
                context={"container_count": container_count},
                success=True,
            )

    def get_connection_info(self, query: str) -> Optional[dict]:
        """
        Get connection info for a proxy query.

        Args:
            query: Query format "provider:region" (e.g., "windscribe:us")

        Returns:
            Dict with connection info including public_ip, country, city, or None if not found.
        """
        parts = query.split(":")
        if len(parts) != 2:
            return None

        provider_name = parts[0].lower()
        region = parts[1].lower()
        query_key = f"{provider_name}:{region}"

        container = self.active_containers.get(query_key)
        if not container:
            return None

        return {
            "provider": container.get("provider"),
            "region": container.get("region"),
            "public_ip": container.get("public_ip"),
            "country": container.get("ip_country"),
            "city": container.get("ip_city"),
            "org": container.get("ip_org"),
        }

    def _validate_provider_config(self, provider_name: str, config: dict):
        """Validate a provider's configuration."""
        vpn_type = config.get("vpn_type", "wireguard").lower()
        credentials = config.get("credentials", {})

        if vpn_type not in ["wireguard", "openvpn"]:
            raise ValueError(f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'")

        if vpn_type == "wireguard":
            # private_key is always required for WireGuard
            if "private_key" not in credentials:
                raise ValueError(f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials")

            # Provider-specific WireGuard requirements based on Gluetun wiki:
            # - NordVPN, ProtonVPN: only private_key required
            # - Windscribe: private_key, addresses, AND preshared_key required (preshared_key MUST be set)
            # - Surfshark, Mullvad, IVPN: private_key AND addresses required
            provider_lower = provider_name.lower()

            # Windscribe requires preshared_key (can be empty string, but must be set)
            if provider_lower == "windscribe":
                if "preshared_key" not in credentials:
                    raise ValueError(
                        f"Provider '{provider_name}': Windscribe WireGuard requires 'preshared_key' in credentials "
                        "(can be empty string, but must be set). Get it from windscribe.com/getconfig/wireguard"
                    )
                if "addresses" not in credentials:
                    raise ValueError(
                        f"Provider '{provider_name}': Windscribe WireGuard requires 'addresses' in credentials. "
                        "Get it from windscribe.com/getconfig/wireguard"
                    )

            # Providers that require addresses (but not preshared_key)
            elif provider_lower in ["surfshark", "mullvad", "ivpn"]:
                if "addresses" not in credentials:
                    raise ValueError(f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials")

        elif vpn_type == "openvpn":
            if "username" not in credentials or "password" not in credentials:
                raise ValueError(
                    f"Provider '{provider_name}': OpenVPN requires 'username' and 'password' in credentials"
                )

    def _get_available_port(self) -> int:
        """Find an available port starting from base_port (thread-safe)."""
        with self._port_lock:
            used_ports = {info["port"] for info in self.active_containers.values()}
            port = self.base_port
            while port in used_ports or self._is_port_in_use(port):
                port += 1
            return port

    def _is_port_in_use(self, port: int) -> bool:
        """Check if a port is in use on the system or by any Docker container."""
        import socket

        # First check if the port is available on the system
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(("127.0.0.1", port))
        except OSError:
            # Port is in use by something on the system
            return True

        # Also check Docker containers (in case of port forwarding)
        try:
            result = subprocess.run(
                ["docker", "ps", "--format", "{{.Ports}}"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                return f":{port}->" in result.stdout or f"0.0.0.0:{port}" in result.stdout
            return False
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return False

    def _build_server_hostname(self, provider_name: str, country_code: str, server_num: str) -> str:
        """
        Build a server hostname for specific server selection.

        Args:
            provider_name: VPN provider name (e.g., "nordvpn")
            country_code: 2-letter country code (e.g., "US")
            server_num: Server number (e.g., "1239")

        Returns:
            Server hostname (e.g., "us1239.nordvpn.com")
        """
        # Convert to lowercase for hostname
        country_lower = country_code.lower()

        # Provider-specific hostname formats
        hostname_formats = {
            "nordvpn": f"{country_lower}{server_num}.nordvpn.com",
            "surfshark": f"{country_lower}-{server_num}.prod.surfshark.com",
            "expressvpn": f"{country_lower}-{server_num}.expressvpn.com",
            "cyberghost": f"{country_lower}-s{server_num}.cg-dialup.net",
            # Generic fallback for other providers
        }

        # Get provider-specific format or use generic
        if provider_name in hostname_formats:
            return hostname_formats[provider_name]
        else:
            # Generic format: country_code + server_num
            return f"{country_lower}{server_num}"

    def _ensure_image_available(self, image: str = "qmcgaw/gluetun:latest") -> bool:
        """
        Ensure the Gluetun Docker image is available locally.

        If the image is not present, it will be pulled. This prevents
        the container creation from timing out during the first run.

        Args:
            image: Docker image name with tag

        Returns:
            True if image is available, False otherwise
        """
        log = logging.getLogger("Gluetun")

        # Check if image exists locally
        try:
            result = subprocess.run(
                ["docker", "image", "inspect", image],
                capture_output=True,
                text=True,
                timeout=10,
                encoding="utf-8",
                errors="replace",
            )
            if result.returncode == 0:
                return True
            log.debug(f"Image inspect failed: {result.stderr}")
        except subprocess.TimeoutExpired:
            log.warning("Docker image inspect timed out")
        except FileNotFoundError:
            log.error("Docker command not found - is Docker installed and in PATH?")
            return False

        # Image not found, pull it
        log.info(f"Pulling Docker image {image}...")
        try:
            result = subprocess.run(
                ["docker", "pull", image],
                capture_output=True,
                text=True,
                timeout=300,  # 5 minutes for pull
                encoding="utf-8",
                errors="replace",
            )
            if result.returncode == 0:
                return True
            log.error(f"Docker pull failed: {result.stderr}")
            return False
        except subprocess.TimeoutExpired:
            raise RuntimeError(f"Timed out pulling Docker image '{image}'")

    def _create_container(
        self,
        container_name: str,
        port: int,
        provider_name: str,
        provider_config: dict,
        country: Optional[str] = None,
        city: Optional[str] = None,
        hostname: Optional[str] = None,
    ):
        """Create and start a Gluetun Docker container."""
        debug_logger = get_debug_logger()
        start_time = time.time()

        if debug_logger:
            debug_logger.log(
                level="DEBUG",
                operation="gluetun_container_create_start",
                message=f"Creating Gluetun container: {container_name}",
                context={
                    "container_name": container_name,
                    "port": port,
                    "provider": provider_name,
                    "country": country,
                    "city": city,
                    "hostname": hostname,
                },
            )

        # Ensure the Gluetun image is available (pulls if needed)
        gluetun_image = "qmcgaw/gluetun:latest"
        if not self._ensure_image_available(gluetun_image):
            if debug_logger:
                debug_logger.log(
                    level="ERROR",
                    operation="gluetun_image_pull_failed",
                    message=f"Failed to pull Docker image: {gluetun_image}",
                    success=False,
                )
            raise RuntimeError(f"Failed to ensure Gluetun Docker image '{gluetun_image}' is available")

        vpn_type = provider_config.get("vpn_type", "wireguard").lower()
        credentials = provider_config.get("credentials", {})
        extra_env = provider_config.get("extra_env", {})

        # Normalize provider name
        gluetun_provider = self.PROVIDER_MAPPING.get(provider_name.lower(), provider_name.lower())

        # Build environment variables
        env_vars = {
            "VPN_SERVICE_PROVIDER": gluetun_provider,
            "VPN_TYPE": vpn_type,
            "HTTPPROXY": "on",
            "HTTPPROXY_LISTENING_ADDRESS": ":8888",
            "HTTPPROXY_LOG": "on",
            "TZ": os.environ.get("TZ", "UTC"),
            "LOG_LEVEL": "info",
        }

        # Add credentials
        if vpn_type == "wireguard":
            env_vars["WIREGUARD_PRIVATE_KEY"] = credentials["private_key"]
            # addresses is optional - not needed for some providers like NordVPN
            if "addresses" in credentials:
                env_vars["WIREGUARD_ADDRESSES"] = credentials["addresses"]
            # preshared_key is required for Windscribe, optional for others
            if "preshared_key" in credentials:
                env_vars["WIREGUARD_PRESHARED_KEY"] = credentials["preshared_key"]
        elif vpn_type == "openvpn":
            env_vars["OPENVPN_USER"] = credentials.get("username", "")
            env_vars["OPENVPN_PASSWORD"] = credentials.get("password", "")

        # Add server location
        # Priority: hostname > country + city > country only
        # Note: Different providers support different server selection variables
        # - Most providers: SERVER_COUNTRIES, SERVER_CITIES
        # - Windscribe, VyprVPN, VPN Secure: SERVER_REGIONS, SERVER_CITIES (no SERVER_COUNTRIES)
        if hostname:
            # Specific server hostname requested (e.g., us1239.nordvpn.com)
            env_vars["SERVER_HOSTNAMES"] = hostname
        else:
            # Providers that use SERVER_REGIONS instead of SERVER_COUNTRIES
            region_only_providers = {"windscribe", "vyprvpn", "vpn secure"}
            uses_regions = gluetun_provider in region_only_providers

            # Use country/city selection
            if country:
                if uses_regions:
                    # Convert country code to provider-specific region name
                    if gluetun_provider == "windscribe":
                        region_name = self.WINDSCRIBE_REGION_MAP.get(country.lower(), country)
                        env_vars["SERVER_REGIONS"] = region_name
                    else:
                        env_vars["SERVER_REGIONS"] = country
                else:
                    env_vars["SERVER_COUNTRIES"] = country
            if city:
                env_vars["SERVER_CITIES"] = city

        # Add authentication if configured
        if self.auth_user:
            env_vars["HTTPPROXY_USER"] = self.auth_user
        if self.auth_password:
            env_vars["HTTPPROXY_PASSWORD"] = self.auth_password

        # Merge extra environment variables
        env_vars.update(extra_env)

        # Debug log environment variables (redact sensitive values)
        if debug_logger:
            redact_markers = ("KEY", "PASSWORD", "PASS", "TOKEN", "SECRET", "USER")
            safe_env = {k: ("***" if any(m in k for m in redact_markers) else v) for k, v in env_vars.items()}
            debug_logger.log(
                level="DEBUG",
                operation="gluetun_env_vars",
                message=f"Environment variables for {container_name}",
                context={"env_vars": safe_env, "gluetun_provider": gluetun_provider},
            )

        # Build docker run command
        cmd = [
            "docker",
            "run",
            "-d",
            "--name",
            container_name,
            "--cap-add=NET_ADMIN",
            "--device=/dev/net/tun",
            "-p",
            f"127.0.0.1:{port}:8888/tcp",
        ]

        # Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
        env_file_path: str | None = None
        try:
            fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env")
            try:
                # Best-effort restrictive permissions.
                if os.name != "nt":
                    if hasattr(os, "fchmod"):
                        os.fchmod(fd, 0o600)
                    else:
                        os.chmod(env_file_path, 0o600)
                else:
                    os.chmod(env_file_path, stat.S_IREAD | stat.S_IWRITE)

                with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
                    for key, value in env_vars.items():
                        if "=" in key:
                            raise ValueError(f"Invalid env var name for docker env-file: {key!r}")
                        v = "" if value is None else str(value)
                        if "\n" in v or "\r" in v:
                            raise ValueError(f"Invalid env var value (contains newline) for {key!r}")
                        f.write(f"{key}={v}\n")
            except Exception:
                # If we fail before fdopen closes the descriptor, make sure it's not leaked.
                try:
                    os.close(fd)
                except Exception:
                    pass
                raise

            cmd.extend(["--env-file", env_file_path])

            # Add Gluetun image
            cmd.append(gluetun_image)

            # Execute docker run
            try:
                result = subprocess.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=30,
                    encoding="utf-8",
                    errors="replace",
                )
            except subprocess.TimeoutExpired:
                if debug_logger:
                    debug_logger.log(
                        level="ERROR",
                        operation="gluetun_container_create_timeout",
                        message=f"Docker run timed out for {container_name}",
                        context={"container_name": container_name},
                        success=False,
                        duration_ms=(time.time() - start_time) * 1000,
                    )
                raise RuntimeError("Docker run command timed out")

            if result.returncode != 0:
                error_msg = result.stderr or "unknown error"
                if debug_logger:
                    debug_logger.log(
                        level="ERROR",
                        operation="gluetun_container_create_failed",
                        message=f"Docker run failed for {container_name}",
                        context={
                            "container_name": container_name,
                            "return_code": result.returncode,
                            "stderr": error_msg,
                        },
                        success=False,
                        duration_ms=(time.time() - start_time) * 1000,
                    )
                raise RuntimeError(f"Docker run failed: {error_msg}")

            # Log successful container creation
            if debug_logger:
                duration_ms = (time.time() - start_time) * 1000
                debug_logger.log(
                    level="INFO",
                    operation="gluetun_container_created",
                    message=f"Gluetun container created: {container_name}",
                    context={
                        "container_name": container_name,
                        "port": port,
                        "provider": provider_name,
                        "vpn_type": vpn_type,
                        "country": country,
                        "city": city,
                        "hostname": hostname,
                        "container_id": result.stdout.strip()[:12] if result.stdout else None,
                    },
                    success=True,
                    duration_ms=duration_ms,
                )
        finally:
            if env_file_path:
                # Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems).
                try:
                    with open(env_file_path, "r+b") as f:
                        try:
                            f.seek(0, os.SEEK_END)
                            length = f.tell()
                            f.seek(0)
                            if length > 0:
                                f.write(b"\x00" * length)
                                f.flush()
                                os.fsync(f.fileno())
                        except Exception:
                            pass
                except Exception:
                    pass
                try:
                    os.remove(env_file_path)
                except FileNotFoundError:
                    pass
                except Exception:
                    pass

    def _is_container_running(self, container_name: str) -> bool:
        """Check if a Docker container is running."""
        try:
            result = subprocess.run(
                [
                    "docker",
                    "ps",
                    "--filter",
                    f"name=^{re.escape(container_name)}$",
                    "--format",
                    "{{.Names}}",
                ],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode != 0:
                return False

            names = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
            return any(name == container_name for name in names)
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return False

    def _get_existing_container_info(self, container_name: str) -> Optional[dict]:
        """
        Check if a container exists in Docker and get its info.

        This handles multiple Unshackle sessions - if another session already
        created the container, we'll reuse it instead of trying to create a duplicate.

        Args:
            container_name: Name of the container to check

        Returns:
            Dict with container info if exists and running, None otherwise
        """
        try:
            # Check if container is running
            if not self._is_container_running(container_name):
                return None

            # Get container port mapping
            # Format: "127.0.0.1:8888->8888/tcp"
            result = subprocess.run(
                ["docker", "inspect", container_name, "--format", "{{.NetworkSettings.Ports}}"],
                capture_output=True,
                text=True,
                timeout=5,
            )

            if result.returncode != 0:
                return None

            # Parse port from output like "map[8888/tcp:[{127.0.0.1 8888}]]"
            port_match = re.search(r"127\.0\.0\.1\s+(\d+)", result.stdout)
            if not port_match:
                return None

            port = int(port_match.group(1))

            # Extract provider and region from container name
            # Format: unshackle-gluetun-provider-region
            name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$"
            name_match = re.match(name_pattern, container_name)
            if not name_match:
                return None

            provider_name = name_match.group(1)
            region = name_match.group(2)

            # Get expected country and hostname from config (if available)
            country = None
            hostname = None

            # Check if region is a specific server (e.g., us1239)
            specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE)
            if specific_server_match:
                country_code = specific_server_match.group(1).upper()
                server_num = specific_server_match.group(2)
                hostname = self._build_server_hostname(provider_name, country_code, server_num)
                country = country_code

            # Otherwise check config
            elif provider_name in self.providers:
                provider_config = self.providers[provider_name]
                server_countries = provider_config.get("server_countries", {})
                country = server_countries.get(region)

                if not country and re.match(r"^[a-z]{2}$", region):
                    country = region.upper()

            return {
                "container_name": container_name,
                "port": port,
                "provider": provider_name,
                "region": region,
                "country": country,
                "city": None,
                "hostname": hostname,
            }

        except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
            return None

    def _wait_for_container(self, container_name: str, timeout: int = 60) -> bool:
        """
        Wait for Gluetun container to be ready by checking logs for proxy readiness.

        Gluetun logs "http proxy listening" when the HTTP proxy is ready to accept connections.

        Args:
            container_name: Name of the container to wait for
            timeout: Maximum time to wait in seconds (default: 60)

        Returns:
            True if container is ready, False if it failed or timed out
        """
        debug_logger = get_debug_logger()
        start_time = time.time()
        last_error = None

        if debug_logger:
            debug_logger.log(
                level="DEBUG",
                operation="gluetun_container_wait_start",
                message=f"Waiting for container to be ready: {container_name}",
                context={"container_name": container_name, "timeout": timeout},
            )

        while time.time() - start_time < timeout:
            try:
                # First check if container is still running
                if not self._is_container_running(container_name):
                    # Container may have exited - check if it crashed
                    exit_info = self._get_container_exit_info(container_name)
                    if exit_info:
                        last_error = f"Container exited with code {exit_info.get('exit_code', 'unknown')}"
                    time.sleep(1)
                    continue

                # Check logs for readiness indicators
                result = subprocess.run(
                    ["docker", "logs", container_name, "--tail", "100"],
                    capture_output=True,
                    text=True,
                    timeout=5,
                    encoding="utf-8",
                    errors="replace",
                )

                if result.returncode == 0:
                    # Combine stdout and stderr for checking (handle None values)
                    stdout = result.stdout or ""
                    stderr = result.stderr or ""
                    all_logs = (stdout + stderr).lower()

                    # Gluetun needs both proxy listening AND VPN connected
                    # The proxy starts before VPN is ready, so we need to wait for VPN
                    proxy_ready = "[http proxy] listening" in all_logs
                    vpn_ready = "initialization sequence completed" in all_logs

                    if proxy_ready and vpn_ready:
                        # Give a brief moment for the proxy to fully initialize
                        time.sleep(1)
                        duration_ms = (time.time() - start_time) * 1000
                        if debug_logger:
                            debug_logger.log(
                                level="INFO",
                                operation="gluetun_container_ready",
                                message=f"Gluetun container is ready: {container_name}",
                                context={
                                    "container_name": container_name,
                                    "proxy_ready": proxy_ready,
                                    "vpn_ready": vpn_ready,
                                },
                                success=True,
                                duration_ms=duration_ms,
                            )
                        return True

                    # Check for fatal errors that indicate VPN connection failure
                    error_indicators = [
                        "fatal",
                        "cannot connect",
                        "authentication failed",
                        "invalid credentials",
                        "connection refused",
                        "no valid servers",
                    ]

                    for error in error_indicators:
                        if error in all_logs:
                            # Extract the error line for better messaging
                            for line in (stdout + stderr).split("\n"):
                                if error in line.lower():
                                    last_error = line.strip()
                                    break
                            # Fatal errors mean we should stop waiting
                            if "fatal" in all_logs or "invalid credentials" in all_logs:
                                return False

            except subprocess.TimeoutExpired:
                pass

            time.sleep(2)

        # Store the last error for potential logging
        if last_error:
            self._last_wait_error = last_error

        # Log timeout/failure
        duration_ms = (time.time() - start_time) * 1000
        if debug_logger:
            debug_logger.log(
                level="ERROR",
                operation="gluetun_container_wait_timeout",
                message=f"Gluetun container failed to become ready: {container_name}",
                context={
                    "container_name": container_name,
                    "timeout": timeout,
                    "last_error": last_error,
                },
                success=False,
                duration_ms=duration_ms,
            )
        return False

    def _get_container_exit_info(self, container_name: str) -> Optional[dict]:
        """Get exit information for a stopped container."""
        try:
            result = subprocess.run(
                ["docker", "inspect", container_name, "--format", "{{.State.ExitCode}}:{{.State.Error}}"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                parts = result.stdout.strip().split(":", 1)
                return {
                    "exit_code": int(parts[0]) if parts[0].isdigit() else -1,
                    "error": parts[1] if len(parts) > 1 else "",
                }
            return None
        except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
            return None

    def _get_container_logs(self, container_name: str, tail: int = 50) -> str:
        """Get recent logs from a container for error reporting."""
        try:
            result = subprocess.run(
                ["docker", "logs", container_name, "--tail", str(tail)],
                capture_output=True,
                text=True,
                timeout=10,
                encoding="utf-8",
                errors="replace",
            )
            return (result.stdout or "") + (result.stderr or "")
        except (subprocess.TimeoutExpired, FileNotFoundError):
            return ""

    def _verify_container(self, query_key: str, max_retries: int = 3):
        """
        Verify container's VPN IP and region using ipinfo.io lookup.

        Uses the shared get_ip_info function with a session configured to use
        the Gluetun proxy. Retries with exponential backoff if the network
        isn't ready immediately after the VPN connects.

        Args:
            query_key: The container query key (provider:region)
            max_retries: Maximum number of retry attempts (default: 3)

        Raises:
            RuntimeError: If verification fails after all retries
        """
        debug_logger = get_debug_logger()
        start_time = time.time()

        if query_key not in self.active_containers:
            return

        container = self.active_containers[query_key]
        proxy_url = self._build_proxy_uri(container["port"])
        expected_country = container.get("country", "").upper()

        if debug_logger:
            debug_logger.log(
                level="DEBUG",
                operation="gluetun_verify_start",
                message=f"Verifying VPN IP for: {query_key}",
                context={
                    "query_key": query_key,
                    "container_name": container.get("container_name"),
                    "expected_country": expected_country,
                    "max_retries": max_retries,
                },
            )

        last_error = None

        # Create a session with the proxy configured
        session = requests.Session()
        try:
            session.proxies = {"http": proxy_url, "https": proxy_url}

            # Retry with exponential backoff
            for attempt in range(max_retries):
                try:
                    # Get external IP through the proxy using shared utility
                    ip_info = get_ip_info(session)

                    if ip_info:
                        actual_country = ip_info.get("country", "").upper()

                        # Check if country matches (if we have an expected country)
                        # ipinfo.io returns country codes (CA), but we may have full names (Canada)
                        # Normalize both to country codes for comparison using shared utility
                        if expected_country:
                            # Convert expected country name to code if it's a full name
                            expected_code = get_country_code(expected_country) or expected_country
                            expected_code = expected_code.upper()

                            if actual_country != expected_code:
                                duration_ms = (time.time() - start_time) * 1000
                                if debug_logger:
                                    debug_logger.log(
                                        level="ERROR",
                                        operation="gluetun_verify_mismatch",
                                        message=f"Region mismatch for {query_key}",
                                        context={
                                            "query_key": query_key,
                                            "expected_country": expected_code,
                                            "actual_country": actual_country,
                                            "ip": ip_info.get("ip"),
                                            "city": ip_info.get("city"),
                                            "org": ip_info.get("org"),
                                        },
                                        success=False,
                                        duration_ms=duration_ms,
                                    )
                                raise RuntimeError(
                                f"Region mismatch for {container['provider']}:{container['region']}: "
                                f"Expected '{expected_code}' but got '{actual_country}' "
                                f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
                            )

                        # Verification successful - store IP info in container record
                        if query_key in self.active_containers:
                            self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
                            self.active_containers[query_key]["ip_country"] = actual_country
                            self.active_containers[query_key]["ip_city"] = ip_info.get("city")
                            self.active_containers[query_key]["ip_org"] = ip_info.get("org")

                        duration_ms = (time.time() - start_time) * 1000
                        if debug_logger:
                            debug_logger.log(
                                level="INFO",
                                operation="gluetun_verify_success",
                                message=f"VPN IP verified for: {query_key}",
                                context={
                                    "query_key": query_key,
                                    "ip": ip_info.get("ip"),
                                    "country": actual_country,
                                    "city": ip_info.get("city"),
                                    "org": ip_info.get("org"),
                                    "attempts": attempt + 1,
                                },
                                success=True,
                                duration_ms=duration_ms,
                            )
                        return

                    # ip_info was None, retry
                    last_error = "Failed to get IP info from ipinfo.io"

                except RuntimeError:
                    raise  # Re-raise region mismatch errors immediately
                except Exception as e:
                    last_error = str(e)
                    if debug_logger:
                        debug_logger.log(
                            level="DEBUG",
                            operation="gluetun_verify_retry",
                            message=f"Verification attempt {attempt + 1} failed, retrying",
                            context={
                                "query_key": query_key,
                                "attempt": attempt + 1,
                                "error": last_error,
                            },
                        )

                # Wait before retry (exponential backoff)
                if attempt < max_retries - 1:
                    wait_time = 2**attempt  # 1, 2, 4 seconds
                    time.sleep(wait_time)
        finally:
            try:
                session.close()
            except Exception:
                pass

        # All retries exhausted
        duration_ms = (time.time() - start_time) * 1000
        if debug_logger:
            debug_logger.log(
                level="ERROR",
                operation="gluetun_verify_failed",
                message=f"VPN verification failed after {max_retries} attempts",
                context={
                    "query_key": query_key,
                    "max_retries": max_retries,
                    "last_error": last_error,
                },
                success=False,
                duration_ms=duration_ms,
            )
        raise RuntimeError(
            f"Failed to verify VPN IP for {container['provider']}:{container['region']} "
            f"after {max_retries} attempts. Last error: {last_error}"
        )

    def _remove_stopped_container(self, container_name: str) -> bool:
        """
        Remove a stopped container with the given name if it exists.

        This prevents "container name already in use" errors when a previous
        container wasn't properly cleaned up.

        Args:
            container_name: Name of the container to check and remove

        Returns:
            True if a container was removed, False otherwise
        """
        try:
            # Check if container exists (running or stopped)
            result = subprocess.run(
                ["docker", "ps", "-a", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}:{{.Status}}"],
                capture_output=True,
                text=True,
                timeout=5,
            )

            if result.returncode != 0 or not result.stdout.strip():
                return False

            # Parse status - format is "name:Up 2 hours" or "name:Exited (0) 2 hours ago"
            output = result.stdout.strip()
            if container_name not in output:
                return False

            # Check if container is stopped (not running)
            if "Exited" in output or "Created" in output or "Dead" in output:
                # Container exists but is stopped - remove it
                subprocess.run(
                    ["docker", "rm", "-f", container_name],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
                return True

            return False

        except (subprocess.TimeoutExpired, FileNotFoundError):
            return False

    def _remove_container(self, container_name: str):
        """Stop and remove a Docker container."""
        try:
            if self.auto_cleanup:
                # Use docker rm -f to force remove (stops and removes in one command)
                subprocess.run(
                    ["docker", "rm", "-f", container_name],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
            else:
                # Just stop the container
                subprocess.run(
                    ["docker", "stop", container_name],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
        except subprocess.TimeoutExpired:
            # Force kill if timeout
            try:
                subprocess.run(
                    ["docker", "rm", "-f", container_name],
                    capture_output=True,
                    text=True,
                    timeout=5,
                )
            except subprocess.TimeoutExpired:
                pass

    def _build_proxy_uri(self, port: int) -> str:
        """Build HTTP proxy URI."""
        if self.auth_user and self.auth_password:
            return f"http://{self.auth_user}:{self.auth_password}@localhost:{port}"
        return f"http://localhost:{port}"

    def __del__(self):
        """Cleanup containers on object destruction."""
        if hasattr(self, "auto_cleanup") and self.auto_cleanup:
            try:
                if hasattr(self, "active_containers") and self.active_containers:
                    self.cleanup()
            except Exception:
                pass
