"""VP canon-lookup — web search + fanwork policy + pending candidates."""

from __future__ import annotations

import json
import re
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Callable

from niannian.soul.search_providers import SearchHit, SearchProvider, filter_hits_by_allowlist
from niannian.virtual_persona.assembly_master import append_section_body
from niannian.virtual_persona.pack import read_pack

if TYPE_CHECKING:
    from niannian.virtual_persona.store import VirtualPersonaStore

CANON_LOOKUP_DIR = "canon_lookup"
PENDING_FILE = "pending.json"

VALID_FANWORK_POLICIES: frozenset[str] = frozenset({"strict", "balanced", "open"})
DEFAULT_FANWORK_POLICY = "balanced"

_FANWORK_POLICY_SECTION_RE = re.compile(r"^## 同人策略\s*$", re.MULTILINE)
_FANWORK_URL_HINTS = (
    "fanfiction",
    "ao3.org",
    "lofter.com",
    "archiveofourown",
    "同人",
    "二创",
    "fanfic",
)

_POLICY_INSTRUCTIONS: dict[str, str] = {
    "strict": "仅采用官方/原作正史设定；禁止同人、二创、粉丝fiction及任何臆造内容。",
    "balanced": "优先原作设定；同人/二创内容须标注来源且置信度为 low。",
    "open": "可纳入标注来源的同人/二创设定，合并前须用户确认。",
}

CanonSynthesizeFn = Callable[[str, str, str, list[SearchHit]], list[dict[str, Any]]]


class CanonLookupError(ValueError):
    """Illegal canon-lookup operation."""


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


def _canon_dir(store: VirtualPersonaStore, vp_id: str):
    from pathlib import Path

    path = store.vp_dir(vp_id) / CANON_LOOKUP_DIR
    path.mkdir(parents=True, exist_ok=True)
    return path


def _pending_path(store: VirtualPersonaStore, vp_id: str):
    return _canon_dir(store, vp_id) / PENDING_FILE


def extract_profile_fanwork_policy(profile_md: str) -> str | None:
    """Parse optional ``## 同人策略`` section for strict|balanced|open."""
    match = _FANWORK_POLICY_SECTION_RE.search(str(profile_md or ""))
    if not match:
        return None
    tail = profile_md[match.end() :].lstrip("\n")
    for line in tail.splitlines():
        stripped = line.strip()
        if stripped.startswith("## "):
            break
        if not stripped:
            continue
        value = stripped.lstrip("- ").strip().lower()
        if value in VALID_FANWORK_POLICIES:
            return value
    return None


def resolve_fanwork_policy(store: VirtualPersonaStore, vp_id: str) -> str:
    """pack.yaml fanwork_policy > profile ## 同人策略 > balanced."""
    try:
        pack = read_pack(store, vp_id)
    except FileNotFoundError:
        pack = {}
    raw = str(pack.get("fanwork_policy") or "").strip().lower()
    if raw in VALID_FANWORK_POLICIES:
        return raw
    row = store.get(vp_id)
    from_profile = extract_profile_fanwork_policy(str(row.get("profile_md") or ""))
    if from_profile:
        return from_profile
    return DEFAULT_FANWORK_POLICY


def build_canon_lookup_prompt(
    *,
    label: str,
    query: str,
    fanwork_policy: str,
    hits: list[SearchHit] | None = None,
) -> str:
    """Build LLM / rule prompt fragment; strict forbids fanfiction invention."""
    policy = fanwork_policy if fanwork_policy in VALID_FANWORK_POLICIES else DEFAULT_FANWORK_POLICY
    instructions = _POLICY_INSTRUCTIONS[policy]
    name = str(label or "角色").strip() or "角色"
    q = str(query or name).strip() or name
    lines = [
        f"角色：{name}",
        f"考据查询：{q}",
        f"同人策略：{policy}",
        instructions,
    ]
    if hits:
        lines.append("检索摘要：")
        for hit in hits[:5]:
            lines.append(f"- {hit.title}：{(hit.snippet or '')[:160].strip()}")
    return "\n".join(lines)


def is_fanwork_hit(hit: SearchHit) -> bool:
    """Return True when hit title/url/snippet looks like fanwork."""
    blob = f"{hit.title} {hit.url} {hit.snippet}".lower()
    return any(hint in blob for hint in _FANWORK_URL_HINTS)


def _confidence_for_hit(hit: SearchHit, *, fanwork: bool) -> str:
    if fanwork:
        return "low"
    snippet = (hit.snippet or "").strip()
    if len(snippet) >= 120:
        return "high"
    if len(snippet) >= 40:
        return "medium"
    return "low"


def _default_synthesize_candidates(
    label: str,
    query: str,
    fanwork_policy: str,
    hits: list[SearchHit],
) -> list[dict[str, Any]]:
    policy = fanwork_policy if fanwork_policy in VALID_FANWORK_POLICIES else DEFAULT_FANWORK_POLICY
    candidates: list[dict[str, Any]] = []
    for idx, hit in enumerate(hits, start=1):
        fanwork = is_fanwork_hit(hit)
        if policy == "strict" and fanwork:
            continue
        snippet = (hit.snippet or hit.title or "").strip()
        if policy == "strict":
            body = f"- 正史考据：{snippet[:240]}"
        elif fanwork:
            body = f"- 同人/二创（待审）：{snippet[:200]}"
        else:
            body = f"- 考据：{snippet[:220]}"
        candidates.append(
            {
                "id": f"c_{idx:03d}",
                "heading": "考据 · 作品与经历",
                "body": body,
                "source": {"title": hit.title, "url": hit.url},
                "canon_hit": not fanwork,
                "fanwork": fanwork,
                "confidence": _confidence_for_hit(hit, fanwork=fanwork),
                "status": "pending",
            }
        )
    return candidates


def _load_pending(store: VirtualPersonaStore, vp_id: str) -> dict[str, Any] | None:
    path = _pending_path(store, vp_id)
    if not path.is_file():
        return None
    with path.open(encoding="utf-8") as f:
        data = json.load(f)
    return data if isinstance(data, dict) else None


def _save_pending(store: VirtualPersonaStore, vp_id: str, payload: dict[str, Any]) -> None:
    path = _pending_path(store, vp_id)
    with path.open("w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)
        f.write("\n")


def run_canon_lookup(
    store: VirtualPersonaStore,
    vp_id: str,
    *,
    query: str,
    sources: list[str] | None = None,
    search: SearchProvider | None = None,
    allowlist: list[str] | None = None,
    max_results: int = 5,
    synthesize_fn: CanonSynthesizeFn | None = None,
    mock: bool = True,
) -> dict[str, Any]:
    """Search + synthesize candidates; write pending file (status=pending)."""
    row = store.get(vp_id)
    label = str(row.get("label") or vp_id)
    q = str(query or label).strip() or label
    fanwork_policy = resolve_fanwork_policy(store, vp_id)

    hits: list[SearchHit] = []
    if search is not None:
        raw_hits = search.search(q, limit=max(1, int(max_results)))
        hits = filter_hits_by_allowlist(raw_hits, allowlist or [])
    for url in sources or []:
        clean = str(url or "").strip()
        if clean:
            hits.append(SearchHit(title=clean, url=clean, snippet="user source"))

    prompt = build_canon_lookup_prompt(label=label, query=q, fanwork_policy=fanwork_policy, hits=hits)
    fn = synthesize_fn or _default_synthesize_candidates
    candidates = fn(label, q, fanwork_policy, hits)

    lookup_id = f"cl_{uuid.uuid4().hex[:12]}"
    payload: dict[str, Any] = {
        "lookup_id": lookup_id,
        "vp_id": vp_id,
        "query": q,
        "fanwork_policy": fanwork_policy,
        "status": "pending",
        "mock": bool(mock),
        "created_at": _now_iso(),
        "prompt": prompt,
        "candidates": candidates,
    }
    _save_pending(store, vp_id, payload)

    return {
        "vp_id": vp_id,
        "lookup_id": lookup_id,
        "query": q,
        "fanwork_policy": fanwork_policy,
        "status": "pending",
        "candidates": candidates,
        "pending_path": str(_pending_path(store, vp_id)),
    }


def approve_canon_candidates(
    store: VirtualPersonaStore,
    vp_id: str,
    *,
    candidate_ids: list[str],
) -> dict[str, Any]:
    """Merge selected pending candidates into assembly_master (append, respect locks)."""
    pending = _load_pending(store, vp_id)
    if pending is None:
        raise CanonLookupError("no pending canon-lookup; run lookup first")

    pack = read_pack(store, vp_id)
    locked_raw = pack.get("locked_sections")
    locked_list = list(locked_raw) if isinstance(locked_raw, list) else None

    selected = {str(cid).strip() for cid in candidate_ids if str(cid).strip()}
    if not selected:
        raise CanonLookupError("candidate_ids required")

    merged: list[dict[str, Any]] = []
    skipped_locked: list[str] = []
    not_found: list[str] = []

    for cand in pending.get("candidates") or []:
        if not isinstance(cand, dict):
            continue
        cid = str(cand.get("id") or "")
        if cid not in selected:
            continue
        if str(cand.get("status") or "pending") != "pending":
            continue
        heading = str(cand.get("heading") or "").strip()
        body = str(cand.get("body") or "").strip()
        if not heading or not body:
            not_found.append(cid)
            continue
        ok = append_section_body(
            store,
            vp_id,
            heading,
            body,
            locked_sections=locked_list,
        )
        if ok:
            cand["status"] = "approved"
            cand["approved_at"] = _now_iso()
            merged.append({"id": cid, "heading": heading})
        else:
            skipped_locked.append(heading)
            cand["status"] = "skipped_locked"

    if not merged and not skipped_locked:
        raise CanonLookupError(f"no matching pending candidates: {sorted(selected)}")

    all_done = all(
        str(c.get("status") or "pending") != "pending"
        for c in pending.get("candidates") or []
        if isinstance(c, dict)
    )
    pending["status"] = "approved" if all_done else "partial"
    pending["approved_at"] = _now_iso()
    _save_pending(store, vp_id, pending)

    return {
        "vp_id": vp_id,
        "lookup_id": pending.get("lookup_id"),
        "merged": merged,
        "skipped_locked": skipped_locked,
        "pending_status": pending["status"],
    }
