"""In-process container support for local development and demos."""

import asyncio
import importlib
import logging
import os
import signal
import socket
import subprocess
import threading
import time
import warnings
from pathlib import Path
from typing import Any, Callable, Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen

try:
    httpx = importlib.import_module("httpx")
except Exception:  # pragma: no cover - optional dependency
    httpx = None  # type: ignore[assignment]
import uvicorn
from uvicorn._types import ASGIApplication

from synth_ai.core.tunnels import TunnelBackend, TunneledContainer
from synth_ai.core.tunnels.errors import LocalAppError
from synth_ai.core.tunnels.rust import (
    stop_tunnel,
)
from synth_ai.core.utils.paths import REPO_ROOT
from synth_ai.core.utils.urls import (
    is_cloudflare_tunnel_url,
    is_free_ngrok_url,
    is_synth_managed_ngrok_url,
)
from synth_ai.sdk.container._impl.apps_common import get_asgi_app, load_module
from synth_ai.sdk.container._impl.server import ContainerConfig, create_container

logger = logging.getLogger(__name__)

# Global registry for signal handlers
_registered_instances: set["InProcessContainer"] = set()


async def _wait_for_local_health_check(
    host: str,
    port: int,
    api_key: str | None,
    timeout: float,
    *,
    poll_interval: float = 0.2,
    request_timeout: float = 2.0,
) -> None:
    deadline = time.monotonic() + max(timeout, 0.0)
    url = f"http://{host}:{port}/health"
    headers: dict[str, str] = {}
    if api_key:
        headers["X-API-Key"] = api_key
        headers["Authorization"] = f"Bearer {api_key}"

    def _probe() -> int:
        req = Request(url, headers=headers)
        try:
            with urlopen(req, timeout=request_timeout) as resp:  # noqa: S310
                return int(resp.status)
        except HTTPError as exc:
            return int(exc.code)
        except URLError:
            return 0

    while time.monotonic() < deadline:
        status = await asyncio.to_thread(_probe)
        if status == 200:
            return
        await asyncio.sleep(poll_interval)

    raise LocalAppError(
        f"health check failed: {url} not ready after {timeout:.0f}s",
        port=port,
    )


def _normalize_tunnel_backend(value: TunnelBackend | str) -> TunnelBackend:
    if isinstance(value, TunnelBackend):
        return value
    key = str(value).strip().lower()
    aliases: dict[str, TunnelBackend] = {
        "synthtunnel": TunnelBackend.SynthTunnel,
        "synth_tunnel": TunnelBackend.SynthTunnel,
        "synth-tunnel": TunnelBackend.SynthTunnel,
        "ngrok_managed": TunnelBackend.NgrokManaged,
        "ngrok-managed": TunnelBackend.NgrokManaged,
        "ngrok": TunnelBackend.NgrokManaged,
        "preconfigured": TunnelBackend.NgrokManaged,
        "cloudflare_quick": TunnelBackend.CloudflareQuickTunnel,
        "cloudflare-quick": TunnelBackend.CloudflareQuickTunnel,
        "quick": TunnelBackend.CloudflareQuickTunnel,
        "cloudflare_managed_lease": TunnelBackend.CloudflareManagedLease,
        "cloudflare-managed-lease": TunnelBackend.CloudflareManagedLease,
        "managed_lease": TunnelBackend.CloudflareManagedLease,
        "managed-lease": TunnelBackend.CloudflareManagedLease,
        "cloudflare_managed": TunnelBackend.CloudflareManagedTunnel,
        "cloudflare-managed": TunnelBackend.CloudflareManagedTunnel,
        "managed": TunnelBackend.CloudflareManagedTunnel,
        "localhost": TunnelBackend.Localhost,
        "local": TunnelBackend.Localhost,
    }
    if key in aliases:
        return aliases[key]
    try:
        return TunnelBackend(key)
    except ValueError as exc:  # pragma: no cover - defensive
        raise ValueError(f"Unknown tunnel backend: {value}") from exc


def _find_available_port(host: str, start_port: int, max_attempts: int = 100) -> int:
    """
    Find an available port starting from start_port.

    Args:
        host: Host to bind to
        start_port: Starting port number
        max_attempts: Maximum number of ports to try

    Returns:
        Available port number

    Raises:
        RuntimeError: If no available port found
    """
    for offset in range(max_attempts):
        port = start_port + offset
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock.bind((host, port))
                return port
        except OSError:
            continue

    raise RuntimeError(
        f"Could not find available port starting from {start_port} (tried {max_attempts} ports)"
    )


def _is_port_available(host: str, port: int) -> bool:
    """Check if a port is available."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind((host, port))
            return True
    except OSError:
        return False


def _kill_process_on_port(host: str, port: int) -> None:
    """
    Attempt to kill any process using the specified port.

    Note: This is a best-effort operation and may not work on all systems.
    """
    import subprocess
    import sys

    try:
        if sys.platform == "win32":
            # Windows
            result = subprocess.run(
                ["netstat", "-ano"], capture_output=True, text=True, check=False
            )
            for line in result.stdout.splitlines():
                if f":{port}" in line and "LISTENING" in line:
                    parts = line.split()
                    if len(parts) > 4:
                        pid = parts[-1]
                        try:
                            subprocess.run(
                                ["taskkill", "/F", "/PID", pid],
                                capture_output=True,
                                check=False,
                            )
                            logger.info(f"Killed process {pid} on port {port}")
                        except Exception:
                            pass
        else:
            # Unix-like (macOS, Linux)
            result = subprocess.run(
                ["lsof", "-ti", f":{port}"],
                capture_output=True,
                text=True,
                check=False,
            )
            if result.stdout.strip():
                pids = result.stdout.strip().split()
                for pid in pids:
                    try:
                        subprocess.run(
                            ["kill", "-9", pid],
                            capture_output=True,
                            check=False,
                        )
                        logger.info(f"Killed process {pid} on port {port}")
                    except Exception:
                        pass
    except Exception as e:
        logger.debug(f"Could not kill process on port {port}: {e}")


async def _resolve_via_public_dns(hostname: str, timeout: float = 5.0) -> Optional[str]:
    """
    Resolve hostname using public DNS servers (1.1.1.1, 8.8.8.8).

    This bypasses local DNS caching issues that can cause NXDOMAIN errors
    when the local resolver has stale cached responses.

    Returns the first resolved IP address, or None if resolution fails.
    """
    loop = asyncio.get_event_loop()

    for dns_server in ("1.1.1.1", "8.8.8.8"):
        try:
            result = await loop.run_in_executor(
                None,
                lambda server=dns_server: subprocess.run(
                    ["dig", f"@{server}", "+short", hostname],
                    capture_output=True,
                    text=True,
                    timeout=timeout,
                ),
            )
            if result.returncode == 0 and result.stdout.strip():
                # Return first IP (dig may return multiple)
                first_ip = result.stdout.strip().splitlines()[0].strip()
                if first_ip and not first_ip.endswith("."):  # Skip CNAME responses
                    logger.debug(f"Resolved {hostname} via {dns_server}: {first_ip}")
                    return first_ip
        except FileNotFoundError:
            # dig not available, try socket resolution instead
            try:
                result = await loop.run_in_executor(
                    None,
                    lambda: socket.gethostbyname(hostname),
                )
                if result:
                    logger.debug(f"Resolved {hostname} via system DNS: {result}")
                    return result
            except socket.gaierror:
                pass
        except subprocess.TimeoutExpired:
            logger.debug(f"DNS timeout resolving {hostname} via {dns_server}")
        except Exception as e:
            logger.debug(f"DNS resolution error for {hostname} via {dns_server}: {e}")

    return None


async def _verify_tunnel_ready(
    tunnel_url: str,
    api_key: str,
    *,
    max_retries: int | None = None,
    retry_delay: float | None = None,
    timeout_per_request: float = 10.0,
    verify_tls: bool = True,
) -> bool:
    """
    Verify that a Cloudflare tunnel is actually routing traffic (not just DNS-resolvable).

    A tunnel can resolve via DNS before HTTP routing is ready. This helper polls both
    /health and /task_info until they return 200 or retries are exhausted.

    IMPORTANT: Uses public DNS (1.1.1.1) to bypass local DNS cache issues.
    Local DNS may have stale NXDOMAIN cached even after tunnel DNS is created.

    Environment variables for tuning:
        SYNTH_TUNNEL_VERIFY_MAX_RETRIES: Number of retry attempts (default: 30)
        SYNTH_TUNNEL_VERIFY_DELAY_SECS: Delay between retries in seconds (default: 2.0)

    With defaults, waits up to 60 seconds for tunnel to become ready.
    """
    # Allow env var overrides for reliability tuning
    if max_retries is None:
        max_retries = int(os.getenv("SYNTH_TUNNEL_VERIFY_MAX_RETRIES", "30"))
    if retry_delay is None:
        retry_delay = float(os.getenv("SYNTH_TUNNEL_VERIFY_DELAY_SECS", "2.0"))

    # Initial delay before first check - tunnels often need a moment after DNS resolves
    initial_delay = float(os.getenv("SYNTH_TUNNEL_VERIFY_INITIAL_DELAY_SECS", "3.0"))
    if initial_delay > 0:
        logger.debug(f"Waiting {initial_delay}s for tunnel to stabilize before verification...")
        await asyncio.sleep(initial_delay)

    # Parse hostname from URL
    parsed = urlparse(tunnel_url)
    hostname = parsed.netloc

    headers = {
        "X-API-Key": api_key,
        "Authorization": f"Bearer {api_key}",
        "Host": hostname,  # Always set Host header for IP-based requests
    }
    aliases = (os.getenv("ENVIRONMENT_API_KEY_ALIASES") or "").strip()
    if aliases:
        headers["X-API-Keys"] = ",".join(
            [api_key, *[p.strip() for p in aliases.split(",") if p.strip()]]
        )

    logger.info(
        f"Verifying tunnel is routing traffic (max {max_retries} attempts, {retry_delay}s delay)..."
    )

    # Track resolved IP to avoid re-resolving every attempt
    resolved_ip: Optional[str] = None

    for attempt in range(max_retries):
        try:
            # Try to resolve IP via public DNS if we don't have it yet
            if resolved_ip is None:
                resolved_ip = await _resolve_via_public_dns(hostname)
                if resolved_ip:
                    logger.info(
                        f"Resolved tunnel hostname via public DNS: {hostname} -> {resolved_ip}"
                    )

            # If we have a resolved IP, use curl with --resolve for proper SNI handling
            # httpx connecting to an IP directly fails SSL handshake due to SNI issues
            if resolved_ip:
                loop = asyncio.get_event_loop()
                # Capture resolved_ip in closure
                current_resolved_ip = resolved_ip

                # Use curl with --resolve to bypass local DNS while maintaining proper SNI
                async def curl_check(
                    path: str, _loop: Any = loop, _resolved_ip: str = current_resolved_ip
                ) -> int:
                    try:
                        curl_cmd = [
                            "curl",
                            "-s",
                            "-o",
                            "/dev/null",
                            "-w",
                            "%{http_code}",
                            "--resolve",
                            f"{hostname}:443:{_resolved_ip}",
                            "-H",
                            f"X-API-Key: {api_key}",
                            "-H",
                            f"Authorization: Bearer {api_key}",
                            f"https://{hostname}{path}",
                        ]
                        result = await _loop.run_in_executor(
                            None,
                            lambda cmd=curl_cmd: subprocess.run(
                                cmd,
                                capture_output=True,
                                text=True,
                                timeout=timeout_per_request,
                            ),
                        )
                        return int(result.stdout.strip()) if result.returncode == 0 else 0
                    except Exception:
                        return 0

                health_status = await curl_check("/health")
                task_info_status = await curl_check("/task_info")

                if health_status == 200 and task_info_status == 200:
                    logger.info(
                        f"Tunnel ready after {attempt + 1} attempt(s): "
                        f"health={health_status}, task_info={task_info_status}"
                    )
                    return True

                logger.debug(
                    "Tunnel not ready (attempt %s/%s): health=%s task_info=%s",
                    attempt + 1,
                    max_retries,
                    health_status,
                    task_info_status,
                )
            else:
                # Fall back to hostname-based request (local DNS)
                base = tunnel_url.rstrip("/")
                async with httpx.AsyncClient(
                    timeout=timeout_per_request, verify=verify_tls
                ) as client:
                    health = await client.get(f"{base}/health", headers=headers)
                    task_info = await client.get(f"{base}/task_info", headers=headers)

                if health.status_code == 200 and task_info.status_code == 200:
                    logger.info(
                        f"Tunnel ready after {attempt + 1} attempt(s): "
                        f"health={health.status_code}, task_info={task_info.status_code}"
                    )
                    return True

                logger.debug(
                    "Tunnel not ready (attempt %s/%s): health=%s task_info=%s",
                    attempt + 1,
                    max_retries,
                    health.status_code,
                    task_info.status_code,
                )
        except Exception as exc:  # pragma: no cover - defensive
            logger.debug(
                "Tunnel readiness check failed (attempt %s/%s): %s",
                attempt + 1,
                max_retries,
                exc,
            )
            # Clear resolved IP on connection errors - might need to re-resolve
            if "connect" in str(exc).lower() or "resolve" in str(exc).lower():
                resolved_ip = None

        if attempt < max_retries - 1:
            # Log progress periodically (every 5 attempts)
            if (attempt + 1) % 5 == 0:
                elapsed = (attempt + 1) * retry_delay
                remaining = (max_retries - attempt - 1) * retry_delay
                logger.info(
                    f"Still waiting for tunnel... ({elapsed:.0f}s elapsed, {remaining:.0f}s remaining)"
                )
            await asyncio.sleep(retry_delay)

    logger.warning(f"Tunnel verification exhausted after {max_retries} attempts")
    return False


async def _verify_preconfigured_url_ready(
    tunnel_url: str,
    api_key: str,
    *,
    extra_headers: Optional[dict[str, str]] = None,
    max_retries: int = 10,
    retry_delay: float = 1.0,
    timeout_per_request: float = 10.0,
) -> bool:
    """
    Verify that a preconfigured tunnel URL is routing traffic.

    This is similar to _verify_tunnel_ready but designed for external tunnel
    providers (ngrok, etc.) where we don't control the tunnel setup.

    Args:
        tunnel_url: The external tunnel URL to verify
        api_key: API key for container authentication
        extra_headers: Additional headers for the tunnel (e.g., auth tokens)
        max_retries: Number of retry attempts
        retry_delay: Delay between retries in seconds
        timeout_per_request: Timeout for each HTTP request

    Returns:
        True if tunnel is accessible, False otherwise
    """
    base = tunnel_url.rstrip("/")
    headers = {
        "X-API-Key": api_key,
        "Authorization": f"Bearer {api_key}",
    }

    # Add any extra headers (e.g., custom auth tokens)
    if extra_headers:
        headers.update(extra_headers)

    logger.info(f"Verifying preconfigured URL is accessible (max {max_retries} attempts)...")

    for attempt in range(max_retries):
        try:
            async with httpx.AsyncClient(timeout=timeout_per_request) as client:
                health = await client.get(f"{base}/health", headers=headers)

            # Only accept 200 for health checks - other codes may indicate misrouting
            if health.status_code == 200:
                logger.info(
                    f"Preconfigured URL ready after {attempt + 1} attempt(s): "
                    f"health={health.status_code}"
                )
                return True

            logger.debug(
                "Preconfigured URL not ready (attempt %s/%s): health=%s",
                attempt + 1,
                max_retries,
                health.status_code,
            )
        except Exception as exc:
            logger.debug(
                "Preconfigured URL check failed (attempt %s/%s): %s",
                attempt + 1,
                max_retries,
                exc,
            )

        if attempt < max_retries - 1:
            await asyncio.sleep(retry_delay)

    logger.warning(f"Preconfigured URL verification exhausted after {max_retries} attempts")
    return False


class InProcessContainer:
    """Context manager for running Local APIs in-process with automatic tunneling.

    This class simplifies local development and demos by:
    1. Starting a Local API server in a background thread
    2. Opening a tunnel automatically (SynthTunnel by default, or Synth-managed ngrok-compatible)
    3. Providing the tunnel URL for GEPA/RL jobs
    4. Cleaning up everything on exit

    (Alias: also known as "container" in older documentation)

    Supports multiple input methods:
    - FastAPI app instance (most direct)
    - ContainerConfig object
    - Config factory function (Callable[[], ContainerConfig])
    - Local API file path (strict for compatibility)

    Tunnel modes:
    - "synthtunnel": SynthTunnel (default, recommended)
    - "ngrok_managed": Synth-managed ngrok-compatible URL (first-class)
    - "local": No tunnel, use localhost URL directly
    - "preconfigured": Deprecated alias for "ngrok_managed"

    Advanced: Use `tunnel_backend` to select a specific backend from TunnelBackend.

    Attributes:
        url: The public URL of the running Local API (tunnel URL or localhost).
            Available after entering the context manager.
        local_url: The local URL (http://host:port) where the server is running.
        port: The actual port the server is bound to (may differ from requested
            port if auto_find_port=True).
        host: The host the server is bound to.
        tunnel_mode: The tunnel mode being used.
        is_running: Whether the server is currently running.

    Example:
        ```python
        from synth_ai.sdk.container._impl.in_process import InProcessContainer
        from heartdisease_container import build_config

        # Default: use SynthTunnel
        async with InProcessContainer(
            config_factory=build_config,
            port=8114,
        ) as container:
            print(f"Local API running at: {container.url}")

        # Use Synth-managed ngrok-compatible URL
        async with InProcessContainer(
            config_factory=build_config,
            port=8000,
            tunnel_mode="ngrok_managed",
            preconfigured_url="https://my-managed-tunnel.usesynth.ai",
        ) as container:
            print(f"Local API running at: {container.url}")
        ```
    """

    def __init__(
        self,
        *,
        app: Optional[ASGIApplication] = None,
        config: Optional[ContainerConfig] = None,
        config_factory: Optional[Callable[[], ContainerConfig]] = None,
        container_path: Optional[Path | str] = None,
        port: int = 8114,
        host: str = "127.0.0.1",
        tunnel_mode: str = "synthtunnel",
        tunnel_backend: TunnelBackend | str | None = None,
        preconfigured_url: Optional[str] = None,
        preconfigured_auth_header: Optional[str] = None,
        preconfigured_auth_token: Optional[str] = None,
        api_key: Optional[str] = None,
        backend_url: Optional[str] = None,
        health_check_timeout: float = 30.0,
        auto_find_port: bool = True,
        skip_tunnel_verification: bool = True,  # Default True - verification is unreliable
        force_new_tunnel: bool = False,
        on_start: Optional[Callable[["InProcessContainer"], None]] = None,
        on_stop: Optional[Callable[["InProcessContainer"], None]] = None,
    ):
        """Initialize in-process Local API.

        Args:
            app: FastAPI app instance (most direct)
            config: ContainerConfig object
            config_factory: Callable that returns ContainerConfig
            container_path: Path to Local API .py file (strict, alias: container)
            port: Local port to run server on
            host: Host to bind to (default: 127.0.0.1, use 0.0.0.0 for external access)
            tunnel_mode: Tunnel mode - "synthtunnel", "ngrok_managed", "local", or "preconfigured"
            tunnel_backend: Explicit tunnel backend (overrides tunnel_mode when set)
            preconfigured_url: Managed tunnel URL to use for ngrok-managed flows.
                              Can also be set via SYNTH_MANAGED_NGROK_URL env var.
            preconfigured_auth_header: Deprecated.
            preconfigured_auth_token: Deprecated.
            api_key: API key for health checks (defaults to ENVIRONMENT_API_KEY env var)
            backend_url: Backend base URL for tunnel control-plane calls.
                         When set, overrides SYNTH_BACKEND_URL for this instance.
            health_check_timeout: Max time to wait for health check in seconds
            auto_find_port: If True, automatically find available port if requested port is busy
            skip_tunnel_verification: If True, skip HTTP verification of tunnel connectivity.
                                      Useful when the tunnel URL is known to be valid.
            force_new_tunnel: If True, create a fresh tunnel instead of reusing existing one.
                             Use this when an existing managed tunnel is stale/broken.
            on_start: Optional callback called when container starts (receives self)
            on_stop: Optional callback called when container stops (receives self)

        Raises:
            ValueError: If multiple or no input methods provided, or invalid parameters
            FileNotFoundError: If container_path doesn't exist
        """
        # Validate: exactly one input method
        inputs = [app, config, config_factory, container_path]
        if sum(x is not None for x in inputs) != 1:
            raise ValueError(
                "Must provide exactly one of: app, config, config_factory, or container_path"
            )

        # Validate port range
        if not (1024 <= port <= 65535):
            raise ValueError(f"Port must be in range [1024, 65535], got {port}")

        # Validate host (allow 0.0.0.0 for container environments)
        allowed_hosts = ("127.0.0.1", "localhost", "0.0.0.0")
        if host not in allowed_hosts:
            raise ValueError(
                f"Host must be one of {allowed_hosts} for security reasons, got {host}"
            )

        # Validate tunnel_mode
        valid_modes = (
            "local",
            "localhost",
            "ngrok",
            "ngrok_managed",
            "quick",
            "cloudflare_quick",
            "cloudflare_managed_lease",
            "cloudflare_managed",
            "managed_lease",
            "managed",
            "named",
            "preconfigured",
            "synthtunnel",
        )
        if tunnel_mode not in valid_modes:
            raise ValueError(f"tunnel_mode must be one of {valid_modes}, got {tunnel_mode}")

        # Validate container_path if provided
        if container_path:
            path = Path(container_path)
            if not path.exists():
                raise FileNotFoundError(f"Container path does not exist: {container_path}")
            if path.suffix != ".py":
                raise ValueError(f"Container path must be a .py file, got {container_path}")

        self._app_input = app
        self._config = config
        self._config_factory = config_factory
        self._container_path = Path(container_path) if container_path else None

        self.port = port
        self.host = host
        self.tunnel_mode = tunnel_mode
        self.tunnel_backend = tunnel_backend
        # Env overrides are convenient for CLI usage, but they should not silently
        # override an explicit tunnel_mode passed by callers. Only allow the
        # SYNTH_TUNNEL_MODE env var to override when using the default settings.
        self._allow_env_tunnel_mode_override = (
            tunnel_mode == "synthtunnel" and tunnel_backend is None
        )
        self.preconfigured_url = preconfigured_url
        self.preconfigured_auth_header = preconfigured_auth_header
        self.preconfigured_auth_token = preconfigured_auth_token
        self.api_key = api_key
        self.backend_url = backend_url
        self.health_check_timeout = health_check_timeout
        self.auto_find_port = auto_find_port
        self.skip_tunnel_verification = skip_tunnel_verification
        self.force_new_tunnel = force_new_tunnel
        self.on_start = on_start
        self.on_stop = on_stop

        self.url: Optional[str] = None
        self._tunnel_proc: Optional[Any] = None
        self._tunnel_handle: Optional[TunneledContainer] = None
        self._app: Optional[ASGIApplication] = None
        self._uvicorn_server: Optional[uvicorn.Server] = None
        self._server_thread: Optional[Any] = None
        self._original_port = port  # Track original requested port
        self._is_preconfigured = False  # Track if using preconfigured URL
        self._dns_verified_by_backend = False  # Track if backend verified DNS propagation
        self.container_worker_token: Optional[str] = None

    def _effective_tunnel_mode(self) -> str:
        env_tunnel_mode = os.getenv("SYNTH_TUNNEL_MODE", "").strip()
        if self._allow_env_tunnel_mode_override and env_tunnel_mode:
            return env_tunnel_mode
        return self.tunnel_mode

    async def __aenter__(self) -> "InProcessContainer":
        """Start container and tunnel."""

        mode = self._effective_tunnel_mode()
        if mode in {
            "quick",
            "cloudflare_quick",
            "cloudflare_managed_lease",
            "cloudflare_managed",
            "managed_lease",
            "managed",
            "named",
        }:
            raise RuntimeError(
                f"tunnel_mode='{mode}' is disabled. Cloudflare is deprecated; use 'synthtunnel' or 'ngrok_managed'."
            )
        logger.debug(f"Starting in-process container on {self.host}:{self.port}")

        # Handle port conflicts
        if not _is_port_available(self.host, self.port):
            if self.auto_find_port:
                print(f"Port {self.port} is in use, attempting to find available port...")
                logger.warning(f"Port {self.port} is in use, attempting to find available port...")
                self.port = _find_available_port(self.host, self.port)
                logger.debug(f"Using port {self.port} instead")
            else:
                # Try to kill process on port
                logger.warning(f"Port {self.port} is in use, attempting to free it...")
                _kill_process_on_port(self.host, self.port)
                await asyncio.sleep(0.5)  # Brief wait for port to free

                if not _is_port_available(self.host, self.port):
                    raise RuntimeError(
                        f"Port {self.port} is still in use. "
                        "Set auto_find_port=True to automatically find an available port."
                    )

        # 1. Get FastAPI app from whichever input method was provided
        if self._app_input:
            # Direct app - use as-is
            self._app = self._app_input

        elif self._config:
            # ContainerConfig - create app from it
            self._app = create_container(self._config)  # type: ignore[assignment]

        elif self._config_factory:
            # Callable - call it to get config, then create app
            config = self._config_factory()
            self._app = create_container(config)  # type: ignore[assignment]

        elif self._container_path:
            # File path - load module and extract app
            from synth_ai.core.utils.paths import temporary_import_paths

            with temporary_import_paths(self._container_path, REPO_ROOT):
                module = load_module(
                    self._container_path,
                    f"_inprocess_{self._container_path.stem}_{id(self)}",
                )

            # Try to get app directly first
            try:
                self._app = get_asgi_app(module)  # type: ignore[assignment]
            except RuntimeError:
                # If no app found, try to get build_config function
                build_config = getattr(module, "build_config", None)
                if build_config and callable(build_config):
                    config = build_config()
                    self._app = create_container(config)  # type: ignore[assignment]
                else:
                    # Try registry lookup as last resort
                    from synth_ai.sdk.container._impl.apps import registry

                    app_id = getattr(module, "APP_ID", None) or self._container_path.stem
                    entry = registry.get(app_id)
                    if entry and entry.config_factory:
                        config = entry.config_factory()
                        self._app = create_container(config)  # type: ignore[assignment]
                    else:
                        raise RuntimeError(
                            f"Container at {self._container_path} must expose either:\n"
                            f"  - An ASGI app via `app = FastAPI(...)` or factory function\n"
                            f"  - A `build_config()` function that returns ContainerConfig\n"
                            f"  - Be registered with register_container() or register_container()"
                        ) from None

        # Resolve the container API key before starting the server.
        #
        # The Container enforces auth via `ENVIRONMENT_API_KEY`. If we allow the server
        # to mint a key during startup, the parent process won't know it, and subsequent
        # health checks / tunnel agents may use the wrong key (often the historical
        # default "test"), leading to confusing timeouts.
        task_api_key = self.api_key or self._get_api_key()
        if task_api_key:
            os.environ["ENVIRONMENT_API_KEY"] = task_api_key

        # 2. Start uvicorn in background thread
        # Use daemon=True for local testing to allow quick exit
        # The thread will be killed when the process exits
        logger.debug(f"Starting uvicorn server on {self.host}:{self.port}")

        config = uvicorn.Config(
            self._app,  # type: ignore[arg-type]
            host=self.host,
            port=self.port,
            reload=False,
            log_level="info",
            # In-process containers can receive high request volumes (e.g. rollouts).
            # Access logs go to stdout and can block if the parent process isn't
            # continuously draining output (common in notebooks/CLI runners).
            access_log=False,
        )
        self._uvicorn_server = uvicorn.Server(config)

        def serve():
            try:
                loop = asyncio.new_event_loop()
                asyncio.set_event_loop(loop)
                loop.run_until_complete(self._uvicorn_server.serve())  # type: ignore[attr-defined]
            except Exception:
                logger.exception("Uvicorn server stopped with error")

        self._server_thread = threading.Thread(
            target=serve,
            name=f"synth-uvicorn-{self.port}",
            daemon=True,  # Daemon thread dies when main process exits
        )
        self._server_thread.start()

        # 3. Wait for health check
        logger.debug(f"Waiting for health check on {self.host}:{self.port}")
        await _wait_for_local_health_check(
            self.host,
            self.port,
            task_api_key,
            timeout=self.health_check_timeout,
        )
        logger.debug(f"Health check passed for {self.host}:{self.port}")

        # 4. Determine tunnel mode (env var can override)
        mode = self._effective_tunnel_mode()

        # Prefer managed ngrok URL env var; keep SYNTH_CONTAINER_URL as deprecated alias.
        env_managed_ngrok_url = (os.getenv("SYNTH_MANAGED_NGROK_URL") or "").strip()
        env_preconfigured_url = (os.getenv("SYNTH_CONTAINER_URL") or "").strip()
        if env_managed_ngrok_url:
            mode = "ngrok_managed"
            self.preconfigured_url = env_managed_ngrok_url
            logger.info("Using managed ngrok URL from SYNTH_MANAGED_NGROK_URL")
        elif env_preconfigured_url:
            mode = "ngrok_managed"
            self.preconfigured_url = env_preconfigured_url
            warnings.warn(
                "SYNTH_CONTAINER_URL is deprecated; use SYNTH_MANAGED_NGROK_URL.",
                DeprecationWarning,
                stacklevel=2,
            )
            logger.info("Using managed ngrok URL from deprecated SYNTH_CONTAINER_URL")

        if mode == "preconfigured":
            warnings.warn(
                "tunnel_mode='preconfigured' is deprecated; use tunnel_mode='ngrok_managed'.",
                DeprecationWarning,
                stacklevel=2,
            )
            mode = "ngrok_managed"

        deprecated_cloudflare_modes = {
            "quick",
            "cloudflare_quick",
            "cloudflare_managed_lease",
            "cloudflare_managed",
            "managed_lease",
            "managed",
            "named",
        }
        if mode in deprecated_cloudflare_modes:
            raise RuntimeError(
                f"tunnel_mode='{mode}' is disabled. Cloudflare is deprecated; use 'synthtunnel' or 'ngrok_managed'."
            )

        # Prefer explicit tunnel_backend when provided (except managed-url mode)
        backend: TunnelBackend | None = None
        if mode != "ngrok_managed" and self.tunnel_backend is not None:
            backend = _normalize_tunnel_backend(self.tunnel_backend)

        # Allow canonical backends to be selected via tunnel_mode string
        if backend is None and mode in (
            "synthtunnel",
            "ngrok",
            "ngrok_managed",
            "cloudflare_quick",
            "cloudflare_managed_lease",
            "cloudflare_managed",
            "localhost",
        ):
            backend = _normalize_tunnel_backend(mode)

        if mode == "ngrok_managed":
            if self.preconfigured_auth_header or self.preconfigured_auth_token:
                raise ValueError(
                    "custom preconfigured auth headers/tokens are not allowed for managed ngrok flows"
                )
            if not self.preconfigured_url:
                raise ValueError(
                    "tunnel_mode='ngrok_managed' requires preconfigured_url, SYNTH_MANAGED_NGROK_URL, "
                    "or SYNTH_CONTAINER_URL (deprecated)."
                )
            if is_cloudflare_tunnel_url(self.preconfigured_url):
                raise ValueError("Cloudflare URLs are forbidden for tunnel_mode='ngrok_managed'.")
            if is_free_ngrok_url(self.preconfigured_url):
                raise ValueError("Free ngrok URLs are forbidden for tunnel_mode='ngrok_managed'.")
            if not is_synth_managed_ngrok_url(self.preconfigured_url):
                raise ValueError(
                    "URL is not recognized as Synth-managed. Set SYNTH_MANAGED_TUNNEL_HOSTS to the approved host patterns."
                )

            self.url = self.preconfigured_url.rstrip("/")
            self._tunnel_proc = None
            self._is_preconfigured = True
            logger.info(f"Using managed ngrok-compatible URL: {self.url}")

            # Optionally verify the managed URL is accessible
            if not self.skip_tunnel_verification:
                ready = await _verify_preconfigured_url_ready(
                    self.url,
                    task_api_key,
                    extra_headers={},
                    max_retries=10,  # Fewer retries - external URL should work quickly
                    retry_delay=1.0,
                )
                if ready:
                    logger.info(f"Managed ngrok URL verified and ready: {self.url}")
                else:
                    logger.warning(
                        f"Managed ngrok URL {self.url} may not be accessible. "
                        "Proceeding anyway - set skip_tunnel_verification=True to suppress this warning."
                    )
        elif backend is not None:
            # Canonical tunnel backends (SynthTunnel and NgrokManaged)
            from synth_ai.core.utils.env import get_api_key as get_synth_api_key

            synth_api_key = get_synth_api_key()
            backend_url = self.backend_url or os.getenv("SYNTH_BACKEND_URL")
            managed_ngrok_url = (
                self.preconfigured_url if backend == TunnelBackend.NgrokManaged else None
            )

            self._tunnel_handle = await TunneledContainer.create(
                local_port=self.port,
                backend=backend,
                api_key=synth_api_key,
                env_api_key=task_api_key,
                backend_url=backend_url,
                verify_dns=not self.skip_tunnel_verification,
                progress=True,
                managed_ngrok_url=managed_ngrok_url,
            )
            self.url = self._tunnel_handle.url
            if backend == TunnelBackend.SynthTunnel:
                self.container_worker_token = self._tunnel_handle.worker_token
            logger.info(f"Using tunnel backend={backend} url={self.url}")
        elif mode == "local":
            # Local mode: skip tunnel, use localhost
            self.url = f"http://{self.host}:{self.port}"
            self._tunnel_proc = None
            logger.debug(f"Using local mode: {self.url}")
        else:
            raise ValueError(f"Unknown SYNTH_TUNNEL_MODE: {mode}")

        # Register for signal handling
        _registered_instances.add(self)

        # Call on_start callback if provided
        if self.on_start:
            try:
                self.on_start(self)
            except Exception as e:
                logger.warning(f"on_start callback raised exception: {e}")

        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Stop tunnel and server."""
        logger.info("Stopping in-process container...")

        # Unregister from signal handling
        _registered_instances.discard(self)

        # Call on_stop callback if provided
        if self.on_stop:
            try:
                self.on_stop(self)
            except Exception as e:
                logger.warning(f"on_stop callback raised exception: {e}")

        # Stop tunnel
        if self._tunnel_handle:
            logger.debug("Closing tunnel handle...")
            try:
                close_async = getattr(self._tunnel_handle, "close_async", None)
                close_sync = getattr(self._tunnel_handle, "close", None)
                if callable(close_async):
                    await close_async()
                elif callable(close_sync):
                    close_sync()
            except Exception as e:
                logger.warning(f"Failed to close tunnel handle: {e}")
            self._tunnel_handle = None
        if self._tunnel_proc:
            logger.debug("Stopping Cloudflare tunnel...")
            stop_tunnel(self._tunnel_proc)
            self._tunnel_proc = None
            logger.info("Tunnel stopped")

        # Stop the uvicorn server thread gracefully to avoid killing the host process
        if self._server_thread and self._server_thread.is_alive():
            logger.debug("Stopping uvicorn server thread...")
            if self._uvicorn_server:
                self._uvicorn_server.should_exit = True
            self._server_thread.join(timeout=5.0)
            if self._server_thread.is_alive():
                if self._uvicorn_server:
                    # Last resort if graceful shutdown hangs
                    self._uvicorn_server.force_exit = True
                self._server_thread.join(timeout=1.0)
            if self._server_thread.is_alive():
                logger.warning(
                    "Uvicorn server thread did not stop cleanly; it will exit with the main process"
                )
            self._server_thread = None
            self._uvicorn_server = None

    def _get_api_key(self) -> str:
        """Get API key from environment or default."""
        import os

        try:
            from synth_ai.core.config.user import load_user_env

            load_user_env(override=False)
        except Exception:
            pass

        existing = (os.getenv("ENVIRONMENT_API_KEY") or "").strip()
        if existing:
            return existing

        # Ensure a stable key exists for both:
        # - Local health checks (which include X-API-Key)
        # - Tunnel agents that must authenticate to the local container
        try:
            from synth_ai.sdk.container.auth import ensure_container_auth

            return ensure_container_auth(upload=False)
        except Exception:
            return "test"


def _setup_signal_handlers() -> None:
    """Set up signal handlers for graceful shutdown."""

    def signal_handler(signum, frame):
        """Handle SIGINT/SIGTERM by cleaning up all registered instances."""
        logger.info(
            f"Received signal {signum}, cleaning up {len(_registered_instances)} instances..."
        )
        for instance in list(_registered_instances):
            try:
                # Trigger cleanup
                if instance._tunnel_proc:
                    stop_tunnel(instance._tunnel_proc)
                    instance._tunnel_proc = None
            except Exception as e:
                logger.error(f"Error cleaning up instance: {e}")
        _registered_instances.clear()

    # Register handlers (only once)
    if not hasattr(_setup_signal_handlers, "_registered"):
        signal.signal(signal.SIGINT, signal_handler)  # type: ignore[misc]
        signal.signal(signal.SIGTERM, signal_handler)  # type: ignore[misc]
        _setup_signal_handlers._registered = True  # type: ignore[attr-defined]


def _should_verify_tls() -> bool:
    """Return True unless explicitly disabled via env."""
    val = (os.getenv("SYNTH_TUNNEL_VERIFY_TLS") or "true").strip().lower()
    return val not in ("0", "false", "no", "off")


# Set up signal handlers on module import
_setup_signal_handlers()
