"""Web toolkits: web_search and web_fetch."""

import html
import ipaddress
import json
import os
import re
from typing import Any
from urllib.parse import urlparse

import httpx
from agno.tools import Toolkit
from loguru import logger

# Shared constants
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36"
MAX_REDIRECTS = 5  # Limit redirects to prevent DoS attacks
SEARCH_TIMEOUT = 10.0
FETCH_TIMEOUT = 30.0
MAX_FETCH_BYTES_HARD_LIMIT = 2_000_000  # 2MB raw response cap


def _strip_tags(text: str) -> str:
    """Remove HTML tags and decode entities."""
    text = re.sub(r'<script[\s\S]*?</script>', '', text, flags=re.I)
    text = re.sub(r'<style[\s\S]*?</style>', '', text, flags=re.I)
    text = re.sub(r'<[^>]+>', '', text)
    return html.unescape(text).strip()


def _normalize(text: str) -> str:
    """Normalize whitespace."""
    text = re.sub(r'[ \t]+', ' ', text)
    return re.sub(r'\n{3,}', '\n\n', text).strip()


def _validate_url(url: str) -> tuple[bool, str]:
    """Validate URL: must be http(s), non-localhost, non-private host."""
    try:
        p = urlparse(url)
        if p.scheme not in ("http", "https"):
            return False, f"Only http/https allowed, got '{p.scheme or 'none'}'"
        if not p.netloc or not p.hostname:
            return False, "Missing domain"

        host = p.hostname.strip().lower()
        if host in {"localhost", "127.0.0.1", "::1"}:
            return False, "Localhost is not allowed"

        # Block direct private/link-local/loopback IP fetches (SSRF guard).
        try:
            ip = ipaddress.ip_address(host)
            if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
                return False, "Private or local IP targets are not allowed"
        except ValueError:
            # Host is a domain, acceptable here.
            pass

        return True, ""
    except Exception as e:
        return False, str(e)


def _error_json(url: str, message: str) -> str:
    return json.dumps({"error": message, "url": url}, ensure_ascii=False)


class WebToolkit(Toolkit):
    """Toolkit for web search and URL fetch."""

    def __init__(
        self,
        api_key: str | None = None,
        max_results: int = 5,
        max_chars: int = 50000,
        proxy: str | None = None,
    ):
        self._init_api_key = api_key
        self.max_results = max_results
        self.max_chars = max_chars
        self.proxy = proxy
        tools = [self.web_fetch]
        super().__init__(name="web_tools", tools=tools)

    @property
    def api_key(self) -> str:
        """Resolve API key at call time so env/config changes are picked up."""
        return self._init_api_key or os.environ.get("BRAVE_API_KEY", "")

    async def web_search(self, query: str, count: int | None = None) -> str:
        query = query.strip()
        if not query:
            return "Error: query cannot be empty"
        if not self.api_key:
            return (
                "Error: Brave Search API key not configured. Set it in "
                "~/.asedclaw/config.json under tools.web.search.apiKey "
                "(or export BRAVE_API_KEY), then restart the gateway."
            )

        try:
            n = min(max(count or self.max_results, 1), 10)
            logger.debug("WebSearch: {}", "proxy enabled" if self.proxy else "direct connection")
            async with httpx.AsyncClient(proxy=self.proxy, timeout=SEARCH_TIMEOUT) as client:
                r = await client.get(
                    "https://api.search.brave.com/res/v1/web/search",
                    params={"q": query, "count": n},
                    headers={
                        "Accept": "application/json",
                        "X-Subscription-Token": self.api_key,
                        "User-Agent": USER_AGENT,
                    },
                )
                r.raise_for_status()

            payload = r.json()
            results = payload.get("web", {}).get("results", [])[:n]
            if not results:
                return f"No results for: {query}"

            lines = [f"Results for: {query}\n"]
            for i, item in enumerate(results, 1):
                lines.append(f"{i}. {item.get('title', '')}\n   {item.get('url', '')}")
                if desc := item.get("description"):
                    lines.append(f"   {desc}")
            return "\n".join(lines)
        except httpx.HTTPStatusError as e:
            logger.error("WebSearch HTTP error: {}", e)
            return f"Error: Web search request failed ({e.response.status_code})"
        except httpx.TimeoutException:
            logger.error("WebSearch timeout")
            return "Error: Web search request timed out"
        except httpx.ProxyError as e:
            logger.error("WebSearch proxy error: {}", e)
            return f"Proxy error: {e}"
        except Exception as e:
            logger.error("WebSearch error: {}", e)
            return f"Error: {e}"

    async def web_fetch(self, url: str, extractMode: str = "markdown", maxChars: int | None = None) -> str:
        mode = (extractMode or "markdown").lower()
        if mode not in {"markdown", "text"}:
            return _error_json(url, f"Invalid extractMode '{extractMode}', expected 'markdown' or 'text'")

        max_chars = max(100, min(maxChars or self.max_chars, 200_000))
        # Keep raw fetch bounded by bytes as well as output chars.
        max_fetch_bytes = min(max(max_chars * 4, 64_000), MAX_FETCH_BYTES_HARD_LIMIT)
        is_valid, error_msg = _validate_url(url)
        if not is_valid:
            return _error_json(url, f"URL validation failed: {error_msg}")

        try:
            logger.debug("WebFetch: {}", "proxy enabled" if self.proxy else "direct connection")
            async with httpx.AsyncClient(
                follow_redirects=True,
                max_redirects=MAX_REDIRECTS,
                timeout=FETCH_TIMEOUT,
                proxy=self.proxy,
            ) as client:
                async with client.stream("GET", url, headers={"User-Agent": USER_AGENT}) as r:
                    r.raise_for_status()
                    ctype = r.headers.get("content-type", "").lower()
                    chunks: list[bytes] = []
                    total = 0
                    async for chunk in r.aiter_bytes():
                        if not chunk:
                            continue
                        chunks.append(chunk)
                        total += len(chunk)
                        if total > max_fetch_bytes:
                            break
                    body = b"".join(chunks)
                    body_truncated = total > max_fetch_bytes
                    final_url = str(r.url)
                    status_code = r.status_code

            if body_truncated:
                logger.warning("WebFetch response truncated at {} bytes for {}", max_fetch_bytes, url)

            if "application/json" in ctype:
                try:
                    obj = json.loads(body.decode("utf-8", errors="replace"))
                    text, extractor = json.dumps(obj, indent=2, ensure_ascii=False), "json"
                except Exception:
                    text, extractor = body.decode("utf-8", errors="replace"), "raw"
            else:
                decoded = body.decode("utf-8", errors="replace")
                if "text/html" in ctype or decoded[:256].lower().startswith(("<!doctype", "<html")):
                    try:
                        from readability import Document
                    except Exception:
                        return _error_json(url, "readability dependency is not installed")
                    doc = Document(decoded)
                    summary = doc.summary()
                    content = self._to_markdown(summary) if mode == "markdown" else _strip_tags(summary)
                    text = f"# {doc.title()}\n\n{content}" if doc.title() else content
                    extractor = "readability"
                else:
                    text, extractor = decoded, "raw"

            output_truncated = len(text) > max_chars
            if output_truncated:
                text = text[:max_chars]

            return json.dumps(
                {
                    "url": url,
                    "finalUrl": final_url,
                    "status": status_code,
                    "extractor": extractor,
                    "truncated": body_truncated or output_truncated,
                    "length": len(text),
                    "text": text,
                },
                ensure_ascii=False,
            )
        except httpx.HTTPStatusError as e:
            logger.error("WebFetch HTTP error for {}: {}", url, e)
            return _error_json(url, f"HTTP error: {e.response.status_code}")
        except httpx.TimeoutException:
            logger.error("WebFetch timeout for {}", url)
            return _error_json(url, "Request timed out")
        except httpx.ProxyError as e:
            logger.error("WebFetch proxy error for {}: {}", url, e)
            return _error_json(url, f"Proxy error: {e}")
        except Exception as e:
            logger.error("WebFetch error for {}: {}", url, e)
            return _error_json(url, str(e))

    def _to_markdown(self, html: str) -> str:
        """Convert HTML to markdown."""
        # Convert links, headings, lists before stripping tags
        text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</a>',
                      lambda m: f'[{_strip_tags(m[2])}]({m[1]})', html, flags=re.I)
        text = re.sub(r'<h([1-6])[^>]*>([\s\S]*?)</h\1>',
                      lambda m: f'\n{"#" * int(m[1])} {_strip_tags(m[2])}\n', text, flags=re.I)
        text = re.sub(r'<li[^>]*>([\s\S]*?)</li>', lambda m: f'\n- {_strip_tags(m[1])}', text, flags=re.I)
        text = re.sub(r'</(p|div|section|article)>', '\n\n', text, flags=re.I)
        text = re.sub(r'<(br|hr)\s*/?>', '\n', text, flags=re.I)
        return _normalize(_strip_tags(text))
