"""Virtual Persona enrich — search + 结构化填 profile（不 ingest atoms）。"""

from __future__ import annotations

import re
from datetime import datetime, timezone
from html import unescape
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen

from niannian.personas import JUDGMENT, RIGHT
from niannian.soul.search_providers import SearchHit, SearchProvider
from niannian.users.avatar import MAX_SIZE
from niannian.virtual_persona.addressing import sync_calls_user_as_from_profile
from niannian.virtual_persona.profile_md import merge_profile_sections, section_is_empty
from niannian.virtual_persona.store import VirtualPersonaStore

SectionMap = dict[str, str]
LlmSynthesizeFn = Callable[[str, list[SearchHit]], SectionMap]

_OG_IMAGE_RE = re.compile(
    r'<meta[^>]+(?:property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']'
    r'|content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\'])',
    re.IGNORECASE,
)
_HTTPS_IMG_RE = re.compile(
    r'https://[^\s"\'<>]+\.(?:png|jpe?g|webp)(?:\?[^\s"\'<>]*)?',
    re.IGNORECASE,
)
_PAGE_MAX_BYTES = 256_000


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _slug(text: str) -> str:
    slug = re.sub(r"[^\w\-]+", "_", str(text or "source").strip())[:48]
    return slug or "source"


def _rule_synthesize_sections(label: str, hits: list[SearchHit]) -> SectionMap:
    if not hits:
        return {}
    lead = hits[0]
    snippet = (lead.snippet or lead.title or "").strip()
    traits = "\n".join(f"- {h.title}：{(h.snippet or '')[:120].strip()}" for h in hits[:3])
    works = "\n".join(f"- [{h.title}]({h.url})" for h in hits)
    return {
        "性格与做事": traits or f"- {snippet[:120]}",
        "作品与经历": works or f"- {label} — {snippet[:160]}",
        "人物故事": f"- 基于公开资料：{snippet[:160]}",
    }


def synthesize_sections(
    label: str,
    hits: list[SearchHit],
    *,
    llm_fn: LlmSynthesizeFn | None = None,
) -> SectionMap:
    if llm_fn is not None:
        try:
            sections = llm_fn(label, hits)
            if isinstance(sections, dict) and sections:
                return {str(k): str(v) for k, v in sections.items()}
        except Exception:
            pass
    return _rule_synthesize_sections(label, hits)


def _write_source_files(sources_dir: Path, hits: list[SearchHit]) -> list[dict[str, Any]]:
    sources_dir.mkdir(parents=True, exist_ok=True)
    rows: list[dict[str, Any]] = []
    for idx, hit in enumerate(hits, start=1):
        fname = f"{idx:02d}_{_slug(hit.title)}.txt"
        body = f"{hit.title}\n{hit.url}\n\n{hit.snippet}\n"
        (sources_dir / fname).write_text(body, encoding="utf-8")
        rows.append(
            {
                "type": "web_search",
                "title": hit.title,
                "url": hit.url,
                "file": fname,
                "fetched_at": _now_iso(),
            }
        )
    return rows


def _allowed_image_url(url: str) -> bool:
    parsed = urlparse(str(url or "").strip())
    return parsed.scheme == "https" and bool(parsed.netloc)


def _fetch_url_bytes(url: str, *, max_bytes: int, opener=None) -> bytes:
    req = Request(url, headers={"User-Agent": "NianNianBot/1.0"})
    open_fn = opener or urlopen
    with open_fn(req, timeout=20) as resp:
        raw = resp.read(max_bytes + 1)
    if len(raw) > max_bytes:
        raise ValueError("response_too_large")
    return raw


def _extract_og_image_url(html: str, base_url: str) -> str | None:
    for match in _OG_IMAGE_RE.finditer(html):
        raw = match.group(1) or match.group(2)
        if raw:
            candidate = urljoin(base_url, unescape(raw.strip()))
            if _allowed_image_url(candidate):
                return candidate
    for match in _HTTPS_IMG_RE.finditer(html):
        candidate = match.group(0)
        if _allowed_image_url(candidate):
            return candidate
    return None


def try_fetch_avatar_from_hits(
    store: VirtualPersonaStore,
    vp_id: str,
    hits: list[SearchHit],
    *,
    opener=None,
) -> bool:
    """从检索结果页抓取 og:image；失败返回 False，不抛异常。"""
    for hit in hits:
        page_url = str(hit.url or "").strip()
        if not page_url:
            continue
        try:
            page_bytes = _fetch_url_bytes(page_url, max_bytes=_PAGE_MAX_BYTES, opener=opener)
            html = page_bytes.decode("utf-8", errors="replace")
            image_url = _extract_og_image_url(html, page_url)
            if not image_url:
                continue
            image_bytes = _fetch_url_bytes(image_url, max_bytes=MAX_SIZE, opener=opener)
            store.save_avatar(vp_id, image_bytes, kind="web_search", source_url=image_url)
            return True
        except Exception:
            continue
    return False


def enrich_virtual_persona(
    store: VirtualPersonaStore,
    vp_id: str,
    *,
    search: SearchProvider,
    query: str,
    max_results: int = 5,
    llm_fn: LlmSynthesizeFn | None = None,
    fetch_avatar: bool = False,
    fetch_opener=None,
) -> dict[str, Any]:
    """联网建档 — 写 sources/ · merge profile · **不** ingest atoms。"""
    row = store.get(vp_id)
    label = str(row.get("label") or vp_id)
    q = str(query or label).strip() or label
    hits = search.search(q, limit=max(1, int(max_results)))
    trace = [f"[{JUDGMENT}] vp enrich query={q}"]
    dest = store.vp_dir(vp_id)
    source_rows = _write_source_files(dest / "sources", hits)
    sections = synthesize_sections(label, hits, llm_fn=llm_fn)
    merged = merge_profile_sections(row.get("profile_md") or "", sections, only_empty=True)
    from niannian.virtual_persona.profile_md import parse_profile_sections

    before = parse_profile_sections(row.get("profile_md") or "")
    after = parse_profile_sections(merged)
    filled_count = sum(
        1
        for name in sections
        if sections.get(name) and section_is_empty(before.get(name, "")) and not section_is_empty(after.get(name, ""))
    )
    trace.append(f"[{RIGHT}] vp draft sections={filled_count}")
    meta_sources = list(row.get("sources") or [])
    meta_sources.append({"type": "web_search", "query": q, "fetched_at": _now_iso(), "hits": len(hits)})
    updated = store.apply_enrich(
        vp_id,
        profile_md=merged,
        sources=meta_sources,
        source_files=source_rows,
        query=q,
    )
    sync_calls_user_as_from_profile(
        store,
        vp_id,
        merged,
        fallback_profile_md=row.get("profile_md") or "",
    )
    avatar_fetched = False
    if fetch_avatar:
        try:
            avatar_fetched = try_fetch_avatar_from_hits(store, vp_id, hits, opener=fetch_opener)
            if avatar_fetched:
                trace.append(f"[{RIGHT}] vp avatar fetched")
        except Exception:
            pass
    return {
        "ok": True,
        "vp_id": vp_id,
        "query": q,
        "profile_preview": updated.get("profile_md"),
        "sources": source_rows,
        "meta_sources": updated.get("sources"),
        "trace": trace,
        "sections_filled": filled_count,
        "avatar_fetched": avatar_fetched,
        "has_avatar": store.has_avatar(vp_id),
    }
