"""Tool instance API endpoints."""

import asyncio
import glob as glob_module
import logging
import os
import subprocess
import uuid
from datetime import datetime

import httpx
from fastapi import (
    APIRouter,
    APIRouter as FastAPIRouter,
    Depends,
    HTTPException,
    Request,
    Response,
    status,
)
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.auth.dependencies import (
    _get_owned_project,
    _get_user,
    get_current_user_id,
    get_db_session,
)
from src.services.event_bus import InstanceEventBus
from src.services.lifecycle_hooks import publish_lifecycle_event
from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.clone import check_dirty_state, clone_repository
from src.services.config_profile_resolver import (
    ConfigProfileCycleError,
    ResolvedProfile,
    apply_resolved_profile,
    expand_container_path,
    resolve_profile,
)
from src.services.docker import (
    connect_container_to_network,
    ensure_instance_directory,
    execute_compose_command,
    find_free_port,
    get_backend_network_name,
    get_container_id,
    get_container_ip_on_network,
    get_container_logs,
    get_container_status,
    is_container_on_network,
    render_compose_template,
    sort_volumes_by_specificity,
    wait_for_container_running,
    write_compose_file,
    write_config_files,
    write_env_file,
)
from src.services.tunnel import (
    check_tunnel_health,
    recreate_tunnel,
    start_tunnel,
    stop_tunnel,
)
from src.services.docker_build import build_image
from src.services.manifest_compiler import (
    compile_compose,
    compile_dockerfile,
    compile_entrypoint,
    compute_image_tag,
    deep_merge,
    get_manifest_home_dir,
    merge_with_config,
    resolve_base,
)
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
from src.services.readiness_probe import execute_probe
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files

logger = logging.getLogger(__name__)
_event_bus = InstanceEventBus()


async def _resolve_git_mounts(
    session: AsyncSession,
    resolved: ResolvedProfile,
    instance_dir: str | None = None,
    working_directory: str | None = None,
    home_dir: str = "/root",
) -> list[dict]:
    """Convert git mounts from resolved profile to Docker volume mounts.

    Looks up repository paths, auto-clones if needed, handles branch checkout,
    expands glob patterns, and prepares bind mount entries.
    Logs warnings for missing repos or invalid paths (non-blocking).
    """
    if not resolved.git_mounts:
        return []

    # Process all git mounts concurrently
    tasks = []
    for git_mount in resolved.git_mounts:
        tasks.append(
            _resolve_single_git_mount(
                session, git_mount, instance_dir, working_directory, home_dir
            )
        )

    results = await asyncio.gather(*tasks, return_exceptions=True)

    volume_mounts = []
    for result in results:
        if isinstance(result, Exception):
            logger.warning("Git mount failed: %s", result)
            continue
        if result:
            volume_mounts.extend(result)

    return volume_mounts


def _normalize_git_mount(entry: dict) -> dict:
    """Normalize a git mount entry to the unified mappings format.

    Converts legacy source_path + target_path into a single-entry mappings array.
    """
    entry = dict(entry)
    if "mappings" not in entry or not entry.get("mappings"):
        source = entry.get("source_path", ".")
        target = entry.get("target_path")
        if target is not None:
            entry["mappings"] = [{"source_path": source, "target_path": target}]
    entry.pop("source_path", None)
    entry.pop("target_path", None)
    return entry


def _clone_git_repo(
    remote_url: str,
    branch: str | None,
    clone_parent: str,
) -> str:
    """Clone or pull a git repository.

    Returns the path to the cloned repo (repo-clone directory).
    """
    import hashlib

    url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
    repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
    clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
    repo_path = os.path.join(clone_dir, "repo-clone")

    if not os.path.exists(repo_path):
        try:
            os.makedirs(clone_dir, exist_ok=True)
            repo_path = clone_repository(
                remote_url,
                None,  # No SSH key for now - can be added later
                clone_dir,
                branch or "main",
            )
            logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
        except Exception as exc:
            logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
            raise
    else:
        # Repo exists - pull latest updates
        try:
            _pull_repository_updates(repo_path, remote_url)
            logger.debug("Pulled updates for git mount %s", remote_url)
        except Exception as exc:
            logger.warning("Failed to pull updates for %s: %s", remote_url, exc)

    # Handle branch checkout if specified
    if branch and repo_path:
        success = _checkout_branch(repo_path, branch)
        if success:
            logger.debug("Checked out branch %s for %s", branch, remote_url)
        else:
            logger.warning(
                "Branch %s not found in %s, using current branch", branch, remote_url
            )

    return repo_path


def _resolve_git_mount_mappings(
    repo_path: str,
    mappings: list[dict],
    working_directory: str | None,
    home_dir: str = "/root",
) -> list[dict]:
    """Resolve mappings from an already-cloned repo to volume mount entries.

    Returns a flat list of volume mount dicts.
    """
    volume_mounts = []

    for mapping in mappings:
        source_path = mapping.get("source_path", ".")
        target_path = mapping.get("target_path")

        if not target_path:
            logger.warning("Invalid mapping skipped: missing target_path")
            continue

        # Expand ~ and $HOME in target path
        target_path = expand_container_path(target_path, home_dir)

        # Resolve relative target paths against working directory
        final_target = target_path
        if not target_path.startswith("/"):
            if not working_directory:
                logger.warning(
                    "Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
                    "Set working_directory in the tool config or use an absolute path.",
                    target_path,
                )
                continue
            final_target = os.path.join(working_directory, target_path)

        # Build source path and expand globs
        if source_path and source_path != ".":
            source_full = os.path.join(repo_path, source_path)
        else:
            source_full = repo_path

        # Expand glob patterns
        matched_paths = _expand_glob_source(source_full, repo_path)

        if not matched_paths:
            logger.warning(
                "Git mount skipped: no files matched source path %s in repo",
                source_path,
            )
            continue

        for matched_path in matched_paths:
            if not os.path.exists(matched_path):
                continue

            # Determine target path for this match
            if len(matched_paths) == 1:
                # Single match: mount directly to target_path
                mount_target = final_target
            else:
                # Multiple matches: append relative path to target
                rel_path = os.path.relpath(matched_path, repo_path)
                mount_target = os.path.join(final_target, rel_path)

            volume_mounts.append(
                {
                    "source": matched_path,
                    "target": mount_target,
                    "type": "bind",
                }
            )
            logger.debug(
                "Added git mount: %s -> %s",
                matched_path,
                mount_target,
            )

    return volume_mounts


async def _resolve_single_git_mount(
    session: AsyncSession,
    git_mount: dict,
    instance_dir: str | None = None,
    working_directory: str | None = None,
    home_dir: str = "/root",
) -> list[dict]:
    """Resolve a single git mount to volume mount entries.

    Clones directly from remote_url, no database lookup needed.
    Returns a list of volume mounts (one for each matched file/directory).
    """
    git_mount = _normalize_git_mount(git_mount)
    remote_url = git_mount.get("remote_url")
    branch = git_mount.get("branch")
    mappings = git_mount.get("mappings", [])

    if not remote_url:
        logger.warning("Invalid git mount skipped: missing remote_url")
        return []

    if not mappings:
        logger.warning("Invalid git mount skipped: no mappings")
        return []

    if not instance_dir:
        logger.warning("Git mount skipped: no instance_dir provided for cloning")
        return []

    # Clone or pull the repository
    try:
        repo_path = await asyncio.to_thread(
            _clone_git_repo, remote_url, branch, instance_dir
        )
    except Exception:
        return []

    # Resolve all mappings from the cloned repo
    return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir)


def _checkout_branch(repo_path: str, branch: str) -> bool:
    """Checkout a specific branch in a git repository.

    Returns True if checkout succeeded, False if it failed.
    On failure, the repository remains on its current branch.
    """
    import subprocess

    # First try to checkout existing branch
    result = subprocess.run(
        ["git", "-C", repo_path, "checkout", branch],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        # Try fetching and checking out
        subprocess.run(
            ["git", "-C", repo_path, "fetch", "origin", branch],
            capture_output=True,
            text=True,
        )
        result = subprocess.run(
            ["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            logger.warning(
                "Failed to checkout branch %s in %s: %s",
                branch,
                repo_path,
                result.stderr.strip(),
            )
            return False

    return True


def _pull_repository_updates(repo_path: str, remote_url: str) -> None:
    """Pull latest updates from remote repository.

    Used when starting a new container with an existing cloned repository
    to ensure the latest code is mounted.
    """
    import subprocess

    # Fetch latest changes
    result = subprocess.run(
        ["git", "-C", repo_path, "fetch", "origin"],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        raise RuntimeError(f"Failed to fetch updates: {result.stderr}")

    # Pull changes for current branch
    result = subprocess.run(
        ["git", "-C", repo_path, "pull", "origin"],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        raise RuntimeError(f"Failed to pull updates: {result.stderr}")


def _expand_glob_source(source_path: str, repo_path: str) -> list[str]:
    """Expand glob patterns in source path.

    Returns a list of matched absolute paths.
    Limits results to prevent abuse.
    """
    MAX_GLOB_MATCHES = 100

    # Check if path contains glob characters
    if not any(c in source_path for c in "*?["):
        # No glob pattern: return single path if it exists
        return [source_path] if os.path.exists(source_path) else []

    # Expand glob pattern
    matched = glob_module.glob(source_path, recursive=True)
    total_matched = len(matched)

    # Filter to only paths within the repo and limit count
    results = []
    for path in matched:
        abs_path = os.path.abspath(path)
        if abs_path.startswith(os.path.abspath(repo_path)):
            results.append(abs_path)
        if len(results) >= MAX_GLOB_MATCHES:
            logger.warning(
                "Glob pattern matched %d files, limited to %d",
                total_matched,
                MAX_GLOB_MATCHES,
            )
            break

    return results


router = APIRouter(prefix="/projects", tags=["tool-instances"])


class CreateInstanceRequest(BaseModel):
    """Request body for creating a tool instance."""

    model_config = {"extra": "ignore"}

    tool_type_id: str = Field(description="UUID of the tool type to instantiate")
    display_name: str | None = Field(
        default=None, description="Optional display name for the instance"
    )
    workspace_id: str | None = Field(
        default=None, description="UUID of workspace to mount (replaces clone_mode)"
    )
    clone_mode: str = Field(
        default="mount", description="Repository access mode: 'mount' or 'clone'"
    )
    branch: str | None = Field(
        default="main", description="Branch to clone (when clone_mode='clone')"
    )
    new_branch: str | None = Field(
        default=None, description="Create a new local branch after cloning"
    )
    config_profile_id: str | None = Field(
        default=None, description="Optional config profile ID for launch"
    )
    ssh_key_ids: list[str] = Field(
        default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
    )


class StartInstanceRequest(BaseModel):
    """Request body for starting a tool instance."""

    model_config = {"extra": "ignore"}

    config_profile_id: str | None = Field(
        default=None, description="Config profile ID to apply, or null for none"
    )
    ssh_key_ids: list[str] = Field(
        default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
    )


async def _validate_config_profile(
    session: AsyncSession,
    profile_id: str | None,
    user_id: uuid.UUID,
    project_id: uuid.UUID,
    tool_type_id: uuid.UUID,
) -> uuid.UUID | None:
    """Validate a config profile selection.

    Args:
        session: Database session.
        profile_id: Profile ID string or None.
        user_id: Authenticated user ID.
        project_id: Project ID for compatibility check.
        tool_type_id: Tool type ID for compatibility check.

    Returns:
        Validated UUID or None.

    Raises:
        HTTPException: If profile is not found, not owned, or incompatible.
    """
    if not profile_id:
        return None

    try:
        profile_uuid = uuid.UUID(profile_id)
    except ValueError:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"Invalid config profile ID: {profile_id}",
        )

    profile = await session.get(ConfigProfile, profile_uuid)
    if profile is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Config profile not found: {profile_id}",
        )

    if profile.user_id != user_id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Not authorized to use this config profile",
        )

    # Check compatibility: profile must be portable or match project/tool
    is_compatible = (
        (profile.project_id is None and profile.tool_type_id is None)
        or (profile.project_id == project_id)
        or (profile.tool_type_id == tool_type_id)
        or (profile.project_id == project_id and profile.tool_type_id == tool_type_id)
    )

    if not is_compatible:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Selected config profile is not compatible with this project and tool type",
        )

    return profile_uuid


def _sanitize_compose_file(compose_path: str) -> None:
    """Remove invalid port mappings (target port 0) from compose file."""
    import yaml
    from pathlib import Path

    compose_file = Path(compose_path)
    if not compose_file.exists():
        return

    content = compose_file.read_text()
    compose_data = yaml.safe_load(content)

    if not compose_data or "services" not in compose_data:
        return

    modified = False
    for service_name, service_config in compose_data["services"].items():
        if "ports" in service_config:
            valid_ports = []
            for port_mapping in service_config["ports"]:
                if isinstance(port_mapping, str) and ":" in port_mapping:
                    parts = port_mapping.split(":")
                    if len(parts) == 2:
                        host_port, container_port = parts
                        # Skip invalid mappings (target port 0 or empty)
                        if container_port == "0" or not container_port:
                            modified = True
                            continue
                valid_ports.append(port_mapping)

            if valid_ports:
                service_config["ports"] = valid_ports
            else:
                del service_config["ports"]
                modified = True
        break  # Only check first service

    if modified:
        compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))


def _modify_compose_file(
    compose_path: str,
    port_override: int | None = None,
    start_command: str | None = None,
    working_directory: str | None = None,
    extra_volumes: list[dict] | None = None,
    home_dir: str = "/root",
) -> None:
    """Modify compose file with runtime overrides."""
    import yaml
    from pathlib import Path

    compose_file = Path(compose_path)
    content = compose_file.read_text()
    compose_data = yaml.safe_load(content)

    if not compose_data or "services" not in compose_data:
        return

    # Apply modifications to the first service
    for service_name, service_config in compose_data["services"].items():
        if port_override and "ports" in service_config:
            # Update port mapping
            for i, port_mapping in enumerate(service_config["ports"]):
                if isinstance(port_mapping, str) and ":" in port_mapping:
                    host_port, container_port = port_mapping.split(":", 1)
                    service_config["ports"][i] = f"{port_override}:{container_port}"
                    break

        if start_command:
            service_config["command"] = start_command

        if working_directory:
            service_config["working_dir"] = expand_container_path(
                working_directory, home_dir
            )

        if extra_volumes:
            if "volumes" not in service_config:
                service_config["volumes"] = []
            for vol in extra_volumes:
                source = vol.get("source", "")
                target = expand_container_path(vol.get("target", ""), home_dir)
                vol_type = vol.get("type", "bind")
                if vol_type == "bind":
                    service_config["volumes"].append(f"{source}:{target}")
                else:
                    service_config["volumes"].append(f"{source}:{target}:{vol_type}")

        # Sort volumes so parent paths come before child paths
        if service_config.get("volumes"):
            service_config["volumes"] = sort_volumes_by_specificity(
                service_config["volumes"]
            )

        break  # Only modify the first service

    # Write back
    compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))


def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None:
    """Ensure compose file has explicit container_name for predictable naming.

    Docker Compose auto-generates container names from the project directory
    when container_name is absent. This breaks tunnel connectivity because
    get_container_name(instance.name) cannot find the container. We inject
    container_name into every service so the container has a predictable name.
    """
    import yaml
    from pathlib import Path

    compose_file = Path(compose_path)
    if not compose_file.exists():
        return

    content = compose_file.read_text()
    compose_data = yaml.safe_load(content)

    if not compose_data or "services" not in compose_data:
        return

    modified = False
    for svc_name, svc_config in compose_data["services"].items():
        if "container_name" not in svc_config:
            svc_config["container_name"] = container_name.lower()
            modified = True

    if modified:
        compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
        logger.info(
            "Injected container_name '%s' into compose file",
            container_name.lower(),
        )


def _ensure_web_bind_address(
    compose_path: str, tool_type_name: str, default_port: int
) -> None:
    """Auto-inject bind address for known web tools that default to 127.0.0.1.

    Many web tools (code-server, jupyter) bind to localhost by default,
    making them inaccessible from the Docker network. This function detects
    known tool images and injects the correct --bind-addr or --ip flag.
    """
    import yaml
    from pathlib import Path

    if default_port <= 0:
        return

    KNOWN_BIND_FIXES: dict[str, str] = {
        "code-server": f"--bind-addr 0.0.0.0:{default_port}",
        "jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser",
    }

    bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
    if not bind_command:
        return

    compose_file = Path(compose_path)
    if not compose_file.exists():
        return

    content = compose_file.read_text()
    compose_data = yaml.safe_load(content)

    if not compose_data or "services" not in compose_data:
        return

    for service_config in compose_data["services"].values():
        image = service_config.get("image", "")
        if not image:
            continue

        # LSIO images already bind to 0.0.0.0 — command override breaks s6 init
        if "linuxserver" in image:
            existing_command = service_config.get("command", "")
            if "--bind-addr" in existing_command or "--host" in existing_command:
                del service_config["command"]
                compose_file.write_text(
                    yaml.dump(compose_data, default_flow_style=False)
                )
                logger.warning(
                    "Removed broken command override from LSIO image: %s",
                    existing_command,
                )
                return
            return

        # Check if the image matches a known tool
        is_code_server = tool_type_name == "code-server" and (
            "code-server" in image or "coder" in image
        )
        is_jupyter = tool_type_name == "jupyter-notebook" and (
            "jupyter" in image or "notebook" in image
        )
        if not is_code_server and not is_jupyter:
            continue

        existing_command = service_config.get("command", "")
        if existing_command:
            # Already correct — nothing to do
            if bind_command in existing_command:
                return
            # Fix broken or outdated bind flags
            if (
                "--bind-addr" in existing_command
                or "--host" in existing_command
                or "--ip=" in existing_command
            ):
                service_config["command"] = bind_command
                compose_file.write_text(
                    yaml.dump(compose_data, default_flow_style=False)
                )
                logger.warning(
                    "Replaced broken bind address for %s: %s → %s",
                    tool_type_name,
                    existing_command,
                    bind_command,
                )
                return
            # Some other command override exists — don't touch it
            return

        # No command yet — inject the correct bind address
        service_config["command"] = bind_command
        compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
        logger.info("Injected bind address for %s: %s", tool_type_name, bind_command)
        return


def _ensure_backend_network_in_compose(compose_path: str) -> None:
    """Inject the backend network into the compose file so compose up attaches it.

    Instead of running 'docker network connect' after container creation (which
    is prone to race conditions and silent failures), we declare the network in
    the compose file itself. Docker Compose then connects the container to the
    network atomically during 'docker compose up'.
    """
    import yaml
    from pathlib import Path

    compose_file = Path(compose_path)
    if not compose_file.exists():
        return

    content = compose_file.read_text()
    compose_data = yaml.safe_load(content)

    if not compose_data or "services" not in compose_data:
        return

    network_name = get_backend_network_name()
    modified = False

    for svc_config in compose_data["services"].values():
        existing = svc_config.get("networks", [])
        if network_name not in existing:
            svc_config["networks"] = existing + [network_name]
            modified = True
        break  # Only modify first service

    # Declare the network as external at the top level
    if "networks" not in compose_data:
        compose_data["networks"] = {}
    if network_name not in compose_data["networks"]:
        compose_data["networks"][network_name] = {"external": True}
        modified = True

    if modified:
        compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
        logger.info("Injected backend network '%s' into compose file", network_name)


@router.post(
    "/{project_id}/repositories/{repo_id}/instances",
    summary="Create tool instance",
    description="Create a new tool instance for a repository.",
)
async def create_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    data: CreateInstanceRequest,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Create a new tool instance for a repository.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        tool_type_id: UUID of the tool type to instantiate.
        display_name: Optional display name for the instance.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with instance details.
    """
    logger.debug(
        "Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s",
        project_id,
        repo_id,
        data.tool_type_id,
        data.display_name,
    )
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    repo = await session.get(GitRepository, repo_id)
    if repo is None or repo.project_id != project_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
        )

    tool_type_id = uuid.UUID(data.tool_type_id)
    tool_type = await session.get(ToolType, tool_type_id)
    if tool_type is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
        )

    # Validate config profile if provided
    selected_profile_id = await _validate_config_profile(
        session, data.config_profile_id, user_id, project_id, tool_type_id
    )

    # Resolve workspace if provided
    workspace = None
    workspace_id = None
    if data.workspace_id:
        from src.models.workspace import Workspace as WorkspaceModel

        try:
            workspace_id = uuid.UUID(data.workspace_id)
        except ValueError:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Invalid workspace_id format",
            )
        workspace = await session.get(WorkspaceModel, workspace_id)
        if workspace is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="workspace not found",
            )
        if workspace.repo_id != repo_id:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="workspace does not belong to this repository",
            )

    try:
        # Validate clone mode requirements (legacy path)
        if data.clone_mode == "clone" and not workspace:
            if not repo.remote_url:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="repository does not have a remote URL for cloning",
                )
            if not repo.ssh_key_id:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="repository must have an SSH key assigned for clone mode",
                )

        # Generate unique name
        instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}"
        instance_display = (
            data.display_name or f"{tool_type.display_name} - {repo.name}"
        )

        # Create instance directory
        instance_dir = ensure_instance_directory(instance_name)
        compose_path = os.path.join(instance_dir, "docker-compose.yml")

        # Find free port
        tool_port = find_free_port()

        # Determine repo path based on workspace or clone mode
        if workspace:
            repo_path = workspace.path
        elif data.clone_mode == "clone":
            # Get SSH key for cloning
            ssh_key = await session.get(SSHKey, repo.ssh_key_id)
            if ssh_key is None:
                raise HTTPException(
                    status_code=status.HTTP_404_NOT_FOUND,
                    detail="repository SSH key not found",
                )

            # Prepare SSH key for clone operation
            ssh_key_path = None
            try:
                ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
                ssh_key_path = os.path.join(ssh_dir, "id_ed25519")

                # Clone repository
                clone_path = clone_repository(
                    remote_url=repo.remote_url,
                    ssh_key_path=ssh_key_path,
                    instance_dir=instance_dir,
                    branch=data.branch or "main",
                )
                repo_path = clone_path
            except Exception as exc:
                logger.exception("Failed to clone repository: %s", exc)
                cleanup_ssh_key_files(instance_dir)
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    detail=f"Failed to clone repository: {exc}",
                )
        else:
            repo_path = repo.path

        # Verify cloned repo has files
        if data.clone_mode == "clone" and repo_path:
            try:
                repo_contents = os.listdir(repo_path)
                if not repo_contents or (
                    len(repo_contents) == 1 and repo_contents[0] == ".git"
                ):
                    logger.error("Cloned repository at %s appears empty", repo_path)
                    raise RuntimeError("Cloned repository is empty")
                logger.debug(
                    "Verified cloned repo at %s has %d items",
                    repo_path,
                    len(repo_contents),
                )
            except Exception as exc:
                logger.exception("Failed to verify cloned repository: %s", exc)
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    detail=f"Cloned repository verification failed: {exc}",
                )

        # Create new local branch if requested
        if data.clone_mode == "clone" and data.new_branch:
            try:
                result = subprocess.run(
                    ["git", "-C", repo_path, "checkout", "-b", data.new_branch],
                    capture_output=True,
                    text=True,
                )
                if result.returncode != 0:
                    logger.error(
                        "Failed to create branch %s: %s", data.new_branch, result.stderr
                    )
                    raise RuntimeError(f"Failed to create branch: {result.stderr}")
                logger.debug(
                    "Created local branch %s in cloned repository", data.new_branch
                )
            except Exception as exc:
                logger.exception("Failed to create local branch: %s", exc)
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    detail=f"Failed to create local branch: {exc}",
                )

        # Handle based on definition type
        if tool_type.definition_type == "dockerfile":
            # Build image from Dockerfile
            image_tag = f"headquarter/{instance_name}:latest".lower()

            if tool_type.dockerfile_template:
                returncode, stdout, stderr = await asyncio.to_thread(
                    build_image,
                    instance_dir=instance_dir,
                    dockerfile=tool_type.dockerfile_template,
                    tag=image_tag,
                    build_context=tool_type.build_context,
                )

                if returncode != 0:
                    logger.error(
                        "Failed to build image for instance %s: %s",
                        instance_name,
                        stderr,
                    )
                    raise HTTPException(
                        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                        detail=f"Failed to build Docker image: {stderr[:500]}",
                    )

                logger.info(
                    "Successfully built image %s for instance %s",
                    image_tag,
                    instance_name,
                )

            # Generate compose for dockerfile-built image
            # Only include ports if tool requires one (skip for terminal-only tools)
            ports_section = (
                f"""    ports:
      - "{tool_port}:{tool_type.default_port}"
"""
                if tool_type.default_port and tool_type.default_port > 0
                else ""
            )

            compose_content = f"""version: "3.8"
services:
  app:
    image: {image_tag}
    container_name: {instance_name.lower()}
    stdin_open: true
    tty: true
{ports_section}    volumes:
      - {repo_path}:/workspace
    restart: unless-stopped
"""
            write_compose_file(instance_dir, compose_content)

        elif tool_type.definition_type == "manifest":
            # Manifest-based: build image and generate compose
            from src.models.tool_definition_manifest import ToolDefinitionManifest

            manifest_def = await session.get(
                ToolDefinitionManifest, tool_type.manifest_id
            )
            if not manifest_def:
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    detail="Manifest definition not found for this tool type",
                )

            manifest = dict(manifest_def.manifest)
            if manifest_def.base_definition_id:
                base_def = await session.get(
                    ToolDefinitionManifest, manifest_def.base_definition_id
                )
                if base_def:
                    manifest = resolve_base(
                        deep_merge(dict(base_def.manifest), manifest)
                    )

            # Determine home directory for path expansion
            _home_dir = get_manifest_home_dir(manifest)

            image_tag = compute_image_tag(tool_type.name, manifest)

            # Build image during creation so start is fast
            dockerfile = compile_dockerfile(manifest)
            entrypoint = compile_entrypoint(manifest)
            build_ctx = {
                "Dockerfile": dockerfile,
                ".headquarter/entrypoint.sh": entrypoint,
            }

            returncode, stdout, stderr = await asyncio.to_thread(
                build_image,
                instance_dir=instance_dir,
                dockerfile=dockerfile,
                tag=image_tag,
                build_context=build_ctx,
            )

            if returncode != 0:
                logger.error(
                    "Failed to build image for manifest instance %s: %s",
                    instance_name,
                    stderr,
                )
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                    detail=f"Failed to build Docker image: {stderr[:500]}",
                )

            logger.info(
                "Built manifest image %s for instance %s",
                image_tag,
                instance_name,
            )

            variables = {
                "IMAGE_TAG": image_tag,
                "INSTANCE_NAME": instance_name.lower(),
                "INSTANCE_DIR": instance_dir,
                "REPO_PATH": repo_path,
                "SSH_PATH": "",
                "TOOL_PORT": tool_port,
                "EXTRA_ENV": {},
                "EXTRA_VOLUMES": [],
            }
            compose_content = compile_compose(manifest, variables)
            write_compose_file(instance_dir, compose_content)

        else:
            # Render compose template (legacy)
            variables = {
                "REPO_PATH": repo_path,
                "INSTANCE_NAME": instance_name,
                "INSTANCE_ID": instance_name,
                "TOOL_NAME": instance_name,
                "TOOL_PORT": tool_port,
                "USER_ID": str(user_id),
                "PROJECT_ID": str(project_id),
            }
            compose_content = render_compose_template(
                tool_type.compose_template, variables
            )

            # Safety check: for clone mode, ensure repo is mounted in compose file
            if data.clone_mode == "clone" and repo_path:
                import yaml

                compose_data = yaml.safe_load(compose_content)
                repo_mounted = False
                if compose_data and "services" in compose_data:
                    for svc in compose_data["services"].values():
                        volumes = svc.get("volumes", [])
                        for vol in volumes:
                            vol_str = str(vol)
                            if repo_path in vol_str:
                                repo_mounted = True
                                break
                        if repo_mounted:
                            break

                if not repo_mounted:
                    logger.warning(
                        "Compose template for tool type %s does not mount repo path; adding default mount",
                        tool_type.name,
                    )
                    # Add default mount to first service
                    if compose_data and "services" in compose_data:
                        for svc in compose_data["services"].values():
                            if "volumes" not in svc:
                                svc["volumes"] = []
                            svc["volumes"].append(f"{repo_path}:/workspace")
                            break
                        compose_content = yaml.dump(
                            compose_data, default_flow_style=False
                        )

            write_compose_file(instance_dir, compose_content)

        # Create database record
        instance = ToolInstance(
            name=instance_name,
            display_name=instance_display,
            tool_type_id=tool_type_id,
            repository_id=repo_id,
            project_id=project_id,
            owner_id=user_id,
            status="pending",
            compose_path=compose_path,
            port=tool_port,
            workspace_id=workspace_id,
            clone_mode=data.clone_mode,
            branch=data.new_branch
            if data.new_branch
            else (data.branch if data.clone_mode == "clone" else None),
            selected_config_profile_id=selected_profile_id,
            ssh_key_ids=data.ssh_key_ids or None,
        )
        session.add(instance)
        await session.commit()
        await session.refresh(instance)
        await publish_lifecycle_event(
            event_bus=_event_bus,
            session=session,
            instance=instance,
            event_type="instance.created",
            created_by=user_id,
            status="pending",
            message="Instance created",
        )

        return {
            "id": str(instance.id),
            "name": instance.name,
            "display_name": instance.display_name,
            "tool_type_id": str(instance.tool_type_id),
            "status": instance.status,
            "clone_mode": instance.clone_mode,
            "branch": instance.branch,
            "selected_config_profile_id": str(instance.selected_config_profile_id)
            if instance.selected_config_profile_id
            else None,
            "created_at": instance.created_at.isoformat(),
        }
    except Exception as exc:
        logger.exception("Failed to create instance: %s", exc)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to create instance: {exc}",
        )


@router.get(
    "/{project_id}/repositories/{repo_id}/instances",
    summary="List instances",
    description="List all tool instances for a repository.",
)
async def list_instances(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """List all instances for a repository.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary containing list of instances.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    repo = await session.get(GitRepository, repo_id)
    if repo is None or repo.project_id != project_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
        )

    result = await session.execute(
        select(ToolInstance)
        .where(ToolInstance.repository_id == repo_id)
        .where(ToolInstance.owner_id == user_id)
        .order_by(ToolInstance.created_at.desc())
    )
    instances = result.scalars().all()

    instances_data = []
    for i in instances:
        tool_type = await session.get(ToolType, i.tool_type_id)
        instances_data.append(
            {
                "id": str(i.id),
                "name": i.name,
                "display_name": i.display_name,
                "tool_type_id": str(i.tool_type_id),
                "tool_type_name": tool_type.name if tool_type else "unknown",
                "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
                "status": i.status,
                "url": i.url,
                "port": i.port,
                "clone_mode": i.clone_mode,
                "branch": i.branch,
                "ssh_key_ids": i.ssh_key_ids or [],
                "created_at": i.created_at.isoformat(),
            }
        )

    return {"instances": instances_data}


@router.get(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}",
    summary="Get instance",
    description="Get a specific instance with real-time status from Docker.",
)
async def get_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Get a specific instance with real-time status.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with instance details and current status.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Get real-time status from Docker
    if instance.container_id:
        docker_status = get_container_status(instance.container_id)
        if docker_status == "running" and instance.status != "running":
            instance.status = "running"
            await session.commit()
        elif docker_status == "exited" and instance.status == "running":
            instance.status = "stopped"
            instance.last_stopped_at = datetime.now()
            await session.commit()

    return {
        "id": str(instance.id),
        "name": instance.name,
        "display_name": instance.display_name,
        "tool_type_id": str(instance.tool_type_id),
        "status": instance.status,
        "container_id": instance.container_id,
        "compose_path": instance.compose_path,
        "url": instance.url,
        "port": instance.port,
        "clone_mode": instance.clone_mode,
        "branch": instance.branch,
        "selected_config_profile_id": str(instance.selected_config_profile_id)
        if instance.selected_config_profile_id
        else None,
        "last_started_at": instance.last_started_at.isoformat()
        if instance.last_started_at
        else None,
        "last_stopped_at": instance.last_stopped_at.isoformat()
        if instance.last_stopped_at
        else None,
        "created_at": instance.created_at.isoformat(),
    }


async def _prepare_manifest_instance(
    session: AsyncSession,
    instance: ToolInstance,
    instance_dir: str,
    repo_path: str,
    env_vars: dict,
    extra_volumes: list,
    working_directory: str | None,
) -> tuple[str, str, dict, str]:
    """Build image and generate compose from a manifest-based tool type.

    Returns:
        Tuple of (image_tag, compose_content, resolved_manifest, home_dir)
    """
    from src.models.tool_definition_manifest import ToolDefinitionManifest

    tool_type = await session.get(ToolType, instance.tool_type_id)
    manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)

    if not manifest_def:
        raise RuntimeError(f"Manifest not found for tool type {tool_type.id}")

    manifest = dict(manifest_def.manifest)

    # Resolve base if referenced
    if manifest_def.base_definition_id:
        base_def = await session.get(
            ToolDefinitionManifest, manifest_def.base_definition_id
        )
        if base_def:
            base_manifest = dict(base_def.manifest)
            manifest = resolve_base(deep_merge(base_manifest, manifest))
        else:
            logger.warning(
                "Base definition %s not found for manifest %s",
                manifest_def.base_definition_id,
                manifest_def.id,
            )

    manifest = merge_with_config(manifest)

    # Resolve extra env and volumes from merge_with_config
    extra_env = manifest.pop("_extra_env", {})
    extra_cfg_volumes = manifest.pop("_extra_volumes", [])
    env_vars.update(extra_env)
    extra_volumes.extend(extra_cfg_volumes)

    # Compute image tag
    image_tag = compute_image_tag(tool_type.name, manifest)

    # Check if image already exists
    check = subprocess.run(
        ["docker", "images", "-q", image_tag],
        capture_output=True,
        text=True,
    )
    image_exists = check.returncode == 0 and check.stdout.strip()

    if not image_exists:
        # Compile and build
        dockerfile = compile_dockerfile(manifest)
        entrypoint = compile_entrypoint(manifest)

        logger.debug(
            "Compiled Dockerfile for instance %s (%d chars)",
            instance.id,
            len(dockerfile),
        )

        build_ctx = {
            "Dockerfile": dockerfile,
            ".headquarter/entrypoint.sh": entrypoint,
        }

        returncode, stdout, stderr = await asyncio.to_thread(
            build_image,
            instance_dir=instance_dir,
            dockerfile=dockerfile,
            tag=image_tag,
            build_context=build_ctx,
        )

        if returncode != 0:
            raise RuntimeError(f"Docker build failed: {stderr}")

        logger.info("Built image %s for instance %s", image_tag, instance.id)
    else:
        logger.info("Reusing existing image %s for instance %s", image_tag, instance.id)

    # Prepare SSH path for mount resolution
    ssh_path = ""
    if instance.clone_mode == "clone":
        ssh_path = os.path.join(instance_dir, ".ssh")

    # Resolve git mount variables from config profile
    git_mount_vars = {}
    if instance.selected_config_profile_id:
        resolved_profile = await resolve_profile(
            session, instance.selected_config_profile_id
        )
        for gm in resolved_profile.git_mounts or []:
            ref = gm.get("git_mount_ref", "default")
            # The actual resolution happens in _resolve_git_mounts; we store placeholder
            git_mount_vars[f"GIT_MOUNT_{ref}"] = ""

    variables = {
        "IMAGE_TAG": image_tag,
        "INSTANCE_NAME": instance.name.lower(),
        "INSTANCE_DIR": instance_dir,
        "REPO_PATH": repo_path,
        "SSH_PATH": ssh_path,
        "TOOL_PORT": instance.port or 0,
        "EXTRA_ENV": env_vars,
        "EXTRA_VOLUMES": extra_volumes,
        **git_mount_vars,
    }

    compose_content = compile_compose(manifest, variables)

    # Cache
    instance.image_tag = image_tag
    instance.manifest_compiled_at = datetime.now()

    home_dir = get_manifest_home_dir(manifest)
    return image_tag, compose_content, manifest, home_dir


@router.post(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/start",
    summary="Start instance",
    description="Start a tool instance using Docker Compose.",
)
async def start_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    data: StartInstanceRequest | None = None,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Start a tool instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance to start.
        data: Optional start configuration including config profile selection.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with status and URL of the running instance.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Validate and store config profile selection
    if data and data.config_profile_id is not None:
        selected_profile_id = await _validate_config_profile(
            session, data.config_profile_id, user_id, project_id, instance.tool_type_id
        )
        instance.selected_config_profile_id = selected_profile_id
        await session.commit()

    # Store SSH key selection if provided
    if data and data.ssh_key_ids is not None:
        instance.ssh_key_ids = data.ssh_key_ids or None
        await session.commit()

    if not instance.compose_path or not os.path.exists(instance.compose_path):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
        )

    instance.status = "building"
    await session.commit()
    logger.info("Starting instance %s (name=%s)", instance.id, instance.name)

    # Runtime overrides populated by config profiles
    env_vars = {}
    config_files = {}
    port_override = None
    start_command = None
    working_directory = None
    extra_volumes = []

    # Fetch tool type early to determine home directory and container user
    tool_type = await session.get(ToolType, instance.tool_type_id)
    home_dir = "/root"
    container_uid = 0
    container_gid = 0
    if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
        from src.models.tool_definition_manifest import ToolDefinitionManifest

        manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
        if manifest_def:
            manifest = dict(manifest_def.manifest)
            # Merge with base definition if referenced (user config is often in base)
            if manifest_def.base_definition_id:
                base_def = await session.get(
                    ToolDefinitionManifest, manifest_def.base_definition_id
                )
                if base_def:
                    manifest = resolve_base(
                        deep_merge(dict(base_def.manifest), manifest)
                    )
            home_dir = get_manifest_home_dir(manifest)
            user_cfg = manifest.get("user")
            if user_cfg:
                container_uid = user_cfg.get("uid", 0)
                container_gid = user_cfg.get("gid", 0)
            logger.debug(
                "Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
                instance.id,
                container_uid,
                container_gid,
                home_dir,
            )

    # Apply selected config profile if any
    instance_dir = os.path.dirname(instance.compose_path)
    if instance.selected_config_profile_id is not None:
        try:
            resolved = await resolve_profile(
                session, instance.selected_config_profile_id
            )
            profile_env, profile_files, profile_mounts, profile_hints = (
                apply_resolved_profile(instance_dir, resolved, home_dir)
            )
            # Profile env vars override tool config env vars
            env_vars.update(profile_env)
            # Profile files are written by apply_resolved_profile
            config_files.update(profile_files)
            # Profile mounts are added to extra volumes
            extra_volumes.extend(profile_mounts)
            # Git repository mounts are resolved and added
            git_mount_volumes = await _resolve_git_mounts(
                session, resolved, instance_dir, working_directory, home_dir
            )
            extra_volumes.extend(git_mount_volumes)
            # Profile runtime hints override tool config values
            if profile_hints.get("start_command"):
                start_command = profile_hints["start_command"]
            if profile_hints.get("working_directory"):
                working_directory = profile_hints["working_directory"]
            if profile_hints.get("port_override"):
                port_override = profile_hints["port_override"]
            logger.debug(
                "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
                resolved.profile_name,
                instance.id,
                len(profile_env),
                len(profile_files),
                len(profile_mounts),
                len(git_mount_volumes),
            )
        except ConfigProfileCycleError as exc:
            logger.error(
                "Cycle detected in config profile for instance %s: %s", instance.id, exc
            )
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=f"Config profile cycle detected: {exc}",
            )
    else:
        logger.debug("No config profile selected for instance %s", instance.id)

    # Write env file and config files
    env_file_path = None

    if env_vars:
        env_file_path = write_env_file(instance_dir, env_vars)
        logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path)

    if config_files:
        write_config_files(instance_dir, config_files)
        logger.debug(
            "Wrote %d config files for instance %s", len(config_files), instance.id
        )

    # Mount selected SSH keys into container home dir
    if instance.ssh_key_ids:
        for key_id in instance.ssh_key_ids:
            ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
            if ssh_key and ssh_key.user_id == user_id:
                try:
                    ssh_dir = prepare_ssh_key_files(
                        instance_dir,
                        ssh_key,
                        subdir=f"mounts/ssh/{key_id}/.ssh",
                        uid=container_uid,
                        gid=container_gid,
                    )
                    ssh_target = os.path.join(home_dir, ".ssh")
                    extra_volumes.append(
                        {
                            "source": ssh_dir,
                            "target": ssh_target,
                            "type": "bind",
                        }
                    )
                    logger.debug(
                        "Mounted SSH key %s for instance %s to %s",
                        ssh_key.name,
                        instance.id,
                        ssh_target,
                    )
                except Exception as exc:
                    logger.error(
                        "Failed to prepare SSH key %s for instance %s: %s",
                        key_id,
                        instance.id,
                        exc,
                    )
            else:
                logger.warning(
                    "SSH key %s not found or not authorized for user %s",
                    key_id,
                    user_id,
                )

    # ── MANIFEST-BASED FLOW ──────────────────────────────────────
    resolved_manifest = None

    if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
        logger.info("Using manifest-based startup for instance %s", instance.id)

        # Determine repo path (workspace takes precedence)
        repo_path = ""
        if instance.workspace_id:
            from src.models.workspace import Workspace as WorkspaceModel

            workspace = await session.get(WorkspaceModel, instance.workspace_id)
            if workspace:
                repo_path = workspace.path
        else:
            repo = await session.get(GitRepository, instance.repository_id)
            repo_path = repo.path if repo else ""
            if instance.clone_mode == "clone":
                repo_path = os.path.join(instance_dir, "repo-clone")

        try:
            (
                image_tag,
                compose_content,
                resolved_manifest,
                _home_dir,
            ) = await _prepare_manifest_instance(
                session=session,
                instance=instance,
                instance_dir=instance_dir,
                repo_path=repo_path,
                env_vars=env_vars,
                extra_volumes=extra_volumes,
                working_directory=working_directory,
            )
            write_compose_file(instance_dir, compose_content)
            logger.debug(
                "Generated manifest-based compose for instance %s", instance.id
            )
        except Exception as exc:
            logger.exception(
                "Manifest compilation failed for instance %s: %s", instance.id, exc
            )
            instance.status = "error"
            await session.commit()
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail=f"Manifest compilation failed: {exc}",
            )
    else:
        # ── LEGACY FLOW ──────────────────────────────────────────
        # Mount SSH key for clone-mode instances (skip for workspace-based)
        if instance.clone_mode == "clone" and not instance.workspace_id:
            repo = await session.get(GitRepository, instance.repository_id)
            if repo and repo.ssh_key_id:
                ssh_key = await session.get(SSHKey, repo.ssh_key_id)
                if ssh_key:
                    try:
                        ssh_dir = prepare_ssh_key_files(
                            instance_dir, ssh_key, uid=0, gid=0
                        )
                        extra_volumes.append(
                            {
                                "source": ssh_dir,
                                "target": "/root/.ssh",
                                "type": "bind",
                            }
                        )
                        logger.debug(
                            "Mounted SSH key for clone-mode instance %s", instance.id
                        )
                    except Exception as exc:
                        logger.error(
                            "Failed to prepare SSH key for instance %s: %s",
                            instance.id,
                            exc,
                        )

        # Modify compose file if needed (port override, start command, working dir, volumes)
        if port_override or start_command or working_directory or extra_volumes:
            _modify_compose_file(
                instance.compose_path,
                port_override,
                start_command,
                working_directory,
                extra_volumes,
                home_dir,
            )
            logger.debug("Modified compose file for instance %s", instance.id)

    # Sanitize compose file to remove invalid port mappings from old instances
    _sanitize_compose_file(instance.compose_path)

    # Auto-fix bind address for known web tools that default to localhost
    if tool_type and tool_type.interface_type == "web":
        _ensure_web_bind_address(
            instance.compose_path, tool_type.name, tool_type.default_port
        )

    # Ensure predictable container name for tunnel connectivity
    _ensure_container_name_in_compose(instance.compose_path, instance.name)
    _ensure_backend_network_in_compose(instance.compose_path)

    # Execute docker compose up with env file
    logger.debug(
        "Running docker compose up for instance %s (compose_path=%s)",
        instance.id,
        instance.compose_path,
    )
    returncode, stdout, stderr = execute_compose_command(
        instance.compose_path, "up", env_file=env_file_path
    )
    logger.debug(
        "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s",
        instance.id,
        returncode,
        stdout[:200] if stdout else "",
        stderr[:500] if stderr else "",
    )

    if returncode != 0:
        instance.status = "error"
        await session.commit()
        logger.error("Failed to start instance %s: %s", instance.id, stderr)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"failed to start instance: {stderr}",
        )

    # Get container ID and name (use predictable name from compose)
    expected_container_name = instance.name.lower()
    container_id = get_container_id(expected_container_name)
    if container_id:
        instance.container_id = container_id
        logger.debug("Container ID for instance %s: %s", instance.id, container_id)

    instance.container_name = expected_container_name
    logger.debug(
        "Container name for instance %s: %s", instance.id, expected_container_name
    )

    # Verify container reached running state
    if instance.container_id:
        instance.status = "starting"
        instance.last_started_at = datetime.now()
        await session.commit()
        await publish_lifecycle_event(
            event_bus=_event_bus,
            session=session,
            instance=instance,
            event_type="instance.started",
            created_by=user_id,
            status="starting",
            message="Container starting...",
        )
        logger.debug("Instance %s: verifying container startup...", instance.id)

        startup_result = wait_for_container_running(
            instance.container_id, timeout=30, interval=2.0
        )

        if not startup_result["success"]:
            # Container failed to start
            error_msg = f"Container failed to start: status={startup_result['status']}"
            if startup_result["exit_code"] is not None:
                error_msg += f", exit_code={startup_result['exit_code']}"

            # Get logs for debugging
            logs = get_container_logs(instance.container_id, tail=50)

            instance.status = "error"
            await session.commit()
            await publish_lifecycle_event(
                event_bus=_event_bus,
                session=session,
                instance=instance,
                event_type="instance.error",
                created_by=user_id,
                status="error",
                message=error_msg,
                metadata={
                    "exit_code": startup_result["exit_code"],
                    "error_type": "container",
                },
            )
            logger.error(
                "Instance %s container startup failed after %.1fs: %s\nLogs:\n%s",
                instance.id,
                startup_result["waited_seconds"],
                error_msg,
                logs,
            )
            return {
                "status": "error",
                "error": error_msg,
                "logs": logs,
            }

        logger.debug(
            "Instance %s container started successfully after %.1fs",
            instance.id,
            startup_result["waited_seconds"],
        )

        # Apply mount permission fixes for manifest-based instances
        if resolved_manifest and instance.container_id:
            mounts = resolved_manifest.get("mounts", [])
            if mounts:
                logger.debug(
                    "Applying permission fixes for instance %s (%d mounts)",
                    instance.id,
                    len(mounts),
                )
                permission_results = apply_mount_permissions(
                    instance.container_id,
                    mounts,
                )
                for result in permission_results:
                    if not result["success"]:
                        logger.warning(
                            "Permission fix failed for mount %s on instance %s: %s",
                            result["mount_name"],
                            instance.id,
                            result["error"],
                        )

        # Fix SSH key ownership/permissions inside the container
        if instance.ssh_key_ids and instance.container_id:
            container_user = (
                "root"
                if home_dir == "/root"
                else home_dir[6:]
                if home_dir.startswith("/home/")
                else "root"
            )
            ssh_target = os.path.join(home_dir, ".ssh")
            logger.debug(
                "Applying SSH permissions for user %s on %s in instance %s",
                container_user,
                ssh_target,
                instance.id,
            )
            ssh_perm_result = apply_ssh_permissions(
                instance.container_id,
                ssh_target,
                container_user,
            )
            if not ssh_perm_result["success"]:
                logger.warning(
                    "SSH permission fix failed for instance %s: %s",
                    instance.id,
                    ssh_perm_result["error"],
                )

    # Execute readiness probe if configured
    tool_type = await session.get(ToolType, instance.tool_type_id)
    if tool_type and instance.container_id:
        # Determine probe command
        probe_command = None
        probe_timeout = 30
        probe_interval = 2

        if tool_type.readiness_probe:
            probe_config = tool_type.readiness_probe
            probe_command = probe_config.get("command", "")
            probe_timeout = probe_config.get("timeout", 30)
            probe_interval = probe_config.get("interval", 2)
        elif tool_type.interface_type == "web":
            # Default probe for web tools
            probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}"
            probe_timeout = 30
            probe_interval = 2

        if probe_command:
            instance.status = "probing"
            await session.commit()
            logger.debug(
                "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d",
                instance.id,
                probe_command,
                probe_timeout,
                probe_interval,
            )

            success, probe_logs = await execute_probe(
                container_id=instance.container_id,
                command=probe_command,
                timeout=probe_timeout,
                interval=probe_interval,
            )

            # Store probe result
            instance.probe_result = {
                "success": success,
                "command": probe_command,
                "logs": probe_logs,
                "timestamp": datetime.now().isoformat(),
            }

            if not success:
                instance.status = "unhealthy"
                await session.commit()
                await publish_lifecycle_event(
                    event_bus=_event_bus,
                    session=session,
                    instance=instance,
                    event_type="instance.health_changed",
                    created_by=user_id,
                    status="unhealthy",
                    message="Readiness probe failed",
                    metadata={"probe_output": "\n".join(probe_logs)},
                )
                logger.error(
                    "Readiness probe failed for instance %s after %ds: %s",
                    instance.id,
                    probe_timeout,
                    "\n".join(probe_logs),
                )
                return {
                    "status": "unhealthy",
                    "error": f"Readiness probe failed after {probe_timeout}s",
                    "probe_logs": probe_logs,
                }

            logger.info("Readiness probe succeeded for instance %s", instance.id)

    instance.status = "running"
    await session.commit()
    await publish_lifecycle_event(
        event_bus=_event_bus,
        session=session,
        instance=instance,
        event_type="instance.health_changed",
        created_by=user_id,
        status="running",
        message="Container running",
        metadata={"previous_status": "starting"},
    )
    logger.info("Instance %s is now running", instance.id)

    # Get tool type for default port
    tool_type = await session.get(ToolType, instance.tool_type_id)
    if not tool_type:
        logger.error("Tool type %s not found", instance.tool_type_id)
        instance.status = "error"
        await session.commit()
        await publish_lifecycle_event(
            event_bus=_event_bus,
            session=session,
            instance=instance,
            event_type="instance.error",
            created_by=user_id,
            status="error",
            message=f"Tool type '{instance.tool_type_id}' not found",
        )
        return {
            "status": "error",
            "error": f"Tool type '{instance.tool_type_id}' not found",
        }

    logger.debug(
        "Tool type for instance %s: name=%s, container_port=%s, interface_type=%s",
        instance.id,
        tool_type.name,
        tool_type.default_port or 0,
        tool_type.interface_type,
    )

    # Only create Cloudflare tunnel for web-enabled tools
    if tool_type.interface_type == "web":
        # Create temporary Cloudflare tunnel for public access
        try:
            logger.debug(
                "Creating tunnel for instance %s (container_port=%d)",
                instance.id,
                tool_type.default_port or 0,
            )
            tunnel_info = start_tunnel(
                instance_name=instance.name,
                container_port=tool_type.default_port or 0,
            )
            instance.tunnel_id = tunnel_info["container_name"]
            instance.public_url = tunnel_info["url"]
            instance.url = tunnel_info["url"]
            await session.commit()
            logger.debug(
                "Created tunnel for instance %s: container=%s, url=%s",
                instance.id,
                tunnel_info["container_name"],
                tunnel_info["url"],
            )
        except Exception as exc:
            import traceback

            error_msg = str(exc)
            error_trace = traceback.format_exc()
            logger.error(
                "Failed to create tunnel for instance %s: %s\nTraceback:\n%s",
                instance.id,
                error_msg,
                error_trace,
            )
            instance.status = "error"
            instance.url = None
            await session.commit()
            return {
                "status": "error",
                "error": f"Failed to create tunnel: {error_msg}",
            }
    else:
        # Terminal-only tool - no tunnel needed
        logger.info(
            "Instance %s is terminal-only (no web interface), skipping tunnel creation",
            instance.id,
        )
        instance.url = None
        instance.public_url = None
        await session.commit()

    return {"status": instance.status, "url": instance.url}


@router.post(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop",
    summary="Stop instance",
    description="Stop a running tool instance.",
)
async def stop_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Stop a tool instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance to stop.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with the stopped status.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Stop Cloudflare tunnel if exists
    if instance.tunnel_id:
        try:
            stop_tunnel(instance.name)
            logger.debug(
                "Stopped tunnel for instance %s (container=%s)",
                instance.id,
                instance.tunnel_id,
            )
        except Exception as exc:
            logger.warning(
                "Failed to stop tunnel for instance %s: %s", instance.id, exc
            )

    if instance.compose_path and os.path.exists(instance.compose_path):
        execute_compose_command(instance.compose_path, "stop")

    instance.status = "stopped"
    instance.last_stopped_at = datetime.now()
    instance.url = None
    instance.public_url = None
    instance.tunnel_id = None
    await session.commit()
    await publish_lifecycle_event(
        event_bus=_event_bus,
        session=session,
        instance=instance,
        event_type="instance.stopped",
        created_by=user_id,
        status="stopped",
        message="Instance stopped",
    )

    return {"status": instance.status}


@router.post(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart",
    summary="Restart instance",
    description="Restart a tool instance.",
)
async def restart_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Restart a tool instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance to restart.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with status and URL of the restarted instance.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Stop old tunnel if exists
    if instance.tunnel_id:
        try:
            stop_tunnel(instance.name)
            logger.debug(
                "Stopped old tunnel for instance %s (container=%s)",
                instance.id,
                instance.tunnel_id,
            )
        except Exception as exc:
            logger.warning(
                "Failed to stop old tunnel for instance %s: %s", instance.id, exc
            )

    # Re-apply stored config profile on restart
    if instance.compose_path and os.path.exists(instance.compose_path):
        instance_dir = os.path.dirname(instance.compose_path)
        if instance.selected_config_profile_id is not None:
            try:
                resolved = await resolve_profile(
                    session, instance.selected_config_profile_id
                )
                profile_env, profile_files, profile_mounts, profile_hints = (
                    apply_resolved_profile(instance_dir, resolved)
                )
                # Write env file with resolved profile env vars
                if profile_env:
                    write_env_file(instance_dir, profile_env)
                    logger.debug(
                        "Re-applied config profile %s on restart for instance %s",
                        resolved.profile_name,
                        instance.id,
                    )
            except ConfigProfileCycleError as exc:
                logger.error(
                    "Cycle detected in stored config profile for instance %s: %s",
                    instance.id,
                    exc,
                )

        # Re-apply compose fixes in case they were updated since last start
        _sanitize_compose_file(instance.compose_path)
        tool_type = await session.get(ToolType, instance.tool_type_id)
        if tool_type and tool_type.interface_type == "web":
            _ensure_web_bind_address(
                instance.compose_path, tool_type.name, tool_type.default_port
            )
        _ensure_container_name_in_compose(instance.compose_path, instance.name)
        _ensure_backend_network_in_compose(instance.compose_path)

        returncode, stdout, stderr = execute_compose_command(
            instance.compose_path, "restart"
        )

        if returncode == 0:
            instance.status = "running"
            instance.last_started_at = datetime.now()

            # Get tool type for default port
            tool_type = await session.get(ToolType, instance.tool_type_id)
            if not tool_type or not tool_type.default_port:
                logger.error(
                    "Tool type %s has no default_port configured. Cannot create tunnel.",
                    instance.tool_type_id,
                )
                instance.status = "error"
                await session.commit()
                return {
                    "status": "error",
                    "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
                }

            # Only create tunnel for web-enabled tools
            if tool_type.interface_type == "web":
                # Create new tunnel
                try:
                    tunnel_info = start_tunnel(
                        instance_name=instance.name,
                        container_port=tool_type.default_port or 0,
                    )
                    instance.tunnel_id = tunnel_info["container_name"]
                    instance.public_url = tunnel_info["url"]
                    instance.url = tunnel_info["url"]
                    logger.debug(
                        "Created new tunnel for instance %s: %s",
                        instance.id,
                        tunnel_info["url"],
                    )
                except Exception as exc:
                    logger.warning(
                        "Failed to create tunnel for instance %s: %s",
                        instance.id,
                        exc,
                    )
                    instance.status = "error"
                    instance.url = None
                    await session.commit()
                    return {
                        "status": "error",
                        "error": f"Failed to create tunnel: {exc}",
                    }
            else:
                # Terminal-only tool
                instance.url = None
                instance.public_url = None

            await session.commit()
            await publish_lifecycle_event(
                event_bus=_event_bus,
                session=session,
                instance=instance,
                event_type="instance.restarted",
                created_by=user_id,
                status="running",
                message="Instance restarted",
            )
            return {"status": instance.status, "url": instance.url}

    instance.status = "error"
    await session.commit()
    return {"status": instance.status}


@router.delete(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}",
    summary="Delete instance",
    description="Delete a tool instance and remove its Docker containers and files.",
)
async def delete_instance(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    force: bool = False,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> None:
    """Delete a tool instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance to delete.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        None with 204 status code.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Check dirty state for clone-mode instances
    if instance.clone_mode == "clone" and not force:
        instance_dir = (
            os.path.dirname(instance.compose_path) if instance.compose_path else None
        )
        if instance_dir:
            clone_path = os.path.join(instance_dir, "repo-clone")
            if os.path.exists(clone_path):
                is_dirty, changed_files = check_dirty_state(clone_path)
                if is_dirty:
                    raise HTTPException(
                        status_code=status.HTTP_409_CONFLICT,
                        detail={
                            "message": "Repository has uncommitted changes",
                            "changed_files": changed_files,
                            "force_required": True,
                        },
                    )

    # Stop Cloudflare tunnel if exists
    if instance.tunnel_id:
        try:
            stop_tunnel(instance.name)
            logger.debug(
                "Stopped tunnel for instance %s (container=%s)",
                instance.id,
                instance.tunnel_id,
            )
        except Exception as exc:
            logger.warning(
                "Failed to stop tunnel for instance %s: %s", instance.id, exc
            )

    # Stop and remove container
    if instance.compose_path and os.path.exists(instance.compose_path):
        execute_compose_command(instance.compose_path, "down")

    # Remove instance directory (includes clone and SSH keys)
    if instance.compose_path:
        instance_dir = os.path.dirname(instance.compose_path)
        if os.path.exists(instance_dir):
            import shutil

            shutil.rmtree(instance_dir)

    await publish_lifecycle_event(
        event_bus=_event_bus,
        session=session,
        instance=instance,
        event_type="instance.deleted",
        created_by=user_id,
        status="deleted",
        message="Instance deleted",
    )
    await session.delete(instance)
    await session.commit()


@router.get(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs",
    summary="Get instance logs",
    description="Get container logs for a tool instance.",
)
async def get_instance_logs(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    tail: int = 100,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Get container logs for an instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        tail: Number of log lines to return (default: 100).
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary containing the container logs.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    if not instance.container_id:
        return {"logs": "No container running"}

    logs = get_container_logs(instance.container_id, tail)
    return {"logs": logs}


@router.post(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel",
    summary="Recreate tunnel",
    description="Recreate the temporary Cloudflare tunnel for a running instance.",
)
async def recreate_tunnel_endpoint(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Recreate the temporary tunnel for an instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with new URL and status.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    if instance.status != "running":
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="instance must be running to recreate tunnel",
        )

    tool_type = await session.get(ToolType, instance.tool_type_id)
    if not tool_type:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Tool type not found for this instance",
        )

    expected_name = instance.name.lower()
    logger.info(
        "Recreate tunnel for instance %s (expected container name: %s, default_port: %s)",
        instance.id,
        expected_name,
        tool_type.default_port,
    )

    # Find the tool container — try stored ID first, then fall back to name lookup
    tool_container_id = instance.container_id
    if tool_container_id:
        logger.info("Using stored container_id: %s", tool_container_id)
    else:
        tool_container_id = get_container_id(expected_name)
        if tool_container_id:
            logger.info("Found container by name: %s", tool_container_id)
        else:
            logger.error("Container %s not found", expected_name)
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail="Could not find running container for this instance",
            )

    # Ensure the tool container is on the backend network so the tunnel can reach it
    network_name = get_backend_network_name()
    on_network = is_container_on_network(tool_container_id, network_name)
    logger.info(
        "Container %s on network %s: %s",
        tool_container_id,
        network_name,
        on_network,
    )
    if not on_network:
        logger.info(
            "Connecting container %s to network %s",
            tool_container_id,
            network_name,
        )
        connected = connect_container_to_network(tool_container_id, network_name)
        logger.info("Network connect result: %s", connected)

    # Get the container's IP on the backend network
    target_ip = get_container_ip_on_network(tool_container_id, network_name)
    if target_ip:
        target_url = f"http://{target_ip}:{tool_type.default_port or 0}"
        logger.info(
            "Tunnel target for instance %s: %s (IP %s on %s)",
            instance.id,
            target_url,
            target_ip,
            network_name,
        )
    else:
        target_url = f"http://{expected_name}:{tool_type.default_port or 0}"
        logger.warning(
            "Could not get container IP, falling back to name-based target: %s",
            target_url,
        )

    try:
        tunnel_info = recreate_tunnel(
            instance_name=instance.name,
            container_port=tool_type.default_port or 0,
            target_url=target_url,
        )
        logger.info(
            "Tunnel recreated: container=%s, url=%s",
            tunnel_info["container_name"],
            tunnel_info["url"],
        )

        # Verify the tunnel can actually reach the origin
        health = check_tunnel_health(tunnel_info["url"], timeout=10)
        logger.info(
            "Tunnel health check: status=%s, code=%s, error=%s",
            health.get("tunnel_status"),
            health.get("status_code"),
            health.get("error"),
        )

        # Also probe from inside the API container directly to the target
        probe = subprocess.run(
            [
                "curl",
                "-s",
                "-o",
                "/dev/null",
                "-w",
                "%{http_code}",
                "--max-time",
                "5",
                target_url,
            ],
            capture_output=True,
            text=True,
        )
        logger.info(
            "Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip()
        )

        instance.tunnel_id = tunnel_info["container_name"]
        instance.public_url = tunnel_info["url"]
        instance.url = tunnel_info["url"]
        await session.commit()
        return {"status": "healthy", "url": instance.url}
    except Exception as exc:
        logger.exception("Failed to recreate tunnel for instance %s", instance.id)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to recreate tunnel: {str(exc)}",
        )


@router.get(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health",
    summary="Check instance health",
    description="Check container and tunnel health for an instance.",
)
async def check_instance_tunnel_health(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Check health for an instance (container + tunnel).

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag.
    """
    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Check container status
    container_info = {"status": "not_found", "exit_code": None, "health": None}
    if instance.container_id:
        container_info = get_container_status(instance.container_id)

    # Build response
    response = {
        "healthy": False,
        "container_status": container_info["status"],
        "container_health": container_info["health"],
        "tunnel_status": "not_applicable",
        "tunnel_status_code": None,
        "probe_status": "not_applicable",
        "last_probe_output": None,
        "error": None,
    }

    # Determine probe status
    if instance.status == "probing":
        response["probe_status"] = "pending"
    elif instance.probe_result:
        response["probe_status"] = (
            "success" if instance.probe_result.get("success") else "failed"
        )
        response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))

    # Check tunnel health if instance has a URL and is web-enabled
    if instance.url and instance.status in ("running", "unhealthy"):
        tunnel_health = check_tunnel_health(instance.url)
        response["tunnel_status"] = tunnel_health["tunnel_status"]
        response["tunnel_status_code"] = tunnel_health.get("status_code")
        if tunnel_health.get("error"):
            response["error"] = tunnel_health["error"]

    # Overall healthy: web tools need running container + healthy tunnel;
    # terminal tools only need running container
    container_healthy = container_info["status"] == "running"
    if instance.url:
        tunnel_healthy = response["tunnel_status"] == "healthy"
        response["healthy"] = container_healthy and tunnel_healthy
    else:
        response["healthy"] = container_healthy

    # If container is not running, override error message
    if not container_healthy:
        response["error"] = f"Container is {container_info['status']}"
        if container_info["exit_code"] is not None:
            response["error"] += f" (exit code: {container_info['exit_code']})"

    return response


@router.get(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/events",
    summary="Get instance events history",
    description="Get lifecycle event history for a tool instance.",
)
async def get_instance_events(
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    limit: int = 50,
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
    """Get lifecycle event history for an instance.

    Args:
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        limit: Maximum number of events to return (default: 50).
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        List of event dictionaries.
    """
    from sqlalchemy import select
    from src.models.instance_event import InstanceEvent

    _user = await _get_user(session, user_id)
    _project = await _get_owned_project(project_id, user_id, session)

    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    result = await session.execute(
        select(InstanceEvent)
        .where(InstanceEvent.instance_id == instance_id)
        .order_by(InstanceEvent.created_at.desc())
        .limit(limit)
    )
    rows = result.scalars().all()

    return [
        {
            "id": str(row.id),
            "event_type": row.event_type,
            "status": row.status,
            "message": row.message,
            "metadata": row.event_metadata,
            "created_at": row.created_at.isoformat() if row.created_at else None,
        }
        for row in rows
    ]


@router.get(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
)
@router.post(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
@router.put(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
@router.delete(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
@router.patch(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
@router.head(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
@router.options(
    "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}",
    summary="Proxy to instance",
    description="Proxy HTTP requests to a running tool instance.",
    include_in_schema=False,
)
async def proxy_to_instance(
    request: Request,
    project_id: uuid.UUID,
    repo_id: uuid.UUID,
    instance_id: uuid.UUID,
    path: str = "",
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> Response:
    """Proxy requests to a running tool instance.

    Args:
        request: The incoming HTTP request.
        project_id: UUID of the project.
        repo_id: UUID of the repository.
        instance_id: UUID of the instance.
        path: The path to proxy to the instance.
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Response from the proxied instance.
    """
    instance = await session.get(ToolInstance, instance_id)
    if instance is None or instance.repository_id != repo_id:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="instance not found"
        )

    # Verify ownership
    if instance.owner_id != user_id:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="not authorized to access this instance",
        )

    if instance.status != "running" or not instance.container_name:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="instance is not running",
        )

    # Build target URL
    target_url = f"http://{instance.container_name}:{instance.port}"
    if path:
        target_url += f"/{path}"

    # Get query string
    query_string = str(request.query_params)
    if query_string:
        target_url += f"?{query_string}"

    # Forward headers (excluding host)
    headers = dict(request.headers)
    headers.pop("host", None)
    headers.pop("cookie", None)  # Don't forward session cookies

    # Forward the request
    try:
        async with httpx.AsyncClient() as client:
            body = await request.body()
            response = await client.request(
                method=request.method,
                url=target_url,
                headers=headers,
                content=body,
                follow_redirects=False,
                timeout=30.0,
            )
    except Exception as exc:
        logger.error("Proxy error: %s", exc)
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=f"failed to reach instance: {exc}",
        )

    # Build response
    response_headers = dict(response.headers)
    # Remove hop-by-hop headers
    for header in ["content-encoding", "transfer-encoding", "connection"]:
        response_headers.pop(header, None)

    return Response(
        content=response.content,
        status_code=response.status_code,
        headers=response_headers,
    )


sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"])


@sessions_router.get(
    "/me/sessions",
    summary="Get user sessions",
    description="Get all active sessions (running instances) for the current user.",
)
async def get_user_sessions(
    user_id: uuid.UUID = Depends(get_current_user_id),
    session: AsyncSession = Depends(get_db_session),
) -> dict:
    """Get all active sessions for the current user.

    Args:
        user_id: ID of the authenticated user.
        session: Database session.

    Returns:
        Dictionary containing list of active sessions with instance details.
    """
    _user = await _get_user(session, user_id)

    result = await session.execute(
        select(ToolInstance)
        .where(ToolInstance.owner_id == user_id)
        .where(
            ToolInstance.status.in_(
                ["running", "building", "pending", "stopped", "error"]
            )
        )
        .order_by(ToolInstance.created_at.desc())
    )
    instances = result.scalars().all()

    sessions = []
    for instance in instances:
        tool_type = await session.get(ToolType, instance.tool_type_id)
        repo = await session.get(GitRepository, instance.repository_id)
        project = await session.get(Project, instance.project_id)

        sessions.append(
            {
                "id": str(instance.id),
                "display_name": instance.display_name,
                "tool_type_name": tool_type.name if tool_type else "unknown",
                "tool_icon": tool_type.name if tool_type else "code",
                "tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
                "repository_name": repo.name if repo else "unknown",
                "repository_id": str(instance.repository_id),
                "project_name": project.name if project else "unknown",
                "project_id": str(instance.project_id),
                "status": instance.status,
                "url": instance.url,
                "clone_mode": instance.clone_mode,
                "branch": instance.branch,
                "selected_config_profile_id": str(instance.selected_config_profile_id)
                if instance.selected_config_profile_id
                else None,
                "created_at": instance.created_at.isoformat()
                if instance.created_at
                else None,
            }
        )

    return {"sessions": sessions}
