"""Web search + fetch sub-app.

Thin HTTP wrappers around `WebSearchTool` and `WebFetchTool` from
`backend.apps.agents.tools.web`. Exists so the in-process MCP server
(`backend.apps.agents.web_mcp_server`) can proxy tool calls to the
backend instead of re-implementing DuckDuckGo scraping + trafilatura
extraction in the MCP process.

Mounted at `/api/web`.
"""

from contextlib import asynccontextmanager
from typing import Any

from fastapi import HTTPException
from pydantic import BaseModel, Field
from typeguard import typechecked

import debug

from backend.config.Apps import SubApp


@asynccontextmanager
async def web_lifespan():
    debug("START")
    yield
    debug("END")


web = SubApp("web", web_lifespan)


# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------


class SearchBody(BaseModel):
    query: str = Field(..., description="The search query.")
    num_results: int = Field(5, ge=1, le=10, description="Max results to return.")
    # Hint from the MCP server about which primary provider the session
    # is using. Lets us route to that provider's native search tool
    # (Gemini googleSearch, OpenAI web_search_preview) when available , 
    # costs come out of the user's existing primary budget.
    primary: str | None = Field(None, description="Primary provider hint: 'gemini' | 'openai' | 'anthropic' | None")


class FetchBody(BaseModel):
    url: str = Field(..., description="The URL to fetch.")
    prompt: str | None = Field(None, description="Optional context hint.")
    primary: str | None = Field(None, description="Primary provider hint.")


# ---------------------------------------------------------------------------
# Helper; extract plain text from a tool's structured output list
# ---------------------------------------------------------------------------


def _join_text(parts: list[dict[str, Any]]) -> str:
    out = []
    for p in parts:
        if isinstance(p, dict) and p.get("type") == "text":
            out.append(str(p.get("text", "")))
    return "\n".join(out)


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
GEMINI_GROUNDING_MODEL = "gemini-2.5-flash"  # cheapest + fastest for grounded calls

OPENAI_API_BASE = "https://api.openai.com/v1"
OPENAI_SEARCH_MODEL = "gpt-5-mini"  # cheapest model that supports web_search_preview


async def _gemini_grounded_call(api_key: str, prompt: str, *, use_url_context: bool) -> dict:
    """Call Gemini with googleSearch (+ optionally urlContext) grounding.

    Returns {"text": grounded_answer, "chunks": [(title, uri), ...],
             "queries": [...]} or raises httpx.HTTPError on failure.
    """
    import httpx
    tools = [{"googleSearch": {}}]
    if use_url_context:
        tools.append({"urlContext": {}})
    body = {
        "contents": [{"role": "user", "parts": [{"text": prompt}]}],
        "tools": tools,
        "generationConfig": {"thinkingConfig": {"thinkingBudget": 0}},
    }
    url = f"{GEMINI_API_BASE}/models/{GEMINI_GROUNDING_MODEL}:generateContent"
    async with httpx.AsyncClient(timeout=45.0) as client:
        r = await client.post(
            url,
            headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
            json=body,
        )
        r.raise_for_status()
        data = r.json()

    cand = (data.get("candidates") or [{}])[0]
    text = "".join(
        p.get("text", "") for p in (cand.get("content", {}).get("parts") or [])
        if isinstance(p, dict)
    )
    gm = cand.get("groundingMetadata") or {}
    chunks = []
    for gc in (gm.get("groundingChunks") or []):
        web = (gc or {}).get("web") or {}
        uri = web.get("uri") or web.get("url") or ""
        title = web.get("title") or uri
        if uri:
            chunks.append((title, uri))
    queries = gm.get("webSearchQueries") or []
    return {"text": text, "chunks": chunks, "queries": queries}


def _format_grounded_as_search_results(grounded: dict, query: str) -> str:
    """Format Gemini grounding output to match WebSearchTool's text shape."""
    lines = []
    chunks = grounded.get("chunks") or []
    for i, (title, uri) in enumerate(chunks[:10], start=1):
        lines.append(f"[{i}] {title}\n    {uri}")
    text = grounded.get("text") or ""
    if text:
        lines.append("\n" + text)
    if not lines:
        return f"No search results found for: {query}"
    return "\n\n".join(lines)


def _format_grounded_as_fetch(grounded: dict, url: str) -> str:
    """Format Gemini urlContext output to match WebFetchTool's text shape."""
    parts = [f"Contents of {url}:", ""]
    text = grounded.get("text") or ""
    if text:
        parts.append(text)
    chunks = grounded.get("chunks") or []
    if chunks:
        parts.append("\nCited sources:")
        for i, (title, uri) in enumerate(chunks[:5], start=1):
            parts.append(f"  [{i}] {title}; {uri}")
    return "\n".join(parts)


def _resolve_gemini_api_key() -> str | None:
    """Pull the AI Studio API key from settings, or None."""
    try:
        from backend.apps.settings.settings import load_settings
        s = load_settings()
        return getattr(s, "google_api_key", None) or None
    except Exception:
        return None


def _resolve_openai_api_key() -> str | None:
    try:
        from backend.apps.settings.settings import load_settings
        s = load_settings()
        return getattr(s, "openai_api_key", None) or None
    except Exception:
        return None


# Cache of which 9Router subscriptions are connected. Refreshed via
# `_refresh_9r_connected()` rather than hit on every search call , 
# 9Router's /api/providers is fast but not free, and we already
# query it from many places.
_NINE_ROUTER_CONNECTED: set[str] = set()
_NINE_ROUTER_CACHE_AT: float = 0.0


async def _refresh_9r_connected() -> set[str]:
    """Return the set of currently-active 9Router subscription providers
    (e.g. {"claude", "codex", "antigravity", "gemini-cli"}). Cached for
    20s to keep search/fetch endpoints snappy."""
    global _NINE_ROUTER_CONNECTED, _NINE_ROUTER_CACHE_AT
    import time as _t
    now = _t.time()
    if now - _NINE_ROUTER_CACHE_AT < 20.0:
        return _NINE_ROUTER_CONNECTED
    try:
        from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
        if not _9r_running():
            _NINE_ROUTER_CONNECTED = set()
        else:
            conns = await _9r_providers()
            _NINE_ROUTER_CONNECTED = {
                c.get("provider")
                for c in conns
                if isinstance(c, dict) and c.get("isActive") and c.get("provider")
            }
        _NINE_ROUTER_CACHE_AT = now
    except Exception:
        # Cache stays; best-effort.
        pass
    return _NINE_ROUTER_CONNECTED


async def _gemini_grounded_via_9router(prompt: str, use_url_context: bool) -> dict:
    """Call 9Router's /v1/messages endpoint with a Gemini model so the
    user's OAuth subscription (Gemini CLI or Antigravity) covers the
    search call instead of needing a separate AI Studio API key.

    Routes through Anthropic-shape against 9Router's translator. We
    request a tool result naturally; the translator surfaces grounded
    URIs as text + cited sources in the response body. Format-shape
    matches the existing `_gemini_grounded_call` so downstream
    `_format_grounded_as_search_results` works unchanged."""
    import httpx
    # Prefer Gemini CLI (broader model coverage). Fall back to
    # Antigravity if CLI isn't connected.
    connected = await _refresh_9r_connected()
    if "gemini-cli" in connected:
        model = "gc/gemini-2.5-flash"
    elif "antigravity" in connected:
        model = "ag/gemini-3-flash"
    else:
        return {}

    sys_prompt = (
        "You search the web and return concise grounded answers with "
        "source citations. Always cite the URLs you used."
        if not use_url_context
        else "You fetch URLs and return concise summaries with citations."
    )
    body = {
        "model": model,
        "max_tokens": 1024,
        "system": sys_prompt,
        "messages": [{"role": "user", "content": prompt}],
    }
    async with httpx.AsyncClient(timeout=20.0) as client:
        r = await client.post(
            "http://localhost:20128/v1/messages",
            json=body,
            headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
        )
        if r.status_code != 200:
            return {}
        data = r.json()
    # Synthesize a grounded shape so the existing formatter works:
    # _format_grounded_as_search_results expects {"text": str, "chunks":
    # [(title, uri), ...]}. 9Router doesn't surface citations as a
    # structured field uniformly across providers, so we hand back
    # text-only and let the formatter do its thing.
    text = ""
    for block in (data.get("content") or []):
        if isinstance(block, dict) and block.get("type") == "text":
            text += block.get("text", "")
    return {"text": text, "chunks": []}


async def _openai_websearch_via_9router(query: str) -> dict:
    """Same idea, but for OpenAI's web_search_preview through Codex's
    9Router connection. Goes through 9Router's openai-compat endpoint
    (the responses API) so the user's Codex subscription covers it."""
    import httpx
    connected = await _refresh_9r_connected()
    if "codex" not in connected:
        return {}
    body = {
        "model": "cx/gpt-5.4-mini",
        "max_tokens": 1024,
        "system": (
            "You search the web and return concise grounded answers "
            "with source citations. Always cite the URLs you used."
        ),
        "messages": [{"role": "user", "content": f"Search the web for: {query}"}],
    }
    async with httpx.AsyncClient(timeout=20.0) as client:
        r = await client.post(
            "http://localhost:20128/v1/messages",
            json=body,
            headers={"x-api-key": "9router", "anthropic-version": "2023-06-01"},
        )
        if r.status_code != 200:
            return {}
        data = r.json()
    text = ""
    for block in (data.get("content") or []):
        if isinstance(block, dict) and block.get("type") == "text":
            text += block.get("text", "")
    return {"text": text, "chunks": []}


async def _openai_websearch(api_key: str, query: str) -> dict:
    """Call OpenAI Responses API with the web_search_preview tool.

    Returns {"text": grounded_answer, "chunks": [(title, uri), ...]}.
    """
    import httpx
    body = {
        "model": OPENAI_SEARCH_MODEL,
        "input": f"Search the web for: {query}\n\nReturn a concise summary. Cite sources.",
        "tools": [{"type": "web_search_preview"}],
    }
    async with httpx.AsyncClient(timeout=45.0) as client:
        r = await client.post(
            f"{OPENAI_API_BASE}/responses",
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=body,
        )
        r.raise_for_status()
        data = r.json()

    text_parts = []
    chunks: list[tuple[str, str]] = []
    for item in (data.get("output") or []):
        if not isinstance(item, dict):
            continue
        for content in (item.get("content") or []):
            if not isinstance(content, dict):
                continue
            if content.get("type") == "output_text":
                text_parts.append(content.get("text", ""))
            for ann in (content.get("annotations") or []):
                if isinstance(ann, dict) and ann.get("type") == "url_citation":
                    uri = ann.get("url", "")
                    title = ann.get("title", uri)
                    if uri:
                        chunks.append((title, uri))
    return {"text": "".join(text_parts), "chunks": chunks, "queries": [query]}


async def _openai_urlfetch(api_key: str, url: str, prompt: str | None) -> dict:
    """Use OpenAI's web_search_preview to fetch/summarize a specific URL."""
    prompt_text = f"Fetch and summarize the content at: {url}"
    if prompt:
        prompt_text += f"\n\nFocus on: {prompt}"
    import httpx
    body = {
        "model": OPENAI_SEARCH_MODEL,
        "input": prompt_text,
        "tools": [{"type": "web_search_preview"}],
    }
    async with httpx.AsyncClient(timeout=45.0) as client:
        r = await client.post(
            f"{OPENAI_API_BASE}/responses",
            headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
            json=body,
        )
        r.raise_for_status()
        data = r.json()
    text_parts = []
    chunks = []
    for item in (data.get("output") or []):
        for content in (item.get("content") or []):
            if isinstance(content, dict) and content.get("type") == "output_text":
                text_parts.append(content.get("text", ""))
            for ann in (content.get("annotations") or []) if isinstance(content, dict) else []:
                if isinstance(ann, dict) and ann.get("type") == "url_citation":
                    uri = ann.get("url", "")
                    title = ann.get("title", uri)
                    if uri:
                        chunks.append((title, uri))
    return {"text": "".join(text_parts), "chunks": chunks}


@web.router.post("/search")
@typechecked
async def search(body: SearchBody) -> dict:
    """Web search, primary-aware. Prefers the native search tool of the
    provider the user is already paying for:

        Gemini primary + Gemini key → googleSearch grounding
        OpenAI primary + OpenAI key → web_search_preview
        Anthropic path available   → handled at agent_manager layer
                                     (MCP isn't registered; built-in
                                     WebSearch routes through 9Router
                                     → Anthropic's server-side tool)
        otherwise                  → DDG fallback (CAPTCHA-prone)

    If the primary's own native path fails, we cascade to whichever
    other provider's credentials are available, then DDG last."""
    gemini_key = _resolve_gemini_api_key()
    openai_key = _resolve_openai_api_key()
    primary = (body.primary or "").lower()
    errors: list[str] = []

    async def try_gemini():
        if not gemini_key:
            return None
        prompt = (
            f"Search the web for: {body.query}\n\n"
            f"Return a concise summary of what you found. Cite sources."
        )
        grounded = await _gemini_grounded_call(gemini_key, prompt, use_url_context=False)
        return {
            "query": body.query,
            "results": _format_grounded_as_search_results(grounded, body.query),
            "backend": "gemini_native",
        }

    async def try_openai():
        if not openai_key:
            return None
        grounded = await _openai_websearch(openai_key, body.query)
        return {
            "query": body.query,
            "results": _format_grounded_as_search_results(grounded, body.query),
            "backend": "openai_native",
        }

    async def try_gemini_subscription():
        prompt = (
            f"Search the web for: {body.query}\n\n"
            f"Return a concise summary of what you found. Cite sources."
        )
        grounded = await _gemini_grounded_via_9router(prompt, use_url_context=False)
        if not grounded.get("text"):
            return None
        return {
            "query": body.query,
            "results": _format_grounded_as_search_results(grounded, body.query),
            "backend": "gemini_subscription",
        }

    async def try_openai_subscription():
        grounded = await _openai_websearch_via_9router(body.query)
        if not grounded.get("text"):
            return None
        return {
            "query": body.query,
            "results": _format_grounded_as_search_results(grounded, body.query),
            "backend": "openai_subscription",
        }

    # Ordered cascade: primary's native API key first (most direct), then
    # the user's connected subscriptions (free via OAuth), then the
    # opposite-provider native key, then DuckDuckGo last as a guaranteed
    # fallback (which is rate-limit-prone but free).
    if primary == "openai":
        cascade = [
            ("openai_native", try_openai),
            ("openai_subscription", try_openai_subscription),
            ("gemini_native", try_gemini),
            ("gemini_subscription", try_gemini_subscription),
        ]
    elif primary in ("gemini", "google"):
        cascade = [
            ("gemini_native", try_gemini),
            ("gemini_subscription", try_gemini_subscription),
            ("openai_native", try_openai),
            ("openai_subscription", try_openai_subscription),
        ]
    else:
        cascade = [
            ("gemini_native", try_gemini),
            ("gemini_subscription", try_gemini_subscription),
            ("openai_native", try_openai),
            ("openai_subscription", try_openai_subscription),
        ]

    for name, fn in cascade:
        try:
            res = await fn()
            if res is not None:
                return res
        except Exception as e:
            errors.append(f"{name}: {str(e)[:150]}")

    # DDG fallback.
    from backend.apps.agents.tools.web import WebSearchTool
    try:
        tool = WebSearchTool()
        parts = await tool.execute(
            {"query": body.query, "num_results": body.num_results},
            None,
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Search failed: {e}")

    text = _join_text(parts)
    hint = ""
    if text.startswith("No search results found"):
        connected = await _refresh_9r_connected()
        has_subscription = bool(connected & {"codex", "antigravity", "gemini-cli"})
        if not (gemini_key or openai_key or has_subscription):
            hint = (
                "\n\n(DuckDuckGo returned no results; likely rate-limiting this IP. "
                "Connect Codex / Antigravity / Gemini CLI in Settings, or add an "
                "OpenAI / Gemini API key, for reliable native search.)"
            )
        else:
            hint = (
                "\n\n(DuckDuckGo returned no results and the connected providers "
                "didn't return useful results either; try rephrasing the query.)"
            )
    return {
        "query": body.query,
        "results": text + hint,
        "backend": "ddg",
        **({"cascade_errors": errors} if errors else {}),
    }


@web.router.post("/fetch")
@typechecked
async def fetch(body: FetchBody) -> dict:
    """Fetch a URL, primary-aware. Mirrors /search cascade logic."""
    gemini_key = _resolve_gemini_api_key()
    openai_key = _resolve_openai_api_key()
    primary = (body.primary or "").lower()

    async def try_gemini():
        if not gemini_key:
            return None
        prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
        if body.prompt:
            prompt_bits.append(f"Focus on: {body.prompt}")
        grounded = await _gemini_grounded_call(
            gemini_key, "\n".join(prompt_bits), use_url_context=True,
        )
        return {
            "url": body.url,
            "content": _format_grounded_as_fetch(grounded, body.url),
            "backend": "gemini_native",
        }

    async def try_openai():
        if not openai_key:
            return None
        grounded = await _openai_urlfetch(openai_key, body.url, body.prompt)
        return {
            "url": body.url,
            "content": _format_grounded_as_fetch(grounded, body.url),
            "backend": "openai_native",
        }

    async def try_gemini_subscription():
        prompt_bits = [f"Fetch and summarize this URL: {body.url}"]
        if body.prompt:
            prompt_bits.append(f"Focus on: {body.prompt}")
        grounded = await _gemini_grounded_via_9router(
            "\n".join(prompt_bits), use_url_context=True,
        )
        if not grounded.get("text"):
            return None
        return {
            "url": body.url,
            "content": _format_grounded_as_fetch(grounded, body.url),
            "backend": "gemini_subscription",
        }

    async def try_openai_subscription():
        # Codex's web_search is general; URL fetch via search query
        # works adequately for our use.
        prompt = f"Fetch this URL and summarize: {body.url}"
        if body.prompt:
            prompt += f"\nFocus on: {body.prompt}"
        grounded = await _openai_websearch_via_9router(prompt)
        if not grounded.get("text"):
            return None
        return {
            "url": body.url,
            "content": _format_grounded_as_fetch(grounded, body.url),
            "backend": "openai_subscription",
        }

    if primary == "openai":
        cascade = [try_openai, try_openai_subscription, try_gemini, try_gemini_subscription]
    else:
        cascade = [try_gemini, try_gemini_subscription, try_openai, try_openai_subscription]

    for fn in cascade:
        try:
            res = await fn()
            if res is not None:
                return res
        except Exception:
            continue

    # Local httpx + trafilatura fallback.
    from backend.apps.agents.tools.web import WebFetchTool
    try:
        tool = WebFetchTool()
        parts = await tool.execute(
            {"url": body.url, "prompt": body.prompt or ""},
            None,
        )
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Fetch failed: {e}")

    return {"url": body.url, "content": _join_text(parts), "backend": "local"}
