#!/usr/bin/env python3
"""Mechanical web-fetch helper for the /research skill.

Talks to the local SearXNG (search) and Firecrawl (scrape) services. Stdlib
only — no third-party runtime dependencies. The /research skill (Claude) does
all the thinking; this file only fetches and parses.

Endpoints resolve from env (WEBTOOLS_HOST / SEARXNG_PORT / FIRECRAWL_PORT) with
defaults for the `webtools` box. Any failure exits nonzero with a stderr message
so the calling skill can react.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


class WebToolsError(Exception):
    """Raised on any unrecoverable search/scrape failure."""


def endpoints() -> tuple[str, str]:
    """Return (searxng_base, firecrawl_base) from env, with webtools defaults."""
    host = os.environ.get("WEBTOOLS_HOST", "10.10.20.37")
    searx_port = os.environ.get("SEARXNG_PORT", "8080")
    fire_port = os.environ.get("FIRECRAWL_PORT", "3002")
    return (f"http://{host}:{searx_port}", f"http://{host}:{fire_port}")


def _request(url, *, data=None, headers=None, timeout=30, retries=2, backoff=1.5):
    """GET (data=None) or POST JSON; parse JSON; retry transient network errors.

    Network failures (URLError/timeout) are retried with backoff. A non-JSON
    response is NOT retried — it is almost always a deterministic
    misconfiguration (e.g. SearXNG json format disabled), so we fail fast with
    a clear message.
    """
    headers = dict(headers or {})
    body = None
    if data is not None:
        body = json.dumps(data).encode("utf-8")
        headers.setdefault("Content-Type", "application/json")

    last = None
    for attempt in range(retries + 1):
        try:
            req = urllib.request.Request(
                url, data=body, headers=headers,
                method="POST" if body is not None else "GET",
            )
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                raw = resp.read()
            break
        except (urllib.error.URLError, TimeoutError) as exc:
            last = exc
            if attempt < retries:
                time.sleep(backoff ** attempt)
    else:
        raise WebToolsError(f"request failed after {retries + 1} attempt(s): {url}: {last}")

    try:
        return json.loads(raw.decode("utf-8"))
    except ValueError as exc:
        raise WebToolsError(
            f"non-JSON response from {url} (is SearXNG json format enabled?): {exc}"
        )


def search(query, max_results=12, timeout=30):
    """Query SearXNG and return up to max_results normalized hits."""
    searx_base, _ = endpoints()
    qs = urllib.parse.urlencode({"q": query, "format": "json"})
    payload = _request(f"{searx_base}/search?{qs}", timeout=timeout)
    hits = []
    for r in payload.get("results", []):
        url = r.get("url")
        if not url:
            continue
        hits.append({
            "title": r.get("title", ""),
            "url": url,
            "snippet": r.get("content", ""),
            "engine": r.get("engine", ""),
        })
    return hits[:max_results]


def scrape(url, timeout=60):
    """Scrape a URL to markdown via Firecrawl. Raises WebToolsError on failure."""
    _, fire_base = endpoints()
    payload = _request(
        f"{fire_base}/v1/scrape",
        data={"url": url, "formats": ["markdown"]},
        timeout=timeout,
    )
    if not payload.get("success", True):
        raise WebToolsError(f"firecrawl failed for {url}: {payload.get('error')}")
    markdown = (payload.get("data") or {}).get("markdown", "")
    if not markdown:
        raise WebToolsError(f"firecrawl returned no markdown for {url}")
    return markdown


def main(argv=None):
    parser = argparse.ArgumentParser(
        prog="web.py",
        description="Search (SearXNG) and scrape (Firecrawl) on the webtools box.",
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("search", help="search SearXNG")
    sp.add_argument("query")
    sp.add_argument("--max", type=int, default=12, help="max results (default 12)")
    sp.add_argument("--json", action="store_true", help="emit JSON")

    cp = sub.add_parser("scrape", help="scrape a URL to markdown")
    cp.add_argument("url")
    cp.add_argument("--out", help="write markdown to this path instead of stdout")

    args = parser.parse_args(argv)

    try:
        if args.cmd == "search":
            hits = search(args.query, max_results=args.max)
            if args.json:
                print(json.dumps(hits, indent=2))
            else:
                for h in hits:
                    print(f"{h['title']}\n  {h['url']}\n  {h['snippet'][:160]}\n")
        elif args.cmd == "scrape":
            markdown = scrape(args.url)
            if args.out:
                Path(args.out).write_text(markdown, encoding="utf-8")
                print(f"wrote {args.out}")
            else:
                print(markdown)
    except WebToolsError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
