"""Tools a playbook can call during chat.

Ollama drives the calling: `/api/chat` with a `tools` param returns
`message.tool_calls`, and this module is just the registry + dispatch.

Most tools READ local state (memory, history, documents, models). A few act:
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
boundary — an action tool only fires when a playbook explicitly lists it.
"""
from __future__ import annotations

import json
from typing import Awaitable, Callable

from .memory.store import store, MemoryItem
from .ollama_manager import get_ollama_manager


async def _search_memory(query: str = "", **_) -> str:
    q = (query or "").strip().lower()
    hits = [
        {"section": it.section, "text": it.text}
        for it in store.all()
        if not q
        or q in it.text.lower()
        or q in (it.section or "").lower()
        or any(q in t.lower() for t in it.tags)
    ]
    return json.dumps(hits[:20])


async def _search_history(query: str = "", **_) -> str:
    # Hybrid recall: semantic (embeddings) unioned with lexical, falls back to
    # lexical if embeddings are down. Same retrieval the chat endpoint uses.
    convs = await store.semantic_search_conversations(
        query or "", get_ollama_manager().embed, limit=3
    )
    return json.dumps([{"matches": c.get("matches", [])} for c in convs])


async def _list_models(**_) -> str:
    return json.dumps(await get_ollama_manager().list_models())


async def _search_documents(query: str = "", **_) -> str:
    hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3)
    return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits])


async def _get_time(**_) -> str:
    from datetime import datetime
    return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})


async def _web_search(query: str = "", **_) -> str:
    import asyncio as _a
    from .search import web_search
    res = await _a.to_thread(web_search, query or "", 4)
    return res or "(no results)"


_FETCH_MAX_REDIRECTS = 5


def _ip_is_blocked(ip: str) -> bool:
    """True if an address is one an outbound fetch has no business reaching:
    loopback, RFC1918/ULA private, link-local (incl. 169.254.169.254 cloud
    metadata), multicast, reserved, or unspecified. IPv4-mapped IPv6 is unwrapped
    first so ::ffff:127.0.0.1 can't sneak a loopback past the check."""
    import ipaddress
    try:
        addr = ipaddress.ip_address(ip.split("%")[0])  # drop any IPv6 zone id
    except ValueError:
        return True  # unparseable -> refuse rather than guess
    mapped = getattr(addr, "ipv4_mapped", None)
    if mapped is not None:
        addr = mapped
    return (
        addr.is_loopback or addr.is_private or addr.is_link_local
        or addr.is_multicast or addr.is_reserved or addr.is_unspecified
    )


def _ssrf_guard(host: str) -> str | None:
    """Resolve a hostname and return an error string if ANY of its A/AAAA
    records is a blocked address, else None. Checking every answer stops a name
    from smuggling one private record alongside a public one.

    ponytail: this validates then httpx re-resolves on connect, so a sub-second
    DNS-rebind could still slip a private address through the TOCTOU gap. That's
    an advanced attack against a playbook-gated, single-user tool; pin the
    connection to the resolved IP if this ever faces untrusted callers."""
    import socket
    if not host:
        return "missing host"
    try:
        infos = socket.getaddrinfo(host, None)
    except socket.gaierror as e:
        return f"cannot resolve host: {e}"
    ips = {info[4][0] for info in infos}
    if not ips:
        return "host did not resolve"
    blocked = [ip for ip in ips if _ip_is_blocked(ip)]
    if blocked:
        return f"refusing to fetch a private/loopback/link-local address ({', '.join(sorted(blocked))})"
    return None


async def _fetch_url(url: str = "", **_) -> str:
    import re
    import httpx
    from urllib.parse import urlparse, urljoin
    url = (url or "").strip()
    if not url.startswith(("http://", "https://")):
        return json.dumps({"error": "url must start with http:// or https://"})
    # SSRF guard: validate the host of the initial URL AND every redirect hop
    # against the private/loopback/link-local block-list before connecting, so a
    # granted fetch_url can't be steered at 127.0.0.1:11434, cloud metadata, or
    # LAN hosts — and a public URL can't 302 its way there either.
    try:
        async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as c:
            for _ in range(_FETCH_MAX_REDIRECTS + 1):
                parsed = urlparse(url)
                if parsed.scheme not in ("http", "https"):
                    return json.dumps({"error": "only http(s) URLs are allowed"})
                err = _ssrf_guard(parsed.hostname or "")
                if err:
                    return json.dumps({"error": f"blocked: {err}"})
                r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
                location = r.headers.get("location")
                if r.is_redirect and location:
                    url = urljoin(url, location)
                    continue
                r.raise_for_status()
                html = r.text
                break
            else:
                return json.dumps({"error": "too many redirects"})
    except Exception as e:
        return json.dumps({"error": f"fetch failed: {e}"})
    text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
    text = re.sub(r"(?s)<[^>]+>", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    return text[:4000]


async def _remember(text: str = "", section: str = "General", **_) -> str:
    """WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
    import uuid as _uuid
    text = (text or "").strip()
    if not text:
        return json.dumps({"error": "text is required"})
    store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
    return json.dumps({"saved": text, "section": section or "General"})


# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
    "search_memory": (
        {
            "type": "function",
            "function": {
                "name": "search_memory",
                "description": "Search the user's persistent memory facts. Empty query returns all facts.",
                "parameters": {
                    "type": "object",
                    "properties": {"query": {"type": "string", "description": "text to match"}},
                },
            },
        },
        _search_memory,
    ),
    "search_history": (
        {
            "type": "function",
            "function": {
                "name": "search_history",
                "description": "Search past conversations for exchanges containing the query text.",
                "parameters": {
                    "type": "object",
                    "properties": {"query": {"type": "string"}},
                    "required": ["query"],
                },
            },
        },
        _search_history,
    ),
    "list_models": (
        {
            "type": "function",
            "function": {
                "name": "list_models",
                "description": "List the locally installed Ollama models.",
                "parameters": {"type": "object", "properties": {}},
            },
        },
        _list_models,
    ),
    "search_documents": (
        {
            "type": "function",
            "function": {
                "name": "search_documents",
                "description": "Search the user's uploaded documents for relevant passages.",
                "parameters": {
                    "type": "object",
                    "properties": {"query": {"type": "string"}},
                    "required": ["query"],
                },
            },
        },
        _search_documents,
    ),
    "get_time": (
        {
            "type": "function",
            "function": {
                "name": "get_time",
                "description": "Get the current local date and time.",
                "parameters": {"type": "object", "properties": {}},
            },
        },
        _get_time,
    ),
    "web_search": (
        {
            "type": "function",
            "function": {
                "name": "web_search",
                "description": "Search the web (DuckDuckGo) and return the top result snippets.",
                "parameters": {
                    "type": "object",
                    "properties": {"query": {"type": "string"}},
                    "required": ["query"],
                },
            },
        },
        _web_search,
    ),
    "fetch_url": (
        {
            "type": "function",
            "function": {
                "name": "fetch_url",
                "description": "Fetch a web page and return its visible text (truncated).",
                "parameters": {
                    "type": "object",
                    "properties": {"url": {"type": "string", "description": "http(s) URL"}},
                    "required": ["url"],
                },
            },
        },
        _fetch_url,
    ),
    "remember": (
        {
            "type": "function",
            "function": {
                "name": "remember",
                "description": "Save a durable fact to the user's persistent memory.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "text": {"type": "string", "description": "the fact to remember"},
                        "section": {"type": "string", "description": "optional category, e.g. Health"},
                    },
                    "required": ["text"],
                },
            },
        },
        _remember,
    ),
}


# Tools that act (write local state or reach the network). These require an
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
# allowlist — a playbook granting one isn't enough on its own.
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})


def is_action(name: str) -> bool:
    return name in ACTION_TOOLS


def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]:
    """Tool schemas for a playbook's allowlist; unknown names are dropped.
    When allow_actions is False, action tools are withheld so the model can't
    even call them."""
    return [
        REGISTRY[n][0] for n in (names or [])
        if n in REGISTRY and (allow_actions or not is_action(n))
    ]


async def dispatch(name: str, args: dict | None) -> str:
    """Run a tool by name. Never raises — returns an error string on failure."""
    entry = REGISTRY.get(name)
    if not entry:
        return json.dumps({"error": f"unknown tool: {name}"})
    try:
        return await entry[1](**(args or {}))
    except Exception as e:  # a broken tool must not kill the chat loop
        return json.dumps({"error": f"{name} failed: {e}"})
