#!/usr/bin/env python3
"""web/search - web search through the self-hosted SearXNG at search.ops.io.

  bin/web/search "LadybugDB vector search"
  bin/web/search "model2vec multilingual" --site github.com
  bin/web/search "uclancy" --category it -n 3 --json | jq -r '.results[].url'
  bin/web/search "sqlite-vec" --refresh          # ignore the cached answer

This complements bin/kb/search: the knowledge base holds our own facts, this
reaches the public web. Use it as the second, independent source that the
detective method asks for.

Exit codes: 0 results, 2 refused as possible PII, 3 throttled (not "nothing
found" - the instance answers 200 with an empty list when it throttles).
"""
from __future__ import annotations

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

TOOLS = Path(__file__).resolve().parents[1] / "tools"
sys.path.insert(0, str(TOOLS))
sys.path.insert(0, str(TOOLS / "web-search"))

import websearch as ws          # noqa: E402
from yamlout import to_yaml     # noqa: E402

CONFIG = Path(os.environ.get("BRAIN_SEARCH_ENV", Path.home() / ".config/brain/search.env"))
CACHE = Path(os.environ.get("BRAIN_SEARCH_CACHE", Path.home() / ".cache/brain/web-search.sqlite"))
LOCK = CACHE.with_suffix(".lock")


def load_config() -> dict:
    if not CONFIG.exists():
        sys.exit(f"no credentials at {CONFIG} (mode 600, BRAIN_SEARCH_URL/USER/PASS)")
    conf = {}
    for line in CONFIG.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        conf[key.strip()] = value.strip().strip("\"'")
    missing = {"BRAIN_SEARCH_URL", "BRAIN_SEARCH_USER", "BRAIN_SEARCH_PASS"} - conf.keys()
    if missing:
        sys.exit(f"{CONFIG} is missing {', '.join(sorted(missing))}")
    return conf


def fetch(conf: dict, query: str, params: dict, timeout: int) -> dict:
    args = {"q": query, "format": "json", **params}
    url = f"{conf['BRAIN_SEARCH_URL'].rstrip('/')}/search?{urllib.parse.urlencode(args)}"
    request = urllib.request.Request(url)
    token = f"{conf['BRAIN_SEARCH_USER']}:{conf['BRAIN_SEARCH_PASS']}".encode()
    import base64
    request.add_header("Authorization", "Basic " + base64.b64encode(token).decode())
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode())


def main() -> int:
    parser = argparse.ArgumentParser(description="web search via SearXNG")
    parser.add_argument("query")
    parser.add_argument("-n", "--limit", type=int, default=ws.DEFAULT_LIMIT)
    parser.add_argument("--site", help="restrict to one domain")
    parser.add_argument("--lang", help="language code, e.g. de")
    parser.add_argument("--fresh", choices=["day", "week", "month", "year"],
                        help="time range")
    parser.add_argument("--category", help="SearXNG category, e.g. it, science, news")
    parser.add_argument("--engines", help="comma separated engine list")
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--refresh", action="store_true", help="bypass the cache")
    parser.add_argument("--ttl", type=float, default=ws.CACHE_TTL)
    parser.add_argument("--timeout", type=int, default=25)
    parser.add_argument("--force", action="store_true",
                        help="send even if the query looks like PII")
    args = parser.parse_args()

    query = f"site:{args.site} {args.query}" if args.site else args.query

    reason = ws.phi_reason(query)
    if reason and not args.force:
        print(f"refused: {reason}. This query would leave the host.", file=sys.stderr)
        print("Rephrase without identifiers, or pass --force if it is genuinely public.",
              file=sys.stderr)
        return 2

    params = {}
    if args.lang:
        params["language"] = args.lang
    if args.fresh:
        params["time_range"] = args.fresh
    if args.category:
        params["categories"] = args.category
    if args.engines:
        params["engines"] = args.engines

    key = ws.cache_key(query, params)
    conn = ws.open_cache(CACHE)

    if not args.refresh:
        cached = ws.cache_get(conn, key, ttl=args.ttl)
        if cached is not None:
            out = ws.project(cached, limit=args.limit)
            out["cached"] = True
            sys.stdout.write(json.dumps(out, indent=2, ensure_ascii=False) + "\n"
                             if args.json else to_yaml(out))
            return 0

    conf = load_config()
    LOCK.parent.mkdir(parents=True, exist_ok=True)

    # One request at a time across every agent on this host: the instance
    # suspends engines for minutes when several of us ask at once.
    with open(LOCK, "w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)

        payload = None
        for attempt in range(1 + len(ws.RETRY_BACKOFF)):
            delay = ws.wait_for(ws.last_call(conn), time.time())
            if delay:
                time.sleep(delay)
            ws.mark_call(conn)
            try:
                payload = fetch(conf, query, params, args.timeout)
            except Exception as error:              # noqa: BLE001 - report, do not crash
                print(f"request failed: {error}", file=sys.stderr)
                return 3
            if ws.classify(payload) == "ok":
                break
            if attempt < len(ws.RETRY_BACKOFF):
                time.sleep(ws.RETRY_BACKOFF[attempt])

    if ws.classify(payload) == "ok":
        ws.cache_put(conn, key, payload)

    out = ws.project(payload, limit=args.limit)
    sys.stdout.write(json.dumps(out, indent=2, ensure_ascii=False) + "\n"
                     if args.json else to_yaml(out))
    return 0 if out["status"] == "ok" else 3


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