"""Tool adapters — launch and interact with AI dev tools.

Each adapter knows how to:
  1. Build CLI arguments from a prompt + config
  2. Launch the tool process
  3. Stream/capture output
  4. Report completion status
"""

from __future__ import annotations

import asyncio
import shutil
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import structlog

from proxym.tools import ToolDefinition, ToolRegistry
from proxym.tools.prompt_builder import build_prompt

logger = structlog.get_logger()


@dataclass
class ToolResult:
    """Result of a tool execution."""
    tool: str
    ticket_id: str
    exit_code: int = -1
    stdout: str = ""
    stderr: str = ""
    duration_s: float = 0
    files_changed: list[str] = field(default_factory=list)
    execution_metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def success(self) -> bool:
        return self.exit_code == 0

    def summary(self) -> dict[str, Any]:
        s = {
            "tool": self.tool,
            "ticket_id": self.ticket_id,
            "success": self.success,
            "exit_code": self.exit_code,
            "duration_s": round(self.duration_s, 2),
            "files_changed": self.files_changed,
            "stdout_lines": len(self.stdout.splitlines()),
            "stderr_lines": len(self.stderr.splitlines()),
        }
        if self.execution_metadata:
            s["execution"] = self.execution_metadata
            s["execution_metadata"] = self.execution_metadata
        return s


class BaseAdapter(ABC):
    """Base class for tool adapters."""

    def __init__(self, tool: ToolDefinition) -> None:
        self.tool = tool

    @abstractmethod
    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        """Build CLI argument list."""
        ...

    async def run(
        self,
        prompt: str,
        project_dir: str,
        ticket_id: str = "",
        mode: str = "",
        model: str = "",
        files: list[str] | None = None,
        timeout: float = 300,
    ) -> ToolResult:
        """Launch the tool and wait for completion.

        If the tool binary is not found, delegates to LLM fallback
        for real execution via the proxy API.
        """
        import time
        binary = shutil.which(self.tool.binary) if self.tool.binary else None
        if not binary:
            logger.info("adapter_llm_fallback", tool=self.tool.name,
                        binary=self.tool.binary, reason="binary not found")
            return await _llm_fallback_execute(
                self.tool.name, prompt, ticket_id, project_dir, mode, model, timeout,
            )

        args = self.build_args(prompt, project_dir, mode, model, files)
        logger.info("tool_adapter_run", tool=self.tool.name, args=args[:3], cwd=project_dir)
        t0 = time.monotonic()
        try:
            proc = await asyncio.create_subprocess_exec(
                *args,
                cwd=project_dir,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout_b, stderr_b = await asyncio.wait_for(
                proc.communicate(), timeout=timeout,
            )
            duration = time.monotonic() - t0
            result = ToolResult(
                tool=self.tool.name,
                ticket_id=ticket_id,
                exit_code=proc.returncode or 0,
                stdout=stdout_b.decode(errors="replace"),
                stderr=stderr_b.decode(errors="replace"),
                duration_s=duration,
                execution_metadata={"adapter": self.tool.name, "binary": binary, "args": args[:5]},
            )
            logger.info("tool_adapter_done", tool=self.tool.name,
                        exit_code=result.exit_code, duration=f"{duration:.1f}s")
            return result
        except asyncio.TimeoutError:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=-1, stderr=f"Timeout after {timeout}s",
                duration_s=duration,
            )
        except FileNotFoundError:
            duration = time.monotonic() - t0
            logger.info("adapter_binary_vanished", tool=self.tool.name)
            return await _llm_fallback_execute(
                self.tool.name, prompt, ticket_id, project_dir, mode, model, timeout,
            )
        except Exception as exc:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=-1, stderr=str(exc), duration_s=duration,
            )


class AiderAdapter(BaseAdapter):
    """Adapter for aider CLI."""

    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        args = ["aider", "--yes-always", "--no-auto-commits"]
        if mode == "architect":
            args.append("--architect")
        if model:
            args.extend(["--model", model])
        if files:
            for f in files:
                args.append(f)
        args.extend(["--message", prompt])
        return args


class ClaudeCodeAdapter(BaseAdapter):
    """Adapter for claude CLI (Anthropic Claude Code)."""

    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        args = ["claude", "--print", "--dangerously-skip-permissions"]
        if model:
            args.extend(["--model", model])
        args.append(prompt)
        return args


class VSCodeAdapter(BaseAdapter):
    """Adapter for VS Code Web / code-server (starts container, sends task via API)."""

    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        # VS Code Web is started via docker compose, not a direct CLI call
        return ["docker", "compose", "--profile", "vscode", "up", "-d", "vscode"]

    async def run(
        self,
        prompt: str,
        project_dir: str,
        ticket_id: str = "",
        mode: str = "",
        model: str = "",
        files: list[str] | None = None,
        timeout: float = 120,
    ) -> ToolResult:
        """Start VS Code Web container and verify it's running."""
        import time

        logger.info("vscode_adapter_run", ticket_id=ticket_id, project_dir=project_dir)
        t0 = time.monotonic()

        # Check docker is available before attempting
        if not shutil.which("docker"):
            duration = time.monotonic() - t0
            logger.warning("vscode_adapter_no_docker", ticket_id=ticket_id)
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=127,
                stderr=(
                    "docker not found on PATH. VS Code Web requires docker compose. "
                    "Run from host: docker compose --profile vscode up -d"
                ),
                duration_s=duration,
            )

        # Find docker-compose.yml directory
        compose_dir = self._find_compose_dir(project_dir)
        args = self.build_args(prompt, project_dir, mode, model, files)

        try:
            proc = await asyncio.create_subprocess_exec(
                *args,
                cwd=compose_dir,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout_b, stderr_b = await asyncio.wait_for(
                proc.communicate(), timeout=timeout,
            )
            duration = time.monotonic() - t0
            stdout = stdout_b.decode(errors="replace")
            stderr = stderr_b.decode(errors="replace")

            if proc.returncode == 0:
                stdout += f"\n[proxym] VS Code Web started for ticket {ticket_id}"
                stdout += f"\n[proxym] Prompt: {prompt[:200]}"

            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=proc.returncode or 0,
                stdout=stdout, stderr=stderr, duration_s=duration,
            )
        except asyncio.TimeoutError:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=-1, stderr=f"Timeout after {timeout}s", duration_s=duration,
            )
        except FileNotFoundError:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=127,
                stderr="docker binary not found. Run from host: docker compose --profile vscode up -d",
                duration_s=duration,
            )
        except Exception as exc:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=-1, stderr=str(exc), duration_s=duration,
            )

    def _find_compose_dir(self, project_dir: str) -> str:
        for candidate in [
            Path(project_dir),
            Path(project_dir).parent,
            Path(__file__).resolve().parent.parent.parent.parent,  # repo root
            Path("/app"),
        ]:
            if (candidate / "docker-compose.yml").exists():
                return str(candidate)
        return project_dir


# ── LLM Fallback — real execution via proxy API ─────────

_TOOL_SYSTEM_PROMPTS: dict[str, str] = {
    "aider": (
        "You are Aider, an AI pair programmer. Analyze the codebase and implement "
        "changes as requested. Show exact file edits with unified diff format. "
        "List every file you would modify and explain each change."
    ),
    "claude-code": (
        "You are Claude Code, Anthropic's coding agent. Analyze the project, "
        "reason step by step about the problem, then provide a complete solution "
        "with code changes. Show file paths and exact edits."
    ),
    "windsurf": (
        "You are Windsurf Cascade, an AI coding agent. Analyze the project "
        "structure, understand the task, and provide a detailed implementation plan "
        "with code changes. Show file paths and explain each modification."
    ),
    "cursor": (
        "You are Cursor Composer, an AI coding agent. Analyze the codebase, "
        "understand the task requirements, and provide complete code changes. "
        "Show file paths, explain reasoning, and list all modifications."
    ),
    "roo-code": (
        "You are Roo Code, an AI coding assistant running in VS Code. Analyze the "
        "project, understand the task, and provide detailed code changes with file "
        "paths and explanations for each modification."
    ),
    "cline": (
        "You are Cline, an autonomous coding agent in VS Code. Analyze the project "
        "structure, plan your approach, then provide complete code implementations "
        "with file paths and exact changes."
    ),
    "browser": (
        "You are a coding assistant accessed via browser. Analyze the task and "
        "provide a detailed response with code changes, file paths, and explanations."
    ),
    "vscode": (
        "You are a VS Code coding assistant. Analyze the project structure, "
        "understand the task, and provide detailed code changes with file paths."
    ),
}


async def _llm_fallback_execute(
    tool_name: str,
    prompt: str,
    ticket_id: str,
    project_dir: str,
    mode: str = "",
    model_override: str = "",
    timeout: float = 120,
) -> ToolResult:
    """Execute a task via LLM API when the tool binary is not available.

    Calls litellm.acompletion through the proxy's own routing, using the
    tool's assigned provider and model. Returns real LLM output.
    """
    import time
    import litellm
    from proxym.config import get_settings
    from proxym.api.completions import _route_to_model, _inject_provider_key

    settings = get_settings()
    t0 = time.monotonic()

    # Resolve model: override → tool default → balanced alias
    model_alias = model_override or "balanced"
    resolved_model = settings.resolve_model_alias(model_alias)

    # Route through proxy's router for best model selection
    system_prompt = _TOOL_SYSTEM_PROMPTS.get(tool_name, _TOOL_SYSTEM_PROMPTS["browser"])
    if mode:
        system_prompt += f"\n\nMode: {mode}. Adapt your approach accordingly."

    # Add project context
    project_context = ""
    project_path = Path(project_dir)
    if project_path.is_dir():
        try:
            # List top-level files for context
            entries = sorted(project_path.iterdir())
            file_list = []
            for e in entries[:50]:
                if e.name.startswith(".") and e.name not in (".env.example", ".gitignore"):
                    continue
                kind = "dir" if e.is_dir() else "file"
                file_list.append(f"  {e.name}/ ({kind})" if e.is_dir() else f"  {e.name}")
            if file_list:
                project_context = f"\n\nProject directory: {project_dir}\nFiles:\n" + "\n".join(file_list[:30])
        except OSError:
            pass

    messages = [
        {"role": "system", "content": system_prompt + project_context},
        {"role": "user", "content": prompt},
    ]

    # Resolve to a concrete litellm model + provider
    # Try to find a known ModelSpec first; fall back to openrouter sonnet
    _OR_FALLBACK = "openrouter/anthropic/claude-sonnet-4.6"
    try:
        from proxym.api.completions import _find_model_spec
        spec = _find_model_spec(resolved_model)
        configured = settings.available_providers()
        if spec and spec.provider in configured:
            litellm_model = spec.litellm_model
            provider = spec.provider
        elif spec and "openrouter" in configured:
            # Re-route through openrouter
            litellm_model = f"openrouter/{spec.litellm_model}"
            provider = "openrouter"
        else:
            litellm_model = _OR_FALLBACK
            provider = "openrouter"
    except Exception:
        litellm_model = _OR_FALLBACK
        provider = "openrouter"

    kwargs: dict[str, Any] = {
        "model": litellm_model,
        "messages": messages,
        "max_tokens": 4096,
        "temperature": 0.3,
    }
    _inject_provider_key(kwargs, provider)

    logger.info(
        "llm_fallback_execute",
        tool=tool_name,
        ticket_id=ticket_id,
        model=litellm_model,
        provider=provider,
        project_dir=project_dir,
        prompt_length=len(prompt),
    )

    try:
        response = await asyncio.wait_for(
            litellm.acompletion(**kwargs),
            timeout=timeout,
        )
        duration = time.monotonic() - t0

        # Extract response content
        content = ""
        if hasattr(response, "choices") and response.choices:
            msg = response.choices[0].message
            content = getattr(msg, "content", "") or ""

        # Extract usage
        usage = getattr(response, "usage", None)
        prompt_tokens = getattr(usage, "prompt_tokens", 0) if usage else 0
        completion_tokens = getattr(usage, "completion_tokens", 0) if usage else 0
        actual_model = getattr(response, "model", litellm_model)

        # Build detailed stdout log
        stdout_parts = [
            f"═══ proxym LLM Execution Log ═══",
            f"Tool: {tool_name} (via LLM fallback)",
            f"Ticket: {ticket_id}",
            f"Model: {actual_model}",
            f"Provider: {provider}",
            f"Mode: {mode or 'code'}",
            f"Duration: {duration:.2f}s",
            f"Tokens: {prompt_tokens} in / {completion_tokens} out",
            f"Project: {project_dir}",
            f"═══════════════════════════════════",
            f"",
            content,
        ]
        stdout = "\n".join(stdout_parts)

        metadata = {
            "adapter": "llm_fallback",
            "model": actual_model,
            "provider": provider,
            "model_requested": litellm_model,
            "prompt_tokens": prompt_tokens,
            "completion_tokens": completion_tokens,
            "total_tokens": prompt_tokens + completion_tokens,
            "system_prompt_length": len(system_prompt),
            "user_prompt_length": len(prompt),
            "response_length": len(content),
        }

        logger.info(
            "llm_fallback_done",
            tool=tool_name,
            ticket_id=ticket_id,
            model=actual_model,
            tokens=f"{prompt_tokens}+{completion_tokens}",
            duration=f"{duration:.1f}s",
            response_len=len(content),
        )

        return ToolResult(
            tool=tool_name,
            ticket_id=ticket_id,
            exit_code=0,
            stdout=stdout,
            stderr="",
            duration_s=duration,
            execution_metadata=metadata,
        )

    except asyncio.TimeoutError:
        duration = time.monotonic() - t0
        return ToolResult(
            tool=tool_name, ticket_id=ticket_id,
            exit_code=-1,
            stderr=f"LLM call timed out after {timeout}s (model: {litellm_model})",
            duration_s=duration,
            execution_metadata={"adapter": "llm_fallback", "model": litellm_model, "error": "timeout"},
        )
    except Exception as exc:
        duration = time.monotonic() - t0
        logger.error("llm_fallback_error", tool=tool_name, error=str(exc)[:300])
        return ToolResult(
            tool=tool_name, ticket_id=ticket_id,
            exit_code=1,
            stderr=f"LLM fallback error: {exc}",
            duration_s=duration,
            execution_metadata={"adapter": "llm_fallback", "model": litellm_model, "error": str(exc)[:200]},
        )


class ShellAdapter(BaseAdapter):
    """Generic shell adapter — runs tool binary with prompt via stdin/args.

    Used for IDE tools (windsurf, cursor, cline, roo-code) and browser
    where a direct CLI invocation is possible but tool-specific args vary.
    When the binary is not found, delegates to LLM fallback for real execution.
    """

    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        binary = shutil.which(self.tool.binary)
        if binary and self.tool.name in ("windsurf", "cursor"):
            # IDE tools: open project dir (no direct prompt injection)
            return [binary, project_dir]
        if binary:
            return [binary, prompt[:200]]
        # No binary — will use LLM fallback in run()
        return []

    async def run(
        self,
        prompt: str,
        project_dir: str,
        ticket_id: str = "",
        mode: str = "",
        model: str = "",
        files: list[str] | None = None,
        timeout: float = 120,
    ) -> ToolResult:
        """Run tool binary or delegate to LLM fallback."""
        import time

        logger.info("shell_adapter_run", tool=self.tool.name,
                    ticket_id=ticket_id, project_dir=project_dir)

        # Check if the real binary exists
        binary = shutil.which(self.tool.binary) if self.tool.binary else None
        if not binary:
            logger.info("shell_adapter_llm_fallback", tool=self.tool.name,
                        binary=self.tool.binary, reason="binary not found")
            return await _llm_fallback_execute(
                self.tool.name, prompt, ticket_id, project_dir, mode, model, timeout,
            )

        # Binary exists — run it directly
        t0 = time.monotonic()
        args = self.build_args(prompt, project_dir, mode, model, files)
        try:
            proc = await asyncio.create_subprocess_exec(
                *args,
                cwd=project_dir if Path(project_dir).is_dir() else None,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout_b, stderr_b = await asyncio.wait_for(
                proc.communicate(), timeout=timeout,
            )
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=proc.returncode or 0,
                stdout=stdout_b.decode(errors="replace"),
                stderr=stderr_b.decode(errors="replace"),
                duration_s=duration,
                execution_metadata={"adapter": "shell", "binary": binary, "args": args[:5]},
            )
        except FileNotFoundError:
            duration = time.monotonic() - t0
            logger.info("shell_adapter_binary_vanished", tool=self.tool.name)
            return await _llm_fallback_execute(
                self.tool.name, prompt, ticket_id, project_dir, mode, model, timeout,
            )
        except Exception as exc:
            duration = time.monotonic() - t0
            return ToolResult(
                tool=self.tool.name, ticket_id=ticket_id,
                exit_code=-1, stderr=str(exc), duration_s=duration,
            )


class HumanAdapter(BaseAdapter):
    """Adapter for human/manual tasks — returns a result indicating manual action required."""

    def build_args(
        self, prompt: str, project_dir: str, mode: str = "", model: str = "",
        files: list[str] | None = None,
    ) -> list[str]:
        """Human tasks don't have CLI args."""
        return []

    async def run(
        self,
        prompt: str,
        project_dir: str,
        ticket_id: str = "",
        mode: str = "",
        model: str = "",
        files: list[str] | None = None,
        timeout: float = 30,
    ) -> ToolResult:
        """Return a result indicating this is a manual task."""
        import time
        t0 = time.monotonic()
        logger.info("human_adapter_run", tool=self.tool.name, ticket_id=ticket_id)
        duration = time.monotonic() - t0
        stdout = (
            "═══ proxym Human/Manual Task ═══\n"
            f"Tool: {self.tool.name}\n"
            f"Ticket: {ticket_id}\n"
            f"This task requires human action and cannot be executed automatically.\n"
            f"Please review the ticket and complete the required steps manually.\n"
            "═══════════════════════════════════\n"
        )
        return ToolResult(
            tool=self.tool.name,
            ticket_id=ticket_id,
            exit_code=0,
            stdout=stdout,
            stderr="",
            duration_s=duration,
            execution_metadata={"adapter": "human", "manual": True, "project_dir": project_dir},
        )


# ── Adapter registry ─────────────────────────────────────

_ADAPTER_MAP: dict[str, type[BaseAdapter]] = {
    "aider": AiderAdapter,
    "claude-code": ClaudeCodeAdapter,
    "vscode": VSCodeAdapter,
    "roo-code": ShellAdapter,
    "cline": ShellAdapter,
    "windsurf": ShellAdapter,
    "cursor": ShellAdapter,
    "browser": ShellAdapter,
    "human": HumanAdapter,
}


def get_adapter(tool_name: str) -> BaseAdapter | None:
    """Return an adapter instance for the named tool, or None."""
    registry = ToolRegistry.get()
    tool = registry.get_tool(tool_name)
    if not tool:
        return None
    adapter_cls = _ADAPTER_MAP.get(tool_name)
    if not adapter_cls:
        return None
    return adapter_cls(tool)


def register_adapter(tool_name: str, adapter_cls: type[BaseAdapter]) -> None:
    """Register a custom adapter class for a tool."""
    _ADAPTER_MAP[tool_name] = adapter_cls
