from __future__ import annotations

import logging
from collections.abc import Awaitable, Callable

from pydantic_ai import FunctionToolset, RunContext, ToolDefinition
from pydantic_ai.toolsets import AbstractToolset

from stirling.documents.service import DocumentService
from stirling.documents.store import SearchResult
from stirling.models import FileId, PrincipalId

logger = logging.getLogger(__name__)


class RagCapability:
    """Bundles RAG instructions and the ``search_knowledge`` toolset for agent injection.

    Agents construct one per run, scoped to the caller's principal set, as::

        rag = RagCapability(
            documents=self.runtime.documents,
            principals=current_principals(),
            collections=[file.id for file in request.files],
        )
        Agent(
            ...,
            instructions=rag.instructions,
            toolsets=[rag.toolset],
        )

    When no collections are pinned, the instructions are generated dynamically at
    run time so the agent sees the collections the caller can read.

    Lifecycle: a ``RagCapability`` instance is intended to live for the duration of a
    single agent run and binds to one caller's principal set.
    """

    def __init__(
        self,
        documents: DocumentService,
        principals: list[PrincipalId],
        collections: list[FileId] | None = None,
        top_k: int = 5,
        max_searches: int = 5,
    ) -> None:
        self._documents = documents
        self._principals = principals
        self._collections = collections
        self._top_k = top_k
        self._max_searches = max_searches
        self._search_count = 0
        toolset: FunctionToolset[None] = FunctionToolset()
        toolset.add_function(
            self._search_knowledge,
            name="search_knowledge",
            prepare=self._prepare_search_knowledge,
        )
        self._toolset = toolset

    @property
    def instructions(self) -> str | Callable[[], Awaitable[str]]:
        if self._collections:
            return self._static_instructions_text(self._collections)
        return self._dynamic_instructions

    @property
    def toolset(self) -> AbstractToolset[None]:
        return self._toolset

    @staticmethod
    def _static_instructions_text(collections: list[FileId]) -> str:
        collection_desc = f"collections: {', '.join(collections)}"
        return (
            "You have access to a knowledge base search tool called 'search_knowledge'. "
            f"It searches {collection_desc} for relevant information. "
            "Use it when the provided context is insufficient to answer the user's question, "
            "or when you think additional background information would improve your answer. "
            "You do not have to use it if the answer is already clear from the provided text."
        )

    async def _dynamic_instructions(self) -> str:
        collections = await self._documents.list_collections(self._principals)
        if collections:
            names = ", ".join(collections)
            collection_desc = f"the following knowledge base collections: {names}"
        else:
            collection_desc = "the knowledge base (currently empty — no collections indexed yet)"
        return (
            "You have access to a knowledge base search tool called 'search_knowledge'. "
            f"It searches {collection_desc} for relevant information. "
            "Use it when the provided context is insufficient to answer the user's question, "
            "or when you think additional background information would improve your answer. "
            "You do not have to use it if the answer is already clear from the provided text."
        )

    async def _prepare_search_knowledge(
        self,
        ctx: RunContext[None],
        tool_def: ToolDefinition,
    ) -> ToolDefinition | None:
        """Remove the search tool from the agent's toolset once the per-run search
        budget is exhausted. The agent then has no choice but to answer from what it
        has already retrieved, which prevents runaway search loops."""
        if self._search_count >= self._max_searches:
            return None
        return tool_def

    async def _search_knowledge(self, query: str, max_results: int | None = None) -> str:
        """Search the knowledge base for information relevant to the query.

        Args:
            query: The search query describing what information you need.
            max_results: Maximum number of results to return.

        Returns:
            Formatted text with the most relevant knowledge base excerpts.
        """
        self._search_count += 1
        k = max_results if max_results is not None else self._top_k
        if self._collections:
            all_results = []
            for col in self._collections:
                col_results = await self._documents.search(query, principals=self._principals, collection=col, top_k=k)
                all_results.extend(col_results)
            all_results.sort(key=lambda r: r.score, reverse=True)
            results = all_results[:k]
        else:
            results = await self._documents.search(query, principals=self._principals, top_k=k)

        if not results:
            logger.info("[rag] search_knowledge query=%r -> 0 results", query)
            return "No relevant results found in the knowledge base."

        formatted = self._format_results(results)
        logger.info(
            "[rag] search_knowledge query=%r -> %d results, %d chars",
            query,
            len(results),
            len(formatted),
        )
        logger.debug("[rag] search_knowledge query=%r returned:\n%s", query, formatted)
        return formatted

    @staticmethod
    def _format_results(results: list[SearchResult]) -> str:
        sections = []
        for i, result in enumerate(results, 1):
            source = result.document.metadata.get("source", "unknown")
            chunk_idx = result.document.metadata.get("chunk_index", "?")
            score = f"{result.score:.3f}"
            sections.append(
                f"[Result {i} | source: {source}, chunk: {chunk_idx}, relevance: {score}]\n{result.document.text}"
            )
        return "\n\n---\n\n".join(sections)
