"""Canonical scenario runner — vm_run_scenario MCP tool.

Single entry point for end-to-end VM scenario execution:

    goal gate → platform detection → provision dispatch → run ID storage → dispatch receipt

The orchestrator calls ``vm_run_scenario`` once and waits for the agent
completion notification.  It does NOT poll during execution.

Platform detection uses the VirtualBox Python API (vboxapi) to inspect
the VM's OS type.  Detection failure raises ``ToolError`` immediately —
no fallback and no guessing (PLAT-01/02).
"""

from __future__ import annotations

import importlib
import logging
import sys
import time
from typing import TYPE_CHECKING, Any, Literal

from fastmcp.dependencies import CurrentContext
from fastmcp.exceptions import ToolError
from fastmcp.tools import tool

from mcp_vm_blackbox._helpers import _get_vboxapi_search_paths, project_root
from mcp_vm_blackbox.goal_state_store import composite_key, default_store, goal_state_key
from mcp_vm_blackbox.models import PlatformDetectionResult, PlatformRunResult
from mcp_vm_blackbox.ssh_exec import _ssh_exec
from mcp_vm_blackbox.vagrant_tools import vagrant_up

if TYPE_CHECKING:
    from fastmcp import Context

__all__ = ["detect_vm_platform", "vm_run_platform_installer", "vm_run_scenario", "vm_set_state"]

logger = logging.getLogger(__name__)

# Key prefix used to store the MCP-level run identifier in session state.
# The skill layer stores the agent task ID separately under its own key.
_RUN_ID_PREFIX = "run_id"

# VirtualBox OS type ID prefix -> platform mapping (PLAT-01/02).
# Inspected against IGuestOSType.id / IMachine.OSTypeId.
# IMPORTANT: more-specific prefixes (darwin, macos) must come BEFORE "win"
# to avoid "darwin" matching the "win" substring.
_OSTYPE_PLATFORM_MAP: dict[str, Literal["windows", "macos", "linux"]] = {
    # macOS — checked before 'win' to prevent darwin/macos false-positive on 'win'
    "macos": "macos",
    "mac_": "macos",
    "darwin": "macos",
    # Windows
    "windows": "windows",
    "win": "windows",
    # Linux
    "linux": "linux",
    "ubuntu": "linux",
    "debian": "linux",
    "fedora": "linux",
    "redhat": "linux",
    "centos": "linux",
    "oracle": "linux",
    "suse": "linux",
    "arch": "linux",
    "gentoo": "linux",
}

# Default SSH connection params for non-Windows guest VMs (Vagrant default).
_DEFAULT_SSH_HOST = "127.0.0.1"
_DEFAULT_SSH_PORT = 2222
_DEFAULT_SSH_USER = "vagrant"


@tool
async def vm_run_scenario(
    vm_name: str,
    scenario_name: str,
    ctx: Context = CurrentContext(),  # noqa: B008
    *,
    video: bool = False,
) -> dict:
    """Run a full scenario: goal gate → provision dispatch → run ID storage.

    Enforces that a committed goal state exists before dispatching.  Stores a
    generated run ID in session state immediately after dispatch so the
    orchestrator can reference it later.  Returns a dispatch receipt.

    Args:
        vm_name: Name of the target VM (e.g. ``"DSR-QA-Windows"``).
        scenario_name: Scenario identifier (e.g. ``"dsr-prod-install"``).
        ctx: FastMCP context (injected by the framework).
        video: When True, enable video generation for this scenario run.
            The video intent is propagated in the dispatch receipt for
            downstream delegated execution (e.g., vm-pilot agent).

    Returns:
        Dispatch receipt dict with keys ``scenario``, ``status``,
        ``run_id_key``, ``video``, and ``next``.

    Raises:
        ToolError: If no committed goal state exists for ``vm_name/scenario_name``.
    """
    ck = composite_key(vm_name, scenario_name)

    # -------------------------------------------------------------------
    # 1. Goal gate — session state first, file store fallback.
    #    Pattern established in Phase 2 (goal_state.py / verification.py).
    # -------------------------------------------------------------------
    goal_key = goal_state_key(ck)
    session_goal = await ctx.get_state(goal_key)
    if session_goal is None:
        # Cross-session fallback: check committed file on disk.
        stored = default_store.load(vm_name, scenario_name)
        if stored is None or stored.status != "committed":
            raise ToolError(
                f"No committed goal state for {ck}.\n"
                "Define pass/fail criteria first:\n"
                f"  mcp__plugin_vm-flightsimulator_vm-blackbox__vm_goal_interview("
                f"vm_name={vm_name!r}, scenario_name={scenario_name!r})"
            )

    # -------------------------------------------------------------------
    # 2. Provision — direct async call to vagrant_up within the package.
    #    vagrant_up is decorated with @tool(task=TaskConfig(mode="required"))
    #    but calling it directly as a Python awaitable bypasses MCP task
    #    dispatch and executes synchronously in this coroutine.
    # -------------------------------------------------------------------
    await vagrant_up(vm=vm_name, scenario_name=scenario_name, ctx=ctx)

    # -------------------------------------------------------------------
    # 3. Store run ID and video intent in session state — non-skippable.
    #    The run_id is a deterministic, sortable, unique-per-invocation
    #    string.  The skill layer stores the vm-pilot agent task ID
    #    separately (Plan 03-02).  Video intent is persisted for downstream
    #    delegated execution.
    # -------------------------------------------------------------------
    run_id = f"{ck}:{int(time.time())}"
    run_id_key = f"{_RUN_ID_PREFIX}:{ck}"
    await ctx.set_state(run_id_key, run_id)

    # Persist video intent for downstream consumers (e.g., vm-pilot agent).
    video_key = f"video:{ck}"
    await ctx.set_state(video_key, str(video))

    # -------------------------------------------------------------------
    # 4. When video=True, create render_staging directory for attribution.
    #    The directory follows canonical evidence structure keyed by run_id.
    # -------------------------------------------------------------------
    if video:
        evidence_root = project_root() / ".mcp-vm-blackbox" / "evidence"
        render_staging_dir = evidence_root / vm_name / run_id / "render_staging"
        render_staging_dir.mkdir(parents=True, exist_ok=True)

    # -------------------------------------------------------------------
    # 5. Return dispatch receipt with explicit video intent.
    # -------------------------------------------------------------------
    return {
        "scenario": ck,
        "status": "dispatched",
        "run_id_key": run_id_key,
        "video": video,
        "next": (
            "Await agent completion notification. "
            "Do not call vm-radio-control or any mcp__plugin_vm-flightsimulator_vm-blackbox__ tools during execution."
        ),
    }


@tool
async def vm_set_state(key: str, value: str, ctx: Context = CurrentContext()) -> dict:  # noqa: B008
    """Store a key-value pair in MCP session state.

    Thin wrapper around ``ctx.set_state()`` so the skill layer (which has no
    direct access to ``ctx``) can persist arbitrary key-value pairs via an
    MCP tool call.

    Args:
        key: Session state key to write.
        value: String value to store.
        ctx: FastMCP context (injected by the framework).

    Returns:
        Dict with ``key`` and ``stored: True``.
    """
    await ctx.set_state(key, value)
    return {"key": key, "stored": True}


# ---------------------------------------------------------------------------
# Platform detection helpers (PLAT-01/02)
# ---------------------------------------------------------------------------


def _classify_ostype(os_type_id: str) -> Literal["windows", "macos", "linux"]:
    """Map a VirtualBox OS type ID to a platform string.

    Args:
        os_type_id: VirtualBox ``OSTypeId`` string (case-insensitive).

    Returns:
        Platform string: ``"windows"``, ``"macos"``, or ``"linux"``.

    Raises:
        ToolError: When the OS type ID does not match any known platform.
    """
    lower = os_type_id.lower()
    for prefix, platform in _OSTYPE_PLATFORM_MAP.items():
        if lower.startswith(prefix):
            return platform
    raise ToolError(
        f"Cannot detect platform from VirtualBox OS type ID {os_type_id!r}.\n"
        "Supported prefixes: windows, macos/mac_/darwin, linux/ubuntu/debian/...\n"
        "No fallback — fix the VM OS type configuration or provide an explicit platform."
    )


def _load_vboxapi_manager() -> Any:
    """Load and return a VirtualBoxManager instance via the vboxapi SDK.

    Raises:
        ToolError: When vboxapi cannot be imported.
    """
    for search_path in _get_vboxapi_search_paths():
        if search_path not in sys.path:
            sys.path.insert(0, search_path)
    try:
        vboxapi = importlib.import_module("vboxapi")
    except ImportError as exc:
        raise ToolError(
            "vboxapi is not available. Install the VirtualBox SDK.\n"
            "Platform detection requires the VirtualBox Python API — "
            "no subprocess fallback is permitted (PLAT-01/02)."
        ) from exc
    return vboxapi.VirtualBoxManager(None, None)


def _detect_platform_from_vbox(vm_name: str) -> PlatformDetectionResult:
    """Detect the guest platform of a VM using the VirtualBox Python API.

    Calls ``IVirtualBox.findMachine(vm_name).OSTypeId`` via vboxapi — no
    subprocess.  Raises ``ToolError`` immediately on any failure.

    Args:
        vm_name: VirtualBox VM name.

    Returns:
        PlatformDetectionResult with detected platform and raw OS type ID.

    Raises:
        ToolError: When vboxapi is unavailable, the VM is not found, or
            the OS type cannot be mapped to a known platform.
    """
    mgr = _load_vboxapi_manager()
    try:
        vbox = mgr.getVirtualBox()
        machine = vbox.findMachine(vm_name)
        os_type_id: str = machine.OSTypeId
    except ToolError:
        raise
    except Exception as exc:
        raise ToolError(
            f"VirtualBox API error while detecting platform for VM {vm_name!r}: {exc}\n"
            "Ensure VirtualBox is running and the VM name is correct."
        ) from exc

    platform = _classify_ostype(os_type_id)
    return PlatformDetectionResult(vm_name=vm_name, platform=platform, os_type_id=os_type_id)


# ---------------------------------------------------------------------------
# Platform-specific installer tools (PLAT-01/02)
# ---------------------------------------------------------------------------


@tool
async def detect_vm_platform(vm_name: str) -> PlatformDetectionResult:  # noqa: RUF029
    """Detect the guest platform of a VM via the VirtualBox Python API (PLAT-01/02).

    Uses ``vboxapi`` to inspect the VM's OS type — no subprocess.
    Raises ``ToolError`` on detection failure with no fallback.

    Args:
        vm_name: VirtualBox VM name.

    Returns:
        PlatformDetectionResult with platform, vm_name, and os_type_id.

    Raises:
        ToolError: When vboxapi is unavailable, the VM is not found, or
            the platform cannot be determined.
    """
    return _detect_platform_from_vbox(vm_name)


@tool
async def vm_run_platform_installer(
    vm_name: str,
    scenario_name: str,
    installer_cmd: str,
    ssh_host: str = _DEFAULT_SSH_HOST,
    ssh_port: int = _DEFAULT_SSH_PORT,
    ssh_user: str = _DEFAULT_SSH_USER,
    ssh_identity_file: str | None = None,
) -> PlatformRunResult:
    """Run a CLI installer on a macOS or Linux VM via SSH (PLAT-01/02).

    Executes ``installer_cmd`` verbatim on the guest via ``ssh_exec.py``.
    Enforces the same goal-state gate as Windows scenarios — the caller
    must have a committed goal state before invoking this tool.

    Uses ``ssh_exec._ssh_exec`` — no subprocess.  The full command string
    is passed as-is; no package manager enum or command construction magic.

    Args:
        vm_name: Target VM name.
        scenario_name: Scenario identifier.
        installer_cmd: Full installer command to run (e.g. ``"brew install dsr"``).
        ssh_host: SSH host for the guest VM (default: Vagrant forwarded port host).
        ssh_port: SSH port for the guest VM (default: Vagrant default 2222).
        ssh_user: SSH username (default: ``vagrant``).
        ssh_identity_file: Optional SSH identity file path.

    Returns:
        PlatformRunResult with stdout, stderr, exit_code, and platform info.

    Raises:
        ToolError: When platform detection fails or SSH execution fails.
    """
    detection = _detect_platform_from_vbox(vm_name)
    platform = detection.platform

    if platform == "windows":
        raise ToolError(
            f"vm_run_platform_installer is for macOS and Linux only. "
            f"VM {vm_name!r} detected as 'windows'. "
            "Use the WinRM-based installer path for Windows targets."
        )

    try:
        stdout, stderr, exit_code = await _ssh_exec(
            host=ssh_host, port=ssh_port, username=ssh_user, command=installer_cmd, identity_file=ssh_identity_file
        )
    except Exception as exc:
        raise ToolError(f"SSH installer execution failed on VM {vm_name!r}: {exc}") from exc

    return PlatformRunResult(
        vm_name=vm_name,
        scenario_name=scenario_name,
        platform=platform,
        installer_cmd=installer_cmd,
        stdout=stdout,
        stderr=stderr,
        exit_code=exit_code,
    )
