import json
import logging
import os
import re
from collections.abc import Callable
from typing import Any
from urllib.parse import quote

import httpx
from langchain_community.tools import DuckDuckGoSearchRun
from langchain_core.tools import tool
from pydantic import BaseModel, Field

from .scholarly_search import (
    ScholarlySearchError,
    format_search_papers_results,
    search_semantic_scholar,
)
from .skills.loader import build_skill_runtime_payload, discover_available_skills
from .tools import LOCAL_OPS_TOOL_METADATA, ToolMetadata, build_local_ops_tools

logger = logging.getLogger(__name__)


class SearchDocumentInput(BaseModel):
    query: str = Field(
        description="Specific paper question or keyword used to retrieve evidence snippets."
    )


class ReadDocumentInput(BaseModel):
    offset: int = Field(
        default=0,
        description="Character offset to start reading from (0 means start from beginning).",
    )
    limit: int = Field(
        default=2000,
        description="Maximum number of characters to read (recommended 1000-3000 for context).",
    )
    include_rag: bool = Field(
        default=False,
        description="Whether to use RAG to retrieve relevant context around the reading position.",
    )


class SearchWebInput(BaseModel):
    query: str = Field(
        description="Supplemental public-web query used only when document evidence is insufficient."
    )


class SearchPapersInput(BaseModel):
    query: str = Field(
        description="Academic search query for finding relevant papers from scholarly sources."
    )
    limit: int = Field(
        default=5,
        ge=1,
        le=20,
        description="Maximum number of papers to return (1-20).",
    )


class SkillInput(BaseModel):
    skill_name: str = Field(
        description=(
            "Skill template name. Available skills include: summary, critical_reading, "
            "method_compare, translation, mindmap, agentic_search."
        )
    )
    task: str = Field(
        description="Current user task where the selected skill guidance should be applied."
    )


DEFAULT_MAX_QUERY_CHARS = 1200
DEFAULT_WEB_MAX_RESULTS = 5
DEFAULT_WEB_TIMEOUT_SECONDS = 8.0
DEFAULT_BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
DEFAULT_SEARXNG_INSTANCES = (
    "https://searx.be",
    "https://search.inetol.net",
    "https://opnxng.com",
)
_DANGEROUS_QUERY_PATTERNS = [
    r"ignore\s+previous\s+instructions",
    r"system\s+prompt",
    r"reveal\s+.*(api\s*key|token|password)",
    r"(api\s*key|access\s*token|password)\s*[:=]",
    r"\brm\s+-rf\b",
    r"\bsudo\b",
    r"\bssh\b",
]
_TOOL_METADATA = (
    ToolMetadata(
        name="search_document",
        description="Search uploaded paper content for relevant evidence snippets using RAG.",
    ),
    ToolMetadata(
        name="read_document",
        description=(
            "Read a specific portion of the uploaded paper with pagination. "
            "Use offset to skip to a position, limit to control chunk size. "
            "Set include_rag=True to get relevant context around the position."
        ),
    ),
    *LOCAL_OPS_TOOL_METADATA,
    ToolMetadata(
        name="search_web",
        description="Search public web content for supplemental context.",
    ),
    ToolMetadata(
        name="search_papers",
        description="Search academic papers from scholarly providers when document evidence is insufficient.",
    ),
    ToolMetadata(
        name="use_skill",
        description="Apply a named skill template to the current task and return operational guidance.",
    ),
)


def _env_flag(name: str, default: bool = False) -> bool:
    value = os.getenv(name)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _env_value(name: str, default: str = "") -> str:
    value = os.getenv(name)
    if isinstance(value, str):
        stripped = value.strip()
        if stripped:
            return stripped
    return default


def _read_from_dotenv(name: str, dotenv_path: str = ".env") -> str:
    try:
        with open(dotenv_path, "r", encoding="utf-8") as handle:
            for raw_line in handle:
                line = raw_line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, raw_value = line.split("=", 1)
                if key.strip() != name:
                    continue
                value = raw_value.strip()
                if not value:
                    return ""
                if value[:1] == value[-1:] and value[:1] in {"'", '"'}:
                    value = value[1:-1]
                return value.strip()
    except Exception:
        return ""
    return ""


def _load_secret(name: str) -> str:
    value = _env_value(name, default="")
    if value:
        return value
    return _read_from_dotenv(name)


def _sanitize_query(query: str) -> str:
    value = query.strip()
    if len(value) > DEFAULT_MAX_QUERY_CHARS:
        value = value[:DEFAULT_MAX_QUERY_CHARS]
    return value


def _preview(text: str, limit: int = 120) -> str:
    collapsed = " ".join(text.split())
    if len(collapsed) <= limit:
        return collapsed
    return f"{collapsed[:limit]}..."


def _is_dangerous_query(query: str) -> bool:
    lowered = query.lower()
    return any(re.search(pattern, lowered) for pattern in _DANGEROUS_QUERY_PATTERNS)


def _tool_enabled(tool_name: str, allowed_tools: set[str] | None = None) -> bool:
    normalized = tool_name.strip().lower()
    if allowed_tools is not None and normalized not in allowed_tools:
        return False
    env_key = f"AGENT_DISABLE_{normalized.upper()}"
    return not _env_flag(env_key, default=False)


def discover_available_tools(*, read_document_enabled: bool = True) -> list[ToolMetadata]:
    """发现可用工具（元信息阶段，不创建运行时实例）"""
    discovered: list[ToolMetadata] = []
    for item in _TOOL_METADATA:
        if item.name == "read_document" and not read_document_enabled:
            continue
        discovered.append(item)
    return discovered


def _get_skill_options() -> str:
    """获取可用的 skill 列表"""
    skills = discover_available_skills()
    if skills:
        return ", ".join(sorted(s.name for s in skills))
    # 回退到旧的行为
    return "summary, critical_reading, method_compare, translation, mindmap"


def _build_native_web_search_client():
    try:
        from duckduckgo_search import DDGS
    except Exception:
        return None

    class _NativeDuckDuckGoSearch:
        def run(self, query: str) -> str:
            with DDGS(timeout=int(DEFAULT_WEB_TIMEOUT_SECONDS)) as client:
                results = client.text(query, max_results=DEFAULT_WEB_MAX_RESULTS)
            return _format_web_results(results, title_key="title", url_key="href", snippet_key="body")

    return _NativeDuckDuckGoSearch()


def _parse_searxng_instances() -> list[str]:
    configured = str(os.getenv("AGENT_SEARXNG_BASE_URLS", "") or "").strip()
    if configured:
        items = [item.strip().rstrip("/") for item in configured.split(",")]
        return [item for item in items if item]
    return [item.rstrip("/") for item in DEFAULT_SEARXNG_INSTANCES]


def _format_web_results(
    results: Any,
    *,
    title_key: str,
    url_key: str,
    snippet_key: str,
) -> str:
    if not isinstance(results, list) or not results:
        return "No web search results found."
    lines: list[str] = []
    for idx, item in enumerate(results[:DEFAULT_WEB_MAX_RESULTS], start=1):
        if not isinstance(item, dict):
            continue
        title = str(item.get(title_key) or "").strip()
        href = str(item.get(url_key) or "").strip()
        body = str(item.get(snippet_key) or "").strip()
        snippet = _preview(body, limit=180) if body else ""
        lines.append(
            f"{idx}. {title or 'Untitled'}\nURL: {href or 'n/a'}\nSnippet: {snippet or '-'}"
        )
    return "\n\n".join(lines) if lines else "No web search results found."


def _build_searxng_web_search_client():
    instances = _parse_searxng_instances()
    if not instances:
        return None

    class _SearxngWebSearch:
        def __init__(self, base_urls: list[str]):
            self.base_urls = base_urls

        def run(self, query: str) -> str:
            last_error = "unknown error"
            for base_url in self.base_urls:
                try:
                    response = httpx.get(
                        f"{base_url}/search",
                        params={
                            "q": query,
                            "format": "json",
                            "safesearch": "1",
                        },
                        headers={"User-Agent": "llm-app/1.0 (+search_web)"},
                        timeout=DEFAULT_WEB_TIMEOUT_SECONDS,
                    )
                    if response.status_code >= 400:
                        last_error = f"{base_url} status={response.status_code}"
                        continue
                    payload = response.json()
                    if not isinstance(payload, dict):
                        last_error = f"{base_url} invalid json payload"
                        continue
                    results = payload.get("results")
                    rendered = _format_web_results(
                        results,
                        title_key="title",
                        url_key="url",
                        snippet_key="content",
                    )
                    if rendered != "No web search results found.":
                        return rendered
                    last_error = f"{base_url} no results"
                except Exception as exc:
                    last_error = f"{base_url} {exc}"
                    continue
            raise RuntimeError(f"SearXNG unavailable: {last_error}")

    return _SearxngWebSearch(instances)


def _build_wikipedia_web_search_client():
    class _WikipediaWebSearch:
        def run(self, query: str) -> str:
            response = httpx.get(
                "https://en.wikipedia.org/w/api.php",
                params={
                    "action": "query",
                    "list": "search",
                    "srsearch": query,
                    "format": "json",
                    "srlimit": DEFAULT_WEB_MAX_RESULTS,
                },
                headers={"User-Agent": "llm-app/1.0 (+search_web)"},
                timeout=DEFAULT_WEB_TIMEOUT_SECONDS,
            )
            if response.status_code >= 400:
                raise RuntimeError(f"wikipedia status={response.status_code}")
            payload = response.json()
            if not isinstance(payload, dict):
                raise RuntimeError("wikipedia invalid payload")
            query_block = payload.get("query")
            search_items = query_block.get("search") if isinstance(query_block, dict) else None
            if not isinstance(search_items, list):
                return "No web search results found."
            normalized: list[dict[str, str]] = []
            for item in search_items[:DEFAULT_WEB_MAX_RESULTS]:
                if not isinstance(item, dict):
                    continue
                title = str(item.get("title") or "").strip()
                snippet = str(item.get("snippet") or "").strip()
                snippet = re.sub(r"<[^>]+>", "", snippet)
                pageid = item.get("pageid")
                if isinstance(pageid, int):
                    url = f"https://en.wikipedia.org/?curid={pageid}"
                else:
                    url = f"https://en.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}"
                normalized.append(
                    {
                        "title": title or "Untitled",
                        "url": url,
                        "content": snippet,
                    }
                )
            return _format_web_results(
                normalized,
                title_key="title",
                url_key="url",
                snippet_key="content",
            )

    return _WikipediaWebSearch()


def _build_brave_web_search_client():
    api_key = _load_secret("BRAVE_SEARCH_API_KEY")
    if not api_key:
        return None
    search_url = _env_value("BRAVE_SEARCH_URL", default=DEFAULT_BRAVE_SEARCH_URL)

    class _BraveWebSearch:
        def __init__(self, key: str, url: str):
            self.api_key = key
            self.search_url = url

        def run(self, query: str) -> str:
            response = httpx.get(
                self.search_url,
                params={
                    "q": query,
                    "count": DEFAULT_WEB_MAX_RESULTS,
                },
                headers={
                    "X-Subscription-Token": self.api_key,
                    "Accept": "application/json",
                    "User-Agent": "llm-app/1.0 (+search_web)",
                },
                timeout=DEFAULT_WEB_TIMEOUT_SECONDS,
            )
            if response.status_code >= 400:
                raise RuntimeError(f"brave status={response.status_code}")
            payload = response.json()
            if not isinstance(payload, dict):
                raise RuntimeError("brave invalid payload")
            web_block = payload.get("web")
            results = web_block.get("results") if isinstance(web_block, dict) else None
            return _format_web_results(
                results,
                title_key="title",
                url_key="url",
                snippet_key="description",
            )

    return _BraveWebSearch(api_key, search_url)


def build_agent_tools(
    search_document_fn: Callable[[str], str],
    search_document_evidence_fn: Callable[[str], dict[str, Any]] | None = None,
    read_document_fn: Callable[[int, int], tuple[str, int]] | None = None,
    allowed_tools: set[str] | None = None,
):
    """构建 Agent 工具集

    Args:
        search_document_fn: RAG 检索函数
        read_document_fn: 分块读取函数，接收 (offset, limit)，返回 (content, total_length)
    """
    if allowed_tools is None:
        normalized_allowlist = None
    else:
        normalized_allowlist = {name.strip().lower() for name in allowed_tools if name.strip()}

    discovered_tools = discover_available_tools(read_document_enabled=read_document_fn is not None)
    enabled_tool_names = {
        item.name for item in discovered_tools if _tool_enabled(item.name, normalized_allowlist)
    }

    # 渐进式加载：web provider 在首次调用 web search 时再初始化。
    web_search_clients: list[tuple[str, Any]] | None = None

    def _ensure_web_search_clients() -> list[tuple[str, Any]]:
        nonlocal web_search_clients
        if web_search_clients is not None:
            return web_search_clients

        clients: list[tuple[str, Any]] = []

        brave_client = _build_brave_web_search_client()
        if brave_client is not None:
            clients.append(("brave_search_api", brave_client))
            logger.info("tool.search_web provider initialized: brave_search_api")

        searxng_client = _build_searxng_web_search_client()
        if searxng_client is not None:
            clients.append(("searxng_public_pool", searxng_client))
            logger.info("tool.search_web provider initialized: searxng_public_pool")

        wikipedia_client = _build_wikipedia_web_search_client()
        if wikipedia_client is not None:
            clients.append(("wikipedia_api", wikipedia_client))
            logger.info("tool.search_web provider initialized: wikipedia_api")

        if not clients:
            logger.warning("tool.search_web no primary provider initialized")

        if not clients:
            allow_ddg_fallback = _env_flag("AGENT_WEB_ENABLE_DDG_FALLBACK", default=False)
            if allow_ddg_fallback:
                fallback_client = _build_native_web_search_client()
                if fallback_client is not None:
                    clients.append(("native_duckduckgo_search", fallback_client))
                    logger.info("tool.search_web provider fallback initialized: native_duckduckgo_search")
                else:
                    try:
                        fallback_client = DuckDuckGoSearchRun(name="search_web")
                        clients.append(("langchain_duckduckgo", fallback_client))
                        logger.info("tool.search_web provider fallback initialized: langchain_duckduckgo")
                    except Exception as exc:
                        logger.warning("tool.search_web ddg fallback unavailable: %s", exc)
            else:
                logger.warning(
                    "tool.search_web provider unavailable: searxng disabled/unreachable and ddg fallback disabled"
                )

        web_search_clients = clients
        return web_search_clients

    def _run_web_search_internal(query: str) -> tuple[str | None, str | None, str | None]:
        clients = _ensure_web_search_clients()
        if not clients:
            return (
                None,
                None,
                (
                    "Web search is unavailable in current environment. "
                    "Set BRAVE_SEARCH_API_KEY in .env, or configure AGENT_SEARXNG_BASE_URLS, "
                    "or enable AGENT_WEB_ENABLE_DDG_FALLBACK=1."
                ),
            )
        errors: list[str] = []
        for provider_name, provider in clients:
            try:
                response = provider.run(query)
                response_text = response if isinstance(response, str) else str(response)
                if not response_text or response_text.strip() == "No web search results found.":
                    errors.append(f"{provider_name}: no results")
                    continue
                return provider_name, response_text, None
            except Exception as exc:
                errors.append(f"{provider_name}: {exc}")
                logger.info("tool.search_web provider failed: %s (%s)", provider_name, exc)
        return None, None, f"Web search failed: {' | '.join(errors)}"

    def _run_paper_search_internal(query: str, limit: int) -> tuple[list[dict[str, Any]], str | None]:
        try:
            papers = search_semantic_scholar(query=query, limit=limit)
        except ScholarlySearchError as exc:
            logger.warning("tool.search_papers failed: %s", exc)
            return [], f"Academic search failed: {exc}"
        return papers, None

    tools = []

    if "search_document" in enabled_tool_names:
        @tool(
            "search_document",
            description="Search uploaded paper content for relevant evidence snippets using RAG.",
            args_schema=SearchDocumentInput,
        )
        def search_document(query: str) -> str:
            safe_query = _sanitize_query(query)
            logger.info(
                "tool.search_document called: query_len=%s query_preview=%s",
                len(safe_query),
                _preview(safe_query),
            )
            if not safe_query:
                logger.warning("tool.search_document blocked: empty query after sanitization")
                return "Document search query is empty after sanitization."
            if _is_dangerous_query(safe_query):
                logger.warning("tool.search_document blocked by policy")
                return "Blocked by tool policy: query appears unsafe for document search."
            if search_document_evidence_fn is not None:
                try:
                    evidence_payload = search_document_evidence_fn(safe_query)
                    evidence_count = (
                        len(evidence_payload.get("evidences", []))
                        if isinstance(evidence_payload, dict)
                        else 0
                    )
                    logger.info("tool.search_document success: mode=evidence_json evidences=%s", evidence_count)
                    return json.dumps(evidence_payload, ensure_ascii=False)
                except Exception:
                    logger.exception("tool.search_document evidence function failed, fallback=text")
                    # 回退到文本输出，避免工具失败影响主流程
                    return search_document_fn(safe_query)
            logger.info("tool.search_document success: mode=text")
            return search_document_fn(safe_query)

        tools.append(search_document)
    tools.extend(build_local_ops_tools(enabled_tool_names=enabled_tool_names))

    if "read_document" in enabled_tool_names and read_document_fn is not None:
        @tool(
            "read_document",
            description="Read a specific portion of the uploaded paper with pagination. "
            "Use offset to skip to a position, limit to control chunk size. "
            "Set include_rag=True to get relevant context around the position.",
            args_schema=ReadDocumentInput,
        )
        def read_document(offset: int = 0, limit: int = 2000, include_rag: bool = False) -> str:
            logger.info(
                "tool.read_document called: offset=%s limit=%s include_rag=%s",
                offset,
                limit,
                include_rag,
            )
            content, total = read_document_fn(offset, limit)

            # 如果启用 RAG，获取相关上下文
            rag_context = ""
            if include_rag:
                # 基于位置获取相关段落
                # 计算当前位置的大致位置（用于检索）
                query = f"position_{offset}"
                rag_context = search_document_fn(query)

            result = f"=== 文档阅读 (字符位置 {offset} - {offset + limit}) ===\n"
            result += f"总长度: {total} 字符\n"
            result += f"当前 chunk: {len(content)} 字符\n\n"
            if rag_context:
                result += f"=== 相关上下文 (RAG) ===\n{rag_context}\n\n"
            result += f"=== 内容 ===\n{content}"
            logger.info("tool.read_document success: chunk_len=%s total_len=%s", len(content), total)
            return result

        tools.append(read_document)

    if "search_web" in enabled_tool_names:
        @tool(
            "search_web",
            description="Search public web content for supplemental context.",
            args_schema=SearchWebInput,
        )
        def search_web(query: str) -> str:
            safe_query = _sanitize_query(query)
            logger.info(
                "tool.search_web called: query_len=%s query_preview=%s",
                len(safe_query),
                _preview(safe_query),
            )
            if not safe_query:
                logger.warning("tool.search_web blocked: empty query after sanitization")
                return "Web search query is empty after sanitization."
            if _is_dangerous_query(safe_query):
                logger.warning("tool.search_web blocked by policy")
                return "Blocked by tool policy: query appears unsafe for web search."
            provider_name, response_text, error_text = _run_web_search_internal(safe_query)
            if response_text is None:
                logger.warning("tool.search_web unavailable: %s", error_text)
                return str(error_text or "Web search failed.")
            logger.info(
                "tool.search_web success: provider=%s response_len=%s",
                provider_name,
                len(response_text),
            )
            return response_text

        tools.append(search_web)

    if "search_papers" in enabled_tool_names:
        @tool(
            "search_papers",
            description="Search academic papers from scholarly providers when document evidence is insufficient.",
            args_schema=SearchPapersInput,
        )
        def search_papers(query: str, limit: int = 5) -> str:
            safe_query = _sanitize_query(query)
            logger.info(
                "tool.search_papers called: limit=%s query_len=%s query_preview=%s",
                limit,
                len(safe_query),
                _preview(safe_query),
            )
            if not safe_query:
                logger.warning("tool.search_papers blocked: empty query after sanitization")
                return "Academic search query is empty after sanitization."
            if _is_dangerous_query(safe_query):
                logger.warning("tool.search_papers blocked by policy")
                return "Blocked by tool policy: query appears unsafe for academic search."
            papers, error_text = _run_paper_search_internal(safe_query, limit)
            if error_text is not None:
                return f"{error_text} Try narrowing the topic or use search_web."
            logger.info("tool.search_papers success: results=%s", len(papers))
            return format_search_papers_results(papers)

        tools.append(search_papers)

    if "use_skill" in enabled_tool_names:
        @tool(
            "use_skill",
            description="Apply a named skill template to the current task and return operational guidance.",
            args_schema=SkillInput,
        )
        def use_skill(skill_name: str, task: str) -> str:
            normalized_name = skill_name.strip().lower()
            logger.info("tool.use_skill called: skill=%s task_len=%s", normalized_name, len(task))
            if _is_dangerous_query(task):
                logger.warning("tool.use_skill blocked by policy")
                return "Blocked by tool policy: task appears unsafe."

            runtime_payload = build_skill_runtime_payload(
                normalized_name,
                task=task,
                max_references=2,
                reference_char_limit=1800,
            )
            if runtime_payload is not None:
                logger.info("tool.use_skill success: skill=%s", normalized_name)
                parts: list[str] = [
                    f"Skill: {runtime_payload['name']}",
                    f"Description: {runtime_payload['description']}",
                    "",
                    "Instructions:",
                    str(runtime_payload["instructions"]),
                ]

                references = runtime_payload.get("references", [])
                if isinstance(references, list) and references:
                    parts.extend(["", "Selected references:"])
                    for item in references:
                        if not isinstance(item, dict):
                            continue
                        path_value = str(item.get("path") or "").strip()
                        content_value = str(item.get("content") or "").strip()
                        if not path_value or not content_value:
                            continue
                        parts.append(f"- {path_value}")
                        parts.append(content_value)

                scripts = runtime_payload.get("scripts", [])
                if isinstance(scripts, list) and scripts:
                    parts.extend(["", "Available scripts:"])
                    parts.extend(
                        f"- {str(item)}" for item in scripts if str(item).strip()
                    )

                agent_metadata = runtime_payload.get("agent_metadata")
                if isinstance(agent_metadata, str) and agent_metadata.strip():
                    parts.extend(["", f"Agent metadata: {agent_metadata}"])

                parts.extend(["", f"Task: {task}"])
                return "\n".join(parts)

            # 如果没找到，返回错误信息
            options = _get_skill_options()
            logger.warning("tool.use_skill unknown skill: %s", normalized_name)
            return f"Unknown skill '{skill_name}'. Available skills: {options}."

        tools.append(use_skill)

    logger.info(
        "Agent tools prepared: discovered=%s enabled=%s names=%s",
        len(discovered_tools),
        len(tools),
        ",".join(tool_item.name for tool_item in tools),
    )
    return tools
