"""EU-Recherche-Schleife. Gesteuerte Websuche über Bedrock plus staan (Phase 2).

Ersetzt für den EU-Modellweg die eingebaute WebSearch-Recherche des Claude CLI.
Das Modell (Opus über Bedrock, EU-Profile Frankfurt) schlägt je Runde
Suchanfragen vor, unser Code fragt staan.ai, liefert Treffer und Volltexte
zurück, und das Modell entscheidet, ob es nachhaken will oder genug hat.
Am Ende produziert die Schleife exakt den JSON-Array-Text, den der heutige
WebSearch-Researcher liefert, sodass Parsing, Quellenfilter und die gesamte
nachgelagerte Pipeline unverändert weiterlaufen (Einstieg in
researcher.ResearcherAgent.search).

Nachgebildet wird auch die vierstufige Tiefenrecherche der Recherche-Lagen
(breite Erfassung, Lückenanalyse, gezielte institutionelle Suche, Vertiefung
per Volltext). Zwei harte Zusagen dieser Schleife gegen Halluzinationen und
für die Aktualität. Erstens akzeptiert die Endauswertung nur URLs, die
wirklich in den staan-Treffern vorkamen (alles andere wird verworfen und
geloggt). Zweitens wird published_at nur gesetzt, wenn das Datum aus Inhalt
oder URL ableitbar ist, staan selbst liefert keine Datumsangaben.
"""
import asyncio
import json
import logging
from dataclasses import dataclass
from datetime import datetime

from config import (
    CLAUDE_MODEL_STANDARD,
    EU_RESEARCH_MAX_NEW_PER_ROUND,
    EU_RESEARCH_MAX_ROUNDS_ADHOC,
    EU_RESEARCH_MAX_ROUNDS_RESEARCH,
    EU_VERIFY_COUNTER_SHARE,
    EU_VERIFY_FULLTEXT_CHARS,
    EU_VERIFY_FULLTEXT_QUERIES,
    EU_VERIFY_HITS_PER_QUERY,
    EU_VERIFY_MAX_ITEMS,
    EU_VERIFY_MAX_QUERIES,
    STAAN_COST_PER_QUERY_USD,
    TIMEZONE,
)
from agents.claude_client import ClaudeUsage, _cancel_event_var
from agents.bedrock_client import call_bedrock
from services.staan_client import staan_search, market_for_language, StaanError

logger = logging.getLogger("osint.eu_researcher")

MAX_QUERIES_PER_ROUND = 4
MAX_FULLTEXT_QUERIES_PER_ROUND = 2
MAX_TOTAL_RESULTS = 60
# Kappung der Textmengen im Abschluss-Prompt (Kosten- und Kontextschutz)
FINAL_FULLTEXT_CHARS = 2500
FINAL_SNIPPET_CHARS = 400
ROUND_SNIPPET_CHARS = 250

_ROUND_HEADER = """Du steuerst eine OSINT-Recherche über eine europäische Such-API.
Du kannst NICHT selbst suchen. Du schlägst Suchanfragen vor, unser System führt sie aus
und zeigt dir die Treffer. Es ist Runde {round_no} von maximal {max_rounds}.
Heute ist der {today}. Formuliere Suchanfragen so, dass sie AKTUELLE Berichterstattung
finden (konkrete Ereignisse, Akteure, Ortsnamen; keine Jahreszahlen anhängen).

AUFTRAG:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
SPRACHREGELN:
{lang_instruction}

{phase_guidance}

BISHER GESAMMELTE TREFFER ({n_results} Stück):
{results_block}

VERLAUF DEINER BISHERIGEN SUCHEN:
{rounds_log}

Antworte NUR mit einem JSON-Objekt in genau diesem Format:
{{"action": "search", "queries": [{{"q": "suchbegriffe", "market": "de-de", "full_content": false}}], "reason": "ein Satz"}}
oder, wenn die Treffer für ein vollständiges Bild reichen:
{{"action": "finish", "reason": "ein Satz"}}

REGELN FÜR QUERIES:
- Maximal {max_queries} Queries je Runde, jede maximal 400 Zeichen.
- "market" ist "de-de", "en-us" oder "fr-fr" (Standard für diese Lage: "{default_market}").
  Fremdsprachige Suchanfragen (z.B. Russisch, Arabisch, Farsi) funktionieren mit "en-us".
- "full_content": true holt die kompletten Artikeltexte der Treffer dieser Query
  (maximal {max_fulltext} Queries je Runde). Nutze das für die wichtigsten Suchen,
  deren Artikel du zusammenfassen willst.
- Wiederhole keine Query aus dem Verlauf wortgleich."""

_PHASE_GUIDANCE_RESEARCH = {
    1: """PHASE 1, BREITE ERFASSUNG:
Suche nach aktueller Berichterstattung bei Nachrichtenagenturen, Qualitätszeitungen und
öffentlich-rechtlichen Medien. Nutze verschiedene Suchbegriffe und Blickwinkel.""",
    2: """PHASE 2 UND 3, LÜCKENANALYSE UND GEZIELTE TIEFENRECHERCHE:
Prüfe die bisherigen Treffer kritisch. Welche Quellentypen fehlen? Typisch fehlen
Parlamentsdokumente, Behörden-Pressemitteilungen, NGO- und UN-Berichte (ohchr.org,
amnesty.org, hrw.org), Think-Tank-Analysen (IISS, Brookings, SWP, DGAP, Chatham House),
investigative Langform-Berichte und Fachmedien. Suche GEZIELT nach diesen Lücken,
auch mit site:-Operatoren für institutionelle Quellen.""",
    3: """PHASE 4, VERIFIKATION UND VERTIEFUNG:
Fordere für die wichtigsten Suchanfragen jetzt Volltexte an ("full_content": true),
damit die Artikel ausführlich zusammengefasst werden können. Priorisiere Primärquellen
und investigative Berichte. Ergänze nur noch gezielt, was für ein vollständiges Bild fehlt.""",
}

_PHASE_GUIDANCE_ADHOC = {
    1: """SCHWERPUNKT DIESER RUNDE:
Aktuelle Berichterstattung zur Lage bei seriösen Nachrichtenquellen (Agenturen,
Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden). Verschiedene Blickwinkel.""",
    2: """SCHWERPUNKT DIESER RUNDE:
Lücken schließen und vertiefen. Fordere für die wichtigsten Suchanfragen Volltexte an
("full_content": true), damit die Artikel fundiert zusammengefasst werden können.""",
    3: """SCHWERPUNKT DIESER RUNDE, GEZIELTE VERTIEFUNG:
Sieh die bisherigen Treffer durch und frage dich, welche Seite der Lage darin fehlt.
Typische Lücken bei einem Ereignis mit politischer Wirkung: die Position des eigenen
Landes und der Nachbarstaaten, die Reaktion der zuständigen Institutionen (EU-Kommission,
Ministerien, Behörden), die Sicht der Gegenseite, der rechtliche Rahmen, die Ursachen und
die Vorgeschichte. Suche gezielt nach dem, was fehlt, nicht noch einmal nach dem
Hauptereignis. Fordere für diese Suchen Volltexte an ("full_content": true).""",
}

_FINAL_PROMPT = """Du bist ein OSINT-Recherche-Agent. Die Recherche über die europäische Such-API ist
abgeschlossen. Unten stehen ALLE gesammelten Treffer mit Textauszügen. Heute ist der {today}.

AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Sätze.
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.

AUFTRAG WAR:
Titel: {title}
Kontext: {description}

WÄHLE aus den Treffern die {target} relevantesten, inhaltlich substanziellen Artikel aus.
Bevorzuge Vielfalt der Quellen und Blickwinkel. Lass Übersichtsseiten, reine Linklisten
und thematisch unpassende Treffer weg.

GESAMMELTE TREFFER:
{results_block}

Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Text davor oder danach.
Jedes Element hat diese Felder:
- "headline": Originale Überschrift (aus dem Treffer)
- "headline_de": Übersetzung in die Ausgabesprache (falls Originalsprache abweicht)
- "source": Name der Quelle (z.B. "Reuters", "tagesschau")
- "source_url": Die EXAKTE URL aus dem Treffer. NIEMALS eine URL verändern oder erfinden.
- "content_summary": Zusammenfassung des Inhalts ({summary_len}, in Ausgabesprache, auf Basis der Textauszüge)
- "language": Sprache des Originals (z.B. "de", "en", "fa")
- "published_at": Veröffentlichungsdatum im ISO-Format, NUR wenn es aus Textauszug oder URL
  eindeutig hervorgeht (z.B. Datumsangabe im Text oder /2026/07/ im Pfad), sonst null.

Antworte NUR mit dem JSON-Array."""


def _norm_url(u: str) -> str:
    return (u or "").strip().rstrip("/").lower()


def _results_block(collected: dict[str, dict], with_texts: bool) -> str:
    """Nummerierte Trefferliste für die Prompts. Kompakt in Zwischenrunden,
    mit Textauszügen im Abschluss-Prompt."""
    if not collected:
        return "(noch keine)"
    lines = []
    for i, (url, r) in enumerate(collected.items(), 1):
        lines.append(f"{i}. {r['title'] or '(ohne Titel)'} | {r['hostname']}\n   URL: {url}")
        if with_texts:
            if r.get("snippet"):
                lines.append(f"   Auszug: {r['snippet'][:FINAL_SNIPPET_CHARS]}")
            for chunk in (r.get("extra_snippets") or [])[:2]:
                lines.append(f"   Auszug: {chunk[:FINAL_SNIPPET_CHARS]}")
            if r.get("full_text"):
                lines.append(f"   Volltext (gekürzt): {r['full_text'][:FINAL_FULLTEXT_CHARS]}")
        else:
            if r.get("snippet"):
                lines.append(f"   {r['snippet'][:ROUND_SNIPPET_CHARS]}")
    return "\n".join(lines)


def _platz_schaffen(collected: dict[str, dict], runde: int) -> bool:
    """Verdraengt den schwaechsten Treffer einer frueheren Runde. True bei Erfolg.

    Frueher war die Trefferzahl eine harte Obergrenze: War sie erreicht, brach
    die Suchphase ab. In der Praxis fuellten die ersten beiden Runden den Korb
    mit breiter Erfassung, und die dritte Runde, die gezielt Luecken schliessen
    soll, lief nie. Genau diese Runde bringt aber die Treffer, die im Bericht
    fehlen.

    Statt abzubrechen macht ein spaeterer, gezielterer Treffer jetzt Platz. Als
    schwaechster gilt ein Treffer ohne Volltext und ohne Textauszug aus der
    fruehesten Runde. Findet sich keiner, bleibt der Korb wie er ist.
    """
    def guete(eintrag: dict) -> tuple:
        return (
            eintrag.get("_runde", 0),
            1 if eintrag.get("full_text") else 0,
            1 if eintrag.get("snippet") else 0,
            len(eintrag.get("extra_snippets") or []),
        )

    kandidaten = [(k, v) for k, v in collected.items() if v.get("_runde", 0) < runde]
    if not kandidaten:
        return False
    schwaechster = min(kandidaten, key=lambda kv: guete(kv[1]))
    if schwaechster[1].get("full_text"):
        # Nur noch Treffer mit Volltext uebrig: die sind zu wertvoll zum
        # Verdraengen, der Korb bleibt wie er ist.
        return False
    del collected[schwaechster[0]]
    return True


def _parse_action(text: str) -> dict:
    """Aktions-JSON des Modells robust parsen. Fallback = finish."""
    from agents.researcher import _extract_json_object
    obj = None
    try:
        parsed = json.loads((text or "").strip())
        if isinstance(parsed, dict):
            obj = parsed
    except (json.JSONDecodeError, ValueError):
        pass
    if obj is None:
        obj = _extract_json_object(text or "")
    if not isinstance(obj, dict) or obj.get("action") not in ("search", "finish"):
        logger.warning("EU-Recherche: Aktions-JSON nicht parsebar, beende Suchphase. Sample: %r", (text or "")[:200])
        return {"action": "finish", "queries": []}
    queries = []
    for q in obj.get("queries") or []:
        if isinstance(q, dict) and str(q.get("q", "")).strip():
            queries.append({
                "q": str(q["q"]).strip()[:400],
                "market": str(q.get("market", "")).strip().lower(),
                "full_content": bool(q.get("full_content")),
            })
    obj["queries"] = queries[:MAX_QUERIES_PER_ROUND]
    return obj


async def run_eu_research(
    *,
    title: str,
    description: str,
    incident_type: str,
    lang_instruction: str,
    existing_context: str,
    preferred_sources_block: str,
    output_language: str,
    excluded_sources: list[str],
    research_language_iso: str,
) -> tuple[str, ClaudeUsage]:
    """Führt die komplette EU-Recherche aus. Gibt (json_array_text, usage) zurück.

    Der Rückgabetext wird vom bestehenden ResearcherAgent._parse_response
    weiterverarbeitet, der Vertrag entspricht damit exakt dem CLI-Weg.
    """
    total = ClaudeUsage()

    def _add(u: ClaudeUsage):
        total.input_tokens += u.input_tokens
        total.output_tokens += u.output_tokens
        total.cache_creation_tokens += u.cache_creation_tokens
        total.cache_read_tokens += u.cache_read_tokens
        total.cost_usd += u.cost_usd
        total.duration_ms += u.duration_ms

    is_research = incident_type == "research"
    max_rounds = EU_RESEARCH_MAX_ROUNDS_RESEARCH if is_research else EU_RESEARCH_MAX_ROUNDS_ADHOC
    guidance_map = _PHASE_GUIDANCE_RESEARCH if is_research else _PHASE_GUIDANCE_ADHOC
    today = datetime.now(TIMEZONE).strftime("%d.%m.%Y")
    default_market = market_for_language(research_language_iso)
    # Nutzer-Ausschlüsse an staan weiterreichen (die Pflichtliste belegt die
    # ersten Plätze, siehe staan_client). Zusätzlich filtert der Aufrufer
    # (ResearcherAgent.search) das Endergebnis erneut gegen die volle Liste.
    extra_exclude = [d for d in (excluded_sources or []) if d and "." in d][:6]

    collected: dict[str, dict] = {}
    rounds_log: list[str] = []
    searches_done = 0

    for round_no in range(1, max_rounds + 1):
        cancel = _cancel_event_var.get(None)
        if cancel and cancel.is_set():
            raise asyncio.CancelledError("Cancel angefordert")

        guidance = guidance_map.get(min(round_no, max(guidance_map.keys())))
        prompt = _ROUND_HEADER.format(
            round_no=round_no,
            max_rounds=max_rounds,
            today=today,
            title=title,
            description=description or "Keine weitere Beschreibung",
            existing_context=existing_context or "",
            preferred_sources_block=preferred_sources_block or "",
            lang_instruction=lang_instruction,
            phase_guidance=guidance,
            n_results=len(collected),
            results_block=_results_block(collected, with_texts=False),
            rounds_log="\n".join(rounds_log) or "(noch keine)",
            max_queries=MAX_QUERIES_PER_ROUND,
            max_fulltext=MAX_FULLTEXT_QUERIES_PER_ROUND,
            default_market=default_market,
        )
        text, usage = await call_bedrock(prompt, model=CLAUDE_MODEL_STANDARD, timeout=420)
        _add(usage)

        action = _parse_action(text)
        if action["action"] == "finish":
            if round_no == 1 and not collected:
                logger.warning("EU-Recherche: Modell wollte ohne eine einzige Suche beenden")
            else:
                logger.info("EU-Recherche: Suchphase nach Runde %d beendet (%s)", round_no - 1, action.get("reason", ""))
            break

        fulltext_used = 0
        neu_in_runde = 0
        for q in action["queries"]:
            if neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
                logger.info(
                    "EU-Recherche: Rundenkontingent %d erreicht, Rest der Runde uebersprungen",
                    EU_RESEARCH_MAX_NEW_PER_ROUND,
                )
                break
            want_full = q["full_content"] and fulltext_used < MAX_FULLTEXT_QUERIES_PER_ROUND
            if want_full:
                fulltext_used += 1
            market = q["market"] if q["market"] else default_market
            try:
                results = await staan_search(
                    q["q"], market=market, extra_snippets=True, max_snippets=4,
                    full_content=want_full, extra_exclude=extra_exclude,
                )
                searches_done += 1
            except StaanError as e:
                logger.warning("EU-Recherche: Suche fehlgeschlagen (%s), überspringe Query", e)
                rounds_log.append(f"Runde {round_no}: '{q['q'][:70]}' -> FEHLER")
                continue
            new_count = 0
            for r in results:
                if not r["url"]:
                    continue
                key = r["url"]
                if key in collected:
                    # Volltext/Snippets aus späteren Suchen ergänzen
                    if r.get("full_text") and not collected[key].get("full_text"):
                        collected[key]["full_text"] = r["full_text"]
                    continue
                if neu_in_runde >= EU_RESEARCH_MAX_NEW_PER_ROUND:
                    break
                if len(collected) >= MAX_TOTAL_RESULTS and not _platz_schaffen(collected, round_no):
                    break
                r["_runde"] = round_no
                collected[key] = r
                new_count += 1
                neu_in_runde += 1
            rounds_log.append(
                f"Runde {round_no}: '{q['q'][:70]}' ({market}{', Volltexte' if want_full else ''}) -> {new_count} neue Treffer"
            )

        if not action["queries"]:
            break

        # Bringt eine ganze Runde keinen einzigen neuen Treffer, ist das Thema
        # ausgeschoepft. Weitere Runden wuerden nur das teuerste Modell
        # befragen, ohne dass danach etwas Neues dazukaeme.
        if neu_in_runde == 0:
            logger.info(
                "EU-Recherche: Suchphase nach Runde %d beendet, keine neuen Treffer mehr",
                round_no,
            )
            break

    total.cost_usd += searches_done * STAAN_COST_PER_QUERY_USD

    if not collected:
        logger.warning("EU-Recherche: keine Treffer gesammelt (%d Suchen)", searches_done)
        return "[]", total

    target = "15 bis 25" if is_research else "8 bis 15"
    summary_len = "5 bis 8 Sätze" if is_research else "3 bis 5 Sätze"
    final_prompt = _FINAL_PROMPT.format(
        today=today,
        output_language=output_language,
        title=title,
        description=description or "Keine weitere Beschreibung",
        target=target,
        summary_len=summary_len,
        results_block=_results_block(collected, with_texts=True),
    )
    text, usage = await call_bedrock(final_prompt, model=CLAUDE_MODEL_STANDARD, timeout=600)
    _add(usage)

    from agents.researcher import _extract_json_array
    arr = None
    try:
        parsed = json.loads((text or "").strip())
        if isinstance(parsed, list):
            arr = parsed
    except (json.JSONDecodeError, ValueError):
        pass
    if arr is None:
        arr = _extract_json_array(text or "")
    if not isinstance(arr, list):
        # Unparsebarer Abschluss: Rohtext zurückgeben, damit der bestehende
        # Recovery-Pfad in _parse_response greift und die Usage verbucht wird.
        logger.warning("EU-Recherche: Abschluss-JSON nicht parsebar, reiche Rohtext weiter")
        return text or "[]", total

    # Anti-Halluzination. Nur URLs akzeptieren, die wirklich in den staan-Treffern
    # vorkamen. published_at, das nicht wie ein Datum aussieht, wird verworfen.
    valid = {_norm_url(u) for u in collected}
    validated = []
    dropped = 0
    for item in arr:
        if not isinstance(item, dict):
            continue
        if _norm_url(item.get("source_url", "")) not in valid:
            dropped += 1
            continue
        pub = item.get("published_at")
        if pub is not None and not isinstance(pub, str):
            item["published_at"] = None
        validated.append(item)
    if dropped:
        logger.warning("EU-Recherche: %d Artikel mit nicht belegten URLs verworfen", dropped)

    logger.info(
        "EU-Recherche fertig: %d Artikel aus %d Treffern, %d Suchen, %d Modellrunden, $%.4f",
        len(validated), len(collected), searches_done, round_no + 1, total.cost_usd,
    )
    return json.dumps(validated, ensure_ascii=False), total


# --- Phase 3: Stützsuche für Faktencheck und Lagebild --------------------------
# Faktenchecker und Analyzer verlangen in ihren Prompts die eingebaute Websuche
# (WebSearch/WebFetch), die es auf Bedrock nicht gibt. Im EU-Modus ersetzt
# eu_call_with_search diese Aufrufe. Optional läuft vorab eine gezielte
# staan-Stützsuche (Haiku plant Verifikations-Queries), deren Treffer als
# Kontextblock in den Prompt wandern, danach geht der Aufruf werkzeuglos über
# Bedrock. Der CLI-Modus bleibt in den Agenten unverändert.

_EU_TOOL_HINT = """

HINWEIS ZUR WEBSUCHE (EU-Modus):
WebSearch und WebFetch stehen NICHT zur Verfügung. Ignoriere alle Anweisungen oben,
diese Werkzeuge zu benutzen.{search_note} Behauptungen, die sich so nicht belegen
lassen, bekommen den entsprechenden Unbestätigt-Status."""

_SEARCH_NOTE_WITH_RESULTS = (
    " Nutze stattdessen die unten angehängten ERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
    "als unabhängige Zweitquellen und zitiere sie als Evidenz, wenn sie eine "
    "Behauptung stützen oder widerlegen. Zusätzlich zählen die übergebenen Meldungen."
    " WICHTIG: Nenne bei jedem Beleg, auf den du dich stützt, immer die vollständige "
    "Adresse (die URL hinter der [S..]-Nummer), nicht nur die Nummer. Ohne Adresse "
    "gilt ein Beleg als nicht nachprüfbar."
    " EBENSO WICHTIG, so prüfst du einen Beleg: Ein Treffer, der nur das Thema "
    "erwähnt, ist KEIN Beleg. Die konkrete Aussage mit ihrer Zahl, ihrem Namen oder "
    "ihrem Datum muss im Titel, im Auszug oder im Artikeltext tatsächlich vorkommen. "
    "Steht dort nur, dass es die Lage gibt, oder ist die Angabe vager als deine "
    "Behauptung, dann trägt dieser Treffer sie nicht. Zähle ihn dann nicht mit und "
    "sage das in der Begründung ehrlich. Lieber ein unbestätigter Punkt mit klarer "
    "Begründung als eine Bestätigung, die einer Nachprüfung nicht standhält."
)
_SEARCH_NOTE_NO_RESULTS = (
    " Stütze dich ausschließlich auf die übergebenen Meldungen und Quellen."
)

_VERIFY_QUERY_PROMPT = """Du planst Verifikations-Suchanfragen für einen OSINT-Faktencheck.
Heute ist der {today}. Lage: {title}

ZU PRÜFENDE PUNKTE (nummeriert):
{items_block}

AUFGABE:
Erzeuge bis zu {max_queries} Websuche-Anfragen. Plane sie GEZIELT je Punkt, nicht
pauschal für alle zusammen. Eine gute Anfrage macht genau einen dieser Punkte
überprüfbar, mit seinen konkreten Zahlen, Namen und Orten.

Davon sind {counter_queries} Anfragen ausdrücklich GEGENPROBEN. Eine Gegenprobe sucht
nicht nach Bestätigung, sondern nach allem, was der Behauptung widerspricht. Also nach
Dementis, Richtigstellungen, abweichenden Zahlen anderer Stellen, nach der Darstellung
der Gegenseite und nach Berichten, die den Vorgang anders einordnen. Wer nur nach
Bestätigung sucht, findet auch nur Bestätigung, und der Faktencheck wird wertlos.
Beispiele für Gegenproben: "Behörde dementiert Angaben zu X", "X Zahlen umstritten
Kritik", "X widerspricht Darstellung". Markiere sie mit "gegenprobe": true.

REGELN:
- Ordne jeder Anfrage über "for_items" zu, welche Punkte sie prüfen soll.
- Decke möglichst viele verschiedene Punkte ab, statt denselben mehrfach zu suchen.
- Priorisiere Punkte mit harten, überprüfbaren Angaben (Zahlen, Daten, Zitate, Beschlüsse).
- Wähle für die Gegenproben die Punkte aus, bei denen ein Irrtum am schwersten wiegt,
  also Opferzahlen, Zuschreibungen von Verantwortung, Beschlüsse und Zitate.
- Setze "full_content": true bei den Anfragen, deren Punkt sich nur im Artikeltext
  wirklich prüfen lässt, höchstens bei {fulltext_queries} Anfragen. Bei diesen bekommst
  du den Artikeltext statt nur der Überschrift.
- Nutze die Sprache, in der die Berichterstattung zu erwarten ist. Für Ereignisse in
  einem Land sind Quellen in dessen Landessprache oft ergiebiger.
- Keine Jahreszahlen anhängen, keine Wiederholung derselben Anfrage.
{avoid_block}
Antworte NUR mit JSON:
{{"queries": [{{"q": "suchbegriffe", "market": "{default_market}", "for_items": [1, 2],
"gegenprobe": false, "full_content": false}}]}}
"market" ist "de-de", "en-us" oder "fr-fr". Fremdsprachige Anfragen funktionieren mit "en-us"."""


def _iso_from_display(output_language: str) -> str:
    """Anzeige-Sprachname der Agenten ('Deutsch') auf ISO für die Marktwahl."""
    mapping = {"deutsch": "de", "english": "en", "french": "fr"}
    return mapping.get((output_language or "").strip().lower(), "en")


def _add_usage(total: ClaudeUsage, u: ClaudeUsage | None) -> None:
    """Zaehlt einen Einzelverbrauch auf die Gesamtsumme."""
    if not u:
        return
    total.input_tokens += u.input_tokens
    total.output_tokens += u.output_tokens
    total.cache_creation_tokens += u.cache_creation_tokens
    total.cache_read_tokens += u.cache_read_tokens
    total.cost_usd += u.cost_usd
    total.duration_ms += u.duration_ms


@dataclass
class Belegsuche:
    """Ergebnis einer gezielten Belegsuche.

    block    = fertiger Kontextblock fuer den Auftrag, leer wenn nichts gefunden
    usage    = Verbrauch aus Planung und Suche
    queries  = tatsaechlich ausgefuehrte Suchanfragen, fuer die Nachrunde
    quellen  = gefundene Quellen einzeln, fuer die Quellenzuordnung im Faktencheck
    """
    block: str
    usage: ClaudeUsage
    queries: list[str]
    quellen: list[dict]


async def plan_verification_searches(
    *,
    title: str,
    search_items: list[str],
    output_language: str = "Deutsch",
    max_queries: int = EU_VERIFY_MAX_QUERIES,
    avoid_queries: list[str] | None = None,
    label: str = "Stützsuche",
    nur_gegenproben: bool = False,
) -> Belegsuche:
    """Plant gezielte Belegsuchen zu den uebergebenen Punkten und fuehrt sie aus.

    Die Planung erfolgt punktbezogen, das planende Modell ordnet jeder Anfrage
    zu, welche Punkte sie belegen soll. avoid_queries verhindert, dass eine
    Nachrunde dieselben Anfragen noch einmal stellt.

    Gibt eine Belegsuche zurueck. Der Kontextblock ist leer, wenn nichts
    gefunden wurde. Die gefundenen Quellen werden zusaetzlich einzeln
    zurueckgegeben, damit die Quellenzuordnung im Faktencheck sie kennt.
    """
    from config import CLAUDE_MODEL_FAST

    total = ClaudeUsage()
    items = [str(s).strip() for s in (search_items or []) if str(s).strip()][:EU_VERIFY_MAX_ITEMS]
    if not items:
        return Belegsuche("", total, [], [])

    default_market = market_for_language(_iso_from_display(output_language))
    avoid_block = ""
    if avoid_queries:
        avoid_block = (
            "- Diese Anfragen wurden bereits gestellt und brachten nicht genug, stelle ANDERE:\n"
            + "\n".join(f"  {q[:120]}" for q in avoid_queries[:10]) + "\n"
        )

    # Ein fester Anteil der Anfragen sucht nach Widerspruch statt nach
    # Bestaetigung. Ohne das sieht der Faktencheck nur stuetzendes Material und
    # bestaetigt praktisch alles.
    if nur_gegenproben:
        anzahl_gegenproben = max_queries
    else:
        anzahl_gegenproben = max(1, round(max_queries * EU_VERIFY_COUNTER_SHARE)) if max_queries >= 3 else 0
    anzahl_volltexte = min(EU_VERIFY_FULLTEXT_QUERIES, max_queries)

    plan_prompt = _VERIFY_QUERY_PROMPT.format(
        today=datetime.now(TIMEZONE).strftime("%d.%m.%Y"),
        title=title or "(ohne Titel)",
        items_block="\n".join(f"{i}. {s[:220]}" for i, s in enumerate(items, 1)),
        max_queries=max_queries,
        counter_queries=anzahl_gegenproben,
        fulltext_queries=anzahl_volltexte,
        default_market=default_market,
        avoid_block=avoid_block,
    )

    try:
        plan_text, plan_usage = await call_bedrock(plan_prompt, model=CLAUDE_MODEL_FAST, timeout=120)
        _add_usage(total, plan_usage)
    except Exception as e:
        logger.warning("EU-%s: Planung der Anfragen fehlgeschlagen (%s), fahre ohne Suche fort", label, e)
        return Belegsuche("", total, [], [])

    from agents.researcher import _extract_json_object
    obj = _extract_json_object(plan_text or "")
    queries: list[dict] = []
    gesehen: set[str] = {(q or "").strip().lower() for q in (avoid_queries or [])}
    for q in (obj or {}).get("queries", []) or []:
        if not isinstance(q, dict):
            continue
        text = str(q.get("q", "")).strip()[:400]
        if not text or text.lower() in gesehen:
            continue
        gesehen.add(text.lower())
        queries.append({
            "q": text,
            "market": str(q.get("market", "")).strip().lower(),
            "gegenprobe": nur_gegenproben or bool(q.get("gegenprobe")),
            "full_content": bool(q.get("full_content")),
        })
        if len(queries) >= max_queries:
            break

    if not queries:
        logger.warning("EU-%s: Planung lieferte keine brauchbaren Anfragen", label)
        return Belegsuche("", total, [], [])

    lines: list[str] = []
    ausgefuehrt: list[str] = []
    quellen: list[dict] = []
    gesehene_urls: set[str] = set()
    treffer = 0
    volltexte_genutzt = 0
    gegenproben_gelaufen = 0
    for q in queries:
        will_volltext = q["full_content"] and volltexte_genutzt < anzahl_volltexte
        try:
            res = await staan_search(
                q["q"],
                market=q["market"] if q["market"] in ("de-de", "en-us", "fr-fr") else default_market,
                extra_snippets=True, max_snippets=3, full_content=will_volltext,
            )
            ausgefuehrt.append(q["q"])
            if will_volltext:
                volltexte_genutzt += 1
            if q["gegenprobe"]:
                gegenproben_gelaufen += 1
        except StaanError as e:
            logger.warning("EU-%s: Suche fehlgeschlagen (%s)", label, e)
            continue
        for r in res[:EU_VERIFY_HITS_PER_QUERY]:
            # Mehrere Anfragen finden oft dieselbe Seite, jede Quelle nur einmal
            # in den Kontextblock legen.
            url = (r.get("url") or "").strip()
            if url and url in gesehene_urls:
                continue
            if url:
                gesehene_urls.add(url)
            treffer += 1
            # Die Quelle wird zusaetzlich einzeln gefuehrt, damit die
            # Quellenzuordnung im Faktencheck sie wie eine Meldung behandeln
            # kann. Ohne das gelten Fakten als unbelegt, die sich allein auf
            # diese Belege stuetzen.
            quellen.append({
                "headline": r["title"] or "",
                "source": r["hostname"] or "",
                "source_url": url,
            })
            marke = " [GEGENPROBE]" if q["gegenprobe"] else ""
            lines.append(
                f"[S{treffer}]{marke} {r['title'] or '(ohne Titel)'} | {r['hostname']} | {r['url']}")
            if r.get("snippet"):
                lines.append(f"     Auszug: {r['snippet'][:300]}")
            for chunk in (r.get("extra_snippets") or [])[:1]:
                lines.append(f"     Auszug: {chunk[:300]}")
            # Volltext, damit das Modell pruefen kann, ob die Quelle die
            # Behauptung wirklich traegt, statt aus der Ueberschrift zu schliessen.
            volltext = (r.get("full_text") or "").strip()
            if volltext:
                lines.append(f"     Artikeltext: {volltext[:EU_VERIFY_FULLTEXT_CHARS]}")

    total.cost_usd += len(ausgefuehrt) * STAAN_COST_PER_QUERY_USD
    logger.info(
        "EU-%s: %d Suchen (davon %d Gegenproben, %d mit Artikeltext), %d Treffer im Kontextblock",
        label, len(ausgefuehrt), gegenproben_gelaufen, volltexte_genutzt, treffer)

    if not lines:
        return Belegsuche("", total, ausgefuehrt, [])

    kopf = (
        "\n\nERGEBNISSE DER EUROPÄISCHEN WEBSUCHE "
        "(von unserem System ausgeführt, als unabhängige Zweitquellen nutzbar).\n"
    )
    if gegenproben_gelaufen:
        kopf += (
            "Ein Teil dieser Suchen war eine GEGENPROBE, sie suchte gezielt nach "
            "Widerspruch, Dementi oder abweichenden Angaben. Diese Treffer sind mit "
            "[GEGENPROBE] gekennzeichnet. Findet sich dort nichts Widersprechendes, "
            "stützt das die Behauptung zusätzlich. Findet sich etwas, muss es in die "
            "Bewertung einfließen.\n"
        )
    block = kopf + "\n".join(lines)
    return Belegsuche(block, total, ausgefuehrt, quellen)


def build_eu_prompt(prompt: str, results_block: str = "") -> str:
    """Haengt den Werkzeug-Hinweis und einen etwaigen Belegblock an."""
    hint = _EU_TOOL_HINT.format(
        search_note=_SEARCH_NOTE_WITH_RESULTS if results_block else _SEARCH_NOTE_NO_RESULTS
    )
    return prompt + hint + results_block


async def eu_call_with_search(
    prompt: str,
    *,
    title: str = "",
    search_items: list[str] | None = None,
    output_language: str = "Deutsch",
    max_queries: int = EU_VERIFY_MAX_QUERIES,
) -> tuple[str, ClaudeUsage]:
    """EU-Ersatz für call_claude mit Default-Tools (Faktencheck/Lagebild).

    Mit search_items läuft vorab die gezielte staan-Belegsuche, ohne wird nur
    der Werkzeug-Hinweis angehängt. Der Modellaufruf geht werkzeuglos über
    call_claude und damit (aktives Bedrock-Backend vorausgesetzt) über
    Frankfurt. Gibt (result_text, gesamt_usage) zurück."""
    from agents.claude_client import call_claude

    total = ClaudeUsage()
    results_block = ""
    if search_items:
        suche = await plan_verification_searches(
            title=title, search_items=search_items,
            output_language=output_language, max_queries=max_queries,
        )
        results_block = suche.block
        _add_usage(total, suche.usage)

    result, usage = await call_claude(build_eu_prompt(prompt, results_block), tools=None)
    _add_usage(total, usage)
    return result, total
