#
#  Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

"""Agentic-RAG capability layer.

``RAGTools`` bundles every retrieval primitive the agentic-search graph
(:mod:`rag.advanced_rag.agentic_rag_graph`) needs — question formalisation,
document scoping, keyword analysis, KB / web / structured retrieval, a
sufficiency judge and follow-up-question generation — plus the two things
the *outer* LLM is ever allowed to call as tools: ``rag`` (run the whole
agentic-search graph) and ``summarize_document`` (dump one document for an
explicit summary request).

The individual search steps are deliberately NOT ``@tool``-decorated: the
graph orchestrates them itself, so ``chat_mdl`` stays a plain reasoning
model (no tool schema is bound onto it) and its ``async_chat*`` calls take
the fast non-tool-calling path.
"""

import logging
import re
from typing import Any, List

import json_repair
from api.db.services.doc_metadata_service import DocMetadataService
from api.db.services.document_service import DocumentService
from api.db.services.knowledgebase_service import KnowledgebaseService
from api.db.services.llm_service import LLMBundle
from common import settings
from common.misc_utils import thread_pool_exec
from common.token_utils import num_tokens_from_string
from rag.advanced_rag.agentic_rag_graph import _strip_think_stream
from rag.app.tag import label_question
from rag.llm.tool_decorator import tool
from rag.prompts.generator import (
    citation_prompt,
    form_message,
    gen_meta_filter,
    kb_prompt,
    message_fit_in,
    multi_queries_gen,
    sufficiency_select,
)
from api.db.db_models import Document, Knowledgebase
from rag.utils.tavily_conn import Tavily


# Tokens held back from the model's context when fitting retrieved evidence
# into the sufficiency / follow-up prompts. The evidence sits in the MIDDLE of
# those templates (question first, JSON output rules last), so if the combined
# prompt overflows the downstream trimmer eats the output rules, not the
# evidence. Reserving headroom for the template skeleton + question + output
# lets us trim the evidence up front instead.
_EVIDENCE_PROMPT_RESERVE_TOKENS = 1024


class RAGTools:
    def __init__(
        self,
        tenant_ids: list[str],
        chat_mdl: LLMBundle,
        embed_mdl: LLMBundle | None = None,
        kb_ids: List[str] | None = None,
        kbs: list[Knowledgebase] | None = None,
        tav: Tavily | None = None,
        meta_data_filter: dict | None = None,
        user_defined_prompts: dict | None = None,
        do_refer: bool | None = True,
        thinking_mode: str = "medium",
    ):
        self.tenant_ids = tenant_ids
        self.chat_mdl = chat_mdl.clone()
        self.embed_mdl = embed_mdl
        self.thinking_mode = thinking_mode
        self.field_map = {}
        self.sql_kbs = []
        self.kbs = []
        self.kb_ids = []

        def _exclude_sql_kb(kb):
            if kb.parser_config and "field_map" in kb.parser_config:
                self.field_map.update(kb.parser_config["field_map"])
                self.sql_kbs.append(kb)
            else:
                self.kbs.append(kb)
                self.kb_ids.append(kb.id)

        if kb_ids:
            for kb in KnowledgebaseService.get_by_ids(kb_ids):
                _exclude_sql_kb(kb)
        elif kbs:
            for kb in kbs:
                _exclude_sql_kb(kb)

        self.tav = tav
        self.meta_data_filter = meta_data_filter
        self.user_defined_prompts = user_defined_prompts or {}
        self.kbinfos = {"chunks": [], "doc_aggs": []}
        self.do_refer = do_refer
        # Citation pool shared with the final-answer node: the graph publishes
        # the chunks it actually used here (in the SAME order the answer's
        # ``[ID:n]`` markers index), so the caller can resolve references.
        self.kbinfos: dict[str, list] = {"chunks": [], "doc_aggs": []}

        # Per-request retrieval cache keyed by the effective query + scope, so
        # the same question is never retrieved twice within one turn (e.g.
        # pre_search vs. an identical claim search in orchestrator_loop).
        self.search_cache: dict = {}

        # The two tools the outer LLM may bind. They are NOT auto-bound here —
        # the agentic-search flow drives the graph directly — but callers that
        # want a tool surface can do ``chat_mdl.bind_tools(tools=rag_tools.tools)``.
        self.tools = [self.rag, self.summarize_document]

    # ------------------------------------------------------------------ #
    # Capability flags / cheap introspection
    # ------------------------------------------------------------------ #
    def has_unstructured(self) -> bool:
        return bool(self.kb_ids)

    def has_structured(self) -> bool:
        return bool(self.sql_kbs and self.field_map)

    def has_web(self) -> bool:
        return self.tav is not None

    def has_llm(self) -> bool:
        return self.chat_mdl is not None

    async def _fit_messages(self, system: str, user: str) -> list:
        """Fit system+user messages into the model's context window."""
        from rag.prompts.generator import form_message, message_fit_in

        _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
        return msg

    def get_citation_guidelines(self) -> str:
        """Return the citation guidelines the final answer must follow."""
        return citation_prompt(self.user_defined_prompts)

    def sys_prompt(self) -> str:
        """Thin router prompt for callers that bind ``self.tools``.

        The heavy workflow now lives inside the ``rag`` graph, so the outer
        model only has to decide between answering-with-retrieval (``rag``)
        and an explicit single-document summary (``summarize_document``).
        """
        summarize_line = (
            "- Call `summarize_document` ONLY when the user explicitly asks to summarise a specific document ('summarise the security audit', 'tldr the onboarding guide'). It needs a document ID.\n"
            if self.has_unstructured()
            else ""
        )
        return (
            "You are a smart agent. For any question that needs "
            "evidence from the knowledge bases or the web, call the `rag` tool "
            "with a self-contained question — it runs the full search-and-answer "
            "pipeline and returns a cited answer.\n"
            "After the `rag` tool returns, do not call `rag` again for the same "
            "user question. Use the returned cited answer as the final answer "
            "unless the user explicitly asks a new question.\n"
            f"{summarize_line}"
            "Do not invent facts and do not fabricate document IDs."
        )

    # ------------------------------------------------------------------ #
    # Graph node helpers (plain async methods — never exposed as tools)
    # ------------------------------------------------------------------ #
    async def formalize(self, messages: List[Any]) -> tuple[str, str]:
        """Rewrite the latest user message into a standalone question AND derive
        its search keywords (each with close synonyms), in one LLM call.

        ``messages`` may be a list of role dicts (``{"role", "content"}``) or
        pre-formatted ``"Speaker: text"`` strings.

        Returns ``(question, keywords)`` where ``keywords`` is a comma-separated
        string of the question's key terms plus 1-2 close synonyms / alternative
        phrasings for each, in the same language as the question.
        """
        if not messages:
            return "", ""

        lines: list[str] = []
        last_user = ""
        for m in messages:
            if isinstance(m, str):
                lines.append(m)
                last_user = m
                continue
            role = m.get("role", "user")
            content = m.get("content", "") or ""
            if role == "user":
                last_user = content
            prefix = "User" if role == "user" else ("Assistant" if role == "assistant" else str(role).capitalize())
            lines.append(f"{prefix}: {content}")
        transcript = "\n".join(lines)

        system = (
            "You are given a conversation. Do BOTH of the following and return JSON only:\n"
            "1. Rewrite the LAST user message into a single, self-contained question that can be "
            "understood without seeing the prior conversation — resolve pronouns, ellipses and "
            "follow-up shortcuts using earlier turns. Preserve the original language of the last "
            "user message. If it is already a complete standalone question, keep it unchanged.\n"
            "2. Extract keywords ONLY from the wording of the STANDALONE QUESTION itself — the "
            "salient content words and phrases that literally appear in it (key nouns, named "
            "entities, domain terms). Do NOT answer the question, and do NOT include any term that "
            "would be part of the answer or is not present in the question. Then, for each extracted "
            "term, you MAY add 1-2 close synonyms or alternative phrasings OF THAT SAME TERM. Output "
            "them all together as one comma-separated list, in the SAME language as the question.\n"
            '   Example — question "In which year did Apple acquire Beats?": keywords = '
            '"Apple, Apple Inc., acquire, acquisition, Beats" (terms from the question + synonyms; '
            "the year is the ANSWER, so it must NOT appear).\n\n"
            "Output ONLY JSON, no prose, no code fences: "
            '{"question": "<standalone question>", "keywords": "<term1, term2, synonym1, ...>"}'
        )
        user = f"Conversation:\n{transcript}\n\nOutput JSON:"
        _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
        ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
        if isinstance(ans, tuple):
            ans = ans[0]
        cleaned = re.sub(r"^.*</think>", "", ans, flags=re.DOTALL)
        cleaned = re.sub(r"```(?:json)?\s*|\s*```", "", cleaned).strip()
        try:
            data = json_repair.loads(cleaned)
        except Exception as e:
            logging.warning(f"formalize could not parse LLM output: {e!r} raw={ans[:200]!r}")
            data = {}
        if not isinstance(data, dict):
            data = {}

        question = str(data.get("question") or "").strip().strip('"').strip("'")
        if not question:
            # Fall back to the raw last user message rather than an empty question.
            question = (last_user or "").strip()

        keywords = data.get("keywords") or ""
        if isinstance(keywords, list):
            keywords = ", ".join(str(k).strip() for k in keywords if str(k).strip())
        keywords = str(keywords).strip()
        return question, keywords

    async def pick_documents(self, question: str) -> List[str] | None:
        """Narrow the search to a document subset for ``question``.

        Uses document metadata when the bound KBs carry any (mirrors the old
        ``filter_docs_by_metadata``); otherwise asks an LLM to pick relevant
        titles (mirrors the old ``select_documents``). Returns ``None`` when
        no useful scope can be derived, meaning "search everything".
        """
        return None
        if not self.kb_ids:
            return None

        metas = await self._get_cached_metas()
        if metas:
            ids = await self._filter_by_metadata(question, metas)
            return ids or None

        ids = await self._select_by_titles(question)
        return ids or None

    async def _filter_by_metadata(self, question: str, metas: dict) -> List[str]:
        filters = await gen_meta_filter(self.chat_mdl, metas, question)
        logging.debug(f"Metadata filter(auto) generated: {filters}")
        conditions = filters.get("conditions") or []
        if not conditions:
            return []
        logic = filters.get("logic", "and")
        try:
            doc_ids = await thread_pool_exec(
                DocMetadataService.filter_doc_ids_by_meta_pushdown,
                self.kb_ids,
                conditions,
                logic,
            )
        except Exception as e:
            logging.error(f"Metadata filter push down errored: {e}")
            return []
        return doc_ids or []

    async def _select_by_titles(self, question: str, max_docs: int = 512) -> List[str]:
        docs = await thread_pool_exec(self._collect_doc_titles, max_docs)
        if not docs:
            return []

        catalogue = "\n".join(f"docID: {doc_id}, title: {title}" for doc_id, title in docs)
        system = (
            "You filter a document catalogue to find which documents are relevant "
            "to a user's question. Use ONLY the titles in the catalogue — do not "
            "invent docIDs. "
            "Output ONLY a JSON array of the docIDs you consider relevant, e.g. "
            '["abc123", "def456"]. If no document is clearly relevant, output []. '
            "No explanations, no Markdown, no code fences, no prose around the array."
        )
        user = f"Question:\n{question}\n\nDocuments:\n{catalogue}\n\nRelevant docIDs (JSON array):"
        _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
        ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
        if isinstance(ans, tuple):
            ans = ans[0]
        cleaned = re.sub(r"^.*</think>", "", ans, flags=re.DOTALL)
        cleaned = re.sub(r"```(?:json)?\s*|\s*```", "", cleaned).strip()
        try:
            ids = json_repair.loads(cleaned)
        except Exception as e:
            logging.warning(f"select_by_titles could not parse LLM output: {e!r} raw={ans[:200]!r}")
            return []
        if not isinstance(ids, list):
            return []
        known = {doc_id for doc_id, _ in docs}
        return [doc_id for doc_id in ids if isinstance(doc_id, str) and doc_id in known]

    async def extract_keywords(self, question: str) -> str:
        """Produce a compact keyword string (terms + a few close synonyms).

        Replaces the keywords the outer LLM used to hand to the retrieval
        tool. Falls back to the question itself when extraction fails.
        """
        if not question:
            return ""
        system = (
            "Extract the search terms for a knowledge-base query from the "
            "question below. Output 3-8 of the most important content terms, "
            "plus 1-2 close synonyms or alternative phrasings for any ambiguous "
            "term. Single words or short noun phrases, space-separated, in the "
            "SAME language as the question. Output ONLY the terms — no labels, "
            "no punctuation lists, no explanation."
        )
        try:
            _, msg = message_fit_in(form_message(system, question), self.chat_mdl.max_length)
            ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.2})
            if isinstance(ans, tuple):
                ans = ans[0]
            ans = re.sub(r"^.*</think>", "", ans, flags=re.DOTALL).strip()
        except Exception:
            logging.exception("extract_keywords failed")
            ans = ""
        return ans or question

    async def retrieve(
        self,
        question: str,
        keywords: str | list = "",
        doc_scope: List[str] | None = None,
        top_n: int = 6,
        similarity_threshold: float = 0.2,
        using_embedding: bool = False,
    ) -> dict[str, list]:
        """Retrieve chunks from the unstructured KBs for one question.

        Returns a raw ``{"chunks": [...], "doc_aggs": [...]}`` dict — no
        citation stamping, no accumulation onto ``self.kbinfos`` (the graph
        owns merging so parallel per-question retrieval stays race-free).
        """
        if not self.kb_ids:
            return {"chunks": [], "doc_aggs": []}
        if isinstance(keywords, list):
            keywords = ",".join(keywords)
        logging.info(f"@retrieve: {question}@{keywords}")

        if doc_scope:
            candidates = [d for d in doc_scope if isinstance(d, str)]
            known = await thread_pool_exec(self._filter_known_doc_ids, candidates)
            valid = [d for d in candidates if d in known]
            if valid:
                doc_scope = valid
            else:
                if candidates:
                    logging.warning("retrieve: every supplied doc ID was unknown; falling back to unfiltered retrieval")
                doc_scope = None

        search_terms = keywords.strip() if keywords else ""
        if not search_terms:
            search_terms = question
        else:
            question = question + " " + search_terms

        embd_mdl = self.embed_mdl if using_embedding else None
        vector_weight = 0.7 if embd_mdl else 0
        kbinfos = await settings.retriever.retrieval(
            question,
            embd_mdl,
            self.tenant_ids,
            self.kb_ids,
            1,
            top_n,
            similarity_threshold,
            vector_similarity_weight=vector_weight,
            aggs=True,
            highlight=True,
            doc_ids=doc_scope,
            rank_feature=label_question(question, self.kbs),
        )
        if not kbinfos:
            return {"chunks": [], "doc_aggs": []}
        kbinfos["chunks"] = settings.retriever.retrieval_by_children(kbinfos.get("chunks", []), self.tenant_ids)
        return {"chunks": kbinfos.get("chunks", []), "doc_aggs": kbinfos.get("doc_aggs", [])}

    async def web_retrieve(self, query: str) -> dict[str, list]:
        """Retrieve chunks from the public web (Tavily). Raw kbinfos shape."""
        if self.tav is None:
            return {"chunks": [], "doc_aggs": []}
        try:
            tav_res = await thread_pool_exec(self.tav.retrieve_chunks, query)
        except Exception:
            logging.exception("web_retrieve failed")
            return {"chunks": [], "doc_aggs": []}
        return {"chunks": tav_res.get("chunks", []), "doc_aggs": tav_res.get("doc_aggs", [])}

    async def structured_retrieve(self, question: str) -> dict[str, Any]:
        """Query the structured (tabular) KBs by translating to SQL.

        Returns ``{"answer": str, "chunks": [...], "doc_aggs": [...]}``. The
        answer is the natural-language SQL result the final node can weave in;
        the chunks/doc_aggs feed the shared citation pool.
        """
        if not self.has_structured():
            return {"answer": "", "chunks": [], "doc_aggs": []}

        # Lazy import — dialog_service constructs RAGTools.
        from api.db.services.dialog_service import use_sql

        sql_kb_ids = [kb.id for kb in self.sql_kbs]
        tenant_id = self.sql_kbs[0].tenant_id
        try:
            ans = await use_sql(question, self.field_map, tenant_id, self.chat_mdl, quota=True, kb_ids=sql_kb_ids)
        except Exception as e:
            logging.exception(f"structured_retrieve: use_sql failed: {e}")
            return {"answer": "", "chunks": [], "doc_aggs": []}
        if not ans:
            return {"answer": "", "chunks": [], "doc_aggs": []}
        reference = ans.get("reference") or {}
        return {
            "answer": ans.get("answer", "") or "",
            "chunks": reference.get("chunks") or [],
            "doc_aggs": reference.get("doc_aggs") or [],
        }

    def _fit_evidence(self, question: str, evidence_md: str) -> str:
        """Trim ``evidence_md`` so ``question`` + evidence + the prompt template
        stay inside the model's context window.

        ``message_fit_in`` keeps the small side (the question) whole and trims
        the large side (the evidence); we shrink the budget by a reserve so the
        template skeleton and JSON output rules still fit afterwards.
        """
        if not evidence_md:
            return evidence_md
        budget = max(256, self.chat_mdl.max_length - _EVIDENCE_PROMPT_RESERVE_TOKENS)
        _, msg = message_fit_in(form_message(question, evidence_md), budget)
        return msg[-1]["content"]

    async def judge_sufficiency(self, question: str, evidence_md: str) -> dict:
        """Judge whether ``evidence_md`` answers ``question`` and pick useful chunks.

        ``evidence_md`` must carry ``ID: n`` markers per chunk (as produced by
        ``kb_prompt``). Returns the verdict dict:
        ``{"is_sufficient": bool, "reasoning": str, "missing_information": [...],
        "useful_chunk_ids": [int, ...]}``.
        """
        evidence_md = self._fit_evidence(question, evidence_md)
        try:
            return await sufficiency_select(self.chat_mdl, question, evidence_md) or {}
        except Exception:
            logging.exception("judge_sufficiency failed")
            return {}

    async def gen_followups(self, question: str, query: str, missing: List[str], evidence_md: str) -> List[dict]:
        """Generate complementary follow-up (question, query) pairs for gaps."""
        evidence_md = self._fit_evidence(question, evidence_md)
        try:
            res = await multi_queries_gen(self.chat_mdl, question, query or question, missing or [], evidence_md) or {}
        except Exception:
            logging.exception("gen_followups failed")
            return []
        qs = res.get("questions") or []
        return [q for q in qs if isinstance(q, dict) and (q.get("question") or "").strip()]

    async def fetch_full_document(self, doc_id: str) -> dict[str, list]:
        """Fetch a whole document's chunks in reading order (raw kbinfos)."""
        if not self.kb_ids:
            return {"chunks": [], "doc_aggs": []}
        resolved = await thread_pool_exec(self._resolve_doc_tenant, doc_id)
        if resolved is None:
            logging.warning(f"fetch_full_document: doc_id {doc_id!r} not in any bound KB — refusing to fetch")
            return {"chunks": [], "doc_aggs": []}
        kb_id, tenant_id = resolved

        cks = []
        tokens = 0
        for offset in range(0, 10000, 128):
            chunks = await thread_pool_exec(
                settings.retriever.chunk_list,
                doc_id,
                tenant_id,
                [kb_id],
                max_count=offset + 128,
                offset=offset,
                fields=["content_with_weight", "docnm_kwd", "doc_id"],
                sort_by_position=True,
                retrieve_all=False,
            )
            if not chunks:
                break
            for ck in chunks:
                num = num_tokens_from_string(str(ck["content_with_weight"]))
                if tokens + num > self.chat_mdl.max_length:
                    break
                tokens += num
                cks.append(ck)
        if not cks:
            return {"chunks": [], "doc_aggs": []}
        doc_name = next((c.get("docnm_kwd") or "" for c in cks if c.get("docnm_kwd")), "")
        return {
            "chunks": cks,
            "doc_aggs": [{"doc_name": doc_name, "doc_id": doc_id, "count": len(cks)}],
        }

    # ------------------------------------------------------------------ #
    # Bound tools
    # ------------------------------------------------------------------ #
    @tool(timeout=600)
    async def rag(self, question: str) -> str:
        """Answer a question with evidence from the knowledge bases and the web.

        Runs the full agentic-search pipeline: it formalises the question,
        narrows the document scope, analyses keywords, retrieves evidence,
        checks whether the evidence is sufficient (looping with follow-up
        searches when it is not), and finally composes a cited answer.

        :param question: a self-contained natural-language question.

        :returns: the composed answer with inline citation markers.
        """
        from rag.advanced_rag.agentic_rag_graph import run_agentic_rag

        messages = [{"role": "user", "content": question}] if question else []
        final = ""
        async for delta in _strip_think_stream(run_agentic_rag(self, messages)):
            if isinstance(delta, str):
                final += delta
        for p, r in [(r"\(\**(ID:\d)\**\)", "[\1]")]:
            final = re.sub(p, r, final)
        return final

    @tool
    async def summarize_document(self, doc_id: str) -> list[str]:
        """Return a single document's content, position-ordered, ready to summarise.

        Call ONLY for an explicit summary request about a specific document.
        For general Q&A use the `rag` tool instead.

        :param doc_id: a 32-character lowercase hex document ID that some
            other tool returned in this turn. Never invent one.

        :returns: formatted chunk blocks (document order) fitting the model's
            context budget, prefixed with the citation rules to apply.
        """
        kbinfos = await self.fetch_full_document(doc_id)
        if not kbinfos["chunks"]:
            return []
        start_idx = len(self.kbinfos.get("chunks", []))
        self.kbinfos["chunks"].extend(kbinfos["chunks"])
        self.kbinfos["doc_aggs"].extend(kbinfos["doc_aggs"])
        blocks = kb_prompt(self.kbinfos, self.chat_mdl.max_length)
        if not self.do_refer:
            return blocks[start_idx:] if start_idx else blocks
        header = "# Citation rules\nApply the following rules VERBATIM to your final answer.\n\n" + citation_prompt(self.user_defined_prompts).strip() + "\n\n----\n\n"
        return [header] + (blocks[start_idx:] if start_idx else blocks)

    # ------------------------------------------------------------------ #
    # Low-level DB helpers (sync — wrap in thread_pool_exec at call sites)
    # ------------------------------------------------------------------ #
    async def _get_cached_metas(self) -> dict:
        cached = getattr(self, "_metas_cache", None)
        if cached is not None:
            return cached
        if not self.kb_ids:
            self._metas_cache = {}
            return self._metas_cache
        self._metas_cache = await thread_pool_exec(DocMetadataService.get_flatted_meta_by_kbs, self.kb_ids)
        return self._metas_cache or {}

    def _collect_doc_titles(self, max_docs: int = 512) -> list[tuple[str, str]] | None:
        result: list[tuple[str, str]] = []
        for kb_id in self.kb_ids:
            for doc in DocumentService.query(kb_id=kb_id):
                result.append((doc.id, doc.name))
                if len(result) >= max_docs:
                    return None
        return result

    def _filter_known_doc_ids(self, candidate_ids: list[str]) -> set[str]:
        if not candidate_ids or not self.kb_ids:
            return set()
        rows = Document.select(Document.id).where((Document.id.in_(list(candidate_ids))) & (Document.kb_id.in_(self.kb_ids)))
        return {row.id for row in rows}

    def _resolve_doc_tenant(self, doc_id: str) -> tuple[str, str] | None:
        rows = list(Document.select(Document.kb_id).where((Document.id == doc_id) & (Document.kb_id.in_(self.kb_ids))))
        if not rows:
            return None
        kb_id = rows[0].kb_id
        for kb in self.kbs:
            if kb.id == kb_id:
                return kb_id, kb.tenant_id
        return None
