"""Agent-facing ``fetch_content`` tool builders.

Public API:
    FETCH_MODES         — tuple of valid mode strings.
    build_fetch_tool()  — returns a LangChain ``@tool`` (or ``None`` when
                          mode == "disabled" so the caller can skip
                          registration).

Modes:
    disabled              — fetch tool is not registered with the agent.
    full                  — return the full extracted page text (legacy
                            behavior; can flood small-model context with
                            boilerplate / metadata enrichment).
    summary_focus         — LLM extracts only spans relevant to a focus
                            question the agent supplies per call.
    summary_focus_query   — same as above, but the prompt also includes
                            the original research query (passed in
                            programmatically by the strategy) so the
                            extractor can disambiguate vague focuses.

Each tool registers fetched URLs in the strategy's
``SearchResultsCollector`` for citation tracking, returning the result as
``[N] Title: ...\\nURL: ...\\n\\n<body>`` exactly like the original
in-strategy implementation, so downstream prompt formatting is unchanged.
"""

from __future__ import annotations

from typing import Any

from langchain_core.language_models import BaseChatModel
from langchain_core.tools import tool
from loguru import logger

from local_deep_research.utilities.js_rendering import (
    read_js_rendering_setting as _read_js_rendering_setting,
)
from local_deep_research.security import (
    redact_url_for_log,
    sanitize_error_for_client,
)

from .prompts import SUMMARY_FOCUS_PROMPT, SUMMARY_FOCUS_QUERY_PROMPT


# Per-call timeouts and caps. Kept here rather than in the strategy file
# because they are properties of the fetch tool, not of agent
# orchestration.
CONTENT_FETCH_TIMEOUT = 30
CONTENT_MAX_LENGTH = 10_000

# Cap for credential-scrubbed fetch-tool error strings. Larger than the
# 200-char HTTP-client default because these errors feed the agent's
# reasoning; credential scrubbing still runs first on the full string (#4633).
_TOOL_ERROR_MAX_LEN = 500


def _scrub_tool_error(message: str) -> str:
    """Scrub credentials from an LLM/agent-facing fetch-tool error string."""
    return sanitize_error_for_client(message, max_length=_TOOL_ERROR_MAX_LEN)


FETCH_MODES = (
    "disabled",
    "full",
    "summary_focus",
    "summary_focus_query",
)


def _register_in_collector(
    collector: Any,
    url: str,
    title: str,
    snippet_source: str,
) -> int:
    """Register a fetched URL in the collector and return its 1-based citation index.

    If the URL was already tracked (via a prior search hit) the existing
    index is reused so the agent sees a stable citation per URL.
    """
    existing_idx = collector.find_by_url(url)
    if existing_idx is not None:
        return existing_idx
    snippet = snippet_source[:200].strip()
    if len(snippet_source) > 200:
        snippet += "..."
    start = collector.add_results(
        [{"title": title, "link": url, "snippet": snippet}],
        engine_name="fetch",
    )
    return start + 1


def _enforce_url_policy(url: str, egress_context: Any) -> None:
    """Run ``evaluate_url`` against ``egress_context`` and raise
    ``PolicyDeniedError`` on denial.

    No-op when no context is configured (callers without policy enforcement,
    e.g. legacy non-LangGraph strategies, see the legacy behavior).
    """
    if egress_context is None:
        return
    from local_deep_research.security.egress.policy import (
        PolicyDeniedError,
        evaluate_url,
    )

    decision = evaluate_url(url, egress_context)
    if not decision.allowed:
        raise PolicyDeniedError(decision, target=url)


def _denial_reason(exc: Any) -> str:
    """Best-effort egress-denial reason code for an agent-facing message."""
    return getattr(getattr(exc, "decision", None), "reason", "policy_denied")


# Shared instruction appended to every per-URL egress denial returned to the
# agent. Tells it WHY the fetch was refused and what to do instead, so it
# adapts (stays in-scope) rather than retrying the same out-of-scope URL.
_EGRESS_DENIAL_HINT = (
    "In this run only local collection/library documents can be fetched; "
    "skip external URLs."
)


def _make_full_fetch_tool(
    collector: Any,
    settings_snapshot: dict | None = None,
    egress_context: Any = None,
):
    mode_label = "full"

    @tool
    def fetch_content(url: str) -> str:
        """Download and read the full text content from a URL. Use when search snippets aren't detailed enough."""
        from local_deep_research.content_fetcher import ContentFetcher
        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        enable_js = _read_js_rendering_setting(settings_snapshot)
        try:
            # Per-URL egress gate (pre-fetch) + ContentFetcher's own
            # per-redirect gate both raise PolicyDeniedError on an out-of-scope
            # URL. Run the gate INSIDE the try so the denial is returned as a
            # recoverable tool message (see the except below).
            _enforce_url_policy(url, egress_context)
            with ContentFetcher(
                timeout=CONTENT_FETCH_TIMEOUT,
                enable_js_rendering=enable_js,
                egress_context=egress_context,
            ) as fetcher:
                result = fetcher.fetch(url, max_length=CONTENT_MAX_LENGTH)
                if result.get("status") == "success":
                    title = result.get("title", "")
                    content = result.get("content", "")
                    cite_idx = _register_in_collector(
                        collector, url, title, content
                    )
                    return (
                        f"[{cite_idx}] Title: {title}\nURL: {url}\n\n{content}"
                    )
                # result['error'] comes from ContentFetcher, which returns a
                # raw str(exception) — scrub it (and the url) before this
                # reaches the agent/LLM and user-visible output (#4633).
                return _scrub_tool_error(
                    f"Failed to fetch {url}: "
                    f"{result.get('error', 'unknown error')}"
                )
        except PolicyDeniedError as exc:
            # An out-of-scope URL is a RECOVERABLE, per-call decision (the agent
            # picked one bad URL among many). Return it as a tool message — like
            # the transient-error path below — so the lead agent and pooled
            # subagents handle it identically and the agent can adapt, instead
            # of re-raising (which aborts a subagent and depends on each agent's
            # tool-error layer). The URL was already NOT fetched; the policy
            # already enforced — only the REPORTING changes, not security.
            return _scrub_tool_error(
                f"Cannot fetch {url}: blocked by egress policy "
                f"({_denial_reason(exc)}). {_EGRESS_DENIAL_HINT}"
            )
        except Exception as exc:
            # Message carries the mode + a REDACTED scheme://host only (no
            # userinfo/path/query) so an operator can locate the failure without
            # the log line leaking credentials, query tokens, or page content.
            # The traceback follows the sink's diagnose setting (off by default;
            # see utilities/log_utils). The agent/user-facing return is scrubbed
            # separately below.
            logger.exception(
                "fetch_content tool error (mode={}, url={})",
                mode_label,
                redact_url_for_log(url),
            )
            return _scrub_tool_error(f"Error fetching {url}: {exc}")

    return fetch_content


def _make_summary_fetch_tool(
    collector: Any,
    model: BaseChatModel,
    overall_query: str | None,
    settings_snapshot: dict | None = None,
    egress_context: Any = None,
):
    """Build the summary-mode fetch tool.

    overall_query=None → focus-only prompt (``summary_focus`` mode).
    overall_query=str  → focus + overall-query prompt (``summary_focus_query``).
    """
    use_query = bool(overall_query)
    template = SUMMARY_FOCUS_QUERY_PROMPT if use_query else SUMMARY_FOCUS_PROMPT

    mode_label = "summary_focus_query" if use_query else "summary_focus"

    @tool
    def fetch_content(url: str, focus: str) -> str:
        """Fetch a URL and return only the spans of text relevant to ``focus``.
        Pass the specific question or claim you want answered as ``focus`` — the
        tool will quote relevant facts verbatim and discard unrelated content.
        """
        from local_deep_research.content_fetcher import ContentFetcher
        from local_deep_research.security.egress.policy import (
            PolicyDeniedError,
        )

        enable_js = _read_js_rendering_setting(settings_snapshot)
        try:
            # Run the per-URL egress gate INSIDE the try so an out-of-scope URL
            # is returned as a recoverable tool message (see the except below),
            # not re-raised.
            _enforce_url_policy(url, egress_context)
            with ContentFetcher(
                timeout=CONTENT_FETCH_TIMEOUT,
                enable_js_rendering=enable_js,
                egress_context=egress_context,
            ) as fetcher:
                result = fetcher.fetch(url, max_length=CONTENT_MAX_LENGTH)
                if result.get("status") != "success":
                    # result['error'] comes from ContentFetcher, which
                    # returns a raw str(exception) — scrub it (and the url)
                    # before this reaches the agent/LLM / user output (#4633).
                    return _scrub_tool_error(
                        f"Failed to fetch {url}: "
                        f"{result.get('error', 'unknown error')}"
                    )

                title = result.get("title") or ""
                content = result.get("content") or ""

                # Guard 1 — empty page content (paywalls, JS-only SPAs that
                # static fetch can't render, deleted pages with HTTP 200).
                # Skipping the LLM call here means we don't pay the round-trip
                # to summarise nothing, AND we don't register an empty
                # citation in the collector — `_register_in_collector` caches
                # by URL, so an empty snippet would lock the URL in as
                # "already fetched, nothing here" and the agent would never
                # retry it under a different focus.
                if not content.strip():
                    logger.info(
                        f"[FETCH] mode={mode_label} url={url} — "
                        "empty page content, returning NOT RELEVANT without "
                        "LLM call or collector registration"
                    )
                    return f"NOT RELEVANT (no extractable content at {url})"

                fmt_kwargs = {
                    "focus": focus,
                    "title": title,
                    "url": url,
                    "content": content,
                }
                if use_query:
                    fmt_kwargs["overall_query"] = overall_query
                prompt = template.format(**fmt_kwargs)

                try:
                    summary_msg = model.invoke(prompt)
                    summary = getattr(
                        summary_msg, "content", str(summary_msg)
                    ).strip()
                except Exception as exc:
                    # Redacted scheme://host + mode only — no page content,
                    # focus, or credentials in the message. Traceback follows
                    # the sink's diagnose setting (off by default).
                    logger.exception(
                        "fetch_content summary LLM error (mode={}, url={})",
                        mode_label,
                        redact_url_for_log(url),
                    )
                    return _scrub_tool_error(f"Error summarizing {url}: {exc}")

                # Diagnostic log: per-fetch input/output for evaluating the
                # summariser. Single multi-line block so it's atomic per call
                # and easy to grep with ``grep -A1000 "[FETCH] mode="``.
                log_lines = [
                    f"[FETCH] mode={mode_label} url={url}",
                    f"[FETCH] focus: {focus}",
                ]
                if use_query:
                    log_lines.append(f"[FETCH] overall_query: {overall_query}")
                log_lines.extend(
                    [
                        f"[FETCH] title: {title}",
                        f"[FETCH] page_text ({len(content)} chars):",
                        content,
                        f"[FETCH] summary returned ({len(summary)} chars):",
                        summary or "(empty)",
                        "[FETCH] ---",
                    ]
                )
                logger.info("\n".join(log_lines))

                # Guard 2 — empty LLM summary. The model decided nothing on
                # the page answers the focus (or it returned a malformed/empty
                # response). Treat as NOT RELEVANT and skip collector
                # registration: the agent should be free to re-fetch the URL
                # later with a different focus instead of seeing it as
                # already-cached with an empty body.
                if not summary:
                    return f"NOT RELEVANT (no spans matched focus at {url})"

                cite_idx = _register_in_collector(
                    collector, url, title, summary
                )
                return f"[{cite_idx}] Title: {title}\nURL: {url}\n\n{summary}"
        except PolicyDeniedError as exc:
            # Recoverable per-URL denial — return a tool message so both the
            # lead agent and pooled subagents handle it identically and the
            # agent stays in-scope. The URL was already NOT fetched; only the
            # reporting changes, not security. (See the full-fetch variant.)
            return _scrub_tool_error(
                f"Cannot fetch {url}: blocked by egress policy "
                f"({_denial_reason(exc)}). {_EGRESS_DENIAL_HINT}"
            )
        except Exception as exc:
            # Message carries the mode + a REDACTED scheme://host only (no
            # userinfo/path/query) so an operator can locate the failure without
            # the log line leaking credentials, query tokens, or page content.
            # The traceback follows the sink's diagnose setting (off by default;
            # see utilities/log_utils). The agent/user-facing return is scrubbed
            # separately below.
            logger.exception(
                "fetch_content tool error (mode={}, url={})",
                mode_label,
                redact_url_for_log(url),
            )
            return _scrub_tool_error(f"Error fetching {url}: {exc}")

    return fetch_content


def build_fetch_tool(
    mode: str,
    collector: Any,
    *,
    model: BaseChatModel | None = None,
    overall_query: str = "",
    settings_snapshot: dict | None = None,
    egress_context: Any = None,
):
    """Build the agent-facing ``fetch_content`` tool for *mode*.

    Returns ``None`` when ``mode == 'disabled'``; the caller should not
    register the tool with the agent in that case (and the system prompt
    should also drop the corresponding instruction line so the agent
    isn't told to use a tool that doesn't exist).

    ``settings_snapshot`` is captured by the tool closure so the per-call
    JS-rendering toggle can be read on a worker thread (where
    ``threading.local`` context does not propagate).

    ``egress_context`` is captured by the closure so the per-call URL
    can be policy-gated; when ``None``, no policy enforcement runs
    (preserves legacy non-LangGraph callers).
    """
    if mode == "disabled":
        return None
    if mode == "full":
        return _make_full_fetch_tool(
            collector,
            settings_snapshot=settings_snapshot,
            egress_context=egress_context,
        )
    if mode == "summary_focus":
        if model is None:
            raise ValueError("summary_focus fetch mode requires a model")
        return _make_summary_fetch_tool(
            collector,
            model,
            overall_query=None,
            settings_snapshot=settings_snapshot,
            egress_context=egress_context,
        )
    if mode == "summary_focus_query":
        if model is None:
            raise ValueError("summary_focus_query fetch mode requires a model")
        # Empty overall_query falls back to focus-only behaviour at format
        # time; we keep the *_query mode label so logs stay diagnostic.
        return _make_summary_fetch_tool(
            collector,
            model,
            overall_query=overall_query or None,
            settings_snapshot=settings_snapshot,
            egress_context=egress_context,
        )
    raise ValueError(
        f"Unknown fetch mode {mode!r}; expected one of {FETCH_MODES}"
    )


__all__ = ["FETCH_MODES", "build_fetch_tool"]
