"""Pipeline — unified tool execution dispatcher."""

import time
import logging
from typing import Any

from rag.advanced_rag.harness.types import ToolResult
from rag.advanced_rag.harness.tools.registry import TOOL_REGISTRY

_LOG = logging.getLogger(__name__)

# Tools that retrieve *within* a set of documents. When a routing tool
# (``dataset_navigation_by_tree``) has produced a relevant-document set, these
# inherit it as their ``doc_scope`` unless the caller passed one explicitly, so
# a follow-up search stays within the routed docs instead of re-scanning the KB.
_DOC_SCOPE_CONSUMERS = {"ontology_navigate", "mindmap_navigate", "graph_explore", "hybrid_search"}


class Pipeline:
    """Unified tool execution layer.

    - execute(tool_name, **kwargs): dispatch to registered tool, normalize result
    - available_tools(mode_tools): return LLM-visible tool definitions (compilation-filtered)
    - get_chunks(evidence_ids): retrieve raw chunks for sufficiency cross-check
    - trace: execution history for auditing
    """

    def __init__(self, rag_tools, compilation_map: dict[str, set[str]] | None = None):
        self.tools = rag_tools
        self.compilation_map = compilation_map or {}
        self.trace: list[dict] = []
        # Latest relevant-document set produced by a routing tool this run.
        self._routed_docs: list[str] = []

    async def execute(self, tool_name: str, **kwargs) -> ToolResult:
        """Execute a registered tool by name."""
        tool = TOOL_REGISTRY.get(tool_name)
        if not tool:
            return ToolResult(chunks=[], metadata={}, error=f"Unknown tool: {tool_name}")

        fn = tool.get("fn")
        if not fn:
            return ToolResult(chunks=[], metadata={}, error=f"Tool {tool_name} has no executor")

        # Downstream scoping: a within-document tool inherits the doc IDs a prior
        # router (dataset_navigation_by_tree) produced, unless the caller passed
        # an explicit doc_scope.
        if tool_name in _DOC_SCOPE_CONSUMERS and self._routed_docs and not kwargs.get("doc_scope"):
            kwargs["doc_scope"] = list(self._routed_docs)

        start = time.time()
        try:
            raw = await fn(self.tools, **kwargs)
            elapsed = time.time() - start
            self.trace.append({"tool": tool_name, "args": kwargs, "elapsed": elapsed, "success": True})
            result = self._normalize(raw)
            # A routing tool (e.g. dataset_navigation_by_tree) yields the relevant
            # document IDs; remember them so the scope-consuming tools above can
            # inherit them on later turns.
            if result.docs:
                self._routed_docs = list(result.docs)
            # Feed the shared citation pool: agent searches go through the
            # pipeline, so without this their evidence never reaches kbinfos and
            # the final answer has nothing to cite.
            self._merge_into_kbinfos(result)
            return result
        except Exception as e:
            elapsed = time.time() - start
            _LOG.exception("Pipeline.execute(%s) failed", tool_name)
            self.trace.append({"tool": tool_name, "args": kwargs, "elapsed": elapsed, "success": False, "error": str(e)})
            return ToolResult(chunks=[], metadata={}, error=str(e))

    def available_tools(self, mode_tools: list[str]) -> list[dict]:
        """Return LLM-visible tool definitions, filtered by compilation availability."""
        names = filter_available_tools(mode_tools, self.compilation_map)
        defs = []
        for name in names:
            tool = TOOL_REGISTRY.get(name)
            if tool and tool.get("function_schema"):
                defs.append(tool["function_schema"])
        return defs

    def get_chunks(self, evidence_ids: list[int]) -> dict[int, dict]:
        """Retrieve raw chunks by ID from current kbinfos."""
        result = {}
        chunks = self.tools.kbinfos.get("chunks", [])
        for eid in evidence_ids:
            if 0 <= eid < len(chunks):
                result[eid] = chunks[eid]
        return result

    def get_trace(self) -> list[dict]:
        return list(self.trace)

    # ── Private ──

    def _merge_into_kbinfos(self, result: ToolResult) -> None:
        """Merge a tool result's chunks/doc_aggs into ``tools.kbinfos``, deduped."""
        if not result or not result.chunks:
            return
        kb = self.tools.kbinfos
        seen = {c.get("chunk_id") or c.get("id") or id(c) for c in kb.get("chunks", [])}
        for c in result.chunks:
            k = c.get("chunk_id") or c.get("id") or id(c)
            if k in seen:
                continue
            seen.add(k)
            kb.setdefault("chunks", []).append(c)
        aggs = result.metadata.get("aggs") if isinstance(result.metadata, dict) else None
        if aggs:
            dseen = {d.get("doc_id") for d in kb.get("doc_aggs", [])}
            for d in aggs:
                if d.get("doc_id") in dseen:
                    continue
                dseen.add(d.get("doc_id"))
                kb.setdefault("doc_aggs", []).append(d)

    @staticmethod
    def _normalize(raw: Any) -> ToolResult:
        if isinstance(raw, ToolResult):
            return raw
        if isinstance(raw, dict):
            return ToolResult(
                chunks=raw.get("chunks", []),
                docs=raw.get("docs") or None,
                metadata={"aggs": raw.get("doc_aggs", []), "answer": raw.get("answer", "")},
            )
        if isinstance(raw, list):
            # A list of doc-id strings is a document-routing result (e.g.
            # dataset_navigation_by_tree); a list of dicts is chunks.
            if raw and all(isinstance(x, str) for x in raw):
                return ToolResult(docs=list(raw), metadata={})
            return ToolResult(chunks=raw, metadata={})
        return ToolResult(chunks=[], metadata={"raw": str(raw)})


def filter_available_tools(tool_names: list[str], compilation_map: dict[str, set[str]]) -> list[str]:
    """Filter tool list by compilation artifact availability."""
    available = []
    for name in tool_names:
        tool = TOOL_REGISTRY.get(name)
        if not tool:
            continue
        if tool.get("requires_compilation"):
            comp_type = tool.get("compilation_type")
            # ``compilation_type`` may name one artifact or several (a tool that
            # reads either one is available when ANY of them is compiled).
            wanted = {comp_type} if isinstance(comp_type, str) else set(comp_type or ())
            if wanted and not any(wanted & comps for comps in compilation_map.values()):
                continue
        available.append(name)
    return available
