"""Researcher-Agent: Sucht nach Informationen via Claude WebSearch."""
import json
import logging
import re
import urllib.parse
from agents.claude_client import call_claude, ClaudeUsage
from config import CLAUDE_MODEL_FAST

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


# Google-News-Locale pro ISO-Sprachcode: (hl, gl). ceid wird daraus gebaut.
_GNEWS_LOCALE = {
    "ja": ("ja", "JP"),
    "de": ("de", "DE"),
    "en": ("en-US", "US"),
    "ru": ("ru", "RU"),
    "ko": ("ko", "KR"),
    "zh": ("zh-CN", "CN"),
    "fr": ("fr", "FR"),
    "es": ("es", "ES"),
    "it": ("it", "IT"),
    "ar": ("ar", "EG"),
    "he": ("iw", "IL"),
    "fa": ("fa", "IR"),
}


def build_news_search_feeds(
    keywords_by_lang: dict | list | None,
    languages: list[str],
    max_keywords: int = 4,
    recency_days: int | None = None,
) -> list[dict]:
    """Baut dynamische Google-News-Volltext-Such-Feeds pro Sprache.

    Statt nur feste site:-RSS-Feeds zu durchsuchen, erzeugt diese Funktion pro
    Sprache einen Google-News-Suchfeed (news.google.com/rss/search?q=...). Damit
    erreicht die Pipeline auch Quellen, die in keinem festen Feed stehen
    (Security-Vendor-Blogs, Fachportale, Regionalmedien). Der Recall steigt
    massiv; die Precision bleibt, weil der nachgelagerte Topic-Filter unveraendert
    greift.

    Args:
        keywords_by_lang: Sprach-Dict {iso: [keyword,...]} aus der Keyword-Extraktion.
        languages: ISO-Codes, fuer die ein Suchfeed gebaut werden soll.
        max_keywords: wie viele (spezifischste) Keywords in die Such-Query gehen.
        recency_days: wenn gesetzt, wird der Google-News-Operator "when:Nd" an die
            Query gehaengt — der Feed liefert dann nur Artikel der letzten N Tage.
            Fuer "Frische-Suchfeeds", die das aktuelle Bild garantiert einfangen.

    Returns:
        Liste von Feed-Config-Dicts (kompatibel mit RSSParser._fetch_feed).
    """
    if not keywords_by_lang or not isinstance(keywords_by_lang, dict):
        return []

    feeds: list[dict] = []
    seen_queries: set[str] = set()
    for lang in languages:
        lang_key = (lang or "").lower().strip()
        locale = _GNEWS_LOCALE.get(lang_key)
        if not locale:
            continue
        lang_kws = [str(k).strip() for k in (keywords_by_lang.get(lang_key) or []) if str(k).strip()]
        en_kws = [str(k).strip() for k in (keywords_by_lang.get("en") or []) if str(k).strip()]

        if lang_key == "en":
            query_terms = en_kws[:max_keywords]
        else:
            # Fuer nicht-englische Sprachen: die ersten 2 englischen Keywords
            # voranstellen. Haiku ordnet Eigennamen/Akronyme (z.B. "Qilin",
            # "Asahi") nach vorne — und die kommen auch in fremdsprachigen
            # Artikeln lateinisch vor. Ohne das fehlt beim ersten Refresh (noch
            # keine Headlines-Historie) der entscheidende Eigenname in der Query.
            # Danach 3 sprach-spezifische Keywords.
            query_terms = en_kws[:2] + lang_kws[:3]
            # Wenn fuer die Sprache gar keine Keywords da sind: ganz auf en.
            if not lang_kws:
                query_terms = en_kws[:max_keywords]

        # Dedup, Reihenfolge erhalten
        seen_terms: set[str] = set()
        deduped: list[str] = []
        for t in query_terms:
            tl = t.lower()
            if tl in seen_terms:
                continue
            seen_terms.add(tl)
            deduped.append(t)

        if not deduped:
            continue
        query = " ".join(deduped)
        # when:Nd-Operator anhaengen (Google-News-Zeitfilter)
        effective_query = query
        if recency_days and recency_days > 0:
            effective_query = f"{query} when:{recency_days}d"
        if not effective_query or effective_query in seen_queries:
            continue
        seen_queries.add(effective_query)

        hl, gl = locale
        ceid_lang = hl.split("-")[0]
        url = (
            "https://news.google.com/rss/search?q="
            + urllib.parse.quote(effective_query)
            + f"&hl={hl}&gl={gl}&ceid={gl}:{ceid_lang}"
        )
        if recency_days and recency_days > 0:
            name = f"Google News Suche ({lang_key}, letzte {recency_days}d): {query}"
            domain = f"google-news-search-{lang_key}-recent"
        else:
            name = f"Google News Suche ({lang_key}): {query}"
            domain = f"google-news-search-{lang_key}"
        feeds.append({
            "name": name,
            "url": url,
            # Eigene Domain-Gruppe, damit der Domain-Cap die Such-Feeds NICHT mit
            # den site:-Google-News-Feeds in einen Topf wirft.
            "domain": domain,
            "primary_language": lang_key,
            "category": "international",
            "media_type": "",
        })
        logger.info("Google-News-Suchfeed (%s): q=%r", lang_key, effective_query)
    return feeds


class ResearcherParseError(Exception):
    """Claude hat eine nicht-leere Antwort geliefert, aus der kein JSON extrahiert werden konnte."""


def _truncate_for_log(text: str, limit: int = 600) -> str:
    """Kürzt eine Claude-Antwort für Logs, damit ein Sample sichtbar ist."""
    if not text:
        return ""
    snippet = text.strip().replace("\n", "\\n")
    if len(snippet) > limit:
        snippet = snippet[:limit] + "..."
    return snippet


def _extract_json_array(text: str):
    """Findet das erste vollständige JSON-Array im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
    if not text:
        return None
    decoder = json.JSONDecoder()
    idx = 0
    while True:
        bracket = text.find("[", idx)
        if bracket == -1:
            return None
        try:
            obj, _ = decoder.raw_decode(text, bracket)
        except json.JSONDecodeError:
            idx = bracket + 1
            continue
        if isinstance(obj, list):
            return obj
        idx = bracket + 1


def _extract_json_object(text: str):
    """Findet das erste vollständige JSON-Objekt im Text (auch mit Vor-/Nachtext oder Markdown-Fence)."""
    if not text:
        return None
    decoder = json.JSONDecoder()
    idx = 0
    while True:
        brace = text.find("{", idx)
        if brace == -1:
            return None
        try:
            obj, _ = decoder.raw_decode(text, brace)
        except json.JSONDecodeError:
            idx = brace + 1
            continue
        if isinstance(obj, dict):
            return obj
        idx = brace + 1


def _normalize_keywords_dict(raw: dict) -> dict | None:
    """Normalisiert ein {iso_lang: [keywords]}-Dict aus Haiku-Output.

    Wir wenden .lower() global an (Python case-folding lässt CJK unverändert und
    lowercased kyrillisch/arabisch/hebräisch sinnvoll), damit der Match später
    konsistent gegen den ebenfalls lowercased Headline-Text läuft.

    Entfernt leere Strings und Duplikate. Gibt None zurück, wenn das Ergebnis leer ist.
    """
    out: dict[str, list[str]] = {}
    for lang, kws in raw.items():
        if not isinstance(lang, str) or not isinstance(kws, list):
            continue
        lang_key = lang.lower().strip()
        clean: list[str] = []
        seen: set[str] = set()
        for k in kws:
            s = str(k).strip().lower()
            if not s or s in seen:
                continue
            seen.add(s)
            clean.append(s)
        if clean:
            out[lang_key] = clean
    return out or None


def flatten_keywords(keywords_by_lang: dict | list | None) -> list[str]:
    """Bequeme Flachsicht aller Keywords (für Logging, Web-Source-Selektion etc.).

    Akzeptiert auch die alte flache Liste, damit Aufrufer schrittweise migrieren können.
    """
    if not keywords_by_lang:
        return []
    if isinstance(keywords_by_lang, list):
        return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
    flat: list[str] = []
    seen: set[str] = set()
    for kws in keywords_by_lang.values():
        if not isinstance(kws, list):
            continue
        for k in kws:
            s = str(k).strip()
            if not s or s in seen:
                continue
            seen.add(s)
            flat.append(s)
    return flat


def keywords_for_language(keywords_by_lang: dict | list | None, lang: str | None) -> list[str]:
    """Liefert die für eine konkrete Feed-/Channel-Sprache anwendbaren Keywords.

    - Universelle "en"-Keywords (lateinische Eigennamen) immer mitgeben.
    - Plus die Keywords der Feed-Sprache, falls vorhanden.
    - Für unbekannte/None-Sprachen: alle Keywords (flach), damit kein Feed leer ausgeht.
    - Akzeptiert auch alte flache Liste -> wird unverändert zurückgegeben.
    """
    if not keywords_by_lang:
        return []
    if isinstance(keywords_by_lang, list):
        return [str(k).strip() for k in keywords_by_lang if str(k).strip()]
    if not lang:
        return flatten_keywords(keywords_by_lang)
    lang_key = lang.lower().strip()
    out: list[str] = []
    seen: set[str] = set()
    for k_lang in ("en", lang_key):
        for k in keywords_by_lang.get(k_lang, []) or []:
            s = str(k).strip()
            if not s or s in seen:
                continue
            seen.add(s)
            out.append(s)
    # Wenn weder "en" noch lang_key Treffer haben (z.B. Haiku-Schema-Mismatch):
    # auf die universelle Flachsicht zurückfallen, damit der Feed nicht leer matched.
    if not out:
        return flatten_keywords(keywords_by_lang)
    return out

RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Recherche-Agent für ein 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).

AUFTRAG: Suche nach aktuellen Informationen zu folgendem Vorfall:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
REGELN:
- Suche nur bei seriösen Nachrichtenquellen (Nachrichtenagenturen, Qualitätszeitungen, öffentlich-rechtliche Medien, Behörden)
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
{language_instruction}
- Faktenbasiert und neutral - keine Spekulationen
- KRITISCH für source_url: Kopiere die EXAKTE URL aus den WebSearch-Ergebnissen. Erfinde oder konstruiere NIEMALS URLs aus Mustern oder Erinnerung. Wenn du die exakte URL eines Artikels nicht aus den Suchergebnissen hast, lass diesen Artikel komplett weg.
- Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. Spiegel+, Zeit+, SZ+): https://www.removepaywall.com/search?url=ARTIKEL_URL
- Nutze WebFetch um die 3-5 wichtigsten Artikel vollständig abzurufen und zusammenzufassen

Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
Jedes Element hat diese Felder:
- "headline": Originale Überschrift
- "headline_de": Übersetzung in Ausgabesprache (falls Originalsprache abweicht)
- "source": Name der Quelle (z.B. "Reuters", "tagesschau")
- "source_url": URL des Artikels
- "content_summary": Zusammenfassung des Inhalts (3-5 Sätze, in Ausgabesprache)
- "language": Sprache des Originals (z.B. "de", "en", "fr")
- "published_at": Veröffentlichungsdatum falls bekannt (ISO-Format)

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

DEEP_RESEARCH_PROMPT_TEMPLATE = """Du bist ein OSINT-Tiefenrecherche-Agent für ein 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).

AUFTRAG: Führe eine umfassende, mehrstufige Hintergrundrecherche durch zu:
Titel: {title}
Kontext: {description}
{existing_context}{preferred_sources_block}
RECHERCHE IN 4 PHASEN — Führe ALLE Phasen nacheinander durch:

PHASE 1 — BREITE ERFASSUNG:
Suche nach aktueller Berichterstattung bei Nachrichtenagenturen, Qualitätszeitungen und öffentlich-rechtlichen Medien. Nutze verschiedene Suchbegriffe und Blickwinkel. Ziel: 8-12 Quellen.

PHASE 2 — LÜCKENANALYSE:
Prüfe deine bisherigen Ergebnisse kritisch. Welche Quellentypen fehlen noch?
Typisch fehlen: Parlamentsdokumente, Gesetzestexte, NGO-/UN-Berichte, Think-Tank-Analysen, investigative Langform-Berichte, akademische Einordnungen, Fachmedien.
Welche Akteure, Perspektiven oder Dimensionen sind noch nicht abgedeckt?

PHASE 3 — GEZIELTE TIEFENRECHERCHE:
Suche GEZIELT nach den in Phase 2 identifizierten Lücken:
- Parlamentarische Quellen (Bundestagsdrucksachen, Congress.gov, Hansard, etc.)
- Offizielle Dokumente und Pressemitteilungen von Behörden
- NGO-Berichte und UN-Dokumente (ohchr.org, amnesty.org, hrw.org, etc.)
- Think-Tank-Analysen (IISS, Brookings, SWP, DGAP, Chatham House, etc.)
- Investigative Recherchen und Langform-Artikel
- Fachzeitschriften und akademische Einordnungen
Nutze spezifische Suchbegriffe für institutionelle Quellen. Ziel: 6-10 weitere Quellen.

PHASE 4 — VERIFIKATION UND VERTIEFUNG:
Nutze WebFetch um die 6-10 wichtigsten Artikel vollständig abzurufen und ausführlich zusammenzufassen.
Priorisiere dabei Primärquellen und investigative Berichte.
Nutze removepaywall.com für Paywall-geschützte Artikel (z.B. https://www.removepaywall.com/search?url=ARTIKEL_URL)

{language_instruction}

ZIEL: 15-25 hochwertige Quellen aus mindestens 5 verschiedenen Quellentypen:
- Nachrichtenagenturen/Qualitätspresse
- Investigative Berichte/Langform
- Parlamentarische/Regierungsquellen
- NGO/Internationale Organisationen
- Fachmedien/Akademische Quellen

AUSSCHLUSS:
- KEIN Social Media (Twitter/X, Facebook, Instagram, TikTok, Reddit)
- KEINE Boulevardmedien (Bild, Sun, Daily Mail etc.)
- KEINE Meinungsblogs ohne Quellenbelege
- KEINE erfundenen oder konstruierten URLs — gib bei source_url NUR die EXAKTE URL zurueck, die WebSearch tatsaechlich angezeigt hat. Wenn du die URL nicht aus den Suchergebnissen kopieren kannst, lass den Artikel weg.

Gib die Ergebnisse AUSSCHLIESSLICH als JSON-Array zurück, ohne Erklärungen davor oder danach.
Jedes Element hat diese Felder:
- "headline": Originale Überschrift
- "headline_de": Übersetzung in Ausgabesprache (falls Originalsprache abweicht)
- "source": Name der Quelle (z.B. "netzpolitik.org", "Handelsblatt")
- "source_url": URL des Artikels
- "content_summary": Ausführliche Zusammenfassung des Inhalts (5-8 Sätze, in Ausgabesprache)
- "language": Sprache des Originals (z.B. "de", "en", "fr")
- "published_at": Veröffentlichungsdatum falls bekannt (ISO-Format)

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

# Sprach-Anweisungen (org-sprach-relativ; primary_display = "Deutsch" | "English")
def lang_international(primary_display: str) -> str:
    if primary_display == "Deutsch":
        return "- Suche in Deutsch UND Englisch für internationale Abdeckung"
    if primary_display == "English":
        return "- Search in English AND other relevant languages for international coverage"
    return f"- Suche in {primary_display} und weiteren relevanten Sprachen"


def lang_primary_only(primary_display: str) -> str:
    if primary_display == "Deutsch":
        return "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen"
    if primary_display == "English":
        return "- Search ONLY in English-language sources\n- NO sources in other languages"
    return f"- Suche NUR auf {primary_display}\n- KEINE Quellen in anderen Sprachen"


def lang_deep_international(primary_display: str) -> str:
    if primary_display == "Deutsch":
        return "- Suche in Deutsch, Englisch und weiteren relevanten Sprachen"
    if primary_display == "English":
        return "- Search in English and other relevant languages"
    return f"- Suche in {primary_display} und weiteren relevanten Sprachen"


def lang_deep_primary_only(primary_display: str) -> str:
    if primary_display == "Deutsch":
        return "- Suche NUR auf Deutsch bei deutschsprachigen Quellen (Deutschland, Österreich, Schweiz)\n- KEINE englischsprachigen oder anderssprachigen Quellen"
    if primary_display == "English":
        return "- Search ONLY in English-language sources\n- NO sources in other languages"
    return f"- Suche NUR auf {primary_display}\n- KEINE Quellen in anderen Sprachen"


FEED_SELECTION_PROMPT_TEMPLATE = """Du bist ein OSINT-Analyst. Wähle aus dieser Feed-Liste die Feeds aus, die für die Lage relevant sein könnten. Generiere außerdem optimierte Suchbegriffe für das RSS-Matching.

LAGE: {title}
KONTEXT: {description}
INTERNATIONALE QUELLEN: {international}

FEEDS (Format: Nr. Name (Domain, Sprache) [Kategorie]):
{feed_list}

REGELN:
- Wähle alle Feeds die thematisch oder regional relevant sein könnten
- Lieber einen Feed zu viel als zu wenig auswählen
- Bei "Internationale Quellen: Nein": Keine internationalen Feeds auswählen
- Allgemeine Nachrichtenfeeds (tagesschau, Spiegel etc.) sind fast immer relevant
- QUELLENVIELFALT: Wähle pro Domain maximal 2-3 Feeds. Bevorzuge eine breite Mischung aus verschiedenen Quellen statt vieler Feeds derselben Domain.

KEYWORDS-REGELN:
- Keywords werden nach Sprache GRUPPIERT zurückgegeben (siehe Format unten).
- "en" enthält universelle Begriffe (Eigennamen, Akronyme, lateinisch geschriebene Marken/Personen),
  die in JEDER Sprache vorkommen (z.B. "iran", "trump", "takaichi", "sdf").
- Für JEDE Sprache, in der ausgewählte Feeds publizieren (z.B. "ja", "ru", "ar", "zh", "ko", "fa",
  "he", "de"), MUSS zusätzlich eine Liste mit 3-8 Suchbegriffen in der jeweiligen ORIGINALSCHRIFT
  generiert werden. Beispiel Japan: "ja": ["自衛隊", "憲法改正", "改憲", "9条", "防衛省"].
  Beispiel Russland: "ru": ["украина", "путин", "москва", "санкции"].
- Wenn die Lage rein deutsch oder englisch ist und keine fremdsprachigen Feeds gewählt werden,
  reichen "de" und/oder "en".
- Nur inhaltlich relevante Begriffe (Personen, Orte, Themen, Organisationen)
- EINZELWÖRTER, keine Phrasen. "ceuta" und "migranten" statt "migrationskrise spanien".
  Die Begriffe werden gegen Schlagzeilen geprüft, und dort steht selten die exakte
  Wortfolge einer Phrase. Nur wo ein Begriff untrennbar ist (Eigenname wie
  "nord stream"), darf er aus zwei Wörtern bestehen.
- Die HARTEN Eigennamen der Lage MÜSSEN dabei sein: Schauplatz (Ort, Region), beteiligte
  Länder, handelnde Personen, betroffene Organisationen. Eine Lage über Ceuta ohne das
  Wort "ceuta" ist unbrauchbar. Diese Namen zuerst nennen, danach die Sachbegriffe.
- KEINE abstrakten Politikbegriffe als Suchwort ("aufnahmeverfahren", "grenzschutz europa").
  Sie stehen fast nie in einer Schlagzeile und ziehen dafür themenfremde Behördenfeeds an.
- KEINE Jahreszahlen (2024, 2025, 2026 etc.)
- KEINE Monatsnamen (Januar, Februar, März etc.)
- KEINE generischen Wörter (aktuell, news, update etc.)
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.

Antworte NUR mit einem JSON-Objekt in genau diesem Format:
{{"feeds": [1, 2, 5, 12], "keywords": {{"de": ["..."], "en": ["..."], "ja": ["..."]}}}}"""


KEYWORD_EXTRACTION_PROMPT = """Analysiere diese aktuellen Nachrichten-Headlines und extrahiere die wichtigsten Suchbegriffe fuer RSS-Feed-Filterung.

THEMA: {title}

AKTUELLE HEADLINES (die letzten Meldungen zu diesem Thema):
{headlines}

AUFGABE:
Generiere 5 Begriffspaare (DE + EN), mit denen neue RSS-Artikel zu diesem Thema gefunden werden.
Ein Artikel gilt als relevant, wenn mindestens 2 dieser Begriffe im Titel oder der Beschreibung vorkommen
- bei spezifischen Begriffen (Eigennamen, lange Begriffe ab 7 Zeichen) reicht 1 Treffer.

Wenn das Thema einen klaren Länderbezug zu einem nicht-lateinischen Sprachraum hat (z.B. Japan,
China, Korea, Russland, Iran, Israel, arabische Welt), GIB ZUSAETZLICH ein Feld "extra" mit
schrift-spezifischen Keywords pro Sprache zurück (siehe Format unten). Diese matchen dann die
Original-Headlines in den jeweiligen Feeds.

REGELN:
- ZWINGEND: Eigennamen oder spezifische Begriffe aus dem THEMA (z.B. Personennamen, Tiernamen,
  Ortsnamen wie "timmy", "buckelwal", "merz", "dobrindt") MUESSEN als eigene Begriffspaare
  enthalten sein. Solche Begriffe sind oft das einzige, was in kurzen Headlines vorkommt.
- Die ersten 2 Begriffspaare sind die zentralen Akteure/Laender/Themen (z.B. iran, israel,
  buckelwal, timmy) — also die Begriffe, die in fast JEDEM Artikel zum Thema vorkommen.
- Die uebrigen 3 Begriffspaare sind aktuelle Entwicklungen aus den Headlines (Orte, Akteure,
  Schluesselwoerter der aktuellen Phase).
- Wenn DE und EN identisch sind (Eigennamen), trotzdem das Paar einreichen.
- Begriffe muessen so gewaehlt sein, dass sie in kurzen RSS-Titeln matchen (einzelne Woerter,
  keine Phrasen, keine Konjunktionen).
- Lateinische Begriffe in Kleinbuchstaben. CJK/Arabisch/Hebräisch/Kyrillisch wie üblich.
- Exakt 5 Begriffspaare im "pairs"-Array.

Antwort NUR als JSON-Objekt, z.B.:
{{"pairs": [{{"de": "japan", "en": "japan"}}, {{"de": "verfassung", "en": "constitution"}}, {{"de": "takaichi", "en": "takaichi"}}, {{"de": "selbstverteidigung", "en": "sdf"}}, {{"de": "pazifismus", "en": "pacifism"}}], "extra": {{"ja": ["自衛隊", "憲法改正", "改憲", "9条", "高市"]}}}}

Wenn kein nicht-lateinischer Sprachraum betroffen ist, lass "extra" weg oder gib `{{}}` zurück."""


WEB_SOURCE_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Pruefe diese eingetragenen Web-Quellen und waehle nur die thematisch passenden aus.

LAGE: {title}
KONTEXT: {description}

WEB-QUELLEN:
{source_list}

REGELN:
- Waehle nur Quellen, die thematisch tatsaechlich zur Lage passen
- Lieber leere Liste zurueckgeben als pauschal alle aufnehmen
- Behoerden- und institutionelle Quellen sind oft hochwertig, aber nur wenn das Thema passt
- Petitions-Plattformen z.B. nur bei Lagen zu Buergerinitiativen, Gesetzen, oeffentlichem Druck
- Bei reinen Kriegs-/Konflikt-/Tagesnachrichten meistens leere Liste

Antworte NUR mit einem JSON-Array der Quellen-Nummern, z.B. [1, 3] oder []."""


TELEGRAM_CHANNEL_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von Telegram-Kanaelen diejenigen aus, die fuer die Lage relevant sein koennten.

LAGE: {title}
KONTEXT: {description}

TELEGRAM-KANAELE:
{channel_list}

REGELN:
- Waehle alle Kanaele die thematisch relevant sein koennten
- Lieber einen Kanal zu viel als zu wenig auswaehlen
- Beachte die Kategorie und Beschreibung jedes Kanals
- Allgemeine OSINT-Kanaele sind oft relevant
- Bei Cybercrime-Themen: Cybercrime + Leaks Kanaele waehlen
- Bei geopolitischen Themen: Relevante Laender-/Regionskanaele waehlen

Antworte NUR mit einem JSON-Array der Kanal-Nummern, z.B.: [1, 3, 5, 12]"""


X_ACCOUNT_SELECTION_PROMPT = """Du bist ein OSINT-Analyst. Waehle aus dieser Liste von X-Accounts (Twitter) diejenigen aus, die fuer die Lage relevant sein koennten.

LAGE: {title}
KONTEXT: {description}

X-ACCOUNTS:
{account_list}

REGELN:
- Waehle alle Accounts die thematisch relevant sein koennten
- Lieber einen Account zu viel als zu wenig auswaehlen
- Beachte die Kategorie und Beschreibung jedes Accounts
- Allgemeine OSINT-Accounts sind oft relevant
- Bei geopolitischen Themen: Relevante Laender-/Regions-Accounts waehlen

Antworte NUR mit einem JSON-Array der Account-Nummern, z.B.: [1, 3, 5, 12]"""


class ResearcherAgent:
    """Führt OSINT-Recherchen über Claude CLI WebSearch durch."""

    async def select_relevant_feeds(
        self,
        title: str,
        description: str,
        international: bool,
        feeds_metadata: list[dict],
    ) -> tuple[list[dict], dict | None, ClaudeUsage | None]:
        """Lässt Claude die relevanten Feeds für eine Lage vorauswählen.

        Nutzt Haiku (CLAUDE_MODEL_FAST) für diese einfache Aufgabe.

        Returns:
            (ausgewählte Feeds, keywords_by_lang, usage)
            keywords_by_lang ist ein Dict {iso_lang: [keyword, ...]} mit mindestens
            den Schlüsseln, für die ausgewählte Feeds publizieren ("en" enthält
            universelle/lateinische Begriffe, die in jedem Feed matchen).
            Bei Fehler: (alle Feeds, None, usage_or_None).
        """
        # Feed-Liste als nummerierte Übersicht formatieren (mit Sprache)
        feed_lines = []
        for i, feed in enumerate(feeds_metadata, 1):
            lang = feed.get("primary_language") or "?"
            feed_lines.append(
                f"{i}. {feed['name']} ({feed['domain']}, {lang}) [{feed['category']}]"
            )

        prompt = FEED_SELECTION_PROMPT_TEMPLATE.format(
            title=title,
            description=description or "Keine weitere Beschreibung",
            international="Ja" if international else "Nein",
            feed_list="\n".join(feed_lines),
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)

            keywords_by_lang: dict | None = None
            indices = None

            obj = _extract_json_object(result)
            if isinstance(obj, dict) and isinstance(obj.get("feeds"), list):
                indices = obj["feeds"]
                raw_keywords = obj.get("keywords")

                # Neues Format: {"de": [...], "en": [...], "ja": [...]}
                if isinstance(raw_keywords, dict):
                    keywords_by_lang = _normalize_keywords_dict(raw_keywords)
                # Backward-Format: flache Liste -> als "en" speichern (universell behandelt)
                elif isinstance(raw_keywords, list) and raw_keywords:
                    flat = [str(k).strip() for k in raw_keywords if str(k).strip()]
                    if flat:
                        keywords_by_lang = {"en": [w.lower() for w in flat]}

                if keywords_by_lang:
                    logger.info(f"Feed-Selektion Keywords (Sprachen): {keywords_by_lang}")

            # Fallback: nacktes Array
            if indices is None:
                arr = _extract_json_array(result)
                if not isinstance(arr, list):
                    logger.warning(
                        "Feed-Selektion: Kein JSON in Antwort, nutze alle Feeds. Sample: %s",
                        _truncate_for_log(result),
                    )
                    return feeds_metadata, None, usage
                indices = arr

            selected = []
            for idx in indices:
                if isinstance(idx, int) and 1 <= idx <= len(feeds_metadata):
                    selected.append(feeds_metadata[idx - 1])

            if not selected:
                logger.warning("Feed-Selektion: Keine gültigen Indizes, nutze alle Feeds")
                return feeds_metadata, keywords_by_lang, usage

            logger.info(
                f"Feed-Selektion: {len(selected)} von {len(feeds_metadata)} Feeds ausgewählt"
            )
            return selected, keywords_by_lang, usage

        except Exception as e:
            logger.warning(f"Feed-Selektion fehlgeschlagen ({e}), nutze alle Feeds")
            return feeds_metadata, None, None


    async def extract_dynamic_keywords(
        self, title: str, recent_headlines: list[str]
    ) -> tuple[dict | None, ClaudeUsage | None]:
        """Extrahiert aktuelle Suchbegriffe aus den letzten Headlines via Haiku.

        Returns:
            (keywords_by_lang, usage) oder (None, None) bei Fehler.
            keywords_by_lang ist ein Dict {iso_lang: [keyword,...]}, mit mindestens
            "de" und "en" gefüllt, optional zusätzlich "ja"/"zh"/"ko"/"ar"/"he"/"fa"/"ru"
            bei nicht-lateinischen Sprachräumen.
        """
        if not recent_headlines:
            return None, None

        headlines_text = "\n".join(f"- {h}" for h in recent_headlines[:30])
        prompt = KEYWORD_EXTRACTION_PROMPT.format(
            title=title,
            headlines=headlines_text,
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)

            # Neues Format: {"pairs": [...], "extra": {"ja": [...]}}
            obj = _extract_json_object(result)
            pairs_raw = None
            extra_raw: dict = {}
            if isinstance(obj, dict) and isinstance(obj.get("pairs"), list):
                pairs_raw = obj["pairs"]
                extra = obj.get("extra")
                if isinstance(extra, dict):
                    extra_raw = extra
            else:
                # Backward: nacktes Array von {de,en}-Paaren
                arr = _extract_json_array(result)
                if isinstance(arr, list):
                    pairs_raw = arr
                else:
                    logger.warning(
                        "Keyword-Extraktion: Kein gueltiges JSON erhalten. Sample: %s",
                        _truncate_for_log(result),
                    )
                    return None, usage

            de_list: list[str] = []
            en_list: list[str] = []
            for entry in pairs_raw or []:
                if not isinstance(entry, dict):
                    continue
                de = str(entry.get("de", "")).lower().strip()
                en = str(entry.get("en", "")).lower().strip()
                if de and de not in de_list:
                    de_list.append(de)
                if en and en not in en_list:
                    en_list.append(en)

            # Bug-2-Fallback: Lagentitel-Wörter (>=4 Zeichen) zwingend in Keyword-Liste,
            # falls Haiku sie weggelassen hat. Verhindert "Buckelwal timmy"-Bug, bei dem
            # der Eigenname "timmy" fehlte und damit Headlines mit nur "Buckelwal" durchfielen.
            STOPWORDS = {"der", "die", "das", "und", "oder", "von", "vom", "zum", "zur",
                         "the", "and", "for", "with", "ueber", "über", "von", "for"}
            for word in (title or "").lower().split():
                w = word.strip(".,;:!?\"\'()[]{}")
                if len(w) >= 4 and w not in STOPWORDS:
                    if w not in en_list:
                        en_list.append(w)
                        logger.info(f"Lagentitel-Keyword '{w}' nachträglich injiziert")

            keywords_by_lang: dict[str, list[str]] = {}
            if de_list:
                keywords_by_lang["de"] = de_list
            if en_list:
                keywords_by_lang["en"] = en_list

            # Extra-Sprachen mit übernehmen
            extra_norm = _normalize_keywords_dict(extra_raw) if extra_raw else None
            if extra_norm:
                for lang, kws in extra_norm.items():
                    keywords_by_lang.setdefault(lang, [])
                    for k in kws:
                        if k not in keywords_by_lang[lang]:
                            keywords_by_lang[lang].append(k)

            if not keywords_by_lang:
                return None, usage

            logger.info(
                "Dynamische Keywords (Sprachen): %s",
                {k: len(v) for k, v in keywords_by_lang.items()},
            )
            return keywords_by_lang, usage

        except Exception as e:
            logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
            return None, None

    async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None, existing_articles: list[dict] = None, preferred_sources: list[dict] = None, output_language: str = "Deutsch", output_language_iso: str = "de", research_language_iso: str | None = None) -> tuple[list[dict], ClaudeUsage | None, bool]:
        """Sucht nach Informationen zu einem Vorfall.

        Args:
            output_language / output_language_iso: Ausgabesprache (Lagebild-Sprache).
            research_language_iso: optionaler Override fuer die Sprache, in der gesucht
                werden soll. Default = output_language_iso. Bei jp_demo z.B. 'ja',
                waehrend output_language_iso 'de' bleibt (Lagebild deutsch, Recherche japanisch).

        Returns:
            (artikel, usage, parse_failed) — parse_failed ist True, wenn Claude geantwortet hat,
            das JSON aber nicht extrahierbar war. So kann der Orchestrator zwischen
            "echt keine Treffer" und "kaputte Antwort" unterscheiden.
        """
        # research_language defaultet auf output_language. Wenn das aber abweicht
        # (z.B. jp_demo: research='ja', output='de'), ueberschreiben wir die
        # Sprach-Anweisung im Prompt mit einer eigenen, dual-sprachigen Variante.
        research_language_iso = (research_language_iso or output_language_iso or "de").lower()
        # Display-Name der Recherche-Sprache fuer Prompts ("Japanese", "Russian", ...)
        from services.org_settings import language_display as _lang_display
        research_language_display = _lang_display(research_language_iso)
        # Bevorzugte Web-Quellen als Prompt-Block (optional)
        preferred_sources_block = ""
        if preferred_sources:
            ps_lines = []
            for s in preferred_sources:
                domain = s.get("domain", "")
                name = s.get("name", domain) or domain
                if not domain:
                    continue
                ps_lines.append(f"- {domain} ({name})")
            if ps_lines:
                preferred_sources_block = (
                    "\nEINGETRAGENE WEB-QUELLEN (vom Betreiber als seriös markiert):\n"
                    + "\n".join(ps_lines) + "\n"
                    "EMPFEHLUNG: Wenn diese Domains thematisch zur Lage passen, suche dort gezielt "
                    "mit \"site:domain [Suchbegriff]\". Sie sind vertrauenswuerdig eingetragen, ersetzen "
                    "aber nicht deine sonstige Recherche.\n"
                )

        # Asymmetrische Sprach-Auswahl: research_language weicht von output_language ab
        # -> eigene Anweisung "primaer in research-language, englische Quellen aus der
        # Region auch erlaubt". Sonst die bisherige Logik (primary_only vs international).
        asymmetric_lang = research_language_iso != output_language_iso

        def _build_lang_instruction(deep: bool) -> str:
            if asymmetric_lang:
                # jp_demo & Co.: Recherche in Quellsprache + lokale Englisch-Outlets.
                return (
                    f"- Fokus liegt auf {research_language_display}-sprachigen Quellen "
                    f"(Behoerden, Qualitaetszeitungen, oeffentlich-rechtliche Medien dieser Sprache).\n"
                    f"- Englischsprachige Outlets mit Fokus auf demselben Sprachraum/Region sind "
                    f"ebenfalls willkommen (z.B. Japan Times, Nikkei Asia, Kyodo English fuer Japan; "
                    f"Moscow Times English fuer Russland).\n"
                    f"- Quellen ausserhalb des Sprachraums NUR, wenn sie exklusive Informationen "
                    f"ueber die Region liefern (z.B. Reuters/AFP/AP-Berichte aus der Region).\n"
                    f"- Antworte in der Ausgabesprache {output_language} (das Lagebild wird in "
                    f"{output_language} angezeigt), aber zitiere die Original-Headlines/Quellen unveraendert."
                )
            if deep:
                return lang_deep_international(output_language) if international else lang_deep_primary_only(output_language)
            return lang_international(output_language) if international else lang_primary_only(output_language)

        if incident_type == "research":
            lang_instruction = _build_lang_instruction(deep=True)
            # Bestehende Artikel als Kontext für den Prompt aufbereiten
            existing_context = ""
            if existing_articles:
                known_lines = []
                for art in existing_articles[:50]:  # Max 50 um Prompt nicht zu überladen
                    source = art.get("source", "Unbekannt")
                    headline = art.get("headline", "")
                    url = art.get("source_url", "")
                    known_lines.append(f"- {source}: {headline} ({url})")
                existing_context = (
                    "BEREITS BEKANNTE QUELLEN — NICHT erneut suchen, finde ANDERE:\n"
                    + "\n".join(known_lines) + "\n\n"
                    "Fokussiere dich auf Quellen und Perspektiven, die in der obigen Liste FEHLEN.\n"
                )
            prompt = DEEP_RESEARCH_PROMPT_TEMPLATE.format(
                title=title, description=description, language_instruction=lang_instruction,
                output_language=output_language, existing_context=existing_context,
                preferred_sources_block=preferred_sources_block,
            )
        else:
            lang_instruction = _build_lang_instruction(deep=False)
            # Bestehende Artikel als Kontext: bei Folge-Refreshes findet Claude andere Quellen
            existing_context = ""
            if existing_articles:
                known_lines = []
                for art in existing_articles[:30]:  # Max 30 bei adhoc (kompakter als research)
                    source = art.get("source", "Unbekannt")
                    headline = art.get("headline", "")
                    known_lines.append(f"- {source}: {headline}")
                existing_context = (
                    "BEREITS BEKANNTE QUELLEN (aus RSS-Feeds und vorherigen Recherchen) — suche ANDERE Blickwinkel und Quellen:\n"
                    + "\n".join(known_lines) + "\n"
                )
            prompt = RESEARCH_PROMPT_TEMPLATE.format(
                title=title, description=description, language_instruction=lang_instruction,
                output_language=output_language, existing_context=existing_context,
                preferred_sources_block=preferred_sources_block,
            )

        try:
            # EU-Modellweg (Phase 2). Läuft der Refresh über das Bedrock-Backend,
            # übernimmt die gesteuerte staan-Schleife die komplette Recherche und
            # liefert denselben JSON-Array-Text wie die CLI-WebSearch. Parsing,
            # Quellenfilter und Sprachfilter darunter bleiben unverändert.
            from agents.claude_client import _ai_backend_var
            from config import AI_BACKEND
            _backend = _ai_backend_var.get(None) or AI_BACKEND
            if _backend == "bedrock":
                from agents.eu_researcher import run_eu_research
                result, usage = await run_eu_research(
                    title=title,
                    description=description,
                    incident_type=incident_type,
                    lang_instruction=lang_instruction,
                    existing_context=existing_context,
                    preferred_sources_block=preferred_sources_block,
                    output_language=output_language,
                    excluded_sources=await self._get_excluded_sources(user_id=user_id),
                    research_language_iso=research_language_iso or output_language_iso,
                )
            else:
                result, usage = await call_claude(prompt)
            try:
                articles = self._parse_response(result)
            except ResearcherParseError as parse_err:
                # Claude hat geantwortet, aber kein verwertbares JSON dabei.
                # Usage trotzdem zurueckgeben, damit Credits korrekt verbucht werden.
                logger.warning("Claude-Recherche: %s", parse_err)
                return [], usage, True

            # Ausgeschlossene Quellen dynamisch aus DB laden
            excluded_sources = await self._get_excluded_sources(user_id=user_id)

            # Ausgeschlossene Quellen filtern
            filtered = []
            for article in articles:
                source = article.get("source", "").lower()
                source_url = article.get("source_url", "").lower()
                excluded = False
                for excl in excluded_sources:
                    if excl in source or excl in source_url:
                        excluded = True
                        break
                if not excluded:
                    # Bei nur-primary: andersprachige Ergebnisse nachfiltern
                    if not international and article.get("language", output_language_iso) != output_language_iso:
                        continue
                    filtered.append(article)

            logger.info(f"Recherche ergab {len(filtered)} Artikel (von {len(articles)} gefundenen, international={international})")
            return filtered, usage, False

        except TimeoutError:
            raise  # Timeout nach oben durchreichen fuer Retry im Orchestrator
        except Exception as e:
            logger.error(f"Recherche-Fehler: {e}")
            return [], None, False

    async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
        """Laedt ausgeschlossene Quellen (global + per-User)."""
        try:
            from source_rules import get_source_rules, get_user_excluded_domains
            rules = await get_source_rules()
            excluded = list(rules.get("excluded_domains", []))

            # User-spezifische Ausschluesse hinzufuegen
            if user_id:
                user_excluded = await get_user_excluded_domains(user_id)
                for domain in user_excluded:
                    if domain not in excluded:
                        excluded.append(domain)

            return excluded
        except Exception as e:
            logger.warning(f"Fallback auf config.py fuer Excluded Sources: {e}")
            from config import EXCLUDED_SOURCES
            return list(EXCLUDED_SOURCES)

    def _parse_response(self, response: str) -> list[dict]:
        """Parst die Claude-Antwort als JSON-Array.

        Wirft ResearcherParseError, wenn die Antwort nicht-leer ist, sich aber
        kein JSON extrahieren laesst. Eine echte leere Liste (z.B. wenn Claude
        wirklich keine Treffer hat) wird als [] zurueckgegeben.
        """
        text = (response or "").strip()
        if not text:
            return []

        # 1) Direkt parsen (Antwort ist bereits sauberes JSON)
        try:
            data = json.loads(text)
            if isinstance(data, list):
                return data
            if isinstance(data, dict) and isinstance(data.get("articles"), list):
                return data["articles"]
        except json.JSONDecodeError:
            pass

        # 2) JSON-Array irgendwo im Text (Markdown-Fence oder Vor-/Nachtext)
        arr = _extract_json_array(text)
        if isinstance(arr, list):
            return arr

        # 3) JSON-Objekt mit "articles"-Key
        obj = _extract_json_object(text)
        if isinstance(obj, dict) and isinstance(obj.get("articles"), list):
            return obj["articles"]

        # 3b) Nicht maskierte Anfuehrungszeichen im Text reparieren (deutsche
        # Zitate in content_summary). Ohne diesen Schritt fiel die Auswertung
        # auf die Einzelobjekt-Rettung zurueck, die Artikel verlieren kann, oder
        # es ging ein kompletter Recherche-Durchlauf verloren.
        from json_utils import extract_json_array as _forgiving_array, extract_json_object as _forgiving_object
        repaired_arr = _forgiving_array(text)
        if isinstance(repaired_arr, list):
            logger.info("JSON-Reparatur: %d Artikel gerettet", len(repaired_arr))
            return repaired_arr
        repaired_obj = _forgiving_object(text)
        if isinstance(repaired_obj, dict) and isinstance(repaired_obj.get("articles"), list):
            logger.info("JSON-Reparatur: %d Artikel gerettet (Objektform)", len(repaired_obj["articles"]))
            return repaired_obj["articles"]

        # 4) Recovery: einzelne Headline-Objekte aus Fliesstext
        recovered = []
        for obj_str in re.findall(r'\{[^{}]*"headline"[^{}]*\}', text, re.DOTALL):
            try:
                parsed = json.loads(obj_str)
            except json.JSONDecodeError:
                continue
            if isinstance(parsed, dict) and "headline" in parsed:
                recovered.append(parsed)
        if recovered:
            logger.info("JSON-Recovery: %d Artikel aus Einzelobjekten extrahiert", len(recovered))
            return recovered

        # Parse fehlgeschlagen — Claude hat geantwortet, aber kein verwertbares JSON dabei.
        # Sample loggen, damit der Fehler debuggbar ist, und Aufrufer signalisieren.
        logger.warning(
            "Konnte Claude-Antwort nicht als JSON parsen (Laenge: %d). Sample: %s",
            len(text),
            _truncate_for_log(text),
        )
        raise ResearcherParseError(f"Claude-Antwort enthielt kein verwertbares JSON (Laenge: {len(text)})")

    async def select_relevant_web_sources(
        self,
        title: str,
        description: str,
        web_sources: list[dict],
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Laesst Claude die thematisch passenden Web-Quellen auswaehlen (Haiku).

        Returns:
            (ausgewaehlte Quellen, usage). Bei Fehler: ([], None).
            Leere Auswahl ist explizit erlaubt — keine Quelle wird zwangsweise aufgenommen.
        """
        if not web_sources:
            return [], None

        # Bei sehr wenigen Quellen lohnt der Selektions-Call kaum — alle weiterreichen.
        if len(web_sources) <= 3:
            logger.info("Web-Source-Selektion: Nur %d Quellen, alle uebernehmen", len(web_sources))
            return list(web_sources), None

        lines = []
        for i, src in enumerate(web_sources, 1):
            cat = src.get("category", "sonstige")
            notes = (src.get("notes") or "")[:80]
            domain = src.get("domain", "")
            line = f"{i}. {src.get('name', domain)} ({domain}) [{cat}]"
            if notes:
                line += f" - {notes}"
            lines.append(line)

        prompt = WEB_SOURCE_SELECTION_PROMPT.format(
            title=title,
            description=description or "Keine weitere Beschreibung",
            source_list="\n".join(lines),
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)
            indices = _extract_json_array(result)
            if not isinstance(indices, list):
                logger.warning(
                    "Web-Source-Selektion: Kein JSON in Antwort, ignoriere Quellen. Sample: %s",
                    _truncate_for_log(result),
                )
                return [], usage

            selected = []
            for idx in indices:
                if isinstance(idx, int) and 1 <= idx <= len(web_sources):
                    selected.append(web_sources[idx - 1])

            logger.info(
                "Web-Source-Selektion: %d von %d ausgewaehlt%s",
                len(selected), len(web_sources),
                f" ({', '.join(s.get('domain', '') for s in selected)})" if selected else "",
            )
            return selected, usage
        except Exception as e:
            logger.warning("Web-Source-Selektion fehlgeschlagen (%s)", e)
            return [], None

    async def select_relevant_telegram_channels(
        self,
        title: str,
        description: str,
        channels_metadata: list[dict],
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Laesst Claude die relevanten Telegram-Kanaele fuer eine Lage vorauswaehlen.

        Nutzt Haiku (CLAUDE_MODEL_FAST) fuer diese einfache Aufgabe.

        Returns:
            (ausgewaehlte Kanaele, usage) -- Bei Fehler: (alle Kanaele, None)
        """
        if len(channels_metadata) <= 10:
            logger.info("Telegram-Selektion: Nur %d Kanaele, nutze alle", len(channels_metadata))
            return channels_metadata, None

        channel_lines = []
        for i, ch in enumerate(channels_metadata, 1):
            cat = ch.get("category", "sonstige")
            notes = (ch.get("notes") or "")[:100]
            channel_lines.append(f"{i}. {ch['name']} [{cat}] - {notes}")

        prompt = TELEGRAM_CHANNEL_SELECTION_PROMPT.format(
            title=title,
            description=description or "Keine weitere Beschreibung",
            channel_list="\n".join(channel_lines),
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)

            indices = _extract_json_array(result)
            if not isinstance(indices, list):
                logger.warning(
                    "Telegram-Selektion: Kein JSON in Antwort, nutze alle Kanaele. Sample: %s",
                    _truncate_for_log(result),
                )
                return channels_metadata, usage

            selected = []
            for idx in indices:
                if isinstance(idx, int) and 1 <= idx <= len(channels_metadata):
                    selected.append(channels_metadata[idx - 1])

            if not selected:
                logger.warning("Telegram-Selektion: Keine gueltigen Indizes, nutze alle Kanaele")
                return channels_metadata, usage

            logger.info(
                "Telegram-Selektion: %d von %d Kanaelen ausgewaehlt",
                len(selected), len(channels_metadata)
            )
            return selected, usage

        except Exception as e:
            logger.warning("Telegram-Selektion fehlgeschlagen (%s), nutze alle Kanaele", e)
            return channels_metadata, None

    async def select_relevant_x_accounts(
        self,
        title: str,
        description: str,
        accounts_metadata: list[dict],
    ) -> tuple[list[dict], ClaudeUsage | None]:
        """Laesst Claude die relevanten X-Accounts fuer eine Lage vorauswaehlen.

        Nutzt Haiku (CLAUDE_MODEL_FAST) fuer diese einfache Aufgabe.

        Returns:
            (ausgewaehlte Accounts, usage) -- Bei Fehler: (alle Accounts, None)
        """
        if len(accounts_metadata) <= 10:
            logger.info("X-Selektion: Nur %d Accounts, nutze alle", len(accounts_metadata))
            return accounts_metadata, None

        account_lines = []
        for i, acc in enumerate(accounts_metadata, 1):
            cat = acc.get("category", "sonstige")
            notes = (acc.get("notes") or "")[:100]
            account_lines.append(f"{i}. {acc['name']} [{cat}] - {notes}")

        prompt = X_ACCOUNT_SELECTION_PROMPT.format(
            title=title,
            description=description or "Keine weitere Beschreibung",
            account_list="\n".join(account_lines),
        )

        try:
            result, usage = await call_claude(prompt, tools=None, model=CLAUDE_MODEL_FAST)

            indices = _extract_json_array(result)
            if not isinstance(indices, list):
                logger.warning(
                    "X-Selektion: Kein JSON in Antwort, nutze alle Accounts. Sample: %s",
                    _truncate_for_log(result),
                )
                return accounts_metadata, usage

            selected = []
            for idx in indices:
                if isinstance(idx, int) and 1 <= idx <= len(accounts_metadata):
                    selected.append(accounts_metadata[idx - 1])

            if not selected:
                logger.warning("X-Selektion: Keine gueltigen Indizes, nutze alle Accounts")
                return accounts_metadata, usage

            logger.info(
                "X-Selektion: %d von %d Accounts ausgewaehlt",
                len(selected), len(accounts_metadata)
            )
            return selected, usage

        except Exception as e:
            logger.warning("X-Selektion fehlgeschlagen (%s), nutze alle Accounts", e)
            return accounts_metadata, None

