"""Shared helpers for agent runtimes.

Runtime-agnostic utilities used by both the SDK-based (claude_code) and
CLI-based (claude_cli) agent implementations.
"""

from __future__ import annotations

import base64
import json
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

import structlog

if TYPE_CHECKING:
    from collections.abc import Callable

    from leashd.connectors.base import Attachment

logger = structlog.get_logger()

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MAX_RETRIES = 3
MAX_BACKOFF_SECONDS: float = 16
MAX_BUFFER_SIZE = 10 * 1024 * 1024  # 10 MB
ERROR_TRUNCATION_LENGTH = 200
STDERR_MAX_LINES = 50
SIGTERM_GRACE_SECONDS = 5

RETRYABLE_PATTERNS = (
    "api_error",
    "overloaded",
    "rate_limit",
    "529",
    "500",
    "maximum buffer size",
)

ERROR_MESSAGES: dict[str, str] = {
    "exit code -2": "The AI agent was interrupted. Your message will be retried automatically.",
    "exit code -1": "The AI agent encountered an unexpected error. Please try again.",
    "exit code 1": "The AI agent process exited unexpectedly. Please try again.",
    "maximum buffer size": "The AI agent's response was too large. Resuming where it left off.",
}

PLAN_MODE_INSTRUCTION = (
    "You are in plan mode. Before implementing, create a detailed plan first. "
    "Use EnterPlanMode to start planning, ask questions with AskUserQuestion "
    "when you need clarification. IMPORTANT: Before calling ExitPlanMode, you "
    "MUST write your complete plan to a file in .claude/plans/ using the Write "
    "tool (e.g., .claude/plans/plan.md). Then call ExitPlanMode so the user can "
    "review the plan. Always call ExitPlanMode before implementation begins — "
    "even if a plan already exists from a previous turn."
)

AUTO_MODE_INSTRUCTION = (
    "You are in accept-edits mode. Implement changes directly — do not create "
    "plans or call EnterPlanMode/ExitPlanMode. File writes and edits are "
    "auto-approved. Always use the Edit and Write tools for file modifications "
    "— never use Bash or python scripts to read/write files. Treat follow-up "
    "messages as continuations of the current implementation task."
)

PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]

SESSION_TO_PERMISSION_MODE: dict[str, PermissionMode] = {
    "auto": "acceptEdits",
    "edit": "acceptEdits",
    "test": "acceptEdits",
    "task": "acceptEdits",
    "web": "acceptEdits",
    "plan": "plan",
    "default": "default",
}

# ---------------------------------------------------------------------------
# Functions
# ---------------------------------------------------------------------------


def truncate(text: str, max_len: int = 60) -> str:
    """Collapse newlines and truncate with ellipsis."""
    collapsed = " ".join(text.split())
    if len(collapsed) <= max_len:
        return collapsed
    return collapsed[: max_len - 1] + "\u2026"


def is_retryable_error(content: str) -> bool:
    lowered = content.lower()
    return any(p in lowered for p in RETRYABLE_PATTERNS)


def friendly_error(raw: str) -> str:
    lowered = raw.lower()
    for pattern, message in ERROR_MESSAGES.items():
        if pattern in lowered:
            return message
    if is_retryable_error(raw):
        return (
            "The AI service is temporarily unavailable. Please try again in a moment."
        )
    return f"Agent error: {raw[:ERROR_TRUNCATION_LENGTH]}"


def backoff_delay(attempt: int) -> float:
    delay: float = 2.0 * (2**attempt)
    return min(delay, MAX_BACKOFF_SECONDS)


def prepend_instruction(instruction: str, base: str) -> str:
    return f"{instruction}\n\n{base}" if base else instruction


def build_workspace_context(name: str, directories: list[str], cwd: str) -> str:
    lines = [f"WORKSPACE: '{name}' — you are working across multiple repositories:"]
    for d in directories:
        short = Path(d).name
        marker = " (primary, cwd)" if d == cwd else ""
        lines.append(f"  - {short}: {d}{marker}")
    lines.append(
        "When the task involves changes across repos, work across all relevant "
        "directories. Use absolute paths when working outside the cwd."
    )
    return "\n".join(lines)


def build_content_blocks(
    prompt: str,
    attachments: list[Attachment],
    working_directory: str,
) -> list[dict[str, Any]]:
    """Build rich content blocks (text + images) for the CLI/SDK transport."""
    blocks: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
    pdf_paths: list[str] = []

    for att in attachments:
        if att.media_type == "application/pdf":
            uploads_dir = Path(working_directory) / ".leashd" / "uploads"
            uploads_dir.mkdir(parents=True, exist_ok=True)
            base_name = Path(att.filename).name or "upload.pdf"
            safe_name = f"{uuid.uuid4().hex[:8]}_{base_name}"
            dest = uploads_dir / safe_name
            dest.write_bytes(att.data)
            pdf_paths.append(str(dest))
        else:
            b64 = base64.b64encode(att.data).decode("ascii")
            blocks.append(
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": att.media_type,
                        "data": b64,
                    },
                }
            )

    if pdf_paths:
        pdf_note = "\n\nPDF files uploaded — read them with the Read tool:\n"
        for p in pdf_paths:
            pdf_note += f"  - {p}\n"
        blocks[0]["text"] += pdf_note

    logger.info(
        "query_prompt_built_with_attachments",
        image_count=len(blocks) - 1,
        pdf_count=len(pdf_paths),
    )
    return blocks


async def safe_callback(
    callback: Callable[..., Any], *args: Any, log_event: str
) -> None:
    try:
        await callback(*args)
    except Exception:
        logger.warning(log_event, exc_info=True)


def describe_tool(name: str, tool_input: dict[str, Any]) -> str:
    """Return a brief human-readable description of a tool call."""
    if name == "Bash":
        return truncate(tool_input.get("command", ""))
    if name in ("Read", "Write", "Edit"):
        return str(tool_input.get("file_path", ""))
    if name == "Glob":
        pattern = tool_input.get("pattern", "")
        path = tool_input.get("path", "")
        return f"{pattern} in {path}" if path else pattern
    if name == "Grep":
        pattern = tool_input.get("pattern", "")
        return f"/{pattern}/"
    if name == "WebFetch":
        return str(tool_input.get("url", ""))
    if name == "WebSearch":
        return str(tool_input.get("query", ""))
    if name in ("TodoWrite", "TaskCreate"):
        return truncate(tool_input.get("subject", ""))
    if name == "TaskUpdate":
        task_id = tool_input.get("taskId", "")
        status = tool_input.get("status", "")
        if task_id and status:
            return f"#{task_id} → {status}"
        return f"#{task_id}" if task_id else ""
    if name == "TaskGet":
        return f"#{tool_input.get('taskId', '')}"
    if name == "TaskList":
        return "all tasks"
    if name == "ExitPlanMode":
        return "Presenting plan for review"
    if name == "EnterPlanMode":
        return "Entering plan mode"
    if name == "AskUserQuestion":
        return "Asking a question"
    if name == "Skill":
        return str(tool_input.get("skill", ""))
    if name == "Agent":
        subagent_type = tool_input.get("subagent_type", "")
        desc = tool_input.get("description", "")
        return f"{subagent_type}: {desc}" if subagent_type else desc
    for v in tool_input.values():
        if isinstance(v, str) and v:
            return truncate(v)
    return ""


def read_local_mcp_servers(directory: str) -> dict[str, Any]:
    """Read MCP server definitions from ``.mcp.json`` in *directory*."""
    mcp_path = Path(directory) / ".mcp.json"
    if not mcp_path.is_file():
        return {}
    try:
        data = json.loads(mcp_path.read_text())
        servers: dict[str, Any] = data.get("mcpServers", {})
        return servers
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning("mcp_json_read_failed", path=str(mcp_path), error=str(exc))
        return {}


class StderrBuffer:
    def __init__(self, max_lines: int = STDERR_MAX_LINES) -> None:
        self._lines: list[str] = []
        self._max_lines = max_lines

    def __call__(self, line: str) -> None:
        if len(self._lines) < self._max_lines:
            self._lines.append(line)

    def get(self) -> str:
        return "\n".join(self._lines)

    def clear(self) -> None:
        self._lines.clear()
