"""搜索抽象 — mock · AnySearch CLI · allowlist。"""

from __future__ import annotations

import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from urllib.parse import urlparse


@dataclass(frozen=True)
class SearchHit:
    title: str
    url: str
    snippet: str


class SearchProvider(Protocol):
    def search(self, query: str, *, limit: int = 3) -> list[SearchHit]: ...


class StaticSearchProvider:
    """测试与离线 mock。"""

    def __init__(self, hits: list[SearchHit]) -> None:
        self._hits = hits

    def search(self, query: str, *, limit: int = 3) -> list[SearchHit]:
        return self._hits[:limit]


def hits_from_mock_cfg(mock: dict[str, Any]) -> list[SearchHit]:
    hits: list[SearchHit] = []
    for row in mock.get("hits") or []:
        if not isinstance(row, dict):
            continue
        hits.append(
            SearchHit(
                title=str(row.get("title") or "mock"),
                url=str(row.get("url") or "https://example.com/mock"),
                snippet=str(row.get("snippet") or ""),
            )
        )
    return hits


def build_mock_search_provider(mock: dict[str, Any]) -> SearchProvider:
    return StaticSearchProvider(hits_from_mock_cfg(mock))


def default_anysearch_cli_path() -> Path:
    home = Path.home()
    candidates = [
        home / ".cursor/skills/anysearch/scripts/anysearch_cli.py",
        home / ".codex/skills/anysearch/scripts/anysearch_cli.py",
    ]
    for path in candidates:
        if path.is_file():
            return path
    return candidates[0]


def parse_anysearch_markdown(text: str) -> list[SearchHit]:
    """解析 AnySearch CLI search 输出的 markdown 结果块。"""
    hits: list[SearchHit] = []
    title: str | None = None
    url: str | None = None
    snippet_lines: list[str] = []

    def flush() -> None:
        nonlocal title, url, snippet_lines
        if title and url:
            snippet = " ".join(snippet_lines).strip()
            hits.append(SearchHit(title=title, url=url, snippet=snippet[:500]))
        title = None
        url = None
        snippet_lines = []

    for raw in text.splitlines():
        line = raw.strip()
        title_m = re.match(r"^###\s+\d+\.\s+(.+)$", line)
        if title_m:
            flush()
            title = title_m.group(1).strip()
            continue
        url_m = re.match(r"^-\s*\*\*URL\*\*:\s*(.+)$", line, flags=re.I)
        if url_m and title:
            url = url_m.group(1).strip()
            continue
        if title and url and line and not line.startswith("#"):
            snippet_lines.append(line.lstrip("- ").strip())

    flush()
    return hits


def url_allowed(url: str, patterns: list[str]) -> bool:
    if not patterns:
        return True
    host = (urlparse(url).hostname or "").lower()
    if not host:
        return False
    for raw in patterns:
        pat = str(raw).strip().lower()
        if not pat:
            continue
        if pat == "*":
            return True
        if pat.startswith("*."):
            suffix = pat[2:]
            if host == suffix or host.endswith("." + suffix):
                return True
        elif host == pat or host.endswith("." + pat):
            return True
    return False


def filter_hits_by_allowlist(hits: list[SearchHit], patterns: list[str]) -> list[SearchHit]:
    if not patterns:
        return hits
    return [h for h in hits if url_allowed(h.url, patterns)]


class AnySearchCLIProvider:
    """通过 AnySearch skill CLI 联网检索（参考 anysearch skill）。"""

    def __init__(
        self,
        *,
        cli_path: str | Path | None = None,
        domain: str | None = None,
        api_key: str | None = None,
        api_key_env: str = "ANYSEARCH_API_KEY",
        timeout_sec: float = 45.0,
    ) -> None:
        path = Path(cli_path) if cli_path else default_anysearch_cli_path()
        self._cli = path
        self._domain = domain
        self._api_key = api_key if api_key is not None else os.environ.get(api_key_env, "")
        self._timeout = timeout_sec

    def search(self, query: str, *, limit: int = 3) -> list[SearchHit]:
        if not self._cli.is_file():
            return []
        cmd = [
            sys.executable,
            str(self._cli),
            "search",
            query,
            "--max_results",
            str(min(max(limit, 1), 10)),
        ]
        if self._domain:
            cmd.extend(["--domain", self._domain])
        if self._api_key:
            cmd.extend(["--api_key", self._api_key])
        try:
            proc = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=self._timeout,
                check=False,
            )
        except (OSError, subprocess.TimeoutExpired):
            return []
        if proc.returncode != 0:
            return []
        return parse_anysearch_markdown(proc.stdout)[:limit]


def build_search_provider_from_yaml(yaml_cfg: dict[str, Any]) -> SearchProvider:
    mock = yaml_cfg.get("mock_search") or {}
    if mock.get("enabled"):
        return build_mock_search_provider(mock)

    search_cfg = yaml_cfg.get("search") or {}
    provider = str(search_cfg.get("provider") or "mock").lower()
    if provider == "anysearch":
        return AnySearchCLIProvider(
            cli_path=search_cfg.get("cli_path"),
            domain=search_cfg.get("domain"),
            api_key_env=str(search_cfg.get("api_key_env") or "ANYSEARCH_API_KEY"),
        )
    return StaticSearchProvider([])
