"""Factchecker-Agent: Prüft Fakten gegen mehrere unabhängige Quellen."""
import asyncio
import json
import logging
import re
from difflib import SequenceMatcher
from agents.claude_client import call_claude, ClaudeUsage, UsageAccumulator

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

FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

VORFALL: {title}

VORLIEGENDE MELDUNGEN:
{articles_text}

STRENGE REGELN - KEINE HALLUZINATIONEN:
- Du darfst NUR Fakten bewerten, die direkt aus den oben übergebenen Meldungen stammen
- KEINE Fakten aus deinem Trainingskorpus - NUR aus den übergebenen Meldungen + WebSearch
- Nutze WebSearch um jeden Claim gegen mindestens 1 weitere unabhängige Quelle zu prüfen
- Rufe die gefundenen URLs per WebFetch ab um den Inhalt zu verifizieren
- Nur wenn du den Claim in der tatsächlich abgerufenen Quelle findest, darfst du ihn als bestätigt markieren
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten
- "confirmed" erst bei 2+ unabhängigen Quellen mit überprüfbarer URL
- Lieber "unconfirmed" als falsch bestätigt

AUFTRAG:
1. Identifiziere die wichtigsten Faktenaussagen aus den Meldungen. Als Richtwert
   sind das etwa {fakten_min} bis {fakten_max} Aussagen. Das ist ein Richtwert und keine
   Zielvorgabe. Nenne weniger, wenn das Material nicht mehr hergibt, und mehr, wenn
   sonst wichtige Punkte der Lage fehlen wuerden.
2. Prüfe jeden Claim aktiv per WebSearch gegen mindestens eine weitere unabhängige Quelle
3. Kategorisiere jede Aussage:
   - "confirmed": Mindestens 2 unabhängige Medienhäuser mit überprüfbarer URL bestätigen die Aussage
   - "unconfirmed": Nur ein Medienhaus, oder nicht unabhängig verifizierbar
   - "contradicted": Die Belege widersprechen EINANDER in der Sache, keine Seite ist entschieden
   - "false": Ein Beleg widerlegt die Behauptung nachweislich
   - "developing": Situation noch unklar, entwickelt sich
4. Markiere WICHTIGE NEUE Entwicklungen mit is_notification: true

Antworte AUSSCHLIESSLICH als JSON-Array. Jedes Element hat:
- "claim": Die Faktenaussage auf {output_language}
- "status": "confirmed" | "unconfirmed" | "contradicted" | "false" | "developing"
- "sources_count": Anzahl unabhängiger Medienhäuser mit überprüfbarer URL
- "evidence_type": "primary" | "secondary" | "agency" (siehe Bewertungsregeln)
- "evidence": Begründung MIT konkreten Quellen-URLs als Beleg (z.B. "Bestätigt durch: tagesschau.de (URL), Reuters (URL)")
- "is_notification": true/false (nur bei wichtigen Entwicklungen true)

Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""

RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA: {title}

VORLIEGENDE QUELLEN:
{articles_text}

STRENGE REGELN - KEINE HALLUZINATIONEN:
- Du darfst NUR Fakten bewerten, die direkt aus den oben übergebenen Quellen stammen
- KEINE Fakten aus deinem Trainingskorpus - NUR aus den übergebenen Quellen + WebSearch
- Nutze WebSearch um jeden Claim gegen mindestens 1 weitere unabhängige Quelle zu prüfen
- Rufe die gefundenen URLs per WebFetch ab um den Inhalt zu verifizieren
- Nur wenn du den Claim in der tatsächlich abgerufenen Quelle findest, darfst du ihn als gesichert markieren
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten
- Lieber "unverified" als falsch bestätigt

AUFTRAG:
Fokus: "Was sind die gesicherten Fakten zu diesem Thema?"

1. Identifiziere die wichtigsten Faktenaussagen aus den Quellen. Als Richtwert
   sind das etwa {fakten_min} bis {fakten_max} Aussagen. Das ist ein Richtwert und keine
   Zielvorgabe. Nenne weniger, wenn das Material nicht mehr hergibt, und mehr, wenn
   sonst wichtige Punkte der Lage fehlen wuerden.
2. Prüfe jeden Claim aktiv per WebSearch gegen weitere unabhängige Quellen
3. Kategorisiere jede Aussage:
   - "established": Breit dokumentierter Fakt, 3+ unabhängige Medienhäuser mit URL
   - "disputed": Die Belege widersprechen EINANDER, verschiedene Positionen dokumentiert
   - "false": Ein Beleg widerlegt die Behauptung nachweislich
   - "unverified": Einzelbehauptung, nicht unabhängig verifizierbar
   - "developing": Aktuelle Entwicklung, Faktenlage noch im Fluss
4. Markiere WICHTIGE Erkenntnisse mit is_notification: true

Antworte AUSSCHLIESSLICH als JSON-Array. Jedes Element hat:
- "claim": Die Faktenaussage auf {output_language}
- "status": "established" | "disputed" | "false" | "unverified" | "developing"
- "sources_count": Anzahl unabhängiger Medienhäuser mit überprüfbarer URL
- "evidence_type": "primary" | "secondary" | "agency" (siehe Bewertungsregeln)
- "evidence": Begründung MIT konkreten Quellen-URLs als Beleg
- "is_notification": true/false

Antworte NUR mit dem JSON-Array. Keine Einleitung, keine Erklärung."""

# --- Inkrementelle Faktencheck-Prompts (für Folge-Refreshes) ---

INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für ein OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

VORFALL: {title}

BEREITS GEPRÜFTE FAKTEN:
{existing_facts_text}

NEUE MELDUNGEN:
{articles_text}

STRENGE REGELN - KEINE HALLUZINATIONEN:
- Du darfst NUR Fakten bewerten, die aus den Meldungen oder bereits geprüften Fakten stammen
- KEINE Fakten aus deinem Trainingskorpus
- Nutze WebSearch zur Verifikation
- Rufe gefundene URLs per WebFetch ab

AUFTRAG:
1. Prüfe ob die neuen Meldungen bereits geprüfte Fakten BESTÄTIGEN, WIDERLEGEN oder ERGÄNZEN
2. Aktualisiere den Status bestehender Fakten wenn nötig (z.B. "unconfirmed" → "confirmed")
3. Identifiziere NEUE Faktenaussagen aus den neuen Meldungen, als Richtwert etwa
   {fakten_min} bis {fakten_max}. Auch das ist ein Richtwert, keine Zielvorgabe.
4. Prüfe neue Claims per WebSearch gegen unabhängige Quellen
5. Markiere wichtige Statusänderungen und neue Entwicklungen mit is_notification: true

Status-Kategorien:
- "confirmed": 2+ unabhängige Medienhäuser mit URL
- "unconfirmed": Nur ein Medienhaus
- "contradicted": Die Belege widersprechen einander
- "false": Ein Beleg widerlegt die Behauptung nachweislich
- "developing": Situation unklar

Antworte AUSSCHLIESSLICH als JSON-Array mit ALLEN Fakten (bestehende aktualisiert + neue).
Jedes Element hat:
- "claim": Die Faktenaussage auf {output_language}
- "status": "confirmed" | "unconfirmed" | "contradicted" | "false" | "developing"
- "sources_count": Anzahl unabhängiger Medienhäuser
- "evidence_type": "primary" | "secondary" | "agency"
- "evidence": Begründung MIT konkreten Quellen-URLs
- "is_notification": true/false

Antworte NUR mit dem JSON-Array."""

INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE = """Du bist ein Faktencheck-Agent für eine Hintergrundrecherche in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA: {title}

BEREITS GEPRÜFTE FAKTEN:
{existing_facts_text}

NEUE QUELLEN:
{articles_text}

STRENGE REGELN - KEINE HALLUZINATIONEN:
- Du darfst NUR Fakten bewerten, die aus den Quellen oder bereits geprüften Fakten stammen
- KEINE Fakten aus deinem Trainingskorpus
- Nutze WebSearch zur Verifikation
- Rufe gefundene URLs per WebFetch ab

AUFTRAG:
1. Prüfe ob die neuen Quellen bereits geprüfte Fakten bestätigen, widerlegen oder ergänzen
2. Aktualisiere den Status bestehender Fakten wenn nötig
3. Identifiziere NEUE Faktenaussagen aus den neuen Quellen, als Richtwert etwa
   {fakten_min} bis {fakten_max}. Auch das ist ein Richtwert, keine Zielvorgabe.
4. Prüfe neue Claims per WebSearch

Status-Kategorien:
- "established": 3+ unabhängige Medienhäuser mit URL
- "disputed": Die Belege widersprechen einander, verschiedene Positionen dokumentiert
- "false": Ein Beleg widerlegt die Behauptung nachweislich
- "unverified": Nicht unabhängig verifizierbar
- "developing": Faktenlage im Fluss

Antworte AUSSCHLIESSLICH als JSON-Array mit ALLEN Fakten (bestehende aktualisiert + neue).
Jedes Element hat:
- "claim": Die Faktenaussage auf {output_language}
- "status": "established" | "disputed" | "false" | "unverified" | "developing"
- "sources_count": Anzahl unabhängiger Medienhäuser
- "evidence_type": "primary" | "secondary" | "agency"
- "evidence": Begründung MIT konkreten Quellen-URLs
- "is_notification": true/false

Antworte NUR mit dem JSON-Array."""

# --- Zwei-Phasen-Faktencheck: Prompt-Templates ---

TRIAGE_PROMPT_TEMPLATE = """Du bist ein Triage-System für Faktenchecks eines OSINT-Lagemonitoring-Systems.
AUSGABESPRACHE: {output_language}

BESTEHENDE FAKTENAUSSAGEN ({fact_count} Stück):
{existing_facts_text}

NEUE NACHRICHTENARTIKEL ({article_count} Stück):
{articles_text}

AUFGABE:
Analysiere die neuen Artikel und identifiziere:

1. BETROFFENE BESTEHENDE FAKTEN: Welche der bestehenden Fakten könnten durch die neuen Artikel
   betroffen sein? (neue Bestätigung, Widerlegung, neue Evidenz, Status-Update nötig)

2. NEUE FAKTENAUSSAGEN: Welche neuen prüfbaren Faktenaussagen enthalten die Artikel,
   die noch nicht in den bestehenden Fakten erfasst sind? Als Richtwert etwa
   {fakten_min} bis {fakten_max} neue Claims, je nachdem was die Artikel hergeben.

3. GRUPPIERUNG: Gruppiere verwandte Fakten thematisch für die parallele Batch-Verarbeitung.
   Verwandte Fakten MÜSSEN in derselben Gruppe sein. Max {max_per_group} Fakten pro Gruppe.

WICHTIG:
- Sei GRÜNDLICH — übersehe keine semantischen Verbindungen
- Auch indirekte Verbindungen beachten (diplomatisch <-> militärisch <-> wirtschaftlich)
- Alle developing/unconfirmed/unverified Fakten IMMER einbeziehen
- Max {max_per_group} Fakten pro Gruppe (teile große Gruppen auf)

Antworte AUSSCHLIESSLICH als JSON:
{{{{
  "affected_fact_ids": [int, ...],
  "new_claims": ["Prüfbare Faktenaussage als vollständiger Satz", ...],
  "groups": [
    {{{{
      "id": 1,
      "theme": "Themenbezeichnung",
      "fact_ids": [int, ...],
      "new_claim_indices": [int, ...]
    }}}}
  ],
  "total_affected": int,
  "reasoning": "Kurze Begründung"
}}}}"""

VERIFY_GROUP_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA DIESER GRUPPE: {theme}

ZU PRÜFENDE BESTEHENDE FAKTEN:
{facts_text}

NEUE CLAIMS ZU PRÜFEN:
{new_claims_text}

KONTEXT (neue Nachrichtenartikel):
{articles_text}

ANWEISUNGEN:
Für JEDE Faktenaussage (bestehend UND neu):
1. Suche per WebSearch nach unabhängigen Bestätigungen oder Widerlegungen
2. Bewerte den Status:
   - "confirmed": Mindestens 2 unabhängige Medienhäuser mit verifizierter URL bestätigen
   - "unconfirmed": Nicht genug unabhängige Bestätigung
   - "contradicted": Die Belege widersprechen einander in der Sache
   - "false": Ein Beleg widerlegt die Behauptung nachweislich
   - "developing": Lage noch im Fluss
3. Dokumentiere die Evidenz mit konkreten, verifizierten URLs

QUALITÄTSREGELN:
- "confirmed" NUR bei mindestens 2 unabhängigen Medienhäusern mit ECHTER URL
- KEINE Halluzinationen — nur tatsächlich per WebSearch gefundene Quellen
- Bestehende Evidenz BEIBEHALTEN, nur neue ergänzen
- Jeder Claim MUSS eine konkrete Quellen-URL als Beleg enthalten

Antworte AUSSCHLIESSLICH als JSON-Array:
[
  {{{{
    "id": 123,
    "claim": "Die Faktenaussage...",
    "status": "confirmed|unconfirmed|contradicted|false|developing",
    "sources_count": 3,
    "evidence_type": "primary|secondary|agency",
    "evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
    "is_notification": false
  }}}}
]

Für NEUE Fakten setze id auf null."""

VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE = """Du prüfst Faktenaussagen gegen unabhängige Quellen in einem OSINT-Lagemonitoring-System.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche (— oder –) verwenden, stattdessen Kommas, Doppelpunkte oder neue Saetze.
WICHTIG: Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß) — NIEMALS Umschreibungen (ae, oe, ue, ss).

THEMA DIESER GRUPPE: {theme}

ZU PRÜFENDE BESTEHENDE FAKTEN:
{facts_text}

NEUE CLAIMS ZU PRÜFEN:
{new_claims_text}

KONTEXT (neue Quellen):
{articles_text}

ANWEISUNGEN:
Für JEDE Faktenaussage (bestehend UND neu):
1. Suche per WebSearch nach unabhängigen Bestätigungen oder Widerlegungen
2. Bewerte den Status:
   - "established": 3+ unabhängige Medienhäuser mit verifizierter URL
   - "unverified": Nicht unabhängig verifizierbar
   - "disputed": Die Belege widersprechen einander, verschiedene Positionen dokumentiert
   - "false": Ein Beleg widerlegt die Behauptung nachweislich
   - "developing": Faktenlage im Fluss
3. Dokumentiere die Evidenz mit konkreten, verifizierten URLs

QUALITÄTSREGELN:
- "established" NUR bei mindestens 3 unabhängigen Medienhäusern mit ECHTER URL
- KEINE Halluzinationen — nur tatsächlich per WebSearch gefundene Quellen
- Bestehende Evidenz BEIBEHALTEN, nur neue ergänzen

Antworte AUSSCHLIESSLICH als JSON-Array:
[
  {{{{
    "id": 123,
    "claim": "Die Faktenaussage...",
    "status": "established|unverified|disputed|false|developing",
    "sources_count": 3,
    "evidence_type": "primary|secondary|agency",
    "evidence": "Bestätigt durch: Quelle1 (URL)\\nQuelle2 (URL)",
    "is_notification": false
  }}}}
]

Für NEUE Fakten setze id auf null."""


# --- Stopwords fuer Keyword-Extraktion ---
_STOPWORDS = frozenset({
    "der", "die", "das", "ein", "eine", "und", "oder", "von", "nach", "bei", "mit",
    "wurde", "wird", "haben", "sein", "dass", "ist", "sind", "hat", "vor", "fuer",
    "den", "dem", "des", "sich", "auf", "als", "auch", "noch", "nicht", "aber",
    "ueber", "durch", "einer", "einem", "eines", "werden", "wurde", "waren",
    "the", "and", "was", "has", "been", "have", "that", "with", "from", "for",
    "are", "were", "this", "which", "into", "their", "than", "about",
})

STATUS_PRIORITY = {
    "confirmed": 5, "established": 5,
    "contradicted": 4, "disputed": 4, "false": 4,
    "unconfirmed": 3, "unverified": 3,
    "developing": 1,
}

# Status, die eine belegte Bestaetigung behaupten und deshalb an der Zahl
# unabhaengiger Medienhaeuser gemessen werden.
BESTAETIGT_STATUS = {"confirmed": 2, "established": 3}

# Ein Primaerbeleg (Originalzitat im Interview, amtliche Mitteilung, Urteil,
# Pressemitteilung der handelnden Stelle) traegt allein. Mehrfache
# Berichterstattung ueber denselben O-Ton ist keine zusaetzliche Verifikation,
# und ihr Fehlen ist kein Mangel. Ohne diese Ausnahme wurde im Vergleich vom
# 01.08.2026 ein ZDF-Interview als "unbestaetigt" gefuehrt, obwohl der
# Betroffene die Aussage dort selbst getroffen hat.
PRIMAERBELEG_MINDESTZAHL = 1

_BEWERTUNGSREGELN = """

BEWERTUNGSREGELN (verbindlich, sie gehen allen Angaben oben vor):
- Unabhängig heißt: verschiedene Medienhäuser. Mehrere Artikel derselben
  Redaktion, Videofassungen derselben Meldung und wortgleiche Übernahmen
  derselben Agenturmeldung zählen zusammen als EINE Quelle.
- Weiterleitungen über Sammelportale (news.google.com und ähnliche) sind kein
  eigenständiger Beleg. Zähle das Zielmedium, wenn es erkennbar ist.
- "sources_count" ist die Zahl unabhängiger MEDIENHÄUSER, nicht die Zahl der
  Fundstellen oder Adressen.
- "evidence_type" beschreibt den besten vorliegenden Beleg:
  "primary" = die handelnde oder sprechende Stelle selbst (Originalzitat im
  Interview, amtliche Mitteilung, Urteil, Pressemitteilung),
  "secondary" = unabhängige eigene Berichterstattung eines Mediums,
  "agency" = Agenturmeldung und ihre Übernahmen.
  Bei "primary" genügt diese eine Quelle für eine Bestätigung.
- Unterschiedliche Zahlenangaben verschiedener Erhebungsstellen sind KEIN
  Widerspruch. Nenne die Spanne mit Zuordnung ("50.000 laut Innenministerium,
  60.000 laut Regionalregierung") und bewerte die Aussage nach ihrer
  Belegtiefe. Ein Widerspruchs-Status ist nur richtig, wenn die Belege
  einander in der Sache widersprechen.
- Der Status für eine nachweisliche Widerlegung ist ausschließlich "false" und
  setzt einen Beleg voraus, der die Behauptung entkräftet. Eine fehlende
  Bestätigung ist keine Widerlegung.
- Prüfe nur Tatsachenbehauptungen. Aussagen über den Kenntnisstand ("Der
  Auslöser ist unklar", "Eine unabhängige Bestätigung steht aus", "Die Lage
  bleibt unübersichtlich") sind nicht prüfbar und gehören NICHT in den
  Faktencheck. Formuliere stattdessen die prüfbare Tatsache dahinter oder lass
  den Punkt weg.
- Die Behauptung nennt die Tatsache, nicht ihre Bewertung. Wertende Zusätze wie
  "freiwillig", "erfolgreich", "chaotisch", "rechtmäßig" gehören nur hinein,
  wenn genau dieser Zusatz belegt ist. Belegen die Quellen etwa eine Rückkehr,
  die nach behördlicher Aufforderung und unter Androhung von Abschiebung
  erfolgte, dann lautet die Behauptung "sind zurückgekehrt", nicht "sind
  freiwillig zurückgekehrt".
- Korrigierte Werte sind eine Zeitreihe, keine Spanne. Wenn eine Zahl im Verlauf
  aktualisiert wurde, nenne den aktuellen Stand und die Korrektur ("mindestens
  67, zuvor wurden 57 gemeldet"), nicht beide Werte als Bandbreite ("57 bis
  67"). Eine Spanne ist nur richtig, wenn verschiedene Stellen zum selben
  Zeitpunkt verschiedene Werte erheben, dann mit Zuordnung.
- Nimm bewusst auch strittige Punkte auf. Ein Faktencheck, in dem jede
  Behauptung bestätigt wird, sagt dem Leser nichts. Wähle unter den Aussagen
  auch solche aus, die nur eine Quelle haben, umstritten sind oder von den
  Beteiligten unterschiedlich dargestellt werden, und bewerte sie ehrlich. Wenn
  die Meldungen wirklich nichts Strittiges hergeben, ist das ein Ergebnis und
  keine Pflicht zur Erfindung."""


def faktenspanne(anzahl_meldungen: int, neu: bool = False) -> tuple[int, int]:
    """Richtwert fuer die Zahl der Faktenaussagen, gemessen am Material.

    Frueher stand in allen Auftraegen fest "5-10 wichtigste Faktenaussagen".
    Das fuehrte dazu, dass aus 16 Meldungen genauso viele Fakten wurden wie aus
    91, obwohl die groessere Lage sichtbar mehr pruefbare Punkte hergab. Das
    Modell auf dem EU-Weg traf die Zehn in neun von siebzehn Laeufen exakt, es
    las die Spanne als Zielvorgabe. Der Weg ueber die Anthropic-CLI behandelte
    sie als Richtwert und lag zwischen neun und einundzwanzig.

    neu=True gilt fuer die Aktualisierung, dort zaehlen nur die neu
    hinzugekommenen Meldungen und der Korridor ist enger.
    """
    from config import (
        FAKTEN_JE_ARTIKEL, FAKTEN_MAX, FAKTEN_MIN,
        FAKTEN_NEU_JE_ARTIKEL, FAKTEN_NEU_MAX, FAKTEN_NEU_MIN,
    )

    je_artikel = FAKTEN_NEU_JE_ARTIKEL if neu else FAKTEN_JE_ARTIKEL
    kleinstwert = FAKTEN_NEU_MIN if neu else FAKTEN_MIN
    groesstwert = FAKTEN_NEU_MAX if neu else FAKTEN_MAX

    aus_material = round(max(0, anzahl_meldungen) / max(1, je_artikel))
    obergrenze = max(kleinstwert * 2, min(groesstwert, aus_material))
    untergrenze = max(kleinstwert, obergrenze // 2)
    return untergrenze, obergrenze


def bewertungsregeln() -> str:
    """Verbindlicher Regelblock, wird an jeden Faktencheck-Auftrag angehaengt.

    Bewusst am Ende des Auftrags und an einer einzigen Stelle gepflegt, damit
    die sechs Vorlagen nicht auseinanderlaufen.
    """
    return _BEWERTUNGSREGELN

# Zwei-Phasen-Faktencheck: Konfiguration
MAX_FACTS_PER_VERIFY_GROUP = 8   # Max Fakten pro Verifikationsgruppe
TWOPHASE_MIN_FACTS = 25          # Ab dieser Anzahl bestehender Fakten wird der Zwei-Phasen-Ansatz genutzt

# Status, die als "noch nicht ausreichend belegt" gelten und im EU-Modus eine
# Nachhak-Runde ausloesen koennen.
OFFENE_STATUS = ("unconfirmed", "unverified", "developing")

# Status, die eine Bestaetigung behaupten. Genau diese werden in der
# Belastungsprobe noch einmal angegriffen.
BESTAETIGTE_STATUS = ("confirmed", "established")

# Status, die einen Widerspruch oder eine Widerlegung ausdruecken. Sie duerfen
# eine Bestaetigung auch zuruecknehmen, sonst gaebe es nur den Weg nach oben.
WIDERSPRUCH_STATUS = ("contradicted", "disputed", "false")

EU_CHALLENGE_PROMPT_TEMPLATE = """Du bist ein OSINT-Faktenchecker und fuehrst eine BELASTUNGSPROBE durch.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Saetze.
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.

LAGE: {title}

Im ersten Durchgang wurden die folgenden Behauptungen als belegt eingestuft. Das ist
verdaechtig, denn bei einer laufenden Lage ist selten alles gesichert. Fuer genau diese
Punkte wurden jetzt GEGENPROBEN gesucht, also gezielt nach Dementis, Richtigstellungen,
abweichenden Angaben und der Darstellung der Gegenseite. Die Ergebnisse stehen unten.

ALS BELEGT EINGESTUFTE BEHAUPTUNGEN:
{claims_text}

AUFGABE:
Pruefe jede dieser Behauptungen kritisch gegen die unten angehaengten Suchergebnisse.
Deine Aufgabe ist NICHT, die Bestaetigung zu wiederholen, sondern zu pruefen, ob sie
haelt.

Stufe eine Behauptung zurueck, wenn einer dieser Punkte zutrifft:
- Eine Quelle widerspricht ihr in der Sache, dann "{widerspruch}".
- Eine Stelle hat sie ausdruecklich dementiert oder richtiggestellt, dann "false".
- Die Belege nennen die konkrete Angabe gar nicht, sondern nur das Thema, dann "{offen}".
- Die Angabe ist praeziser formuliert als die Quellen sie hergeben, etwa eine genaue
  Zahl, wo die Quellen von einer Schaetzung oder einer Spanne sprechen, dann "{offen}".
- Nur eine einzige Stelle berichtet es und keine zweite bestaetigt es, dann "{offen}".

Lass sie unveraendert, wenn die Gegenprobe nichts Belastendes gebracht hat. Das ist ein
gutes Ergebnis und keine Niederlage.

Erfinde keinen Zweifel. Ein Zurueckstufen braucht einen konkreten Beleg oder eine
konkrete Luecke, die du benennen kannst.

Antworte NUR mit einem JSON-Array, ein Objekt je oben genannter Behauptung:
[{{"claim": "Behauptung im Wortlaut wie oben", "status": "{status_werte}",
"evidence": "Begruendung mit [S..] und vollstaendiger Adresse", "unveraendert": true}}]
Setze "unveraendert" auf true, wenn die Behauptung haelt, sonst auf false."""

EU_FOLLOWUP_PROMPT_TEMPLATE = """Du bist ein OSINT-Faktenchecker.
AUSGABESPRACHE: {output_language}
- KEINE Gedankenstriche verwenden, stattdessen Kommas oder neue Saetze.
- Verwende IMMER echte UTF-8-Umlaute (ä, ö, ü, ß), NIEMALS Umschreibungen.

LAGE: {title}

Die folgenden Behauptungen konnten im ersten Durchgang NICHT ausreichend belegt werden.
Fuer genau diese Punkte wurden zusaetzliche Suchergebnisse beschafft, sie stehen unten.

OFFENE BEHAUPTUNGEN:
{open_claims_text}

AUFGABE:
Pruefe jede dieser Behauptungen erneut, ausschliesslich gegen die unten angehaengten
Suchergebnisse.

REGELN:
- Stufe eine Behauptung nur dann hoch, wenn die neuen Belege sie tatsaechlich stuetzen.
- Widersprechen die Belege der Behauptung, setze den Status auf "contradicted".
- Findest du keinen tragfaehigen Beleg, lass den Status unveraendert.
- Nenne in "evidence" die stuetzende Quelle mit ihrer [S..]-Nummer UND ihrer vollstaendigen
  URL, damit der Beleg nachpruefbar bleibt, und fasse kurz zusammen, was sie belegt.
- Erfinde nichts und uebernimm nichts aus deinem Gedaechtnis.

Antworte NUR mit einem JSON-Array, ein Objekt je oben genannter Behauptung:
[{{"claim": "Behauptung im Wortlaut wie oben", "status": "{status_werte}", "evidence": "Begruendung mit [S..]"}}]"""


_URL_RE = re.compile(r'https?://[^\s\)\]\},;"\'<>]+')


def zaehle_medienhaeuser(urls: list[str]) -> int:
    """Zaehlt unabhaengige Medienhaeuser statt Fundstellen.

    Sechs France24-Treffer, davon vier Videofassungen derselben Meldung, sind
    ein Haus und kein sechsfacher Beleg. Gezaehlt wird deshalb nach
    registrierbarer Domain.

    Weiterleitungen ueber Sammelportale zaehlen zusammen als EIN Beleg. Sie
    verbergen ihr Zielmedium, also laesst sich nicht behaupten, dass dahinter
    verschiedene Redaktionen stehen. Vier news.google.com-Adressen als vier
    unabhaengige Quellen zu zaehlen, hat im Vergleich vom 02.08.2026 den
    Anthropic-Weg rechnerisch bessergestellt: dort standen 10 Belege fuer
    tatsaechlich 7 Haeuser, davon vier Weiterleitungen.
    """
    from services import media_registry

    haeuser: set[str] = set()
    for url in urls or []:
        sauber = (url or "").rstrip(".,;:)]}\"'")
        if not sauber:
            continue
        domain = media_registry.registrable_domain(sauber)
        if not domain:
            continue
        haeuser.add("weiterleitung" if media_registry.ist_aggregator(sauber) else domain)
    return len(haeuser)


def pruefe_belegtiefe(fact: dict) -> dict:
    """Stuft einen Fakt ab, wenn sein Status mehr Beleg behauptet als vorliegt.

    Ohne diese Nachbedingung stand im Bericht vom 01.08.2026 eine Behauptung
    als BESTAETIGT mit einer einzigen Quelle, deren Evidenz woertlich mit "Nur
    durch Al Jazeera berichtet" begann. Ursache war, dass die Nachhak-Runde den
    Status hochsetzte, ohne die Belegzahl neu zu bestimmen.

    Ein Primaerbeleg (evidence_type 'primary') traegt allein, siehe
    PRIMAERBELEG_MINDESTZAHL.
    """
    status = (fact.get("status") or "").strip().lower()
    noetig = BESTAETIGT_STATUS.get(status)
    if not noetig:
        return fact

    if (fact.get("evidence_type") or "").strip().lower() == "primary":
        noetig = PRIMAERBELEG_MINDESTZAHL

    vorhanden = fact.get("sources_count")
    if not isinstance(vorhanden, int):
        vorhanden = zaehle_medienhaeuser(_URL_RE.findall(fact.get("evidence") or ""))
        fact["sources_count"] = vorhanden

    if vorhanden < noetig:
        neuer = "unconfirmed" if status == "confirmed" else "unverified"
        logger.warning(
            "Fakt herabgestuft (%s -> %s): %d unabhaengige Quelle(n), noetig waeren %d: '%s...'",
            status, neuer, vorhanden, noetig, (fact.get("claim") or "")[:60],
        )
        fact["status"] = neuer
    return fact


def normalize_claim(claim: str) -> str:
    """Normalisiert einen Claim fuer Aehnlichkeitsvergleich."""
    c = claim.lower().strip()
    c = c.replace("\u00e4", "ae").replace("\u00f6", "oe").replace("\u00fc", "ue").replace("\u00df", "ss")
    c = re.sub(r'[^\w\s]', '', c)
    c = re.sub(r'\s+', ' ', c).strip()
    return c


def _keyword_set(text: str) -> set[str]:
    """Extrahiert signifikante Woerter fuer Overlap-Vergleich."""
    words = set(normalize_claim(text).split())
    return {w for w in words if len(w) >= 4 and w not in _STOPWORDS}


def find_matching_claim(new_claim: str, existing_claims: list[dict], threshold: float = 0.75) -> dict | None:
    """Findet den besten passenden bestehenden Claim per kombiniertem Scoring.

    Verwendet SequenceMatcher (70%) + Jaccard-Keyword-Overlap (30%) fuer robusteres Matching.
    """
    norm_new = normalize_claim(new_claim)
    if not norm_new:
        return None

    kw_new = _keyword_set(new_claim)
    best_match = None
    best_score = 0.0

    for existing in existing_claims:
        norm_existing = normalize_claim(existing.get("claim", ""))
        if not norm_existing:
            continue

        # Fruehzeitiger Abbruch bei grossem Laengenunterschied
        len_ratio = len(norm_new) / len(norm_existing) if norm_existing else 0
        if len_ratio > 2.5 or len_ratio < 0.4:
            continue

        seq_ratio = SequenceMatcher(None, norm_new, norm_existing).ratio()

        kw_existing = _keyword_set(existing.get("claim", ""))
        kw_union = kw_new | kw_existing
        jaccard = len(kw_new & kw_existing) / len(kw_union) if kw_union else 0.0

        combined = 0.7 * seq_ratio + 0.3 * jaccard

        if combined > best_score:
            best_score = combined
            best_match = existing

    if best_score >= threshold:
        logger.debug(
            f"Claim-Match ({best_score:.2f}): "
            f"'{new_claim[:50]}...' -> '{best_match['claim'][:50]}...'"
        )
        return best_match
    return None


def deduplicate_new_facts(facts: list[dict], threshold: float = 0.70) -> list[dict]:
    """Dedupliziert Fakten aus einer einzelnen LLM-Antwort vor dem DB-Insert.

    Clustert aehnliche Claims und behaelt pro Cluster den mit dem
    hoechsten Status und den meisten Quellen.
    """
    if not facts:
        return []

    clusters: list[list[dict]] = []
    for fact in facts:
        matched_cluster = None
        for cluster in clusters:
            if find_matching_claim(fact.get("claim", ""), cluster, threshold=threshold):
                matched_cluster = cluster
                break
        if matched_cluster is not None:
            matched_cluster.append(fact)
        else:
            clusters.append([fact])

    result = []
    for cluster in clusters:
        best = max(cluster, key=lambda f: (
            STATUS_PRIORITY.get(f.get("status", "developing"), 0),
            f.get("sources_count", 0),
        ))
        result.append(best)

    if len(result) < len(facts):
        logger.info(
            f"Fakten-Dedup: {len(facts)} -> {len(result)} "
            f"(-{len(facts) - len(result)} Duplikate)"
        )
    return result


class FactCheckerAgent:
    """Prüft Fakten über Claude CLI gegen unabhängige Quellen."""

    def _format_articles_text(self, articles: list[dict], max_articles: int = 20) -> str:
        """Formatiert Artikel als Text für den Prompt.

        Foren-Quellen (media_type='forum', z.B. 5ch/Hatena/Note) werden hier
        ausgeschlossen — sie sind Stimmungsmaterial, kein Faktenbeleg. Ein
        anonymer Forenpost darf nicht als "Quelle bestaetigt Behauptung X"
        gelten.
        """
        # Falls media_type am Dict vorhanden ist, Foren-Quellen ausfiltern.
        # Bei Article-Dicts aus dem RSS-/Pre-Topic-Pfad ist das Feld gesetzt;
        # bei Reload aus der DB muss der Orchestrator das per JOIN annotieren.
        non_forum = [a for a in articles if (a.get("media_type") or "").lower() != "forum"]
        skipped = len(articles) - len(non_forum)
        if skipped > 0:
            logger.info(
                "Faktencheck: %d Foren-Quellen (media_type='forum') ausgeschlossen, "
                "%d Artikel als Faktenbeleg-Kandidaten",
                skipped, len(non_forum),
            )

        articles_text = ""
        for i, article in enumerate(non_forum[:max_articles]):
            articles_text += f"\n--- Meldung {i+1} ---\n"
            articles_text += f"Quelle: {article.get('source', 'Unbekannt')}\n"
            source_url = article.get('source_url', '')
            if source_url:
                articles_text += f"URL: {source_url}\n"
            headline = article.get('headline_de') or article.get('headline', '')
            articles_text += f"Überschrift: {headline}\n"
            content = article.get('content_de') or article.get('content_original', '')
            if content:
                articles_text += f"Inhalt: {content[:500]}\n"
        return articles_text

    def _format_existing_facts(self, facts: list[dict]) -> str:
        """Formatiert bestehende Fakten als Text für den inkrementellen Prompt."""
        if not facts:
            return "Keine bisherigen Fakten"
        lines = []
        for fc in facts:
            status = fc.get("status", "developing")
            claim = fc.get("claim", "")
            sources = fc.get("sources_count", 0)
            evidence = (fc.get("evidence") or "")[:200]
            line = f"- [{status}] ({sources} Quellen) {claim}"
            if evidence:
                line += f"\n  Evidenz: {evidence}"
            lines.append(line)
        return "\n".join(lines)

    @staticmethod
    def _ist_eu_modus() -> bool:
        from agents.claude_client import _ai_backend_var
        from config import AI_BACKEND
        return (_ai_backend_var.get(None) or AI_BACKEND) == "bedrock"

    async def _eu_pruefen(
        self,
        prompt: str,
        *,
        title: str,
        articles: list[dict],
        output_language: str,
        search_items: list[str],
        incident_type: str = "adhoc",
        max_queries: int | None = None,
        followup_queries: int | None = None,
    ) -> tuple[list[dict], ClaudeUsage]:
        """Faktencheck im EU-Modus, mit gezielter Belegsuche und zweiter Runde.

        Ablauf. Erstens werden zu den uebergebenen Punkten gezielt Belege
        gesucht und in den Auftrag gelegt, ein Teil davon als Gegenprobe.
        Zweitens laeuft der eigentliche Faktencheck. Drittens folgt eine zweite
        Runde, und zwar in beide Richtungen. Sind Behauptungen offen geblieben,
        wird fuer genau diese noch einmal gesucht und nachbelegt. Sieht dagegen
        alles bestaetigt aus, laeuft eine Belastungsprobe, die gezielt nach
        Widerspruch zu den staerksten Behauptungen sucht.

        Der zweite Fall ist der wichtigere. Der EU-Weg bekommt seine Belege
        vorab beschafft und sieht deshalb nie, dass etwas fehlt. Ohne
        Belastungsprobe bestaetigte er acht Laeufe in Folge jede einzelne
        Behauptung, waehrend der Weg ueber die Anthropic-CLI bei derselben Lage
        rund ein Drittel offen liess.
        """
        from agents.claude_client import call_claude
        from agents.eu_researcher import (
            plan_verification_searches, build_eu_prompt, _add_usage,
        )
        from config import (
            EU_VERIFY_MAX_QUERIES, EU_VERIFY_FOLLOWUP_QUERIES, EU_VERIFY_FOLLOWUP_MIN_OPEN,
        )

        gesamt = ClaudeUsage()
        anzahl_anfragen = EU_VERIFY_MAX_QUERIES if max_queries is None else max_queries
        anzahl_nachhak = EU_VERIFY_FOLLOWUP_QUERIES if followup_queries is None else followup_queries

        suche = await plan_verification_searches(
            title=title, search_items=search_items,
            output_language=output_language, max_queries=anzahl_anfragen,
        )
        _add_usage(gesamt, suche.usage)

        result, usage = await call_claude(build_eu_prompt(prompt, suche.block), tools=None)
        _add_usage(gesamt, usage)
        # Die Suchbelege zaehlen bei der Quellenzuordnung wie Meldungen. Ohne
        # sie gelten Fakten als unbelegt, die sich allein auf die europaeische
        # Suche stuetzen, und werden faelschlich herabgestuft.
        facts = self._parse_response(result, articles=(articles or []) + suche.quellen)

        if anzahl_nachhak <= 0:
            return facts, gesamt

        # Die zweite Runde ist eine Verbesserung, keine Voraussetzung. Faellt sie
        # aus, bleibt das Ergebnis des ersten Durchgangs erhalten, statt dass der
        # ganze Faktencheck verloren geht.
        gestellte_anfragen = list(suche.queries)
        try:
            # Erster Schritt: offene Punkte nachbelegen, sofern welche da sind.
            offene = [f for f in facts if f.get("status") in OFFENE_STATUS]
            if len(offene) >= EU_VERIFY_FOLLOWUP_MIN_OPEN:
                logger.info(
                    "EU-Faktencheck: %d von %d Behauptungen offen, starte Nachhak-Runde",
                    len(offene), len(facts))
                facts, nachhak_usage, nachhak_anfragen = await self._eu_nachhaken(
                    facts=facts, offene=offene, title=title,
                    output_language=output_language, incident_type=incident_type,
                    avoid_queries=gestellte_anfragen, followup_queries=anzahl_nachhak,
                )
                _add_usage(gesamt, nachhak_usage)
                gestellte_anfragen += nachhak_anfragen

            # Zweiter Schritt: bleibt danach kaum etwas offen, wird das
            # geprueft. Ohne diesen Schritt hob die Nachhak-Runde am 02.08.2026
            # alle drei offenen Punkte wieder hoch und der Lauf endete erneut
            # ohne eine einzige Unsicherheit.
            offene = [f for f in facts if f.get("status") in OFFENE_STATUS]
            bestaetigt = [f for f in facts if f.get("status") in BESTAETIGTE_STATUS]
            if bestaetigt and len(offene) < EU_VERIFY_FOLLOWUP_MIN_OPEN:
                logger.info(
                    "EU-Faktencheck: nur %d von %d Behauptungen offen, starte Belastungsprobe",
                    len(offene), len(facts))
                facts, probe_usage = await self._eu_belastungsprobe(
                    facts=facts, bestaetigt=bestaetigt, title=title,
                    output_language=output_language, incident_type=incident_type,
                    avoid_queries=gestellte_anfragen, queries=anzahl_nachhak,
                )
                _add_usage(gesamt, probe_usage)
        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.warning(
                "EU-Faktencheck: zweite Runde fehlgeschlagen (%s), Ergebnis des ersten "
                "Durchgangs bleibt bestehen", e)
        return facts, gesamt

    async def _eu_belastungsprobe(
        self,
        *,
        facts: list[dict],
        bestaetigt: list[dict],
        title: str,
        output_language: str,
        incident_type: str,
        avoid_queries: list[str],
        queries: int,
    ) -> tuple[list[dict], ClaudeUsage]:
        """Greift die als belegt eingestuften Behauptungen gezielt an.

        Sucht ausschliesslich nach Widerspruch und laesst das Modell die
        Bestaetigungen daran messen. Nur so kann der EU-Weg ueberhaupt zu einem
        unbestaetigten Ergebnis kommen, denn seine normale Belegsuche ist auf
        Bestaetigung ausgelegt und findet deshalb auch nur Bestaetigung.
        """
        from agents.claude_client import call_claude
        from agents.eu_researcher import (
            plan_verification_searches, build_eu_prompt, _add_usage,
        )
        from json_utils import extract_json_arrays

        gesamt = ClaudeUsage()
        kandidaten = bestaetigt[:MAX_FACTS_PER_VERIFY_GROUP]
        claims = [f.get("claim", "") for f in kandidaten if f.get("claim")]
        if not claims:
            return facts, gesamt

        suche = await plan_verification_searches(
            title=title, search_items=claims, output_language=output_language,
            max_queries=queries, avoid_queries=avoid_queries,
            label="Belastungsprobe", nur_gegenproben=True,
        )
        _add_usage(gesamt, suche.usage)
        if not suche.block:
            logger.info("EU-Belastungsprobe: keine Gegenbelege gefunden, Bewertungen bleiben bestehen")
            return facts, gesamt

        forschung = incident_type == "research"
        prompt = EU_CHALLENGE_PROMPT_TEMPLATE.format(
            output_language=output_language,
            title=title,
            claims_text="\n".join(f"- {c}" for c in claims),
            widerspruch="disputed" if forschung else "contradicted",
            offen="unverified" if forschung else "unconfirmed",
            status_werte=("established|unverified|disputed|false"
                          if forschung else "confirmed|unconfirmed|contradicted|false"),
        ) + bewertungsregeln()

        try:
            result, usage = await call_claude(build_eu_prompt(prompt, suche.block), tools=None)
            _add_usage(gesamt, usage)
        except TimeoutError:
            logger.warning("EU-Belastungsprobe: Timeout, Bewertungen bleiben bestehen")
            return facts, gesamt
        except Exception as e:
            logger.warning("EU-Belastungsprobe fehlgeschlagen (%s), Bewertungen bleiben bestehen", e)
            return facts, gesamt

        fassungen = extract_json_arrays(result or "")
        if not fassungen:
            logger.warning("EU-Belastungsprobe: Antwort nicht auswertbar, Bewertungen bleiben bestehen")
            return facts, gesamt

        zurueckgestuft = 0
        for eintrag in max(fassungen, key=len):
            if not isinstance(eintrag, dict):
                continue
            claim = str(eintrag.get("claim", "")).strip()
            neuer_status = str(eintrag.get("status", "")).strip().lower()
            if not claim or neuer_status not in STATUS_PRIORITY:
                continue
            treffer = find_matching_claim(claim, kandidaten)
            if not treffer:
                continue
            alt = treffer.get("status", "")
            if neuer_status == alt:
                continue
            # Die Belastungsprobe darf nur zurueckstufen. Eine Bestaetigung
            # laesst sich nicht dadurch erhaerten, dass die Gegenprobe nichts
            # gefunden hat.
            if neuer_status not in OFFENE_STATUS + WIDERSPRUCH_STATUS:
                continue
            treffer["status"] = neuer_status
            begruendung = str(eintrag.get("evidence", "")).strip()
            if begruendung:
                bisher = (treffer.get("evidence") or "").strip()
                treffer["evidence"] = (
                    f"{bisher} Belastungsprobe: {begruendung}".strip() if bisher else begruendung)
            zurueckgestuft += 1
            logger.info(
                "EU-Belastungsprobe: '%s...' %s -> %s", claim[:60], alt, neuer_status)

        logger.info(
            "EU-Belastungsprobe abgeschlossen: %d von %d Bestaetigungen zurueckgenommen",
            zurueckgestuft, len(kandidaten),
        )
        return facts, gesamt

    async def _eu_nachhaken(
        self,
        *,
        facts: list[dict],
        offene: list[dict],
        title: str,
        output_language: str,
        incident_type: str,
        avoid_queries: list[str],
        followup_queries: int | None = None,
    ) -> tuple[list[dict], ClaudeUsage, list[str]]:
        """Sucht gezielt Belege fuer die offenen Behauptungen und bewertet nur
        diese erneut.

        Gibt die zusammengefuehrte Faktenliste, den Verbrauch und die gestellten
        Anfragen zurueck. Letztere braucht die Belastungsprobe, damit sie nicht
        dieselben Anfragen noch einmal stellt.
        """
        from agents.claude_client import call_claude
        from agents.eu_researcher import (
            plan_verification_searches, build_eu_prompt, _add_usage,
        )
        from config import EU_VERIFY_FOLLOWUP_QUERIES
        from json_utils import extract_json_arrays

        gesamt = ClaudeUsage()
        kandidaten = offene[:MAX_FACTS_PER_VERIFY_GROUP]
        claims = [f.get("claim", "") for f in kandidaten if f.get("claim")]
        if not claims:
            return facts, gesamt, []

        suche = await plan_verification_searches(
            title=title, search_items=claims, output_language=output_language,
            max_queries=(EU_VERIFY_FOLLOWUP_QUERIES if followup_queries is None else followup_queries),
            avoid_queries=avoid_queries, label="Nachhak-Suche",
        )
        block = suche.block
        _add_usage(gesamt, suche.usage)
        if not block:
            logger.info("EU-Nachhak-Runde: keine zusaetzlichen Belege gefunden, Fakten bleiben unveraendert")
            return facts, gesamt, suche.queries

        status_werte = ("established|unverified|disputed|false|developing"
                        if incident_type == "research"
                        else "confirmed|unconfirmed|contradicted|false|developing")
        prompt = EU_FOLLOWUP_PROMPT_TEMPLATE.format(
            output_language=output_language,
            title=title,
            open_claims_text="\n".join(f"- {c}" for c in claims),
            status_werte=status_werte,
        ) + bewertungsregeln()

        try:
            result, usage = await call_claude(build_eu_prompt(prompt, block), tools=None)
            _add_usage(gesamt, usage)
        except TimeoutError:
            logger.warning("EU-Nachhak-Runde: Timeout, Fakten bleiben unveraendert")
            return facts, gesamt, suche.queries
        except Exception as e:
            logger.warning("EU-Nachhak-Runde fehlgeschlagen (%s), Fakten bleiben unveraendert", e)
            return facts, gesamt, suche.queries

        # Korrigiert sich das Modell selbst und haengt eine zweite Fassung an,
        # zaehlt die inhaltsreichste, nicht die erste.
        fassungen = extract_json_arrays(result or "")
        if not fassungen:
            logger.warning("EU-Nachhak-Runde: Antwort nicht auswertbar, Fakten bleiben unveraendert")
            return facts, gesamt, suche.queries
        aktualisierungen = max(fassungen, key=len)

        hochgestuft = 0
        for eintrag in aktualisierungen:
            if not isinstance(eintrag, dict):
                continue
            claim = str(eintrag.get("claim", "")).strip()
            neuer_status = str(eintrag.get("status", "")).strip().lower()
            if not claim or neuer_status not in STATUS_PRIORITY:
                continue
            treffer = find_matching_claim(claim, kandidaten)
            if not treffer:
                continue
            alt = treffer.get("status", "developing")
            # Widerspricht die Nachrecherche der Behauptung, gilt das auch dann,
            # wenn der neue Status in der Rangfolge nicht hoeher steht. Ohne
            # diese Ausnahme kennt die Runde nur den Weg nach oben.
            if neuer_status in WIDERSPRUCH_STATUS and neuer_status != alt:
                treffer["status"] = neuer_status
                widerspruch = str(eintrag.get("evidence", "")).strip()
                if widerspruch:
                    bisher = (treffer.get("evidence") or "").strip()
                    treffer["evidence"] = (
                        f"{bisher} Nachrecherche: {widerspruch}".strip() if bisher else widerspruch)
                logger.info(
                    "EU-Nachhak-Runde: '%s...' %s -> %s, Beleg widerspricht",
                    claim[:60], alt, neuer_status)
                continue
            if STATUS_PRIORITY.get(neuer_status, 0) <= STATUS_PRIORITY.get(alt, 0):
                continue
            # "developing" heisst: der Sachverhalt selbst ist noch offen. Das
            # ist keine schwache Beleglage, die sich nachrecherchieren laesst,
            # und wird deshalb nicht hochgestuft. Ohne diese Sperre wurde am
            # 01.08.2026 die Behauptung "Der Ausloeser ist weiterhin unklar"
            # auf BESTAETIGT gesetzt, weil mehrere Quellen die Unklarheit
            # beschrieben. Eine Unklarheit laesst sich nicht bestaetigen.
            if alt == "developing" and neuer_status not in ("contradicted", "disputed", "false"):
                logger.info(
                    "EU-Nachhak-Runde: '%s...' bleibt developing, %s nicht zulaessig",
                    claim[:60], neuer_status,
                )
                continue
            treffer["status"] = neuer_status
            belege = str(eintrag.get("evidence", "")).strip()
            if belege:
                bisher = (treffer.get("evidence") or "").strip()
                treffer["evidence"] = f"{bisher} Nachrecherche: {belege}".strip() if bisher else belege
            # Die Belegzahl folgt der neuen Evidenz, nicht dem alten Stand.
            treffer["sources_count"] = zaehle_medienhaeuser(
                _URL_RE.findall(treffer.get("evidence") or "")
            )
            if eintrag.get("evidence_type"):
                treffer["evidence_type"] = eintrag.get("evidence_type")
            pruefe_belegtiefe(treffer)
            if treffer["status"] != neuer_status:
                logger.info(
                    "EU-Nachhak-Runde: '%s...' Hochstufung auf %s nicht gedeckt, bleibt %s",
                    claim[:60], neuer_status, treffer["status"],
                )
                continue
            hochgestuft += 1
            logger.info("EU-Nachhak-Runde: '%s...' %s -> %s", claim[:60], alt, neuer_status)

        logger.info(
            "EU-Nachhak-Runde abgeschlossen: %d von %d offenen Behauptungen belegt",
            hochgestuft, len(kandidaten),
        )
        return facts, gesamt, suche.queries

    async def check(self, title: str, articles: list[dict], incident_type: str = "adhoc", output_language: str = "Deutsch") -> tuple[list[dict], ClaudeUsage | None]:
        """Führt vollständigen Faktencheck durch (erster Refresh)."""
        if not articles:
            return [], None

        articles_text = self._format_articles_text(articles)

        template = RESEARCH_FACTCHECK_PROMPT_TEMPLATE if incident_type == "research" else FACTCHECK_PROMPT_TEMPLATE
        fakten_min, fakten_max = faktenspanne(len(articles))
        logger.info(
            "Faktencheck: %d Meldungen, Richtwert %d bis %d Faktenaussagen",
            len(articles), fakten_min, fakten_max)
        prompt = template.format(
            title=title,
            articles_text=articles_text,
            output_language=output_language,
            fakten_min=fakten_min,
            fakten_max=fakten_max,
        ) + bewertungsregeln()

        try:
            # EU-Modus: gezielte staan-Belegsuche plus Nachhak-Runde statt WebSearch.
            if self._ist_eu_modus():
                headlines = [a.get("headline_de") or a.get("headline", "") for a in articles[:10]]
                facts, usage = await self._eu_pruefen(
                    prompt, title=title, articles=articles,
                    output_language=output_language, incident_type=incident_type,
                    search_items=[title] + headlines,
                )
            else:
                result, usage = await call_claude(prompt)
                facts = self._parse_response(result, articles=articles)
            logger.info(f"Faktencheck: {len(facts)} Fakten geprüft")
            return facts, usage
        except TimeoutError:
            raise  # Timeout nach oben durchreichen fuer Retry im Orchestrator
        except Exception as e:
            logger.error(f"Faktencheck-Fehler: {e}")
            return [], None

    async def check_incremental(
        self,
        title: str,
        new_articles: list[dict],
        existing_facts: list[dict],
        incident_type: str = "adhoc",
        output_language: str = "Deutsch",
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Inkrementeller Faktencheck: Prüft nur neue Artikel gegen bestehende Fakten.

        Spart Tokens, da nur neue Artikel + Zusammenfassung der bestehenden Fakten gesendet werden.
        """
        if not new_articles:
            logger.info("Inkrementeller Faktencheck übersprungen: keine neuen Artikel")
            return [], None

        articles_text = self._format_articles_text(new_articles, max_articles=15)
        existing_facts_text = self._format_existing_facts(existing_facts)

        if incident_type == "research":
            template = INCREMENTAL_RESEARCH_FACTCHECK_PROMPT_TEMPLATE
        else:
            template = INCREMENTAL_FACTCHECK_PROMPT_TEMPLATE

        fakten_min, fakten_max = faktenspanne(len(new_articles), neu=True)
        logger.info(
            "Inkrementeller Faktencheck: %d neue Meldungen, Richtwert %d bis %d neue Aussagen",
            len(new_articles), fakten_min, fakten_max)
        prompt = template.format(
            title=title,
            articles_text=articles_text,
            existing_facts_text=existing_facts_text,
            output_language=output_language,
            fakten_min=fakten_min,
            fakten_max=fakten_max,
        ) + bewertungsregeln()

        try:
            # EU-Modus: gezielte Belegsuche zu offenen Fakten plus Nachhak-Runde.
            if self._ist_eu_modus():
                open_claims = [
                    f.get("claim", "") for f in existing_facts
                    if f.get("status") in ("unconfirmed", "developing", "disputed", "unverified")
                ][:8]
                headlines = [a.get("headline_de") or a.get("headline", "") for a in new_articles[:6]]
                facts, usage = await self._eu_pruefen(
                    prompt, title=title, articles=new_articles,
                    output_language=output_language, incident_type=incident_type,
                    search_items=open_claims + headlines,
                )
            else:
                result, usage = await call_claude(prompt)
                facts = self._parse_response(result, articles=new_articles)
            logger.info(f"Inkrementeller Faktencheck: {len(facts)} Fakten (neu + aktualisiert)")
            return facts, usage
        except TimeoutError:
            raise  # Timeout nach oben durchreichen fuer Retry im Orchestrator
        except Exception as e:
            logger.error(f"Inkrementeller Faktencheck-Fehler: {e}")
            return [], None

    async def check_incremental_twophase(
        self,
        title: str,
        new_articles: list[dict],
        existing_facts: list[dict],
        incident_type: str = "adhoc",
        output_language: str = "Deutsch",
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Zwei-Phasen inkrementeller Faktencheck: Haiku-Triage + parallele Opus-Verifikation.

        Phase 1: Haiku identifiziert betroffene Fakten und neue Claims (schnell, günstig)
        Phase 2: Opus verifiziert nur die betroffenen Fakten parallel per WebSearch

        Fällt bei Triage-Fehler auf den Standard-check_incremental zurück.
        """
        if not new_articles:
            logger.info("Zwei-Phasen-Faktencheck übersprungen: keine neuen Artikel")
            return [], None

        usage_acc = UsageAccumulator()

        # --- Phase 1: Triage (Haiku) ---
        logger.info(f"Zwei-Phasen-Faktencheck Phase 1: Triage ({len(existing_facts)} Fakten, {len(new_articles)} Artikel)")

        triage_facts_text = self._format_facts_for_triage(existing_facts)
        articles_text = self._format_articles_text(new_articles, max_articles=15)

        from config import CLAUDE_MODEL_FAST
        triage_min, triage_max = faktenspanne(len(new_articles), neu=True)
        triage_prompt = TRIAGE_PROMPT_TEMPLATE.format(
            output_language=output_language,
            fact_count=len(existing_facts),
            existing_facts_text=triage_facts_text,
            article_count=len(new_articles),
            articles_text=articles_text,
            max_per_group=MAX_FACTS_PER_VERIFY_GROUP,
            fakten_min=triage_min,
            fakten_max=triage_max,
        )

        try:
            triage_result, triage_usage = await call_claude(
                triage_prompt, tools=None, model=CLAUDE_MODEL_FAST
            )
            if triage_usage:
                usage_acc.add(triage_usage)
        except Exception as e:
            logger.warning(f"Triage fehlgeschlagen, Fallback auf Standard-Faktencheck: {e}")
            return await self.check_incremental(title, new_articles, existing_facts, incident_type)

        # Triage-Ergebnis parsen
        triage = self._parse_triage_response(triage_result)
        if not triage:
            logger.warning("Triage-Antwort nicht parsbar, Fallback auf Standard-Faktencheck")
            return await self.check_incremental(title, new_articles, existing_facts, incident_type)

        affected_ids = set(triage.get("affected_fact_ids", []))
        new_claims = triage.get("new_claims", [])
        groups = triage.get("groups", [])

        if not groups:
            logger.warning("Triage hat keine Gruppen identifiziert, Fallback auf Standard-Faktencheck")
            return await self.check_incremental(title, new_articles, existing_facts, incident_type)

        logger.info(
            f"Triage: {len(affected_ids)} betroffene Fakten, {len(new_claims)} neue Claims, {len(groups)} Gruppen"
        )

        # --- Phase 2: Parallele Verifikation (Opus) ---
        fact_lookup = {f["id"]: f for f in existing_facts}

        async def _verify_one_group(group: dict) -> tuple[list[dict], ClaudeUsage | None]:
            """Verifiziert eine einzelne Faktengruppe."""
            group_fact_ids = group.get("fact_ids", [])
            group_new_indices = group.get("new_claim_indices", [])
            theme = group.get("theme", "Allgemein")

            group_facts = [fact_lookup[fid] for fid in group_fact_ids if fid in fact_lookup]
            group_claims = [new_claims[i] for i in group_new_indices if i < len(new_claims)]

            if not group_facts and not group_claims:
                return [], None

            # Fakten formatieren (mit Evidenz für Kontext)
            facts_text = self._format_facts_for_verify(group_facts) if group_facts else "Keine bestehenden Fakten in dieser Gruppe."
            new_claims_text = "\n".join(f"- {c}" for c in group_claims) if group_claims else "Keine neuen Claims."

            if incident_type == "research":
                template = VERIFY_GROUP_RESEARCH_PROMPT_TEMPLATE
            else:
                template = VERIFY_GROUP_PROMPT_TEMPLATE

            prompt = template.format(
                output_language=output_language,
                theme=theme,
                facts_text=facts_text,
                new_claims_text=new_claims_text,
                articles_text=articles_text,
            ) + bewertungsregeln()

            try:
                # EU-Modus: gezielte Belegsuche je Faktengruppe plus Nachhak-Runde.
                if self._ist_eu_modus():
                    from config import (
                        EU_VERIFY_GROUP_QUERIES, EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
                    )
                    claims = [f.get("claim", "") for f in group_facts] + list(group_claims)
                    # Kleinere Suchmenge je Gruppe, da mehrere Gruppen parallel
                    # laufen und jede nur ein Teilthema abdeckt.
                    facts, v_usage = await self._eu_pruefen(
                        prompt, title=f"{title} ({theme})", articles=None,
                        output_language=output_language, incident_type=incident_type,
                        search_items=claims[:8],
                        max_queries=EU_VERIFY_GROUP_QUERIES,
                        followup_queries=EU_VERIFY_GROUP_FOLLOWUP_QUERIES,
                    )
                else:
                    result, v_usage = await call_claude(prompt)
                    facts = self._parse_response(result)
                logger.info(f"Gruppe '{theme}': {len(facts)} Fakten geprüft")
                return facts, v_usage
            except TimeoutError:
                logger.error(f"Gruppe '{theme}': Timeout")
                return [], None
            except Exception as e:
                logger.error(f"Gruppe '{theme}': Fehler: {e}")
                return [], None

        logger.info(f"Zwei-Phasen-Faktencheck Phase 2: {len(groups)} Gruppen parallel verifizieren")

        # Alle Gruppen parallel starten
        group_results = await asyncio.gather(
            *(_verify_one_group(g) for g in groups)
        )

        # Ergebnisse zusammenführen
        all_facts = []
        for facts, v_usage in group_results:
            all_facts.extend(facts)
            if v_usage:
                usage_acc.add(v_usage)

        # Kombinierte Usage erstellen
        combined_usage = ClaudeUsage(
            input_tokens=usage_acc.input_tokens,
            output_tokens=usage_acc.output_tokens,
            cache_creation_tokens=usage_acc.cache_creation_tokens,
            cache_read_tokens=usage_acc.cache_read_tokens,
            cost_usd=usage_acc.total_cost_usd,
            duration_ms=0,
        )

        logger.info(
            f"Zwei-Phasen-Faktencheck abgeschlossen: {len(all_facts)} Fakten "
            f"({usage_acc.call_count} API-Calls, ${usage_acc.total_cost_usd:.4f})"
        )

        return all_facts, combined_usage

    def _format_facts_for_triage(self, facts: list[dict]) -> str:
        """Formatiert Fakten kompakt mit IDs für die Triage."""
        lines = []
        for f in facts:
            status = f.get("status", "developing")
            claim = f.get("claim", "")
            sources = f.get("sources_count", 0)
            fid = f.get("id", "?")
            lines.append(f"- [ID:{fid}] [{status}] ({sources} Quellen) {claim}")
        return "\n".join(lines)

    def _format_facts_for_verify(self, facts: list[dict]) -> str:
        """Formatiert Fakten detailliert für die Verifikation."""
        parts = []
        for f in facts:
            evidence = (f.get("evidence") or "")[:300]
            parts.append(
                f"ID: {f.get('id', '?')}\n"
                f"Status: {f.get('status', 'developing')}\n"
                f"Claim: {f.get('claim', '')}\n"
                f"Bisherige Evidenz: {evidence}\n"
                f"Quellen: {f.get('sources_count', 0)}"
            )
        return "\n---\n".join(parts)

    def _parse_triage_response(self, response: str) -> dict | None:
        """Parst die Triage-Antwort als JSON-Objekt."""
        try:
            data = json.loads(response)
            if isinstance(data, dict) and "groups" in data:
                return data
        except json.JSONDecodeError:
            pass

        match = re.search(r'\{.*\}', response, re.DOTALL)
        if match:
            try:
                data = json.loads(match.group())
                if isinstance(data, dict) and "groups" in data:
                    return data
            except json.JSONDecodeError:
                pass

        logger.warning("Konnte Triage-Antwort nicht als JSON parsen")
        return None

    def _validate_facts(self, facts: list[dict], articles: list[dict] = None) -> list[dict]:
        """Validiert Fakten und ordnet Quellen-URLs aus den Artikeln zu.

        Stellt sicher, dass jeder confirmed/established Fakt URLs in der
        evidence hat, damit das Frontend die Quellen korrekt anzeigen kann,
        zaehlt die Belege nach unabhaengigen Medienhaeusern und stuft Fakten
        ab, deren Status mehr Belegtiefe behauptet als vorhanden ist.
        """
        url_pattern = _URL_RE
        # Verfuegbare Artikel-URLs sammeln (dedupliziert nach URL)
        article_sources = []
        seen_urls = set()
        if articles:
            for a in articles:
                url = a.get("source_url", "")
                source = a.get("source", "")
                headline = a.get("headline_de") or a.get("headline", "")
                if url and url not in seen_urls:
                    seen_urls.add(url)
                    article_sources.append({"url": url, "source": source, "headline": headline})

        for fact in facts:
            status = fact.get("status", "")
            evidence = fact.get("evidence") or ""

            # Fuer alle Fakten: Quellen zuordnen
            if status not in ("retracted",):
                # Bereits vorhandene URLs in der evidence zaehlen
                existing_urls = set(url_pattern.findall(evidence))

                # Passende Quellen per Keyword-Match finden
                claim_lower = (fact.get("claim") or "").lower()
                evidence_lower = evidence.lower()
                claim_words = [w for w in claim_lower.split() if len(w) >= 4][:10]

                scored_sources = []
                for src in article_sources:
                    if src["url"] in existing_urls:
                        continue  # Bereits in evidence
                    src_text = (src["headline"] + " " + src["source"]).lower()
                    matches = sum(1 for w in claim_words if w in src_text)
                    if matches >= max(1, len(claim_words) // 5):
                        scored_sources.append((matches, src))

                # Nach Relevanz sortieren, Top 5 nehmen
                scored_sources.sort(key=lambda x: x[0], reverse=True)
                matched_sources = [s for _, s in scored_sources[:5]]

                if matched_sources:
                    source_refs = "; ".join(
                        f"{s['source']} ({s['url']})" for s in matched_sources
                    )
                    if existing_urls:
                        # Bereits URLs vorhanden, weitere ergaenzen
                        fact["evidence"] = (
                            evidence.rstrip(". ") +
                            ". [Weitere Quellen: " + source_refs + "]"
                        )
                    else:
                        # Keine URLs vorhanden, Quellen anhaengen
                        fact["evidence"] = (
                            evidence.rstrip(". ") +
                            ". [Quellen: " + source_refs + "]"
                        )

                    # sources_count aktualisieren
                    fact["sources_count"] = zaehle_medienhaeuser(
                        url_pattern.findall(fact["evidence"])
                    )

                    logger.info(
                        f"Fakt '{fact.get('claim', '')[:50]}...' ergaenzt mit "
                        f"{len(matched_sources)} Quelle(n), gesamt: {fact['sources_count']}"
                    )
                elif not existing_urls:
                    # Weder bestehende URLs noch passende Quellen
                    old_status = status
                    fact["status"] = "unconfirmed" if status == "confirmed" else "unverified"
                    logger.warning(
                        f"Fakt herabgestuft ({old_status} -> {fact['status']}): "
                        f"keine Quellen zuordnebar: '{fact.get('claim', '')[:60]}...'"
                    )
                else:
                    # Nur Belege aus der Antwort des Modells: die Zahl trotzdem
                    # selbst bestimmen, statt der Selbstauskunft zu vertrauen.
                    fact["sources_count"] = zaehle_medienhaeuser(
                        url_pattern.findall(fact["evidence"] or "")
                    )

        for fact in facts:
            pruefe_belegtiefe(fact)
        return facts

    def _parse_response(self, response: str, articles: list[dict] = None) -> list[dict]:
        """Parst die Claude-Antwort als JSON-Array."""
        try:
            data = json.loads(response)
            if isinstance(data, list):
                return self._validate_facts(data, articles=articles)
        except json.JSONDecodeError:
            pass

        match = re.search(r'\[.*\]', response, re.DOTALL)
        if match:
            try:
                data = json.loads(match.group())
                if isinstance(data, list):
                    return self._validate_facts(data)
            except json.JSONDecodeError:
                pass

        # Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche Zitate
        # in claim/evidence). Ohne diesen Schritt lieferte der Faktencheck bei
        # einem einzigen Stoerzeichen still eine leere Liste.
        # Liefert das Modell mehrere Fassungen, weil es sich selbst korrigiert
        # hat, zaehlt die mit den meisten Fakten.
        from json_utils import extract_json_arrays as _forgiving_arrays
        fassungen = _forgiving_arrays(response)
        if fassungen:
            beste = max(fassungen, key=len)
            if len(fassungen) > 1:
                logger.warning(
                    "Faktencheck-Antwort enthielt %d Fassungen, nehme die mit den "
                    "meisten Fakten (%d statt %d)",
                    len(fassungen), len(beste), len(fassungen[0]),
                )
            return self._validate_facts(beste, articles=articles)

        logger.warning(
            "Konnte Faktencheck-Antwort nicht als JSON parsen (Laenge: %d, Anfang: %r)",
            len(response or ""), (response or "")[:200],
        )
        return []
