"""Built-in tool that returns the detailed manual for a given tool.

Two ways of answering: a hand-written ``manuals/<tool>.md`` when one exists, and
a manual generated from the tool's live schema otherwise. Resolution goes through
the same ``resolve_tool`` the executor uses, so a name that works when the agent
*calls* the tool also works when it *asks about* it — the two used to disagree and
a manual the file system held was unreachable under its MCP spelling.
"""

from __future__ import annotations

import difflib
from pathlib import Path

from ._internal.base import Tool, ToolContext, ToolResult, current_profile_id

MANUALS_DIR = Path(__file__).parent.parent.parent / "manuals"
GUIDES_DIR = MANUALS_DIR / "guides"

# Same spelling list_tools buckets by, so the two agree on where a tool comes from.
NATIVE_SOURCE = "native"
_MCP_PREFIX = "mcp__"
_MCP_SEP = "__"

_MAX_SUGGESTIONS = 5
_MAX_DEPTH = 5


class ToolManualTool(Tool):
    name = "tool_manual"
    description = (
        "Returns the detailed manual for a tool: full usage instructions, parameter "
        "reference, and examples. Call this before using an unfamiliar tool, or when you "
        "are unsure about the correct format or parameters. Name the tool bare "
        "('compile_scad') or by its full MCP name ('mcp__navi-3d__compile_scad')."
    )
    parameters = {
        "type": "object",
        "properties": {
            "tool_name": {
                "type": "string",
                "description": (
                    "Name of the tool to look up, e.g. 'filesystem', 'todo', "
                    "'mcp__navi-3d__compile_scad'."
                ),
            }
        },
        "required": ["tool_name"],
    }

    def __init__(self, registry=None, profile_registry=None, mcp_manager=None) -> None:
        self._registry = registry
        self._profile_registry = profile_registry
        self._mcp_manager = mcp_manager

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        # Deferred: navi.core builds the registry, which imports this module.
        from navi.core.tool_utils import resolve_tool

        requested = params.get("tool_name")
        # params["tool_name"] raised KeyError on a missing key, and .strip() raised
        # AttributeError on null or a number. Neither is something the agent can act
        # on — anything that is not a usable string is "no name given".
        tool_name = requested.strip() if isinstance(requested, str) else ""
        if not tool_name:
            return self._index()

        # A hand-written manual wins, under any spelling the model may use for it.
        manual = _read_manual(tool_name)
        if manual is not None:
            return ToolResult(success=True, output=manual)

        profile_id = self._active_profile_id(ctx)
        scoped = self._profile_tool_map(profile_id)
        _, tool = resolve_tool(scoped, tool_name)
        if tool is not None:
            return ToolResult(success=True, output=_auto_manual(tool))

        # Registered, but not enabled for this profile. Documenting it is still the
        # useful answer, and saying so stops the agent from calling it and getting
        # "tool not found" back from the executor. A hint, never a refusal.
        _, elsewhere = resolve_tool(self._registry_map(), tool_name)
        if elsewhere is not None:
            where = f"profile '{profile_id}'" if profile_id else "this run"
            note = (
                f"'{elsewhere.name}' is registered but not enabled for {where}. Calling it "
                "would fail with 'tool not found' — pick an enabled tool, or ask the user "
                "to enable it."
            )
            return ToolResult(success=True, output=f"[{note}]\n\n{_auto_manual(elsewhere)}")

        return self._not_found(tool_name, scoped)

    # ── helpers ──────────────────────────────────────────────────────────

    @staticmethod
    def _active_profile_id(ctx: ToolContext | None) -> str:
        return ((ctx.profile_id if ctx else None) or current_profile_id.get() or "").strip()

    def _registry_map(self) -> dict[str, Tool]:
        """Every registered tool, as {name: Tool} for resolve_tool()."""
        if self._registry is None:
            return {}
        return {tool.name: tool for tool in self._registry.all()}

    def _profile_tool_map(self, profile_id: str) -> dict[str, Tool]:
        """The tools this profile can call, as {name: Tool} for resolve_tool().

        Union of the agent and subagent scopes — a sub-agent asking about a tool that
        only the parent's agent scope enables must not be told it is "not enabled": it
        cannot tell which scope it runs in, and half the answers would be wrong.

        Built with build_tool_list, the same builder the agent uses for its real
        toolset, so this cannot drift from what the executor will resolve.
        """
        if not profile_id or self._registry is None or self._profile_registry is None:
            return {}

        # Deferred: see execute().
        from navi.core.tool_utils import build_tool_list

        try:
            profile = self._profile_registry.get(profile_id)
        except Exception:
            return {}

        tools: list[Tool] = []
        for scope_config in (profile.get_agent_tools(), profile.get_subagent_tools()):
            tools.extend(
                build_tool_list(
                    scope_config.native, scope_config.mcp, self._registry, self._mcp_manager
                )
            )
        return {tool.name: tool for tool in tools}

    def _index(self) -> ToolResult:
        """No name given — the catalogue of hand-written manuals, by tool source.

        An agent that does not know which tools have real docs can otherwise only
        guess names and collect 'not found' answers. This is the answer to "what is
        documented?", so it succeeds: nothing was asked for, nothing is wrong.
        """
        by_source: dict[str, list[str]] = {}
        registry_map = self._registry_map()
        for path in _manual_index().values():
            if path.parent == GUIDES_DIR:
                continue  # a guide is not a tool
            stem = path.stem
            by_source.setdefault(_source_for_manual(stem, registry_map), []).append(stem)

        if not by_source:
            return ToolResult(
                success=False,
                output="No hand-written manuals are installed — pass tool_name to get "
                "a manual generated from that tool's schema.",
                error="no_manuals",
            )

        lines = ["Hand-written manuals, by tool source — call tool_manual(tool_name=…) for one:"]
        for source in sorted(by_source, key=lambda s: (s != NATIVE_SOURCE, s)):
            names = sorted(by_source[source])
            lines.append(f"{source} ({len(names)}): {', '.join(names)}")
        guides = sorted(p.stem for p in _guide_paths())
        if guides:
            lines.append(f"guides (not tools; readable by name): {', '.join(guides)}")
        lines.append(
            "Any other tool still works: tool_manual returns a manual generated from "
            "its schema."
        )
        return ToolResult(success=True, output="\n".join(lines))

    def _not_found(self, tool_name: str, scoped: dict[str, Tool]) -> ToolResult:
        """Nothing matched — suggest instead of leaving the agent to guess."""
        known = {
            name.lower(): name
            for name in set(scoped) | set(self._registry_map()) | set(_manual_index())
        }
        # A bare MCP name ("web_search") is a legitimate spelling of
        # "mcp__navi-web__web_search" — resolve_tool accepts it, so a typo of one has
        # to be suggestible too, not filed under "nothing registered".
        for full in list(known.values()):
            segment = full.rsplit("__", 1)[-1]
            if segment != full:
                known.setdefault(segment.lower(), full)
        suggestions = difflib.get_close_matches(
            tool_name.lower(), list(known), n=_MAX_SUGGESTIONS, cutoff=0.6
        )
        lines = [f"No tool named '{tool_name}' — nothing is registered under that name."]
        if suggestions:
            lines.append(f"Did you mean: {', '.join(known[s] for s in suggestions)}?")
        available = sorted(manual_names())
        if available:
            lines.append(f"Tools with a hand-written manual: {', '.join(available)}.")
        lines.append("list_tools lists every tool this profile can call.")
        return ToolResult(success=False, output="\n".join(lines), error="not_found")


def _manual_index() -> dict[str, Path]:
    """Lower-cased name -> manual file, over manuals/ and manuals/guides/.

    Manuals are named after the tool they document. Guides are not tools, but they
    are indexed too so a guide can still be read by name.
    """
    index: dict[str, Path] = {}
    for base in (MANUALS_DIR, GUIDES_DIR):
        if not base.is_dir():
            continue
        for path in sorted(base.glob("*.md")):
            index.setdefault(path.stem.lower(), path)
    return index


def _manual_candidates(name: str) -> list[str]:
    """Spellings `name` may be filed under, best first, de-duplicated."""
    out = [name]
    # The agent calls MCP tools by their full name (mcp__navi-3d__compile_scad) while
    # the file is named after the tool alone (compile_scad.md).
    for separator in ("__", ":"):
        if separator in name:
            out.append(name.rsplit(separator, 1)[-1])
    # Dash vs underscore is the other way the same tool gets spelled.
    for candidate in list(out):
        out += [candidate.replace("-", "_"), candidate.replace("_", "-")]

    seen: set[str] = set()
    unique: list[str] = []
    for candidate in out:
        key = candidate.lower()
        if candidate and key not in seen:
            seen.add(key)
            unique.append(candidate)
    return unique


def _read_manual(name: str) -> str | None:
    """The hand-written manual for `name`, or None when no file matches."""
    index = _manual_index()
    for candidate in _manual_candidates(name):
        path = index.get(candidate.lower())
        if path is None:
            continue
        try:
            return path.read_text(encoding="utf-8")
        except OSError:
            return None
    return None


def _guide_paths() -> list[Path]:
    if not GUIDES_DIR.is_dir():
        return []
    return sorted(GUIDES_DIR.glob("*.md"))


def manual_names() -> set[str]:
    """Tool names with a hand-written manual (guides excluded — they are not tools)."""
    if not MANUALS_DIR.is_dir():
        return set()
    return {path.stem for path in MANUALS_DIR.glob("*.md")}


def _source_of(name: str) -> str:
    """Where a tool comes from, spelled as the agent sees it: 'native' or 'mcp__<server>__'."""
    if not name.startswith(_MCP_PREFIX):
        return NATIVE_SOURCE
    server = name[len(_MCP_PREFIX) :].split(_MCP_SEP, 1)[0]
    return f"{_MCP_PREFIX}{server}{_MCP_SEP}"


def _source_for_manual(stem: str, registry_map: dict) -> str:
    """A manual is filed under the tool's own name, so ask the registry where that
    tool actually lives — 'compile_scad.md' documents an MCP tool, not a built-in.
    """
    from navi.core.tool_utils import resolve_tool

    _, tool = resolve_tool(registry_map, stem)
    return _source_of(tool.name if tool is not None else stem)


def _auto_manual(tool) -> str:
    """A manual generated from the tool's live JSON schema.

    The header says which source it is and that this is a schema, not a curated
    guide — otherwise the agent cannot tell a thin parameter contract from a real
    manual and may read the absence of examples as the tool being simple.
    """
    schema = tool.parameters or {}
    lines = [
        f"# {tool.name}",
        "",
        tool.description or "(this tool has no description)",
        "",
        f"> Generated from the tool's JSON schema (source: {_source_of(tool.name)}). "
        "This is the parameter contract, not a hand-written manual.",
        "",
        "## Parameters",
    ]

    props = schema.get("properties") or {}
    if not props:
        lines.append("")
        lines.append("This tool takes no parameters.")
        return "\n".join(lines)

    lines.append("")
    _render_properties(props, schema.get("required") or [], lines, "", 0)
    return "\n".join(lines)


def _render_properties(props: dict, required, lines: list[str], indent: str, depth: int) -> None:
    """One bullet per parameter, with its nested structure indented underneath."""
    required = set(required)
    for param_name, spec in props.items():
        if not isinstance(spec, dict):
            spec = {}
        lines.append(f"{indent}- `{param_name}`{_describe(spec, param_name in required)}")
        _render_nested(spec, lines, indent + "  ", depth)


def _render_nested(spec: dict, lines: list[str], indent: str, depth: int) -> None:
    """Whatever hangs under a parameter: object fields, array items, union branches.

    A model that cannot see the shape of an array of objects guesses it and gets the
    call wrong, so object fields and array items are always spelled out.
    """
    if depth >= _MAX_DEPTH:
        lines.append(f"{indent}… (nested deeper; see the tool's schema)")
        return

    nested = spec.get("properties")
    if nested:
        _render_properties(nested, spec.get("required") or [], lines, indent, depth + 1)
        return

    items = spec.get("items")
    if isinstance(items, dict) and (items.get("properties") or items.get("oneOf") or items.get("anyOf")):
        lines.append(f"{indent}each item:")
        _render_nested(items, lines, indent + "  ", depth + 1)

    for union in ("oneOf", "anyOf"):
        branches = [b for b in (spec.get(union) or []) if isinstance(b, dict)]
        if not branches:
            continue
        lines.append(f"{indent}{union} — one of:")
        for branch in branches:
            lines.append(f"{indent}  • {_branch_summary(branch)}")
            _render_nested(branch, lines, indent + "    ", depth + 1)


def _describe(spec: dict, required: bool) -> str:
    """`(string, required): description — one of: …; default: …`"""
    label = ", ".join([_type_of(spec), "required" if required else "optional"])

    notes = []
    enum = spec.get("enum")
    if enum:
        notes.append("one of: " + ", ".join(repr(v) for v in enum))
    if "const" in spec:
        notes.append(f"const: {spec['const']!r}")
    if "default" in spec:
        notes.append(f"default: {spec['default']!r}")
    note = (" — " + "; ".join(notes)) if notes else ""

    return f" ({label}): {spec.get('description') or ''}{note}"


def _type_of(spec: dict) -> str:
    """The type label, inferred when the schema expresses it as a union or array."""
    declared = spec.get("type")
    if isinstance(declared, list):
        return " | ".join(str(t) for t in declared)
    if declared == "array":
        items = spec.get("items")
        return f"array of {_type_of(items)}" if isinstance(items, dict) else "array"
    if declared:
        return str(declared)

    alternatives = [b for b in (spec.get("oneOf") or []) + (spec.get("anyOf") or []) if isinstance(b, dict)]
    if alternatives:
        return " | ".join(_type_of(branch) for branch in alternatives)

    if spec.get("properties"):
        return "object"
    return "any"


def _branch_summary(branch: dict) -> str:
    """One line for a oneOf/anyOf alternative: its type plus whatever pins it down."""
    label = _type_of(branch)
    if "const" in branch:
        return f"{label} = {branch['const']!r}"
    enum = branch.get("enum")
    if enum:
        return f"{label}: {', '.join(repr(v) for v in enum)}"
    if branch.get("description"):
        return f"{label} — {branch['description']}"
    return label
