"""OneTool core introspection tools (ot pack).

Provides tool discovery and messaging under the unified `ot` pack.
These are core introspection functions, not external tools, so they
live in the core package rather than tools_dir.

Functions:
    ot.tools() - List or get tools with full documentation
    ot.packs() - List or get packs with instructions
    ot.aliases() - List or get alias definitions
    ot.snippets() - List or get snippet definitions
    ot.config() - Show configuration summary
    ot.health() - Check system health
    ot.stats() - Get runtime statistics
    ot.result() - Query stored large output results
    ot.notify() - Publish message to topic
    ot.reload() - Force configuration reload
"""

from __future__ import annotations

import asyncio
import fnmatch
import getpass
import inspect
import os
import platform
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal

import aiofiles
import yaml

from ot import __version__
from ot.config import get_config
from ot.logging import LogSpan
from ot.paths import get_global_dir, get_project_dir, resolve_cwd_path
from ot.proxy import get_proxy_manager

# Track when module was first loaded (OneTool start time)
_MODULE_LOAD_TIME = time.time()

# Alias for cleaner logging calls in this module
log = LogSpan


def resolve_ot_path(path: str) -> Path:
    """Resolve a path relative to the OT_DIR (.onetool/ directory).

    Resolution priority:
    1. If absolute or ~ path: use as-is
    2. If project .onetool/ exists: resolve relative to it
    3. Fall back to global ~/.onetool/

    Args:
        path: Path string (relative, absolute, or with ~)

    Returns:
        Resolved absolute Path
    """
    p = Path(path).expanduser()
    if p.is_absolute():
        return p.resolve()

    # Try project .onetool/ first
    project_dir = get_project_dir()
    if project_dir:
        return (project_dir / p).resolve()

    # Fall back to global
    return (get_global_dir() / p).resolve()

# Info level type for discovery functions
InfoLevel = Literal["list", "min", "full"]
ServerInfoLevel = Literal["list", "min", "full", "resources", "prompts"]

# Pack name for dot notation: ot.tools(), ot.packs(), etc.
PACK_NAME = "ot"

# Documentation URL mapping for packs with misaligned slugs
DOC_SLUGS: dict[str, str] = {
    "brave": "brave-search",
    "db": "database",
    "ground": "grounding-search",
    "llm": "transform",
    "web": "web-fetch",
}

DOC_BASE_URL = "https://onetool.beycom.online/reference/tools/"

__all__ = [
    "PACK_NAME",
    "agent_hints",
    "aliases",
    "config",
    "debug",
    "get_ot_pack_functions",
    "health",
    "help",
    "notify",
    "packs",
    "reload",
    "result",
    "security",
    "snippets",
    "stats",
    "tools",
    "version",
]


def version() -> str:
    """Return OneTool version string.

    Returns:
        Version string (e.g., "1.0.0")

    Example:
        ot.version()
    """
    return __version__


def agent_hints() -> str:
    """Return the OneTool agent hints reference document.

    Provides a project-agnostic quick reference for AI agents with
    tool usage patterns, parameter names, and common workflows.

    Reads from ~/.onetool/config/agent-hints.md if available,
    otherwise falls back to the package's built-in template.

    Returns:
        Markdown content of the agent hints document

    Example:
        ot.agent_hints()
    """
    from ot.paths import get_global_templates_dir

    with log(span="ot.agent_hints") as s:
        # Try user-editable copy first
        user_path = resolve_ot_path("config/agent-hints.md")
        if user_path.is_file():
            s.add("source", "user")
            return user_path.read_text()

        # Fall back to package template
        template_path = get_global_templates_dir() / "agent-hints.md"
        if template_path.is_file():
            s.add("source", "package")
            return template_path.read_text()

        s.add("error", "not_found")
        return "Error: agent-hints.md not found"


def _get_version_info() -> dict[str, Any]:
    """Get version information from package metadata."""
    return {"version": __version__}


def _get_paths_info() -> dict[str, Any]:
    """Get all relevant path information."""
    install_path = Path(__file__).parent.parent.resolve()
    global_dir = get_global_dir()
    cfg = get_config()

    paths: dict[str, Any] = {
        "install": str(install_path),
        "global_dir": str(global_dir),
        "cwd": str(resolve_cwd_path(".")),
        "python": sys.executable,
    }

    if cfg._config_dir:
        paths["config_file"] = str(cfg._config_dir / "onetool.yaml")
        paths["config_dir"] = str(cfg._config_dir)

    paths["log_dir"] = str(cfg.get_log_dir_path())

    if cfg.stats.enabled:
        paths["stats_file"] = str(cfg.get_stats_file_path())

    paths["result_store"] = str(cfg.get_result_store_path())

    return paths


def _get_config_info(verbose: bool = False) -> dict[str, Any]:
    """Get configuration summary."""
    from ot.executor.tool_loader import load_tool_registry

    cfg = get_config()
    registry = load_tool_registry()

    info: dict[str, Any] = {
        "version": cfg.version,
        "servers": list(cfg.servers.keys()),
        "packs_loaded": len(registry.packs),
        "aliases": len(cfg.alias) if cfg.alias else 0,
        "snippets": len(cfg.snippets) if cfg.snippets else 0,
    }

    if verbose:
        info["includes"] = cfg.include
        info["tools_dir"] = cfg.tools_dir
        info["stats_enabled"] = cfg.stats.enabled
        info["log_verbose"] = cfg.log_verbose

    return info


def _get_python_info() -> dict[str, Any]:
    """Get Python environment information."""
    return {
        "version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
        "implementation": sys.implementation.name,
        "platform": sys.platform,
        "executable": sys.executable,
    }


def _get_system_info() -> dict[str, Any]:
    """Get OS/system information."""
    info = {
        "platform": platform.system(),
        "machine": platform.machine(),
        "user": getpass.getuser(),
        "pid": os.getpid(),
    }

    # Add memory usage if psutil available
    try:
        import psutil  # type: ignore[import-untyped]

        process = psutil.Process(os.getpid())
        mem_info = process.memory_info()
        info["memory"] = {
            "rss_mb": round(mem_info.rss / 1024 / 1024, 2),
            "vms_mb": round(mem_info.vms / 1024 / 1024, 2),
            "percent": round(process.memory_percent(), 2),
        }
    except ImportError:
        pass

    return info


def _get_runtime_info() -> dict[str, Any]:
    """Get current runtime state."""
    from ot.executor.tool_loader import load_tool_registry
    from ot.executor.worker_proxy import WorkerPackProxy
    from ot.proxy import get_proxy_manager

    registry = load_tool_registry()
    proxy = get_proxy_manager()
    cfg = get_config()

    # Count local tools
    tool_count = 0
    for funcs in registry.packs.values():
        if isinstance(funcs, WorkerPackProxy):
            tool_count += len(funcs.functions)
        else:
            tool_count += len(funcs)

    # Count proxy connections
    connected = sum(1 for name in cfg.servers if proxy.get_connection(name))
    disconnected = len(cfg.servers) - connected

    # Timing information
    current_time = time.time()
    uptime_seconds = current_time - _MODULE_LOAD_TIME
    start_time = datetime.fromtimestamp(_MODULE_LOAD_TIME, tz=UTC)

    return {
        "packs_loaded": len(registry.packs),
        "tools_local": tool_count,
        "tools_proxied": proxy.tool_count,
        "servers_configured": len(cfg.servers),
        "servers_connected": connected,
        "servers_disconnected": disconnected,
        "start_time": start_time.isoformat(),
        "uptime_seconds": round(uptime_seconds, 2),
    }


def debug(
    *,
    verbose: bool = False,
    env_vars: bool = False,
    dependencies: bool = False,
) -> dict[str, Any]:
    """Get comprehensive debug information about this OneTool installation.

    Essential for multi-version development - clearly identifies which
    version is running and where it's configured.

    Args:
        verbose: Include detailed configuration information
        env_vars: Include relevant environment variables
        dependencies: Include dependency versions

    Returns:
        Structured debug information with sections:
        - version: Package version
        - paths: All relevant file paths
        - config: Configuration summary
        - python: Python environment details
        - system: OS/platform information
        - runtime: Current runtime state (packs, servers, tools, timing)

    Example:
        ot.debug()
        ot.debug(verbose=True, env_vars=True)
    """
    with log(span="ot.debug", verbose=verbose) as s:
        result: dict[str, Any] = {
            "version": _get_version_info(),
            "paths": _get_paths_info(),
            "config": _get_config_info(verbose=verbose),
            "python": _get_python_info(),
            "system": _get_system_info(),
            "runtime": _get_runtime_info(),
        }

        if env_vars:
            # Include relevant environment variables
            result["env"] = {
                "OT_GLOBAL_DIR": os.getenv("OT_GLOBAL_DIR"),
                "ONETOOL_CONFIG": os.getenv("ONETOOL_CONFIG"),
                "OT_CWD": os.getenv("OT_CWD"),
            }

        if dependencies:
            # Include dependency versions
            from importlib.metadata import PackageNotFoundError
            from importlib.metadata import version as get_version

            deps = {}
            for pkg in ["fastmcp", "pydantic", "pyyaml", "loguru", "requests", "openai"]:
                try:
                    deps[pkg] = get_version(pkg)
                except PackageNotFoundError:
                    deps[pkg] = "not installed"
            result["dependencies"] = deps

        version_str = result["version"].get("version", "unknown")
        s.add("version", version_str)

        return result


def security(*, check: str = "") -> dict[str, Any]:
    """Check security rules for code validation.

    OneTool uses an allowlist-based security model: everything is blocked
    by default, and only explicitly allowed builtins, imports, and calls
    are permitted. Tool namespaces (ot.*, brave.*, etc.) are auto-allowed.

    Args:
        check: Pattern to check (e.g., "os", "json.loads", "pickle.*").
               If empty, returns a summary of all security rules.

    Returns:
        If check is provided: Dict with 'pattern', 'status' (allowed/blocked/warned),
            'category', and 'reason' explaining why.
        If check is empty: Dict with summary of all security categories
            (builtins, imports, calls, dunders, tool_namespaces).

    Example:
        ot.security()                      # Show all rules
        ot.security(check="os")            # "blocked: import"
        ot.security(check="json")          # "allowed: import"
        ot.security(check="json.loads")    # "allowed: module in imports"
        ot.security(check="pickle.load")   # "blocked: calls"
        ot.security(check="brave.search")  # "allowed: tool namespace"
    """
    from ot.executor.validator import get_security_status, get_security_summary

    with log(span="ot.security", check=check or None) as s:
        if check:
            result = get_security_status(check)
            s.add("status", result["status"])
            s.add("category", result["category"])
            return result
        else:
            summary = get_security_summary()
            s.add("status", summary.get("status", "unknown"))
            return summary


def get_ot_pack_functions() -> dict[str, Any]:
    """Get all ot pack functions for registration.

    Returns:
        Dict mapping function names to callables
    """
    return {
        "agent_hints": agent_hints,
        "tools": tools,
        "packs": packs,
        "servers": servers,
        "aliases": aliases,
        "snippets": snippets,
        "config": config,
        "debug": debug,
        "health": health,
        "help": help,
        "result": result,
        "security": security,
        "stats": stats,
        "notify": notify,
        "reload": reload,
        "version": version,
    }


# ============================================================================
# Help Function Utilities
# ============================================================================


def _get_doc_url(pack: str) -> str:
    """Get documentation URL for a pack.

    Args:
        pack: Pack name (e.g., "brave", "file")

    Returns:
        Documentation URL for the pack
    """
    slug = DOC_SLUGS.get(pack, pack)
    return f"{DOC_BASE_URL}{slug}/"


def _fuzzy_match(query: str, candidates: list[str], threshold: float = 0.6) -> list[str]:
    """Return candidates that fuzzy match query, sorted by score.

    Args:
        query: Search query string
        candidates: List of candidate strings to match against
        threshold: Minimum similarity ratio (0.0 to 1.0) for fuzzy matches

    Returns:
        List of matching candidates, sorted by match score (best first)
    """
    from difflib import SequenceMatcher

    query_lower = query.lower()
    scored: list[tuple[str, float]] = []

    for candidate in candidates:
        candidate_lower = candidate.lower()
        # Substring match gets high score
        if query_lower in candidate_lower:
            scored.append((candidate, 1.0))
        else:
            ratio = SequenceMatcher(None, query_lower, candidate_lower).ratio()
            if ratio >= threshold:
                scored.append((candidate, ratio))

    return [c for c, _ in sorted(scored, key=lambda x: -x[1])]


def _format_general_help() -> str:
    """Format general help overview shown when no query is provided.

    Returns:
        Formatted help text with discovery commands, info levels, and examples
    """
    return """# OneTool Help

## Discovery
  ot.tools()              - List all tools
  ot.tools(pattern="web") - Filter by pattern
  ot.packs()              - List all packs (local + MCP)
  ot.servers()            - List MCP proxy servers
  ot.snippets()           - List all snippets
  ot.aliases()            - List all aliases
  ot.help(query="..")     - Search for help

## Info Levels
  info="list" - Names only
  info="min"  - Name + description (default)
  info="full" - Everything (includes instructions)

## Quick Examples
  brave.search(query="AI news")
  web.fetch(url="https://...")
  $b_q q=search terms

## Tips
  - Use keyword args: func(arg=value)
  - Batch when possible: func(items=[...])"""


def _format_tool_help(tool_info: dict[str, Any], pack: str) -> str:
    """Format detailed help for a single tool.

    Args:
        tool_info: Tool info dict from _build_tool_info with info="full"
        pack: Pack name for documentation URL

    Returns:
        Formatted tool help text
    """
    lines = [f"# {tool_info['name']}", ""]

    if tool_info.get("description"):
        lines.append(tool_info["description"])
        lines.append("")

    if tool_info.get("signature"):
        lines.append("## Signature")
        lines.append(tool_info["signature"])
        lines.append("")

    if tool_info.get("args"):
        lines.append("## Arguments")
        for arg in tool_info["args"]:
            lines.append(f"- {arg}")
        lines.append("")

    if tool_info.get("returns"):
        lines.append("## Returns")
        lines.append(tool_info["returns"])
        lines.append("")

    if tool_info.get("example"):
        lines.append("## Example")
        lines.append(tool_info["example"])
        lines.append("")

    lines.append("## Docs")
    lines.append(_get_doc_url(pack))

    return "\n".join(lines)


def _format_pack_help(pack_name: str, pack_info: str) -> str:
    """Format detailed help for a pack.

    Args:
        pack_name: Name of the pack
        pack_info: Pack info string from packs(info="full")

    Returns:
        Formatted pack help text with doc URL appended
    """
    lines = [pack_info, "", "## Docs", _get_doc_url(pack_name)]
    return "\n".join(lines)


def _format_snippet_help(snippet_info: str) -> str:
    """Format detailed help for a snippet.

    Args:
        snippet_info: Snippet info string from snippets(info="full")

    Returns:
        Formatted snippet help text
    """
    return f"# Snippet\n\n{snippet_info}"


def _format_alias_help(alias_name: str, target: str) -> str:
    """Format detailed help for an alias.

    Args:
        alias_name: Name of the alias
        target: Target function the alias maps to

    Returns:
        Formatted alias help text
    """
    lines = [
        f"# Alias: {alias_name}",
        "",
        f"Maps to: `{target}`",
        "",
        "Use this alias as a shorthand for the target function.",
    ]
    return "\n".join(lines)


def _item_matches(item: dict[str, Any] | str, matched_names: list[str], key: str = "name") -> bool:
    """Check if item name is in matched_names list.

    Args:
        item: Either a string name or dict with name key
        matched_names: List of matched name strings
        key: Dict key to extract name from (default: "name")

    Returns:
        True if item's name is in matched_names
    """
    if isinstance(item, str):
        # For "name: desc" or "name -> target" formats, extract the name part
        if ": " in item:
            name = item.split(":")[0]
        elif " ->" in item:
            name = item.split(" ->")[0]
        else:
            name = item
        return name in matched_names
    return item.get(key) in matched_names


def _snippet_matches(item: dict[str, Any] | str, matched_names: list[str]) -> bool:
    """Check if snippet item matches any of the matched names.

    Args:
        item: Either a string or dict snippet item
        matched_names: List of matched snippet name strings

    Returns:
        True if snippet matches
    """
    if not matched_names:
        return False
    if isinstance(item, str):
        name = item.split(":")[0]
        if name in matched_names:
            return True
        return any(item == m or item.startswith(m + ":") for m in matched_names)
    return item.get("name") in matched_names


def _format_search_results(
    query: str,
    tools_results: list[dict[str, Any] | str],
    packs_results: list[dict[str, Any] | str],
    snippets_results: list[dict[str, Any] | str],
    aliases_results: list[dict[str, Any] | str],
    info: InfoLevel,
) -> str:
    """Format search results grouped by type.

    Args:
        query: Original search query
        tools_results: Matching tools
        packs_results: Matching packs
        snippets_results: Matching snippets
        aliases_results: Matching aliases
        info: Output verbosity level

    Returns:
        Formatted search results text
    """
    lines = [f'# Search results for "{query}"', ""]

    if tools_results:
        lines.append("## Tools")
        for tool in tools_results:
            if isinstance(tool, str):
                lines.append(f"- {tool}")
            elif info == "min":
                lines.append(f"- {tool['name']}: {tool.get('description', '')}")
            else:
                lines.append(f"- {tool['name']}")
        lines.append("")

    if packs_results:
        lines.append("## Packs")
        for pack in packs_results:
            if isinstance(pack, str):
                lines.append(f"- {pack}")
            elif info == "min":
                lines.append(f"- {pack['name']} ({pack.get('tool_count', 0)} tools)")
            else:
                lines.append(f"- {pack['name']}")
        lines.append("")

    if snippets_results:
        lines.append("## Snippets")
        for snippet in snippets_results:
            if isinstance(snippet, str):
                # For info="min", format is "name: description"
                snippet_name = snippet.split(":")[0] if ":" in snippet else snippet
                lines.append(f"- ${snippet_name}")
            else:
                lines.append(f"- ${snippet}")
        lines.append("")

    if aliases_results:
        lines.append("## Aliases")
        for alias in aliases_results:
            if isinstance(alias, str):
                lines.append(f"- {alias}")
            else:
                lines.append(f"- {alias['name']} -> {alias['target']}")
        lines.append("")

    if not any([tools_results, packs_results, snippets_results, aliases_results]):
        lines.append("No matches found.")
        lines.append("")
        lines.append("Try browsing with:")
        lines.append("  ot.tools()    - List all tools")
        lines.append("  ot.packs()    - List all packs (local + MCP)")
        lines.append("  ot.servers()  - List MCP proxy servers")
        lines.append("  ot.snippets() - List all snippets")
        lines.append("  ot.aliases()  - List all aliases")

    return "\n".join(lines).rstrip()


# ============================================================================
# Tool Discovery Functions
# ============================================================================


def _parse_docstring(doc: str | None) -> dict[str, Any]:
    """Parse docstring using docstring-parser library.

    Args:
        doc: Function docstring

    Returns:
        Dict with 'short', 'args', 'returns', and 'example' keys
    """
    from docstring_parser import parse as parse_docstring

    if not doc:
        return {"short": "", "args": [], "returns": "", "example": ""}

    parsed = parse_docstring(doc)

    # Extract example from examples section
    example = ""
    if parsed.examples:
        example = "\n".join(
            ex.description or "" for ex in parsed.examples if ex.description
        )

    # Format args as "name: description" strings
    args = [
        f"{p.arg_name}: {p.description or '(no description)'}" for p in parsed.params
    ]

    return {
        "short": parsed.short_description or "",
        "args": args,
        "returns": parsed.returns.description if parsed.returns else "",
        "example": example,
    }


def _build_tool_info(
    full_name: str, func: Any, source: str, info: InfoLevel
) -> dict[str, Any] | str:
    """Build tool info dict for a single tool.

    Args:
        full_name: Full tool name (e.g., "brave.search")
        func: The function object
        source: Source identifier (e.g., "local", "mcp:github")
        info: Output verbosity level ("list", "min", "full")

    Returns:
        Tool name string if info="list", otherwise dict with tool info
    """
    if info == "list":
        return full_name

    if func:
        try:
            sig = inspect.signature(func)
            signature = f"{full_name}{sig}"
        except (ValueError, TypeError):
            signature = f"{full_name}(...)"
        parsed = _parse_docstring(func.__doc__)
        description = parsed["short"]
    else:
        signature = f"{full_name}(...)"
        description = ""
        parsed = _parse_docstring(None)

    if info == "min":
        return {"name": full_name, "description": description}

    # info == "full"
    tool_info: dict[str, Any] = {
        "name": full_name,
        "signature": signature,
        "description": description,
    }
    # Include full documentation for LLM context
    if parsed["args"]:
        tool_info["args"] = parsed["args"]
    if parsed["returns"]:
        tool_info["returns"] = parsed["returns"]
    if parsed["example"]:
        tool_info["example"] = parsed["example"]
    tool_info["source"] = source
    return tool_info


def _schema_to_signature(full_name: str, schema: dict[str, Any]) -> str:
    """Convert JSON Schema to Python-like signature string.

    Args:
        full_name: Full tool name (e.g., "github.search")
        schema: JSON Schema dict with 'properties' and 'required' keys

    Returns:
        Signature string like "github.search(query: str, repo: str = '...')"
    """
    props = schema.get("properties", {})
    required = set(schema.get("required", []))

    if not props:
        return f"{full_name}()"

    params: list[str] = []
    # Process required params first, then optional
    for prop_name in sorted(props.keys(), key=lambda k: (k not in required, k)):
        prop_def = props[prop_name]
        prop_type = prop_def.get("type", "Any")

        # Map JSON Schema types to Python-like types
        type_map = {
            "string": "str",
            "integer": "int",
            "number": "float",
            "boolean": "bool",
            "array": "list",
            "object": "dict",
        }

        # Handle JSON Schema union types (e.g., ["string", "null"])
        if isinstance(prop_type, list):
            # Filter out "null" and map remaining types
            non_null = [t for t in prop_type if t != "null"]
            if non_null:
                mapped = [type_map.get(t, t) for t in non_null]
                py_type = " | ".join(mapped)
                if "null" in prop_type:
                    py_type = f"{py_type} | None"
            else:
                py_type = "None"
        else:
            py_type = type_map.get(prop_type, prop_type)

        if prop_name in required:
            params.append(f"{prop_name}: {py_type}")
        else:
            default = prop_def.get("default")
            if default is not None:
                params.append(f"{prop_name}: {py_type} = {default!r}")
            else:
                params.append(f"{prop_name}: {py_type} = ...")

    return f"{full_name}({', '.join(params)})"


def _parse_input_schema(schema: dict[str, Any]) -> list[str]:
    """Extract argument descriptions from JSON Schema properties.

    Args:
        schema: JSON Schema dict with 'properties' key

    Returns:
        List of "param_name: description" strings matching local tool format
    """
    props = schema.get("properties", {})
    required = set(schema.get("required", []))

    args: list[str] = []
    # Process required params first, then optional
    for prop_name in sorted(props.keys(), key=lambda k: (k not in required, k)):
        prop_def = props[prop_name]
        description = prop_def.get("description", "(no description)")
        args.append(f"{prop_name}: {description}")

    return args


def _build_proxy_tool_info(
    full_name: str,
    description: str,
    input_schema: dict[str, Any],
    source: str,
    info: InfoLevel,
) -> dict[str, Any] | str:
    """Build tool info dict for a proxy tool using its input schema.

    Args:
        full_name: Full tool name (e.g., "github.search")
        description: Tool description from MCP server
        input_schema: JSON Schema for tool input
        source: Source identifier (e.g., "mcp:github")
        info: Output verbosity level ("list", "min", "full")

    Returns:
        Tool name string if info="list", otherwise dict with tool info
    """
    if info == "list":
        return full_name

    if info == "min":
        return {"name": full_name, "description": description}

    # info == "full"
    tool_info: dict[str, Any] = {
        "name": full_name,
        "signature": _schema_to_signature(full_name, input_schema),
        "description": description,
    }

    # Include args if schema has properties with descriptions
    args = _parse_input_schema(input_schema)
    if args:
        tool_info["args"] = args

    tool_info["source"] = source
    return tool_info


def tools(
    *,
    pattern: str = "",
    info: InfoLevel = "min",
) -> list[dict[str, Any] | str]:
    """List all available tools with optional filtering.

    Lists registered local tools and proxied MCP server tools.
    Use pattern for substring filtering.

    Args:
        pattern: Filter tools by name pattern (case-insensitive substring match)
        info: Output verbosity level - "list" (names only), "min" (name + description),
              or "full" (complete details including args, returns, example)

    Returns:
        List of tool names (info="list") or tool dicts (info="min"/"full")

    Example:
        ot.tools()
        ot.tools(pattern="search")
        ot.tools(pattern="brave.")
        ot.tools(info="list")
        ot.tools(pattern="brave.search", info="full")
    """
    from ot.executor.tool_loader import load_tool_registry

    with log(span="ot.tools", pattern=pattern or None, info=info) as s:
        runner_registry = load_tool_registry()
        proxy = get_proxy_manager()

        tools_list: list[dict[str, Any] | str] = []

        # Local tools from registry
        from ot.executor.worker_proxy import WorkerPackProxy

        for pack_name, pack_funcs in runner_registry.packs.items():
            # Handle both dict and WorkerPackProxy
            if isinstance(pack_funcs, WorkerPackProxy):
                func_names = list(pack_funcs.functions)
                func_items = [(n, getattr(pack_funcs, n)) for n in func_names]
            else:
                func_items = list(pack_funcs.items())

            for func_name, func in func_items:
                full_name = f"{pack_name}.{func_name}"

                if pattern and pattern.lower() not in full_name.lower():
                    continue

                tools_list.append(_build_tool_info(full_name, func, "local", info))

        # Proxied tools
        for proxy_tool in proxy.list_tools():
            tool_name = f"{proxy_tool.server}.{proxy_tool.name}"

            if pattern and pattern.lower() not in tool_name.lower():
                continue

            tools_list.append(
                _build_proxy_tool_info(
                    tool_name,
                    proxy_tool.description or "",
                    proxy_tool.input_schema,
                    f"mcp:{proxy_tool.server}",
                    info,
                )
            )

        # Sort by name (handle both str and dict)
        tools_list.sort(key=lambda t: t if isinstance(t, str) else t["name"])
        s.add("count", len(tools_list))
        return tools_list


def packs(
    *,
    pattern: str = "",
    info: InfoLevel = "min",
) -> list[dict[str, Any] | str]:
    """List all packs with optional filtering.

    Lists all available packs (local and proxy).
    Use pattern for substring filtering.

    Args:
        pattern: Filter packs by name pattern (case-insensitive substring)
        info: Output verbosity level - "list" (names only), "min" (name + source + tool_count),
              or "full" (detailed info with instructions and tool list)

    Returns:
        List of pack names (info="list") or pack dicts/strings (info="min"/"full")

    Example:
        ot.packs()
        ot.packs(pattern="brav")
        ot.packs(info="list")
        ot.packs(pattern="brave", info="full")
    """
    from ot.executor.tool_loader import load_tool_registry
    from ot.prompts import PromptsError, get_pack_instructions, get_prompts

    with log(span="ot.packs", pattern=pattern or None, info=info) as s:
        runner_registry = load_tool_registry()
        proxy = get_proxy_manager()

        # Collect all packs
        local_packs = set(runner_registry.packs.keys())
        proxy_packs = set(proxy.servers)
        all_pack_names = sorted(local_packs | proxy_packs)

        # Filter by pattern
        if pattern:
            all_pack_names = [p for p in all_pack_names if pattern.lower() in p.lower()]

        # info="list" - just names
        if info == "list":
            s.add("count", len(all_pack_names))
            return all_pack_names  # type: ignore[return-value]

        # info="full" - detailed info for each matching pack
        if info == "full":
            results: list[dict[str, Any] | str] = []
            cfg = get_config()

            for pack_name in all_pack_names:
                is_local = pack_name in local_packs

                # Build detailed pack info
                lines = [f"# {pack_name} pack", ""]

                # Show source type
                if is_local:
                    lines.append("**Type:** Local")
                else:
                    lines.append("**Type:** MCP Proxy Server")
                lines.append("")

                # Get instructions from prompts.yaml
                try:
                    prompts_config = get_prompts()
                    configured = get_pack_instructions(prompts_config, pack_name)
                    if configured:
                        lines.append("## Instructions")
                        lines.append("")
                        lines.append(configured)
                        lines.append("")
                except PromptsError:
                    pass

                # For proxy packs, also check server config for instructions
                if not is_local and pack_name in cfg.servers:
                    server_cfg = cfg.servers[pack_name]
                    if server_cfg.instructions:
                        # Only add header if not already added from prompts
                        if "## Instructions" not in "\n".join(lines):
                            lines.append("## Instructions")
                            lines.append("")
                        lines.append(server_cfg.instructions.strip())
                        lines.append("")

                # List tools in this pack
                lines.append("## Tools")
                lines.append("")

                if is_local:
                    from ot.executor.worker_proxy import WorkerPackProxy

                    pack_funcs = runner_registry.packs[pack_name]
                    if isinstance(pack_funcs, WorkerPackProxy):
                        func_items = [(n, getattr(pack_funcs, n)) for n in pack_funcs.functions]
                    else:
                        func_items = list(pack_funcs.items())

                    for func_name, func in sorted(func_items):
                        doc = func.__doc__ or "(no description)"
                        first_line = doc.split("\n")[0].strip()
                        lines.append(f"- **{pack_name}.{func_name}**: {first_line}")
                else:
                    proxy_tools = proxy.list_tools(server=pack_name)
                    for tool in sorted(proxy_tools, key=lambda t: t.name):
                        desc = tool.description or "(no description)"
                        first_line = desc.split("\n")[0].strip()
                        lines.append(f"- **{pack_name}.{tool.name}**: {first_line}")

                results.append("\n".join(lines))

            s.add("count", len(results))
            return results

        # info="min" (default) - summary for each pack
        packs_list: list[dict[str, Any] | str] = []

        for pack_name in all_pack_names:
            is_local = pack_name in local_packs
            source = "local" if is_local else "mcp"

            # Count tools in pack
            if is_local:
                from ot.executor.worker_proxy import WorkerPackProxy

                pack_funcs = runner_registry.packs[pack_name]
                if isinstance(pack_funcs, WorkerPackProxy):
                    tool_count = len(pack_funcs.functions)
                else:
                    tool_count = len(pack_funcs)
            else:
                tool_count = len(proxy.list_tools(server=pack_name))

            packs_list.append({
                "name": pack_name,
                "source": source,
                "tool_count": tool_count,
            })

        s.add("count", len(packs_list))
        return packs_list


def servers(
    *,
    pattern: str = "",
    info: ServerInfoLevel = "min",
) -> list[dict[str, Any] | str]:
    """List configured MCP proxy servers with optional filtering.

    Shows all MCP servers configured in servers.yaml, including their
    connection status, tool count, and instructions.

    Args:
        pattern: Filter servers by name pattern (case-insensitive substring)
        info: Output verbosity level:
            - "list": names only
            - "min": name + status + tool_count (default)
            - "full": detailed info with instructions and tools
            - "resources": list resources per server
            - "prompts": list prompts per server

    Returns:
        List of server names (info="list") or server dicts/strings (info="min"/"full"/"resources"/"prompts")

    Example:
        ot.servers()
        ot.servers(pattern="github")
        ot.servers(info="full")
        ot.servers(info="resources")
        ot.servers(pattern="devtools", info="prompts")
    """
    proxy = get_proxy_manager()
    cfg = get_config()

    with log(span="ot.servers", pattern=pattern or None, info=info) as s:
        # Get all configured servers
        all_server_names = sorted(cfg.servers.keys())

        # Filter by pattern
        if pattern:
            all_server_names = [
                name for name in all_server_names if pattern.lower() in name.lower()
            ]

        # info="list" - just names
        if info == "list":
            s.add("count", len(all_server_names))
            return all_server_names  # type: ignore[return-value]

        # info="full" - detailed info for each server
        if info == "full":
            results: list[dict[str, Any] | str] = []

            for server_name in all_server_names:
                server_cfg = cfg.servers[server_name]
                conn = proxy.get_connection(server_name)
                status = "connected" if conn else "disconnected"
                tool_count = len(proxy.list_tools(server=server_name)) if conn else 0

                lines = [f"# {server_name} server", ""]
                lines.append(f"**Type:** MCP Proxy Server ({server_cfg.type})")
                lines.append(f"**Status:** {status}")
                lines.append(f"**Enabled:** {server_cfg.enabled}")
                if server_cfg.type == "http" and server_cfg.url:
                    lines.append(f"**URL:** {server_cfg.url}")
                elif server_cfg.type == "stdio" and server_cfg.command:
                    cmd = f"{server_cfg.command} {' '.join(server_cfg.args)}"
                    lines.append(f"**Command:** {cmd}")

                # Add resource and prompt counts if connected
                if conn:
                    try:
                        if proxy._loop and proxy._loop.is_running():
                            future_res = asyncio.run_coroutine_threadsafe(
                                proxy.list_resources(server_name), proxy._loop
                            )
                            future_pmt = asyncio.run_coroutine_threadsafe(
                                proxy.list_prompts(server_name), proxy._loop
                            )
                            resource_count = len(future_res.result(timeout=5))
                            prompt_count = len(future_pmt.result(timeout=5))
                            lines.append(f"**Resources:** {resource_count}")
                            lines.append(f"**Prompts:** {prompt_count}")
                    except Exception:
                        # Silently skip if resources/prompts not supported
                        pass

                lines.append("")

                # Show instructions if configured
                if server_cfg.instructions:
                    lines.append("## Instructions")
                    lines.append("")
                    lines.append(server_cfg.instructions.strip())
                    lines.append("")

                # List tools if connected
                if conn:
                    lines.append(f"## Tools ({tool_count})")
                    lines.append("")
                    proxy_tools = proxy.list_tools(server=server_name)
                    for tool in sorted(proxy_tools, key=lambda t: t.name):
                        desc = tool.description or "(no description)"
                        first_line = desc.split("\n")[0].strip()
                        lines.append(f"- **{server_name}.{tool.name}**: {first_line}")
                elif server_cfg.enabled:
                    lines.append("## Tools")
                    lines.append("")
                    lines.append("(not connected)")
                    # Show error if available
                    error = proxy.get_error(server_name)
                    if error:
                        lines.append("")
                        lines.append(f"**Error:** {error}")

                results.append("\n".join(lines))

            s.add("count", len(results))
            return results

        # info="resources" - list resources per server
        if info == "resources":
            results_resources: list[dict[str, Any] | str] = []

            for server_name in all_server_names:
                conn = proxy.get_connection(server_name)
                if not conn:
                    results_resources.append({
                        "server": server_name,
                        "status": "disconnected",
                        "resources": [],
                    })
                    continue

                try:
                    # Run async list_resources using ProxyManager's event loop
                    if proxy._loop and proxy._loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            proxy.list_resources(server_name),
                            proxy._loop,
                        )
                        resources = future.result(timeout=10)
                    else:
                        # No event loop available - server not initialized
                        resources = []

                    results_resources.append({
                        "server": server_name,
                        "status": "connected",
                        "resource_count": len(resources),
                        "resources": resources,
                    })
                except Exception as e:
                    results_resources.append({
                        "server": server_name,
                        "status": "error",
                        "error": str(e),
                        "resources": [],
                    })

            s.add("count", len(results_resources))
            return results_resources

        # info="prompts" - list prompts per server
        if info == "prompts":
            results_prompts: list[dict[str, Any] | str] = []

            for server_name in all_server_names:
                conn = proxy.get_connection(server_name)
                if not conn:
                    results_prompts.append({
                        "server": server_name,
                        "status": "disconnected",
                        "prompts": [],
                    })
                    continue

                try:
                    # Run async list_prompts using ProxyManager's event loop
                    if proxy._loop and proxy._loop.is_running():
                        future = asyncio.run_coroutine_threadsafe(
                            proxy.list_prompts(server_name),
                            proxy._loop,
                        )
                        prompts = future.result(timeout=10)
                    else:
                        # No event loop available - server not initialized
                        prompts = []

                    results_prompts.append({
                        "server": server_name,
                        "status": "connected",
                        "prompt_count": len(prompts),
                        "prompts": prompts,
                    })
                except Exception as e:
                    results_prompts.append({
                        "server": server_name,
                        "status": "error",
                        "error": str(e),
                        "prompts": [],
                    })

            s.add("count", len(results_prompts))
            return results_prompts

        # info="min" (default) - summary for each server
        servers_list: list[dict[str, Any] | str] = []

        for server_name in all_server_names:
            server_cfg = cfg.servers[server_name]
            conn = proxy.get_connection(server_name)
            status = "connected" if conn else "disconnected"
            tool_count = len(proxy.list_tools(server=server_name)) if conn else 0

            server_info: dict[str, Any] = {
                "name": server_name,
                "type": server_cfg.type,
                "enabled": server_cfg.enabled,
                "status": status,
                "tool_count": tool_count,
            }
            # Include error if disconnected
            if not conn:
                error = proxy.get_error(server_name)
                if error:
                    server_info["error"] = error
            servers_list.append(server_info)

        s.add("count", len(servers_list))
        return servers_list


# ============================================================================
# Messaging Functions
# ============================================================================

_background_tasks: set[asyncio.Task[None]] = set()


def _resolve_path(path: str) -> Path:
    """Resolve a topic file path relative to OT_DIR (.onetool/).

    Uses SDK resolve_ot_path() for consistent path resolution.

    Path resolution for topic files follows OT_DIR conventions:
        - Relative paths: resolved relative to OT_DIR (.onetool/)
        - Absolute paths: used as-is
        - ~ paths: expanded to home directory
        - Prefixed paths (CWD/, GLOBAL/, OT_DIR/): resolved to respective dirs

    Note: ${VAR} patterns are NOT expanded here. Use ~/path instead of
    ${HOME}/path. Secrets (e.g., ${API_KEY}) are expanded during config
    loading, not path resolution.

    Args:
        path: Path string from topic config.

    Returns:
        Resolved absolute Path.
    """
    return resolve_ot_path(path)


def _match_topic_to_file(topic: str) -> Path | None:
    """Match topic to file path using first matching pattern.

    Paths in topic config are resolved relative to OT_DIR (.onetool/).
    See _resolve_path() for full path resolution behaviour.

    Args:
        topic: Topic string to match (e.g., "status:scan").

    Returns:
        Resolved file path for matching topic, or None if no match.
    """
    cfg = get_config()
    msg_config = cfg.tools.msg

    for topic_config in msg_config.topics:
        topic_pattern = topic_config.pattern
        file_path = topic_config.file

        if fnmatch.fnmatch(topic, topic_pattern):
            return _resolve_path(file_path)

    return None


async def _write_to_file(file_path: Path, doc: dict[str, Any]) -> None:
    """Write message document to file asynchronously."""
    with log(span="ot.write", file=str(file_path), topic=doc.get("topic")) as s:
        try:
            file_path.parent.mkdir(parents=True, exist_ok=True)
            async with aiofiles.open(file_path, "a") as f:
                await f.write("---\n")
                await f.write(
                    yaml.safe_dump(doc, default_flow_style=False, allow_unicode=True)
                )
            s.add("written", True)
        except Exception as e:
            s.add("error", str(e))


def notify(*, topic: str, message: str) -> str:
    """Publish a message to the matching topic file.

    Routes the message to a YAML file based on topic pattern matching
    configured in onetool.yaml. The write happens asynchronously.

    Args:
        topic: Topic string for routing (e.g., "status:scan", "notes")
        message: Message content (text, can be multiline)

    Returns:
        "OK: <topic> -> <file>" if routed, "OK: no matching topic" if no match

    Example:
        ot.notify(topic="notes", message="Remember to review PR #123")
    """
    with log(span="ot.notify", topic=topic) as s:
        file_path = _match_topic_to_file(topic)

        if file_path is None:
            s.add("matched", False)
            return "SKIP: no matching topic"

        doc = {
            "ts": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
            "topic": topic,
            "message": message,
        }

        try:
            loop = asyncio.get_running_loop()
            task = loop.create_task(_write_to_file(file_path, doc))
            _background_tasks.add(task)
            task.add_done_callback(_background_tasks.discard)
        except RuntimeError:
            asyncio.run(_write_to_file(file_path, doc))

        s.add("matched", True)
        s.add("file", str(file_path))
        return f"OK: {topic} -> {file_path}"


# ============================================================================
# Configuration & Health Functions
# ============================================================================


def config() -> dict[str, Any]:
    """Show key configuration values.

    Returns tools_dir, include, aliases, snippets, and server names.

    Returns:
        Dict with configuration summary

    Example:
        ot.config()
    """
    with log(span="ot.config") as s:
        cfg = get_config()

        result: dict[str, Any] = {
            "tools_dir": cfg.tools_dir,
            "include": cfg.include,
            "aliases": dict(cfg.alias) if cfg.alias else {},
            "snippets": {
                name: {"description": snippet.description}
                for name, snippet in cfg.snippets.items()
            }
            if cfg.snippets
            else {},
            "servers": list(cfg.servers.keys()) if cfg.servers else [],
        }

        s.add("toolsDirCount", len(result["tools_dir"]))
        s.add("includeCount", len(result["include"]))
        s.add("aliasCount", len(result["aliases"]))
        s.add("snippetCount", len(result["snippets"]))
        s.add("serverCount", len(result["servers"]))

        return result


def health() -> dict[str, Any]:
    """Check health of OneTool components.

    Returns:
        Dict with component status for registry and proxy

    Example:
        ot.health()
    """
    from ot.executor.tool_loader import load_tool_registry

    with log(span="ot.health") as s:
        from ot.executor.worker_proxy import WorkerPackProxy

        runner_registry = load_tool_registry()
        proxy = get_proxy_manager()
        cfg = get_config()

        # Count functions, handling both dict and WorkerPackProxy
        tool_count = 0
        for funcs in runner_registry.packs.values():
            if isinstance(funcs, WorkerPackProxy):
                tool_count += len(funcs.functions)
            else:
                tool_count += len(funcs)
        registry_status = {
            "status": "ok",
            "tool_count": tool_count,
        }

        server_statuses: dict[str, Any] = {}
        for server_name in cfg.servers:
            conn = proxy.get_connection(server_name)
            if conn:
                server_statuses[server_name] = "connected"
            else:
                error = proxy.get_error(server_name)
                server_statuses[server_name] = {"status": "disconnected", "error": error} if error else "disconnected"

        proxy_status: dict[str, Any] = {
            "status": "ok"
            if all(
                (s == "connected" if isinstance(s, str) else s.get("status") == "connected")
                for s in server_statuses.values()
            )
            or not server_statuses
            else "degraded",
            "server_count": len(cfg.servers),
        }
        if server_statuses:
            proxy_status["servers"] = server_statuses

        result = {
            "version": __version__,
            "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
            "cwd": str(resolve_cwd_path(".")),
            "registry": registry_status,
            "proxy": proxy_status,
        }

        s.add("registryOk", registry_status["status"] == "ok")
        s.add("proxyOk", proxy_status["status"] == "ok")

        return result


def reload() -> str:
    """Force reload of all configuration.

    Clears all cached state and reloads from disk:
    - Configuration (onetool.yaml and includes)
    - Secrets (secrets.yaml)
    - Tool registry (tool files from tools_dir)
    - Prompts
    - MCP proxy connections
    - Parameter resolution caches
    - Security validation caches

    Use after modifying config files, adding/removing tools, or
    changing secrets during a session.

    Returns:
        Status message confirming reload

    Example:
        ot.reload()
    """
    import sys

    with log(span="ot.reload") as s:
        # Import modules
        import ot.config
        import ot.executor.param_resolver
        import ot.executor.tool_loader
        import ot.executor.validator
        import ot.prompts
        import ot.proxy
        import ot.registry

        # Clear in dependency order (config first, others depend on it)
        ot.config.reset()  # Clears both config and secrets
        ot.prompts.reset()
        ot.registry.reset()
        ot.executor.tool_loader.reset()
        ot.executor.validator.reset()

        # Clear param resolver cache
        ot.executor.param_resolver.get_tool_param_names.cache_clear()
        ot.executor.param_resolver._mcp_param_cache.clear()

        # Clean up dynamically loaded tool modules from sys.modules
        # Tool loader uses "tools.{stem}" naming pattern
        tool_modules = [name for name in sys.modules if name.startswith("tools.")]
        for mod_name in tool_modules:
            del sys.modules[mod_name]
        s.add("toolModulesCleared", len(tool_modules))

        # Reload config to validate and report stats
        cfg = get_config()

        # Reconnect MCP proxy servers with fresh config
        ot.proxy.reconnect_proxy_manager()
        s.add("aliasCount", len(cfg.alias) if cfg.alias else 0)
        s.add("snippetCount", len(cfg.snippets) if cfg.snippets else 0)
        s.add("serverCount", len(cfg.servers) if cfg.servers else 0)

        return "OK: Configuration reloaded"


def stats(
    *,
    period: str = "all",
    tool: str = "",
    info: InfoLevel = "min",
    output: str = "",
) -> dict[str, Any] | str:
    """Get runtime statistics for OneTool usage.

    Returns aggregated statistics including call counts, success rates,
    durations, and estimated context/time savings from tool consolidation.

    Args:
        period: Time period to filter - "day", "week", "month", or "all" (default: "all")
        tool: Filter by tool name (e.g., "brave.search"). Empty for all tools.
        info: Output verbosity level - "list" (summary only, no tools),
              "min" (summary + top 10 tools, default), or "full" (everything)
        output: Path to write HTML report. Empty for JSON output only.

    Returns:
        Dict with aggregated statistics. Detail depends on info level:
        - "list": total_calls, success_rate, error_count, savings_usd
        - "min": summary stats + top 10 tools sorted by calls
        - "full": all fields including per-tool chars, durations, model info

    Example:
        ot.stats()
        ot.stats(period="day")
        ot.stats(info="full")
        ot.stats(period="week", tool="brave.search")
        ot.stats(output="stats_report.html")
    """
    from ot.stats import Period, StatsReader
    from ot.support import get_support_dict

    with log(span="ot.stats", period=period, tool=tool or None, info=info) as s:
        cfg = get_config()

        # Validate period
        valid_periods: list[Period] = ["day", "week", "month", "all"]
        if period not in valid_periods:
            s.add("error", "invalid_period")
            return f"Error: Invalid period '{period}'. Valid: day, week, month, all. Example: ot.stats(period='day')"

        # Validate info level
        valid_info: list[InfoLevel] = ["list", "min", "full"]
        if info not in valid_info:
            s.add("error", "invalid_info")
            return f"Error: Invalid info level '{info}'. Valid: list, min, full. Example: ot.stats(info='min')"

        # Check if stats are enabled
        if not cfg.stats.enabled:
            s.add("error", "stats_disabled")
            return "Error: Statistics collection is disabled in configuration"

        # Read stats
        stats_path = cfg.get_stats_file_path()
        reader = StatsReader(
            path=stats_path,
            context_per_call=cfg.stats.context_per_call,
            time_overhead_per_call_ms=cfg.stats.time_overhead_per_call_ms,
            model=cfg.stats.model,
            cost_per_million_input_tokens=cfg.stats.cost_per_million_input_tokens,
            cost_per_million_output_tokens=cfg.stats.cost_per_million_output_tokens,
            chars_per_token=cfg.stats.chars_per_token,
        )

        aggregated = reader.read(
            period=period,  # type: ignore[arg-type]
            tool=tool if tool else None,
        )

        full_result = aggregated.to_dict()
        s.add("totalCalls", full_result["total_calls"])
        s.add("toolCount", len(full_result["tools"]))

        # Format based on info level
        if info == "list":
            # Summary only, no tools breakdown
            result: dict[str, Any] = {
                "period": full_result["period"],
                "total_calls": full_result["total_calls"],
                "success_rate": full_result["success_rate"],
                "error_count": full_result["error_count"],
                "savings_usd": full_result["savings_usd"],
            }
        elif info == "min":
            # Summary + top 10 tools by calls
            top_tools = sorted(
                full_result["tools"], key=lambda t: t["total_calls"], reverse=True
            )[:10]
            compact_tools = [
                {
                    "tool": t["tool"],
                    "calls": t["total_calls"],
                    "success_rate": t["success_rate"],
                    "avg_ms": t["avg_duration_ms"],
                }
                for t in top_tools
            ]
            result = {
                "period": full_result["period"],
                "total_calls": full_result["total_calls"],
                "success_rate": full_result["success_rate"],
                "error_count": full_result["error_count"],
                "total_duration_ms": full_result["total_duration_ms"],
                "savings_usd": full_result["savings_usd"],
                "coffees": full_result["coffees"],
                "top_tools": compact_tools,
            }
        else:
            # info == "full" — everything
            result = full_result
            result["support"] = get_support_dict()

        # Generate HTML report if output path specified
        if output:
            from ot.stats.html import generate_html_report

            # Resolve output path relative to tmp directory
            output_path = cfg.get_result_store_path() / output
            html_content = generate_html_report(aggregated)
            try:
                output_path.parent.mkdir(parents=True, exist_ok=True)
                output_path.write_text(html_content)
                result["html_report"] = str(output_path)
                s.add("htmlReport", str(output_path))
            except OSError as e:
                s.add("error", "write_failed")
                return f"Error: Cannot write to '{output}': {e.strerror}"

        return result


# ============================================================================
# Result Query Function
# ============================================================================


def result(
    *,
    handle: str,
    offset: int = 1,
    limit: int = 100,
    search: str = "",
    fuzzy: bool = False,
) -> dict[str, Any]:
    """Query stored large output results with pagination.

    When tool outputs exceed max_inline_size, they are stored to disk
    and a handle is returned. Use this function to retrieve the content
    with offset/limit semantics matching Claude's Read tool.

    Args:
        handle: The result handle from a stored output
        offset: Starting line number (1-indexed, like Claude's Read tool)
        limit: Maximum lines to return (default 100)
        search: Regex pattern to filter lines (optional)
        fuzzy: Use fuzzy matching instead of regex (optional)

    Returns:
        Dict with:
        - lines: List of matching lines
        - total_lines: Total lines in stored result
        - returned: Number of lines returned
        - offset: Starting offset used
        - has_more: Boolean indicating if more lines exist

    Raises:
        ValueError: If handle not found or expired

    Example:
        ot.result(handle="abc123")
        ot.result(handle="abc123", offset=101, limit=50)
        ot.result(handle="abc123", search="error")
        ot.result(handle="abc123", search="config", fuzzy=True)
    """
    from ot.executor.result_store import get_result_store

    # Validate offset and limit (1-indexed)
    if offset < 1:
        raise ValueError(f"offset must be >= 1 (1-indexed), got {offset}")
    if limit < 1:
        raise ValueError(f"limit must be >= 1, got {limit}")

    with log(
        span="ot.result",
        handle=handle,
        offset=offset,
        limit=limit,
        search=search if search else None,
    ) as s:
        store = get_result_store()

        try:
            query_result = store.query(
                handle=handle,
                offset=offset,
                limit=limit,
                search=search,
                fuzzy=fuzzy,
            )
            s.add("returned", query_result.returned)
            s.add("totalLines", query_result.total_lines)
            return query_result.to_dict()
        except ValueError as e:
            s.add("error", str(e))
            raise


# ============================================================================
# Introspection Functions
# ============================================================================


def aliases(
    *,
    pattern: str = "",
    info: InfoLevel = "min",
) -> list[dict[str, Any] | str]:
    """List aliases with optional filtering.

    Lists all configured aliases.
    Use pattern for substring filtering.

    Args:
        pattern: Filter aliases by name or target pattern (case-insensitive substring)
        info: Output verbosity level - "list" (names only), "min" (name -> target),
              or "full" (structured dict with name and target)

    Returns:
        List of alias names, strings, or dicts depending on info level

    Example:
        ot.aliases()
        ot.aliases(pattern="search")
        ot.aliases(info="list")
        ot.aliases(pattern="ws", info="full")
    """
    with log(span="ot.aliases", pattern=pattern or None, info=info) as s:
        cfg = get_config()

        if not cfg.alias:
            s.add("count", 0)
            return []

        # Filter by pattern or list all
        items = sorted(cfg.alias.items())
        if pattern:
            pattern_lower = pattern.lower()
            items = [(k, v) for k, v in items if pattern_lower in k.lower() or pattern_lower in v.lower()]

        s.add("count", len(items))

        # info="list" - just names
        if info == "list":
            return [k for k, v in items]

        # info="full" - structured dicts
        if info == "full":
            return [{"name": k, "target": v} for k, v in items]

        # info="min" (default) - "name -> target" strings
        return [f"{k} -> {v}" for k, v in items]


def snippets(
    *,
    pattern: str = "",
    info: InfoLevel = "min",
) -> list[dict[str, Any] | str]:
    """List snippets with optional filtering.

    Lists all configured snippets.
    Use pattern for substring filtering.

    Args:
        pattern: Filter snippets by name/description pattern (case-insensitive substring)
        info: Output verbosity level - "list" (names only), "min" (name: description),
              or "full" (complete definition with params, body, example)

    Returns:
        List of snippet names, strings, or dicts depending on info level

    Example:
        ot.snippets()
        ot.snippets(pattern="pkg")
        ot.snippets(info="list")
        ot.snippets(pattern="brv_research", info="full")
    """
    with log(span="ot.snippets", pattern=pattern or None, info=info) as s:
        cfg = get_config()

        if not cfg.snippets:
            s.add("count", 0)
            return []

        # Filter by pattern or list all
        items = sorted(cfg.snippets.items())
        if pattern:
            pattern_lower = pattern.lower()
            items = [
                (k, v) for k, v in items
                if pattern_lower in k.lower() or pattern_lower in (v.description or "").lower()
            ]

        s.add("count", len(items))

        # info="list" - just names
        if info == "list":
            return [k for k, v in items]

        # info="full" - complete definition for each snippet
        if info == "full":
            results: list[dict[str, Any] | str] = []
            for snippet_name, snippet_def in items:
                # Format output as YAML-like
                lines = [f"name: {snippet_name}"]

                if snippet_def.description:
                    lines.append(f"description: {snippet_def.description}")

                if snippet_def.params:
                    lines.append("params:")
                    for param_name, param_def in snippet_def.params.items():
                        param_parts = []
                        if param_def.default is not None:
                            param_parts.append(f"default: {param_def.default}")
                        if param_def.description:
                            param_parts.append(f'description: "{param_def.description}"')
                        lines.append(f"  {param_name}: {{{', '.join(param_parts)}}}")

                lines.append("body: |")
                for body_line in snippet_def.body.rstrip().split("\n"):
                    lines.append(f"  {body_line}")

                # Add example invocation
                lines.append("")
                lines.append("# Example:")

                # Build example with defaults
                example_args = []
                for param_name, param_def in snippet_def.params.items():
                    if param_def.default is not None:
                        continue  # Skip params with defaults in example
                    example_args.append(f'{param_name}="..."')

                if example_args:
                    lines.append(f"# ${snippet_name} {' '.join(example_args)}")
                else:
                    lines.append(f"# ${snippet_name}")

                results.append("\n".join(lines))

            return results

        # info="min" (default) - "name: description" strings
        return [f"{k}: {v.description or '(no description)'}" for k, v in items]


# ============================================================================
# Help Function
# ============================================================================


def help(*, query: str = "", info: InfoLevel = "min") -> str:
    """Get help on OneTool commands, tools, packs, snippets, or aliases.

    Provides a unified entry point for discovering and getting help on
    any OneTool component. With no arguments, shows a general overview.
    With a query, searches across all types and returns detailed help.

    Args:
        query: Tool name, pack name, snippet, alias, or search term.
               Empty string shows general help overview.
        info: Detail level - "list" (names only), "min" (name + description),
              "full" (everything). Default is "min".

    Returns:
        Formatted help text

    Example:
        ot.help()
        ot.help(query="brave.search")
        ot.help(query="brave")
        ot.help(query="$b_q")
        ot.help(query="web fetch", info="list")
    """
    with log(span="ot.help", query=query or None, info=info) as s:
        # No query - show general help
        if not query:
            s.add("type", "general")
            return _format_general_help()

        cfg = get_config()

        # Check for exact tool match (contains ".")
        if "." in query:
            tool_results = tools(pattern=query, info="full")
            # Look for exact match
            for tool in tool_results:
                if isinstance(tool, dict) and tool.get("name") == query:
                    pack = query.split(".")[0]
                    s.add("type", "tool")
                    s.add("match", query)
                    return _format_tool_help(tool, pack)

        # Check for exact server match (MCP proxy servers)
        server_names = servers(info="list")
        if query in server_names:
            server_results = servers(pattern=query, info="full")
            if server_results:
                s.add("type", "server")
                s.add("match", query)
                return str(server_results[0])

        # Check for exact pack match
        pack_names = packs(info="list")
        if query in pack_names:
            pack_results = packs(pattern=query, info="full")
            if pack_results:
                s.add("type", "pack")
                s.add("match", query)
                return _format_pack_help(query, str(pack_results[0]))

        # Check for snippet match (starts with "$")
        if query.startswith("$"):
            snippet_name = query[1:]  # Remove "$"
            snippet_results = snippets(pattern=snippet_name, info="full")
            # Look for exact match
            for snippet in snippet_results:
                if isinstance(snippet, str) and snippet.startswith(f"name: {snippet_name}"):
                    s.add("type", "snippet")
                    s.add("match", query)
                    return _format_snippet_help(snippet)

        # Check for exact alias match
        if cfg.alias and query in cfg.alias:
            target = cfg.alias[query]
            s.add("type", "alias")
            s.add("match", query)
            return _format_alias_help(query, target)

        # Fuzzy search across all types
        s.add("type", "search")

        # Get all candidates for fuzzy matching
        all_tool_names = tools(info="list")
        all_pack_names = pack_names  # Already have this
        all_snippet_names = snippets(info="list")
        all_alias_names = aliases(info="list")

        # Fuzzy match across types
        matched_tools = _fuzzy_match(query, [str(t) for t in all_tool_names])
        matched_packs = _fuzzy_match(query, [str(p) for p in all_pack_names])
        matched_snippets = _fuzzy_match(query, [str(sn) for sn in all_snippet_names])
        matched_aliases = _fuzzy_match(query, [str(a) for a in all_alias_names])

        total_matches = len(matched_tools) + len(matched_packs) + len(matched_snippets) + len(matched_aliases)
        s.add("matches", total_matches)

        # Show search results - info parameter controls detail level
        tools_results = [t for t in tools(info=info) if _item_matches(t, matched_tools)]
        packs_results = [p for p in packs(info=info) if _item_matches(p, matched_packs)]
        snippets_results = [sn for sn in snippets(info=info) if _snippet_matches(sn, matched_snippets)]
        aliases_results = [a for a in aliases(info=info) if _item_matches(a, matched_aliases)]

        return _format_search_results(
            query=query,
            tools_results=tools_results,
            packs_results=packs_results,
            snippets_results=snippets_results,
            aliases_results=aliases_results,
            info=info,
        )
