"""HOME ``data/site_bookmarks.yaml``：站点级网址收藏（非单条文章）。"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit

import httpx
import yaml

from aicd.network_policy import outbound_tool_url_blocked_reason
from aicd.tools.web_extract import extract_main_text_from_html
from aicd.version import VERSION

SCHEMA_VERSION = 1
SITE_BOOKMARKS_BASENAME = "site_bookmarks.yaml"


def site_bookmarks_path(data_dir: Path) -> Path:
    return (data_dir / SITE_BOOKMARKS_BASENAME).resolve()


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def default_store_skeleton() -> dict[str, Any]:
    return {
        "schema_version": SCHEMA_VERSION,
        "about": (
            "站点级收藏：只应收录可提供大量检索/浏览内容的网站入口（如搜索引擎、代码托管、文档站首页），"
            "不要收录单篇文章或深层内容页 URL。由 AI 与用户通过 site_bookmarks_* 工具维护。"
        ),
        "entries": [],
    }


def ensure_site_bookmarks_file(data_dir: Path) -> Path:
    """若无则写入空清单（幂等）。"""
    data_dir.mkdir(parents=True, exist_ok=True)
    p = site_bookmarks_path(data_dir)
    if not p.is_file():
        p.write_text(
            yaml.safe_dump(
                default_store_skeleton(), allow_unicode=True, sort_keys=False
            ),
            encoding="utf-8",
        )
    return p


def load_store(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return default_store_skeleton()
    try:
        raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    except Exception:  # noqa: BLE001
        return default_store_skeleton()
    if not isinstance(raw, dict):
        return default_store_skeleton()
    if int(raw.get("schema_version") or 0) != SCHEMA_VERSION:
        raw["schema_version"] = SCHEMA_VERSION
    ent = raw.get("entries")
    if not isinstance(ent, list):
        raw["entries"] = []
    return raw


def save_store(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    data["schema_version"] = SCHEMA_VERSION
    data["updated_at_utc"] = utc_now_iso()
    with path.open("w", encoding="utf-8") as f:
        yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False)


def _host_slug(host: str) -> str:
    h = (host or "").lower().strip(".")
    s = re.sub(r"[^a-z0-9]+", "-", h).strip("-")
    return s or "site"


def normalize_site_url(
    url: str,
    *,
    strict_site_root: bool = True,
    allow_single_path_segment: bool = False,
) -> tuple[str | None, str | None]:
    """
    规范为站点入口 URL。``strict_site_root`` 时路径须为空（仅域名根）。
    ``allow_single_path_segment`` 允许单段路径（如某文档站 /docs），仍拒绝多级路径。
    """
    u = (url or "").strip()
    if not u:
        return None, "url 为空"
    if not u.startswith(("http://", "https://")):
        u = "https://" + u
    parts = urlsplit(u)
    if parts.scheme not in ("http", "https"):
        return None, "仅支持 http(s)"
    host = parts.hostname
    if not host:
        return None, "无效主机名"
    path = (parts.path or "").rstrip("/")
    segs = [x for x in path.split("/") if x]
    if strict_site_root and not allow_single_path_segment:
        if len(segs) > 0:
            return (
                None,
                "仅收录网站根入口（无路径），例如 https://github.com 或 https://huggingface.co ；"
                "单条文章或深层链接请改用书签外的检索流程。若确需单段门户路径可设 allow_single_path_segment=true",
            )
    if len(segs) > 1:
        return None, "路径层级 >1，疑似内容页；请改为站点首页或顶级入口"
    if len(segs) == 1:
        seg = segs[0]
        if len(seg) > 48:
            return None, "路径段过长，疑似 slug"
        low = seg.lower()
        if low in frozenset(
            {"post", "posts", "article", "articles", "p", "item", "story", "news", "blog"}
        ):
            return None, "该路径更像单条内容目录名，请使用站点根 URL"
        if seg.isdigit() or re.match(r"^\d{4}-\d{2}-\d{2}", seg):
            return None, "疑似文章 ID 或日期路径"

    netloc = host.lower()
    if parts.port and (
        (parts.scheme == "http" and parts.port != 80)
        or (parts.scheme == "https" and parts.port != 443)
    ):
        netloc = f"{host.lower()}:{parts.port}"
    path_out = f"/{segs[0]}" if len(segs) == 1 else "/"
    canonical = urlunsplit((parts.scheme, netloc, path_out, "", ""))
    return canonical, None


def _unique_id(base: str, existing: set[str]) -> str:
    if base not in existing:
        return base
    n = 2
    while f"{base}-{n}" in existing:
        n += 1
    return f"{base}-{n}"


def _entry_ids(entries: list[Any]) -> set[str]:
    out: set[str] = set()
    for e in entries:
        if isinstance(e, dict) and isinstance(e.get("id"), str):
            out.add(e["id"])
    return out


async def fetch_site_summary(
    canonical_url: str,
    *,
    user_agent: str,
    timeout_sec: float,
    max_bytes: int,
    summary_max_chars: int = 1200,
    block_external_network: bool = False,
) -> dict[str, Any]:
    """抓取首页正文前几段作为摘要素材（非 LLM 摘要）。"""
    br = outbound_tool_url_blocked_reason(
        canonical_url, block_external=block_external_network
    )
    if br:
        return {
            "ok": False,
            "content_summary": "",
            "error": br,
            "network_policy": "block_external_network",
        }
    headers = {
        "User-Agent": user_agent.strip()
        or f"AICD/{VERSION} (+site_bookmarks)",
        "Accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
    }
    try:
        async with httpx.AsyncClient(
            timeout=timeout_sec, headers=headers, follow_redirects=True
        ) as client:
            r = await client.get(canonical_url)
        ok = r.is_success
        body = r.content[:max_bytes]
        ct = (r.headers.get("content-type") or "").lower()
        text_out = ""
        err = None
        if ok and "html" in ct:
            html = body.decode("utf-8", errors="replace")
            text_out, meta = extract_main_text_from_html(
                html, str(r.url), max_chars=summary_max_chars
            )
            hint = meta.get("hint")
            if hint:
                err = hint
        elif ok:
            text_out = body.decode("utf-8", errors="replace")[:summary_max_chars]
        else:
            err = f"HTTP {r.status_code}"
        return {
            "ok": ok,
            "final_url": str(r.url),
            "content_summary": (text_out or "").strip(),
            "error": err,
            "truncated": len(body) >= max_bytes,
        }
    except httpx.TimeoutException:
        return {"ok": False, "content_summary": "", "error": "timeout"}
    except Exception as e:  # noqa: BLE001
        return {"ok": False, "content_summary": "", "error": str(e)}


def list_entries(
    data_dir: Path,
    *,
    tag: str | None = None,
    query: str | None = None,
) -> dict[str, Any]:
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    entries = [e for e in store.get("entries", []) if isinstance(e, dict)]
    tag_l = (tag or "").strip().lower()
    q_l = (query or "").strip().lower()
    filtered: list[dict[str, Any]] = []
    for e in entries:
        tags = e.get("tags") if isinstance(e.get("tags"), list) else []
        tag_strs = [str(t).lower() for t in tags]
        if tag_l and not any(tag_l in t for t in tag_strs):
            continue
        if q_l:
            blob = " ".join(
                [
                    str(e.get("id", "")),
                    str(e.get("display_name", "")),
                    str(e.get("canonical_url", "")),
                    str(e.get("content_summary", "")),
                    str(e.get("notes", "")),
                    " ".join(str(t) for t in tags),
                ]
            ).lower()
            if q_l not in blob:
                continue
        row = {k: v for k, v in e.items() if k != "content_summary"}
        cs = e.get("content_summary")
        if isinstance(cs, str) and cs:
            row["content_summary_preview"] = (
                cs[:400] + "…" if len(cs) > 400 else cs
            )
        filtered.append(row)
    return {
        "ok": True,
        "path": str(path),
        "count": len(filtered),
        "entries": filtered,
    }


def get_entry(data_dir: Path, *, entry_id: str | None, url: str | None) -> dict[str, Any]:
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    entries = store.get("entries", [])
    if not isinstance(entries, list):
        return {"ok": False, "error": "invalid store"}
    eid = (entry_id or "").strip()
    if not eid and not (url and str(url).strip()):
        return {"ok": False, "error": "需要 entry_id 或 url"}
    u_norm = None
    if url and str(url).strip():
        u_norm, err = normalize_site_url(
            str(url), strict_site_root=False, allow_single_path_segment=True
        )
        if err:
            return {"ok": False, "error": err}
    for e in entries:
        if not isinstance(e, dict):
            continue
        if eid and e.get("id") == eid:
            return {"ok": True, "entry": e, "path": str(path)}
        if u_norm and e.get("canonical_url") == u_norm:
            return {"ok": True, "entry": e, "path": str(path)}
    return {"ok": False, "error": "not found"}


async def add_entry(
    data_dir: Path,
    *,
    url: str,
    display_name: str | None = None,
    tags: list[str] | None = None,
    notes: str | None = None,
    strict_site_root: bool = True,
    allow_single_path_segment: bool = False,
    fetch_summary: bool = True,
    user_agent: str = "",
    timeout_sec: float = 30.0,
    max_bytes: int = 800_000,
    block_external_network: bool = False,
) -> dict[str, Any]:
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    entries = store.get("entries", [])
    if not isinstance(entries, list):
        entries = []

    canonical, err = normalize_site_url(
        url,
        strict_site_root=strict_site_root,
        allow_single_path_segment=allow_single_path_segment,
    )
    if err or not canonical:
        return {"ok": False, "error": err or "normalize failed"}

    for e in entries:
        if isinstance(e, dict) and e.get("canonical_url") == canonical:
            return {
                "ok": False,
                "error": f"已存在相同 canonical_url: {canonical}",
                "duplicate_id": e.get("id"),
            }

    ids = _entry_ids(entries)
    slug_base = _host_slug(urlsplit(canonical).hostname or "")
    eid = _unique_id(slug_base, ids)

    now = utc_now_iso()
    row: dict[str, Any] = {
        "id": eid,
        "canonical_url": canonical,
        "display_name": (display_name or "").strip() or urlsplit(canonical).hostname,
        "tags": [str(t).strip() for t in (tags or []) if str(t).strip()],
        "notes": (notes or "").strip(),
        "content_summary": "",
        "summary_source": "none",
        "fetch_ok": False,
        "added_at_utc": now,
        "updated_at_utc": now,
        "last_checked_utc": None,
    }

    if fetch_summary:
        fr = await fetch_site_summary(
            canonical,
            user_agent=user_agent,
            timeout_sec=timeout_sec,
            max_bytes=max_bytes,
            block_external_network=block_external_network,
        )
        row["content_summary"] = (fr.get("content_summary") or "").strip()
        row["fetch_ok"] = bool(fr.get("ok"))
        row["last_checked_utc"] = now
        row["summary_source"] = "fetch"
        if fr.get("final_url"):
            row["fetch_final_url"] = fr["final_url"]
        if fr.get("error"):
            row["fetch_note"] = fr["error"]

    entries.append(row)
    store["entries"] = entries
    save_store(path, store)
    return {"ok": True, "entry": row, "path": str(path)}


async def update_entry(
    data_dir: Path,
    *,
    entry_id: str,
    patch: dict[str, Any] | None,
    refetch_summary: bool = False,
    user_agent: str = "",
    timeout_sec: float = 30.0,
    max_bytes: int = 800_000,
    block_external_network: bool = False,
) -> dict[str, Any]:
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    entries = store.get("entries", [])
    if not isinstance(entries, list):
        return {"ok": False, "error": "invalid store"}
    patch = dict(patch) if isinstance(patch, dict) else {}
    if not patch and not refetch_summary:
        return {"ok": False, "error": "patch 为空时请设 refetch_summary=true 以仅刷新摘要"}
    eid = (entry_id or "").strip()
    for i, e in enumerate(entries):
        if not isinstance(e, dict) or e.get("id") != eid:
            continue
        now = utc_now_iso()
        if "display_name" in patch and patch["display_name"] is not None:
            e["display_name"] = str(patch["display_name"]).strip()
        if "tags" in patch and isinstance(patch["tags"], list):
            e["tags"] = [
                str(t).strip() for t in patch["tags"] if str(t).strip()
            ]
        if "notes" in patch and patch["notes"] is not None:
            e["notes"] = str(patch["notes"]).strip()
        if "content_summary" in patch and patch["content_summary"] is not None:
            e["content_summary"] = str(patch["content_summary"]).strip()
            e["summary_source"] = "manual"
        if "canonical_url" in patch and patch["canonical_url"]:
            nu, nerr = normalize_site_url(
                str(patch["canonical_url"]),
                strict_site_root=bool(patch.get("strict_site_root", True)),
                allow_single_path_segment=bool(
                    patch.get("allow_single_path_segment", False)
                ),
            )
            if nerr or not nu:
                return {"ok": False, "error": nerr or "bad url"}
            # duplicate check
            for j, other in enumerate(entries):
                if i != j and isinstance(other, dict) and other.get("canonical_url") == nu:
                    return {"ok": False, "error": f"canonical_url 冲突: {nu}"}
            e["canonical_url"] = nu

        if refetch_summary:
            cu = str(e.get("canonical_url") or "")
            if cu:
                fr = await fetch_site_summary(
                    cu,
                    user_agent=user_agent,
                    timeout_sec=timeout_sec,
                    max_bytes=max_bytes,
                    block_external_network=block_external_network,
                )
                e["content_summary"] = (fr.get("content_summary") or "").strip()
                e["fetch_ok"] = bool(fr.get("ok"))
                e["last_checked_utc"] = now
                e["summary_source"] = "fetch"
                if fr.get("final_url"):
                    e["fetch_final_url"] = fr["final_url"]
                if fr.get("error"):
                    e["fetch_note"] = fr["error"]

        e["updated_at_utc"] = now
        store["entries"] = entries
        save_store(path, store)
        return {"ok": True, "entry": e, "path": str(path)}
    return {"ok": False, "error": f"entry_id not found: {eid!r}"}


def remove_entry(data_dir: Path, *, entry_id: str | None, url: str | None) -> dict[str, Any]:
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    entries = store.get("entries", [])
    if not isinstance(entries, list):
        return {"ok": False, "error": "invalid store"}
    eid = (entry_id or "").strip()
    if not eid and not (url and str(url).strip()):
        return {"ok": False, "error": "需要 entry_id 或 url"}
    u_norm = None
    if url and str(url).strip():
        u_norm, err = normalize_site_url(
            str(url), strict_site_root=False, allow_single_path_segment=True
        )
        if err:
            return {"ok": False, "error": err}

    new_list: list[dict[str, Any]] = []
    removed = False
    for e in entries:
        if not isinstance(e, dict):
            continue
        match = False
        if eid and e.get("id") == eid:
            match = True
        if u_norm and e.get("canonical_url") == u_norm:
            match = True
        if match:
            removed = True
            continue
        new_list.append(e)
    if not removed:
        return {"ok": False, "error": "not found"}
    store["entries"] = new_list
    save_store(path, store)
    return {"ok": True, "removed": True, "path": str(path)}


def store_meta_for_prompt(data_dir: Path) -> dict[str, Any]:
    """供系统提示注入的轻量信息。"""
    ensure_site_bookmarks_file(data_dir)
    path = site_bookmarks_path(data_dir)
    store = load_store(path)
    n = len([e for e in store.get("entries", []) if isinstance(e, dict)])
    return {
        "bookmark_store_path": str(path),
        "entry_count": n,
        "tools": [
            "site_bookmarks_list",
            "site_bookmarks_get",
            "site_bookmarks_add",
            "site_bookmarks_update",
            "site_bookmarks_remove",
        ],
    }
