"""MCP server exposing VirtualBox, Vagrant, Podman, and CI tooling as FastMCP tools.

OpenTelemetry: For distributed tracing, run with the opentelemetry-instrument CLI:

    pip install opentelemetry-distro opentelemetry-exporter-otlp
    opentelemetry-bootstrap -a install
    opentelemetry-instrument --service_name mcp-vm-blackbox --exporter_otlp_endpoint http://localhost:4317 mcp-vm-blackbox

Alternatively, configure the SDK in code before importing FastMCP (see fastmcp docs/servers/telemetry.mdx).
"""

from __future__ import annotations

import asyncio
import logging
import os
import socket
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any

from fastmcp import FastMCP
from fastmcp.server.lifespan import lifespan
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.logging import LoggingMiddleware
from fastmcp.server.providers import FileSystemProvider
from fastmcp.server.providers.skills import SkillsDirectoryProvider
from fastmcp.server.transforms import PromptsAsTools

from mcp_vm_blackbox._helpers import _TOML_PATH, project_root
from mcp_vm_blackbox.event_emitter import EventEmitterMiddleware, get_dashboard_url

_PACKER_API_HOST: str = os.environ.get("MCP_VM_BLACKBOX_PACKER_API_HOST", "localhost")
_PACKER_API_PORT: int = int(os.environ.get("MCP_VM_BLACKBOX_PACKER_API_PORT", "8888"))
# Unique agent identity — consumed by every job tool to tag ownership.
_AGENT_ID: str = os.environ.get("MCP_VM_BLACKBOX_AGENT_ID", f"{socket.gethostname()}:{os.getpid()}")

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator, Callable

    from fastmcp.server.providers import Provider

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Config check lifespan — warns at startup when infra_targets.toml is absent.
# ---------------------------------------------------------------------------

_TOML_TEMPLATE = """\
# mcp-vm-blackbox target configuration
# Generated by: mcp-vm-blackbox --init  (or create manually)
#
# Each [targets.<name>] section defines a named host.
# Use ssh_host = "" for the local machine.

[targets.local]
ssh_host = ""
"""


@lifespan
async def config_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any], None]:  # noqa: RUF029
    """Check for required configuration at startup and warn if absent.

    Emits a WARNING to stderr with exact remediation steps when
    ``infra_targets.toml`` does not exist.  The server still starts —
    tools that need target resolution will fail on first call, but the
    operator knows exactly why and what to do.

    Yields:
        Empty dict — no shared context provided by this component.
    """
    if not _TOML_PATH.exists():
        _log.warning(
            "\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "  mcp-vm-blackbox: configuration not found\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "  Missing: %s\n"
            "\n"
            "  To initialise with a minimal local config, run:\n"
            "\n"
            "    mkdir -p %s\n"
            "    cat > %s << 'EOF'\n"
            "%s"
            "EOF\n"
            "\n"
            "  Or set MCP_VM_BLACKBOX_TARGETS_TOML to point at an existing file.\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
            _TOML_PATH,
            _TOML_PATH.parent,
            _TOML_PATH,
            _TOML_TEMPLATE,
        )
    else:
        _log.debug("Config found: %s", _TOML_PATH)
    yield {}


@lifespan
async def packer_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any], None]:
    """Check Packer HTTP API reachability at startup and warn if absent.

    TCP-probes _PACKER_API_HOST:_PACKER_API_PORT using asyncio.open_connection.
    Emits a WARNING with exact start command when the port is unreachable.
    The server still starts — packer_build_image will fail on first call
    with a rich ToolError, but the operator knows exactly why and what to do.

    Yields:
        Empty dict — no shared context provided by this component.
    """
    try:
        _, writer = await asyncio.wait_for(asyncio.open_connection(_PACKER_API_HOST, _PACKER_API_PORT), timeout=2.0)
    except (TimeoutError, OSError):
        _log.warning(
            "\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "  mcp-vm-blackbox: Packer HTTP API not reachable\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
            "  Host: %s  Port: %s\n"
            "\n"
            "  To start the Packer HTTP API, run:\n"
            "\n"
            "    packer server --address 0.0.0.0:%s\n"
            "\n"
            "  Override host/port via environment variables:\n"
            "    MCP_VM_BLACKBOX_PACKER_API_HOST (default: localhost)\n"
            "    MCP_VM_BLACKBOX_PACKER_API_PORT (default: 8888)\n"
            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
            _PACKER_API_HOST,
            _PACKER_API_PORT,
            _PACKER_API_PORT,
        )
    else:
        writer.close()
        await writer.wait_closed()
        _log.debug("Packer HTTP API reachable at %s:%s", _PACKER_API_HOST, _PACKER_API_PORT)
    yield {}


# ---------------------------------------------------------------------------
# Optional connection dependencies — unavailable in CI and offline envs.
# Imported at module level (satisfies PLC0415). None when dep is absent.
# ---------------------------------------------------------------------------

_WinRMPool: type | None
try:
    from mcp_vm_blackbox.winrm_client import WinRMPool as _ImportedWinRMPool

    _WinRMPool = _ImportedWinRMPool
except Exception:  # noqa: BLE001
    _WinRMPool = None


# ---------------------------------------------------------------------------
# Lifespan components — each manages one connection pool independently.
# Composed left-to-right with |; teardown runs right-to-left.
# ---------------------------------------------------------------------------


@lifespan
async def winrm_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any], None]:  # noqa: RUF029
    """Initialize the WinRM connection pool.

    Yields the WinRM pool when the module is available, otherwise yields None.
    No live WinRM connection is required at startup.

    Yields:
        dict with key ``winrm_pool``: pool instance, or ``None`` if unavailable.
    """
    if _WinRMPool is None:
        _log.debug("WinRM pool unavailable; yielding None")
        yield {"winrm_pool": None}
        return
    pool = _WinRMPool()
    _log.debug("WinRM pool initialized")
    try:
        yield {"winrm_pool": pool}
    finally:
        pool.close()


@lifespan
async def ssh_tunnel_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any], None]:  # noqa: RUF029
    """SSH tunnels are created per-request with ``SshTunnel(cfg)`` — no global instance.

    Yields:
        dict with key ``ssh_tunnel`` always ``None`` (reserved for future shared state).
    """
    yield {"ssh_tunnel": None}


@lifespan
async def vboxmanage_lifespan(server: FastMCP) -> AsyncGenerator[dict[str, Any], None]:  # noqa: RUF029
    """VBox dispatch uses ``VBoxDispatch(vm_name, cfg)`` per call — no global singleton.

    Yields:
        dict with key ``vboxmanage`` always ``None`` (reserved for future shared state).
    """
    yield {"vboxmanage": None}


# Phase 2 modules — imported to register their tool functions with FileSystemProvider
# Phase 4 modules — job lifecycle and fleet status tools
import mcp_vm_blackbox.fleet_tools  # noqa: E402, F401
import mcp_vm_blackbox.goal_state  # noqa: E402, F401

# Phase 3 modules — scenario runner and session state wrapper
import mcp_vm_blackbox.guest_file_tools  # noqa: E402, F401
import mcp_vm_blackbox.infra_provisioning  # noqa: E402, F401
import mcp_vm_blackbox.issue_tools  # noqa: E402, F401
import mcp_vm_blackbox.job_tools  # noqa: E402, F401
import mcp_vm_blackbox.kvm_tools  # noqa: E402, F401
import mcp_vm_blackbox.packer_tools  # noqa: E402, F401
import mcp_vm_blackbox.scenario_runner  # noqa: E402, F401
import mcp_vm_blackbox.steering_tools  # noqa: E402, F401
import mcp_vm_blackbox.todo_tools  # noqa: E402, F401
import mcp_vm_blackbox.vagrant_preflight  # noqa: E402, F401
import mcp_vm_blackbox.verification  # noqa: E402, F401
import mcp_vm_blackbox.viewport_tools  # noqa: E402, F401
import mcp_vm_blackbox.vm_lifecycle  # noqa: E402, F401
import mcp_vm_blackbox.vm_readiness  # noqa: E402, F401
import mcp_vm_blackbox.vm_recording  # noqa: E402, F401

# Discover tools from the package directory via FileSystemProvider.
# Tools with structured_content (e.g. @tool(meta={"ui": True})) are auto-discovered by FileSystemProvider — no explicit registration needed.
_tools_root = Path(__file__).resolve().parent
_fs_provider = FileSystemProvider(root=_tools_root)

# Expose skills/ directory as MCP resources (skill:// URIs).
_root = project_root()
_skills_dir = _root / "skills"
_providers: list[Provider] = [_fs_provider]
if _skills_dir.exists():
    _providers.append(SkillsDirectoryProvider(roots=_skills_dir))

mcp = FastMCP(
    "vm-blackbox",
    instructions=(
        "VM flight control suite for VirtualBox automation. Provides tools for VM lifecycle management, "
        "Vagrant provisioning, CI/CD orchestration, Packer image building, and fleet coordination "
        "with durable job tracking."
    ),
    on_duplicate="error",
    providers=_providers,
    lifespan=config_lifespan | winrm_lifespan | ssh_tunnel_lifespan | vboxmanage_lifespan | packer_lifespan,
)

# Phase 2: verification tools are hidden until a scenario run completes per-session.
mcp.disable(tags={"verify"})

mcp.add_middleware(ErrorHandlingMiddleware(include_traceback=True, transform_errors=True))
mcp.add_middleware(LoggingMiddleware())

_dashboard_url = get_dashboard_url()
if _dashboard_url:
    mcp.add_middleware(EventEmitterMiddleware(dashboard_url=_dashboard_url))
    _log.info("Event emission active: DASHBOARD_URL=%s", _dashboard_url)
else:
    _log.info("Event emission disabled: DASHBOARD_URL not set")

# Expose agents/ .md files as prompts (and via PromptsAsTools for tool-only clients).
_agents_dir = _root / "agents"
if _agents_dir.exists():

    def _make_agent_prompt(content: str) -> Callable[[], list[dict[str, str]]]:
        """Create a prompt that returns the agent markdown as a user message."""

        def prompt_fn() -> list[dict[str, str]]:
            return [{"role": "user", "content": content}]

        return prompt_fn

    for _md_file in _agents_dir.glob("*.md"):
        _agent_name = _md_file.stem.replace("-", "_")
        _content = _md_file.read_text()
        _fn = _make_agent_prompt(_content)
        mcp.prompt(_fn, name=_agent_name)

# Expose packer-prerequisites skill as an MCP prompt.
_packer_skill_md = _root / "skills" / "packer-prerequisites" / "SKILL.md"
if _packer_skill_md.exists():
    _packer_skill_content = _packer_skill_md.read_text()

    def _packer_prerequisites_prompt() -> str:
        return _packer_skill_content

    mcp.prompt(_packer_prerequisites_prompt, name="packer_prerequisites")

mcp.add_transform(PromptsAsTools(mcp))


def main() -> None:
    """Entry point for the mcp-vm-blackbox package."""
    # Handle help flag explicitly
    if "--help" in sys.argv or "-h" in sys.argv:
        print("🖥️  mcp-vm-blackbox - Model Context Protocol Server")
        print("=" * 60)
        print("This is an MCP server. It is designed to be run by an MCP client")
        print("(like Claude Code or Claude Desktop) which communicates via stdio pipes,")
        print("not interactively by a human.\n")
        print("To install this server in Claude Code, run:")
        print("  claude mcp add mcp-vm-blackbox -- uv run mcp-vm-blackbox\n")
        print("For more details, please read the README.md in this repository.")
        print("=" * 60)
        sys.exit(0)

    # If run interactively without arguments, it would hang waiting for JSON-RPC on stdin.
    if sys.stdin.isatty() and len(sys.argv) == 1:
        print("🖥️  mcp-vm-blackbox - Model Context Protocol Server")
        print("=" * 60)
        print("Error: MCP server started interactively without an MCP client.")
        print("It is designed to be run by an MCP client (like Claude Code or Claude")
        print("Desktop) which communicates via stdio pipes, not interactively.\n")
        print("To install this server in Claude Code, run:")
        print("  claude mcp add mcp-vm-blackbox -- uv run mcp-vm-blackbox\n")
        print("For more details, please read the README.md in this repository.")
        print("=" * 60)
        sys.exit(1)

    mcp.run()


if __name__ == "__main__":
    main()
