"""Python role workers for task-card execution."""

from __future__ import annotations

import json
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Callable, Protocol, Any

from scripts.runtime.roles import RoleDefinition, RuntimeProfile
from scripts.runtime.skills import SkillRegistry
from scripts.runtime.tasks import TaskCard, validate_packet
from scripts.runtime.sources import append_packet_sources


class ChatClient(Protocol):
    def chat_complete(self, **kwargs) -> str:
        ...


class SearchProvider(Protocol):
    def search(self, *, query: str, route: str, num_results: int) -> list[dict[str, Any]]:
        ...


class ProjectSearchProvider:
    """Thin adapter over the project-owned search client."""

    def __init__(self, *, strict_specialized: bool = True) -> None:
        from scripts.lib.search_client import SearchClient

        self.client = SearchClient(strict_specialized=strict_specialized)

    def search(self, *, query: str, route: str, num_results: int) -> list[dict[str, Any]]:
        if route == "scholar":
            hits = self.client.scholar(query, num_results=num_results, year_low=2020)
        elif route == "patents":
            hits = self.client.patents(query, num_results=num_results)
        elif route == "news":
            hits = self.client.news(query, num_results=num_results, time_range="y")
        elif route == "fda":
            hits = self.client.fda(query, num_results=num_results)
        elif route == "evidence":
            hits = self.client.evidence(query, num_results=num_results)
        else:
            hits = self.client.search(query, num_results=num_results)
        return [
            {
                "title": hit.title,
                "url": hit.url,
                "snippet": hit.snippet,
                "route": route,
            }
            for hit in hits
        ]

    def close(self) -> None:
        self.client.close()


def _extract_json_object(text: str) -> dict:
    stripped = text.strip()
    if stripped.startswith("```"):
        stripped = stripped.strip("`")
        if stripped.startswith("json"):
            stripped = stripped[4:].strip()
    start = stripped.find("{")
    end = stripped.rfind("}")
    if start == -1 or end == -1 or end < start:
        raise ValueError("worker response does not contain a JSON object")
    return json.loads(stripped[start : end + 1])


def _safe_source_stem(task_id: str) -> str:
    return re.sub(r"[^a-zA-Z0-9]+", "_", task_id).strip("_").lower()


def contains_cjk(text: str) -> bool:
    return any("\u4e00" <= char <= "\u9fff" for char in text)


def strip_cjk(text: str) -> str:
    return re.sub(r"[\u3400-\u9fff]+", " ", text)


def validate_packet_against_allowed_context(
    packet: dict,
    search_context: dict[str, Any] | None,
    material_context: dict[str, Any] | None,
) -> None:
    """Ensure the model did not invent source IDs or URLs beyond candidates."""
    if not search_context and not material_context:
        return
    candidates = (search_context or {}).get("candidate_sources") or []
    materials = (material_context or {}).get("materials") or []
    if not candidates and not materials:
        return
    candidate_ids = {source.get("id") for source in candidates}
    candidate_ids.update(item.get("source_id") for item in materials)
    candidate_urls = {source.get("url") for source in candidates if source.get("url")}
    candidate_urls.update(item.get("path") for item in materials if item.get("path"))
    packet_sources = packet.get("sources") or []
    unknown_ids = sorted(
        source.get("id")
        for source in packet_sources
        if source.get("id") and source.get("id") not in candidate_ids
    )
    unknown_urls = sorted(
        source.get("url")
        for source in packet_sources
        if source.get("url") and source.get("url") not in candidate_urls
    )
    if (candidates or materials) and not packet_sources:
        raise ValueError("packet must include source metadata from candidate_sources or local materials")
    if unknown_ids:
        raise ValueError(f"packet sources include non-candidate source IDs: {unknown_ids}")
    if unknown_urls:
        raise ValueError(f"packet sources include non-candidate URLs: {unknown_urls}")


def normalize_packet_against_context(
    packet: dict[str, Any],
    search_context: dict[str, Any] | None,
    material_context: dict[str, Any] | None,
) -> dict[str, Any]:
    """Deterministically fill schema metadata the model often omits."""
    packet = dict(packet)
    referenced: set[str] = set(packet.get("source_ids") or [])
    for section in ("claims", "counter_evidence"):
        for item in packet.get(section) or []:
            referenced.update(item.get("source_ids") or [])
    for item in packet.get("evidence_items") or []:
        if item.get("source_id"):
            referenced.add(item["source_id"])
    if "source_ids" not in packet or not packet.get("source_ids"):
        packet["source_ids"] = sorted(referenced)

    available_sources: dict[str, dict[str, Any]] = {}
    for source in (search_context or {}).get("candidate_sources") or []:
        if source.get("id"):
            available_sources[source["id"]] = source
    for material in (material_context or {}).get("materials") or []:
        source_id = material.get("source_id")
        if source_id:
            available_sources[source_id] = {
                "id": source_id,
                "title": material.get("title") or Path(material.get("path", "")).name,
                "url": material.get("path") or "",
                "tier": "local_material",
                "score": 8,
            }

    existing_sources = {
        source.get("id"): source
        for source in packet.get("sources") or []
        if source.get("id")
    }
    for source_id in packet.get("source_ids") or []:
        if source_id not in existing_sources and source_id in available_sources:
            existing_sources[source_id] = available_sources[source_id]
    if existing_sources:
        packet["sources"] = [existing_sources[source_id] for source_id in packet.get("source_ids", []) if source_id in existing_sources]
    return packet


FDA_AXIS_TERMS = {
    "nmpa_fda_ema_ich_who_baseline": "CGMP pharmaceutical quality system process validation aseptic processing data integrity",
    "quality_system_gap": "CGMP CAPA deviation change control data integrity quality unit pharmaceutical",
    "manufacturing_process_risk": "aseptic processing sterile drug manufacturing process validation PPQ cleaning validation water system",
    "operations_management_gap": "pharmaceutical quality system quality metrics management review senior management FDA",
    "capa_roadmap": "CGMP CAPA effectiveness remediation warning letter close-out pharmaceutical",
    "verification_evidence": "FDA 483 response CAPA effectiveness verification EIR pharmaceutical quality",
    "counter": "FDA warning letter CGMP pharmaceutical quality data integrity remediation limitations",
    "fda_enforcement_precedents": "FDA warning letter CGMP pharmaceutical aseptic processing data integrity CAPA process validation",
}


FDA_CHAPTER_TERMS = {
    "ch01": "commercial readiness phase gate remediation governance",
    "ch02": "regulatory baseline CGMP EU GMP Annex 1 ICH Q9 ICH Q10",
    "ch03": "aseptic processing RABS first air media fill visual inspection depyrogenation tunnel",
    "ch04": "biologics drug substance WFI clean utilities SCADA EMS single-use system",
    "ch05": "process validation master batch record CPP CQA PPQ cleaning validation technology transfer",
    "ch06": "deviation change control CAPA document control training data integrity quality unit",
    "ch07": "training effectiveness quality culture operator qualification human factors",
    "ch08": "quality metrics management review escalation cross-functional governance operations",
    "ch09": "CDMO quality organization technology transfer project governance capability matrix",
    "ch10": "CAPA remediation plan effectiveness check owner due date verification evidence",
    "ch11": "regulatory mapping CAPA tracker closure evidence quality assurance verification",
}


ROUTE_CHAPTER_TERMS = {
    **FDA_CHAPTER_TERMS,
}

ROUTE_SUFFIX_TERMS = {
    "scholar": "pharmaceutical GMP review validation risk management quality system",
    "patents": "biologics manufacturing patent process formulation device",
    "news": "pharmaceutical quality operations CDMO quality governance",
    "evidence": "pharmaceutical GMP evidence guidance enforcement best practice quality operations",
    "general": "pharmaceutical GMP best practice guidance quality operations remediation",
}

INTERNAL_QUERY_TOKENS = {
    "chapter_integrated",
    "input_material_findings",
}


def _compact_english_query(*parts: str, max_terms: int = 16) -> str:
    text = strip_cjk(" ".join(part for part in parts if part))
    text = re.sub(r"[^A-Za-z0-9./+-]+", " ", text)
    terms: list[str] = []
    seen: set[str] = set()
    for raw in text.split():
        term = raw.strip(" ./+-").lower()
        if not term or term in INTERNAL_QUERY_TOKENS:
            continue
        key = term.casefold()
        if key in seen:
            continue
        seen.add(key)
        terms.append(term)
        if len(terms) >= max_terms:
            break
    return " ".join(terms)


def _chapter_terms(card: TaskCard) -> str:
    mapped = " ".join(ROUTE_CHAPTER_TERMS.get(chapter_id, "") for chapter_id in card.chapter_ids)
    if mapped.strip():
        return mapped
    return strip_cjk(card.chapter_title)


def build_route_query(card: TaskCard, route: str) -> str:
    """Build short, route-aware queries instead of sending whole task cards."""
    if route == "fda":
        terms = FDA_AXIS_TERMS.get(card.topic_axis, "FDA warning letter CGMP pharmaceutical quality")
        chapter_terms = " ".join(FDA_CHAPTER_TERMS.get(chapter_id, "") for chapter_id in card.chapter_ids)
        query = f"{terms} {chapter_terms}".strip()
        if contains_cjk(query):
            raise ValueError(f"FDA route query must not contain Chinese text: {query}")
        return query
    if route == "scholar":
        return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["scholar"])
    if route == "patents":
        return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["patents"])
    if route == "news":
        return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["news"])
    if route == "evidence":
        return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["evidence"])
    return _compact_english_query(_chapter_terms(card), ROUTE_SUFFIX_TERMS["general"])


def _material_excerpt(project_root: Path | None, rel_path: str, *, max_chars: int = 6000) -> dict[str, str] | None:
    if project_root is None:
        return None
    path = project_root / rel_path
    if not path.exists() or not path.is_file():
        return None
    text = path.read_text(encoding="utf-8", errors="ignore")
    return {
        "path": rel_path,
        "source_id": f"src_local_{_safe_source_stem(Path(rel_path).stem)}",
        "title": Path(rel_path).name,
        "excerpt": text[:max_chars],
    }


def build_material_context(card: TaskCard, project_root: Path | None, *, max_chars_per_material: int = 6000) -> dict[str, Any]:
    materials = []
    seen: set[str] = set()
    for rel in card.allowed_materials:
        if rel in seen:
            continue
        seen.add(rel)
        item = _material_excerpt(project_root, rel, max_chars=max_chars_per_material)
        if item:
            materials.append(item)
    return {"materials": materials}


def build_search_context(
    card: TaskCard,
    search_provider: SearchProvider,
    *,
    num_results_per_route: int = 5,
) -> dict[str, Any]:
    candidate_sources: list[dict[str, Any]] = []
    routes_used: list[str] = []
    source_stem = _safe_source_stem(card.task_id)
    idx = 1
    for route in card.search_routes:
        routes_used.append(route)
        query = build_route_query(card, route)
        hits = search_provider.search(query=query, route=route, num_results=num_results_per_route)
        for hit in hits:
            candidate_sources.append(
                {
                    "id": f"src_{source_stem}_{idx:03d}",
                    "title": hit.get("title", ""),
                    "url": hit.get("url", ""),
                    "snippet": hit.get("snippet", ""),
                    "route": hit.get("route", route),
                    "tier": "Tier 2",
                    "score": 6,
                }
            )
            idx += 1
    return {"routes_used": routes_used, "candidate_sources": candidate_sources}


def build_packet_user_prompt(
    card: TaskCard,
    search_context: dict[str, Any] | None = None,
    material_context: dict[str, Any] | None = None,
) -> str:
    context = search_context or {"routes_used": [], "candidate_sources": []}
    materials = material_context or {"materials": []}
    return (
        "请根据以下 task card 产出一个证据包 JSON。\n"
        "正式结论、summary、open_questions 用中文；英文原文摘录、source title、DOI/URL 可以保留英文。\n"
        "必须主动包含 counter_evidence，且所有引用的 source_id 必须出现在 source_ids 中。\n\n"
        "只能使用 candidate_sources 或 Local material context 中的来源，不得编造 URL、DOI、trial ID 或 source_id。\n"
        "输出 JSON 必须包含 sources 字段；sources 只能来自 candidate_sources 或 Local material context。\n"
        "如 Local material context 非空，必须至少提取 1 条本地材料原文证据；如果与本章无关，必须在 open_questions 说明为什么无关。\n\n"
        f"{json.dumps(card.to_dict(), ensure_ascii=False, indent=2)}\n\n"
        f"Local material context:\n{json.dumps(materials, ensure_ascii=False, indent=2)}\n\n"
        f"Search context:\n{json.dumps(context, ensure_ascii=False, indent=2)}\n\n"
        "只输出 JSON，不要输出 Markdown 解释。"
    )


def build_packet_repair_prompt(
    *,
    card: TaskCard,
    raw_response: str,
    error: Exception,
    search_context: dict[str, Any] | None = None,
    material_context: dict[str, Any] | None = None,
) -> str:
    context = search_context or {"routes_used": [], "candidate_sources": []}
    materials = material_context or {"materials": []}
    return (
        "请修复上一次 evidence packet 输出，使其成为合法且通过 schema 校验的 JSON。\n"
        "只输出 JSON 对象，不要输出 Markdown、解释或代码块。\n"
        "保留中文主写作；英文只允许出现在来源标题、URL、DOI、原文摘录或检索笔记中。\n"
        "不得编造 candidate_sources 或 Local material context 以外的来源、URL、DOI、trial ID 或 source_id。\n\n"
        f"Schema error:\n{error}\n\n"
        f"Task card:\n{json.dumps(card.to_dict(), ensure_ascii=False, indent=2)}\n\n"
        f"Search context:\n{json.dumps(context, ensure_ascii=False, indent=2)}\n\n"
        f"Local material context:\n{json.dumps(materials, ensure_ascii=False, indent=2)}\n\n"
        f"Previous raw response:\n{raw_response[:12000]}"
    )


class PacketWorker:
    def __init__(
        self,
        *,
        role: RoleDefinition,
        client: ChatClient,
        project_root: Path | None = None,
        search_provider: SearchProvider | None = None,
        skill_registry: SkillRegistry | None = None,
        num_results_per_route: int = 5,
    ) -> None:
        self.role = role
        self.client = client
        self.project_root = project_root
        self.search_provider = search_provider
        self.skill_registry = skill_registry or SkillRegistry()
        self.num_results_per_route = num_results_per_route

    def _system_prompt(self) -> str:
        skill_texts = []
        for name in self.role.skills:
            try:
                skill_texts.append(f"# Skill: {name}\n\n{self.skill_registry.read(name)}")
            except FileNotFoundError:
                skill_texts.append(f"# Skill: {name}\n\n[missing skill: {name}]")
        return (
            f"{self.role.identity}\n\n"
            "你是 Deep Research v0.20 Python runtime 的证据包 worker。\n"
            "你的唯一任务是把一个 task card 转换为结构化 evidence packet。\n"
            "遵循中文主写作原则；不要写章节正文；不要编造 URL、DOI、trial ID 或 source_id。\n\n"
            "搜索只能走项目 Python search gateway 或调用方提供的 search_context；不要直接使用 Tavily MCP、browser MCP、平台 web search 或任何需要用户权限确认的外部搜索工具。\n\n"
            + "\n\n".join(skill_texts)
        )

    def run(self, card: TaskCard) -> dict:
        search_context = None
        if self.search_provider:
            search_context = build_search_context(
                card,
                self.search_provider,
                num_results_per_route=self.num_results_per_route,
            )
        material_context = build_material_context(card, self.project_root)
        raw = self.client.chat_complete(
            model=self.role.model,
            system=self._system_prompt(),
            user=build_packet_user_prompt(card, search_context, material_context),
            temperature=self.role.temperature,
            max_tokens=self.role.max_tokens,
            tag=f"packet:{card.task_id}",
        )
        try:
            packet = normalize_packet_against_context(
                _extract_json_object(raw),
                search_context,
                material_context,
            )
            validate_packet(packet)
            validate_packet_against_allowed_context(packet, search_context, material_context)
            return packet
        except Exception as error:
            repaired = self.client.chat_complete(
                model=self.role.model,
                system=self._system_prompt(),
                user=build_packet_repair_prompt(
                    card=card,
                    raw_response=raw,
                    error=error,
                    search_context=search_context,
                    material_context=material_context,
                ),
                temperature=0,
                max_tokens=self.role.max_tokens,
                tag=f"packet-repair:{card.task_id}",
            )
            packet = normalize_packet_against_context(
                _extract_json_object(repaired),
                search_context,
                material_context,
            )
            validate_packet(packet)
            validate_packet_against_allowed_context(packet, search_context, material_context)
            return packet


def _write_packet_error(project_root: Path, card: TaskCard, error: Exception) -> None:
    path = project_root / "phase2" / "packet_errors" / f"{card.task_id}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "task_id": card.task_id,
        "status": "failed",
        "error": str(error),
        "output_packet": card.output_packet,
    }
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def run_packet_workers(
    *,
    project_root: Path,
    cards: list[TaskCard],
    runtime: RuntimeProfile,
    client_factory: Callable[[RoleDefinition], ChatClient],
    search_provider_factory: Callable[[], SearchProvider] | None = None,
    workers: int,
) -> int:
    role = runtime.role_for_task("evidence_packet")
    max_workers = max(1, min(workers, role.max_concurrency))

    def run_one(card: TaskCard) -> tuple[TaskCard, dict | None, Exception | None]:
        search_provider = search_provider_factory() if search_provider_factory else None
        try:
            worker = PacketWorker(role=role, client=client_factory(role), project_root=project_root, search_provider=search_provider)
            return card, worker.run(card), None
        except Exception as error:
            return card, None, error
        finally:
            close = getattr(search_provider, "close", None)
            if close:
                close()

    written = 0
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        futures = [pool.submit(run_one, card) for card in cards]
        for future in as_completed(futures):
            card, packet, error = future.result()
            if error is not None:
                _write_packet_error(project_root, card, error)
                continue
            if packet is None:
                _write_packet_error(project_root, card, RuntimeError("packet worker returned no packet"))
                continue
            path = project_root / card.output_packet
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(json.dumps(packet, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
            append_packet_sources(project_root / "phase2" / "sources.jsonl", packet)
            written += 1
    return written
