#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
[CYBER-STRAT] Module de synthese NLP — GeoSynthesizer v1.1
Agent responsable : NLP_SYNTHESIZER

Synthese geopolitique : Groq API (priorite 1) + Ollama fallback.
Utilise /api/chat (PAS /api/generate).
Zero API Claude — regle absolue.
Zero import requests — urllib.request uniquement.
"""

import json
import os
import urllib.request
import urllib.error
from datetime import datetime, date

# Configuration Ollama centralisee
from ollama_config import (
    OLLAMA_URL,
    OLLAMA_MODEL,
    OLLAMA_FALLBACK_CHAIN,
    MAX_SUMMARY_CHARS,
    MIN_SUMMARY_CHARS,
    OLLAMA_TEMPERATURE,
    OLLAMA_NUM_PREDICT,
)

# Configuration Groq (priorite 1 — rapide, puissant)
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
GROQ_MODEL = "llama-3.3-70b-versatile"
GROQ_TIMEOUT = 8

# Timeout pour les appels Ollama (secondes)
# v3.2 fix : reduit a 3s — si Ollama ne repond pas en 3s, inutile d'attendre
# Avec 1 modele dans la chain (gemma2:2b), pire cas = 3s check + 3s call = 6s
OLLAMA_TIMEOUT = 3

# ── Instruction temporelle (injectee dans tous les prompts) ───────────
# Resout BUG 1 (obsolescence ages) et BUG 2 (present pour structures passees)

TEMPORAL_INSTRUCTION = (
    "\n\nCONTRAINTES TEMPORELLES (OBLIGATOIRES) :\n"
    "- La date du jour est : {today}.\n"
    "- REGLE AGE CRITIQUE : Ne JAMAIS recopier un age provenant "
    "des sources tel quel. Si une source datee de l'annee X "
    "indique qu'une personne a N ans, son age actuel est "
    "approximativement N + ({current_year} - X). "
    "Exemple : source de 2017 indiquant \"48 ans\" → en {current_year} "
    "la personne a environ {example_age} ans. "
    "Ne PAS mentionner de date de naissance sauf si explicitement "
    "donnee dans les sources. Formuler : \"agee d'environ X ans "
    "en {current_year}\" ou omettre l'age si incertain.\n"
    "- Si une institution, fonction ou structure a une date de fin, "
    "de dissolution ou de cessation, utiliser le PASSE.\n"
    "- Ne JAMAIS affirmer qu'une personne \"est actuellement\" en poste "
    "si la source date de plus de 2 ans et qu'aucune source recente "
    "ne le confirme. Utiliser \"occupait\" ou \"a occupe\".\n"
    "- Les sources peuvent etre anciennes : croiser les dates "
    "des sources avec la date du jour pour eviter les anachronismes.\n"
)

# v4.9f — Instruction temporelle SANS estimation d'age (pour person_notoriete)
TEMPORAL_INSTRUCTION_NO_AGE = (
    "\n\nCONTRAINTES TEMPORELLES (OBLIGATOIRES) :\n"
    "- La date du jour est : {today}.\n"
    "- NE MENTIONNE JAMAIS l'age sauf si une date de naissance EXACTE "
    "(jour/mois/annee) est explicitement presente dans les sources. "
    "Cela inclut TOUTE formulation : 'N ans', 'a l'age de N ans', "
    "'age de N ans', 'ne en XXXX'. INTERDIT.\n"
    "- Si une institution, fonction ou structure a une date de fin, "
    "de dissolution ou de cessation, utiliser le PASSE.\n"
    "- Ne JAMAIS affirmer qu'une personne \"est actuellement\" en poste "
    "si la source date de plus de 2 ans et qu'aucune source recente "
    "ne le confirme. Utiliser \"occupait\" ou \"a occupe\".\n"
    "- Les sources peuvent etre anciennes : croiser les dates "
    "des sources avec la date du jour pour eviter les anachronismes.\n"
)

# ── Prompts contextuels par content_type ──────────────────────────────
# Remplace le SYSTEM_PROMPT unique pour adapter la synthese au contenu reel

CONTENT_PROMPTS = {
    "fiction": (
        "Tu es un critique litteraire et analyste culturel.\n"
        "On te donne des extraits sur une oeuvre de fiction (roman, saga, jeu, etc.).\n"
        "\n"
        "CONSIGNES :\n"
        "- Presente l'oeuvre : titre, auteur, genre, univers\n"
        "- Decris le contexte narratif et les themes principaux\n"
        "- Si l'oeuvre a un ancrage geopolitique ou philosophique, l'analyser\n"
        "- Structure en sections : PRESENTATION, UNIVERS, THEMES, RECEPTION\n"
        "- Termine par : GENRE: [FANTASY|SF|THRILLER|HISTORIQUE|AUTRE]\n"
        "- Jamais d'opinion personnelle, style analytique\n"
        "- 800 a 4500 caracteres maximum\n"
        "- Toujours en francais"
    ),

    "person": (
        "Tu es un analyste biographique. Synthetise le profil de la personne indiquee dans SUJET.\n"
        "\n"
        "REGLE ANTI-DERIVE : si les sources parlent d'une personne differente du SUJET, les ignorer.\n"
        "\n"
        "CONNAISSANCES : tu peux completer avec tes propres connaissances si la personne est publique "
        "(Wikipedia, presse, publications). Privilegier les sources fournies, mais ne pas dire "
        "\"aucune information disponible\" si tu connais la personne.\n"
        "\n"
        "LONGUEUR OBLIGATOIRE : MINIMUM 1500 caracteres, MAXIMUM 4000 caracteres.\n"
        "Une synthese d'une seule phrase est un echec. Developpe chaque section.\n"
        "Utilise TOUTES les sources fournies, pas seulement Wikipedia.\n"
        "\n"
        "STRUCTURE — dans cet ordre, omettre si vide :\n"
        "\n"
        "**IDENTITE**\n"
        "Nom, profession, nationalite. 2-3 phrases.\n"
        "\n"
        "**PARCOURS**\n"
        "Formation, postes, institutions. Passe pour ce qui n'est plus actuel.\n"
        "\n"
        "**TRAVAUX ET POSITIONS**\n"
        "Expertise, prises de position publiques, tribunes, debats. Ne pas repeter PARCOURS.\n"
        "\n"
        "**INFLUENCE ET PRESENCE PUBLIQUE**\n"
        "Medias, emissions, conferences, controverses si pertinent. Omettre si inconnu.\n"
        "\n"
        "**PUBLICATIONS**\n"
        "Ouvrages, du plus recent au plus ancien, annee + editeur. Omettre si aucune.\n"
        "\n"
        "**ACTUALITE RECENTE**\n"
        "3 dernieres annees uniquement. Omettre si rien de notable.\n"
        "\n"
        "STATUT : [ACTIF|DISCRET|INACTIF]\n"
        "\n"
        "REGLES : zero repetition entre sections, distinguer personne/collectif homonyme, "
        "jamais d'opinion, toujours en francais. Croiser les sources pour une synthese riche."
    ),

    "person_notoriete": (
        "Tu es un analyste de renseignement open source (OSINT). "
        "Tu rediges un portrait factuel et dense a partir des sources fournies.\n"
        "\n"
        "REGLES ABSOLUES :\n"
        "- Exploite TOUTES les informations concretes presentes dans les sources.\n"
        "- Chaque phrase doit contenir une information factuelle verifiable.\n"
        "- NE DIS JAMAIS \"les informations sont limitees\" ou "
        "\"les sources ne permettent pas\".\n"
        "- NE DIS JAMAIS \"il est difficile de determiner\" ou "
        "\"les details ne sont pas clairs\".\n"
        "- Si une information n'est pas disponible dans les sources, "
        "OMETS la section correspondante silencieusement. "
        "Ne signale pas l'absence.\n"
        "- NE MENTIONNE JAMAIS l'age ou la date de naissance SAUF si une date "
        "de naissance EXACTE (jour/mois/annee) est explicitement presente "
        "dans les sources. Cela inclut TOUTE formulation : 'N ans', "
        "'a l'age de N ans', 'age de N ans', 'ne en XXXX'. INTERDIT.\n"
        "- N'INVENTE aucune information. Ne deduis pas, ne suppose pas, "
        "ne specule pas.\n"
        "- Si les sources parlent d'evenements, de prises de position, "
        "de publications : CITE-LES avec precision (noms, dates, lieux, titres).\n"
        "\n"
        "REGLE ANTI-DERIVE : si les sources parlent d'une personne differente du SUJET, les ignorer.\n"
        "\n"
        "LONGUEUR OBLIGATOIRE : MINIMUM 1000 caracteres, MAXIMUM 3500 caracteres.\n"
        "Une synthese d'une seule phrase est un echec. Developpe chaque section.\n"
        "\n"
        "STRUCTURE (inclure uniquement les sections pour lesquelles tu as des informations) :\n"
        "\n"
        "**IDENTITE**\n"
        "Nom complet, profession/fonction principale, organisation/entreprise.\n"
        "\n"
        "**PARCOURS**\n"
        "Formation, postes occupes, chronologie si disponible.\n"
        "\n"
        "**ACTIVITE**\n"
        "Domaines d'expertise, prises de position, roles publics.\n"
        "\n"
        "**PUBLICATIONS**\n"
        "Livres, articles, interventions mediatiques.\n"
        "\n"
        "**PRESENCE PUBLIQUE**\n"
        "Medias, conferences, reseaux professionnels.\n"
        "\n"
        "STATUT : [ACTIF|DISCRET|INACTIF]\n"
        "\n"
        "REGLES : zero repetition entre sections, distinguer personne/collectif homonyme, "
        "jamais d'opinion, toujours en francais."
    ),

    "country": (
        "Tu es un analyste geopolitique senior.\n"
        "On te donne des sources sur un pays ou une region.\n"
        "\n"
        "CONSIGNES :\n"
        "- Produis une synthese analytique de 800 a 4500 caracteres (espaces inclus)\n"
        "- Structure en sections :\n"
        "  PRESENTATION : position geographique, regime, population\n"
        "  CONTEXTE : situation politique, economique, diplomatique\n"
        "  ENJEUX : enjeux strategiques actuels, tensions, alliances\n"
        "  POSITIONNEMENT : positionnement international, zones d'influence\n"
        "  DEVELOPPEMENTS RECENTS : faits marquants des 12 derniers mois si disponibles\n"
        "- Ton : analytique, factuel, dense, style rapport de veille strategique\n"
        "- Termine par : STATUT: [STABLE|INSTABLE|CRITIQUE|CONFLIT]\n"
        "- JAMAIS d'opinion, JAMAIS de jugement moral\n"
        "- Si les sources sont insuffisantes, dis-le explicitement en 2 lignes\n"
        "- Toujours en francais"
    ),

    "organization": (
        "Tu es un analyste en intelligence strategique.\n"
        "On te donne des sources sur une organisation (institution, entreprise, alliance).\n"
        "\n"
        "CONSIGNES :\n"
        "- Produis une synthese analytique de 800 a 4500 caracteres (espaces inclus)\n"
        "- Structure en sections :\n"
        "  PRESENTATION : nature, siege, dirigeants, domaine d'activite\n"
        "  CONTEXTE : historique, evolution recente\n"
        "  ENJEUX : enjeux strategiques, economiques, influence\n"
        "  POSITIONNEMENT : alliances, rivalites, zone d'action\n"
        "  DEVELOPPEMENTS RECENTS : faits marquants des 12 derniers mois si disponibles\n"
        "- Ton : analytique, factuel, dense, style rapport de veille strategique\n"
        "- Termine par : STATUT: [ACTIF|EMERGENT|ETABLI|DISCRET|CONFIDENTIEL]\n"
        "- JAMAIS d'opinion\n"
        "- Si les sources sont insuffisantes, dis-le explicitement en 2 lignes\n"
        "- Toujours en francais"
    ),

    "elu_local": (
        "Tu es un analyste specialise en gouvernance locale francaise.\n"
        "On te donne des informations sur un ELU LOCAL (maire, adjoint, "
        "conseiller municipal) issu du Repertoire National des Elus.\n"
        "\n"
        "STRUCTURE OBLIGATOIRE — dans cet ordre exact :\n"
        "\n"
        "**IDENTITE ET MANDAT**\n"
        "Nom complet, fonction (maire, adjoint...), commune, departement, "
        "code INSEE si disponible. Date de debut de mandat et date de debut "
        "de fonction. Categorie socio-professionnelle.\n"
        "\n"
        "**COMMUNE**\n"
        "Presentation de la commune : localisation geographique, population "
        "si connue, caractere (rural/urbain/periurbain), intercommunalite "
        "si mentionnee. Si un extrait Wikipedia de la commune est fourni, "
        "utiliser ces informations.\n"
        "\n"
        "**CONTEXTE LOCAL**\n"
        "Enjeux locaux si disponibles dans les sources : amenagement, "
        "developpement economique, services publics, environnement. "
        "Si aucune source locale disponible, l'indiquer brievement.\n"
        "\n"
        "**INFORMATIONS COMPLEMENTAIRES**\n"
        "Toute information additionnelle sur la personne issue des sources "
        "(parcours, profession, engagements). Si aucune info : omettre.\n"
        "\n"
        "STATUT : [EN FONCTION|MANDAT EXPIRE|INCONNU]\n"
        "\n"
        "REGLES :\n"
        "- Source OFFICIELLE : les donnees RNE (Repertoire National des Elus) "
        "sont la source primaire et font autorite\n"
        "- Ne PAS inventer d'informations non presentes dans les sources\n"
        "- Si peu d'informations disponibles, produire une synthese courte "
        "mais factuelle (minimum 400 caracteres)\n"
        "- Maximum 3000 caracteres au total\n"
        "- Toujours en francais"
    ),

    "default": (
        "Tu es un analyste en intelligence strategique et geopolitique.\n"
        "On te donne des extraits d'articles de presse et de medias sur un sujet.\n"
        "\n"
        "CONSIGNES :\n"
        "- Produis une synthese analytique de 800 a 4500 caracteres (espaces inclus)\n"
        "- Structure ta synthese en sections clairement identifiees :\n"
        "  PRESENTATION : identite, fonction, rattachement\n"
        "  CONTEXTE : cadre geopolitique, historique recent, situation actuelle\n"
        "  ENJEUX : enjeux strategiques, economiques, diplomatiques ou securitaires\n"
        "  POSITIONNEMENT : positionnement de l'entite, alliances, rivalites, influence\n"
        "  DEVELOPPEMENTS RECENTS : faits marquants des 12 derniers mois si disponibles\n"
        "- Ton : analytique, factuel, dense, style rapport de veille strategique\n"
        "- Termine par : STATUT: [ACTIF|EMERGENT|ETABLI|DISCRET|CONFIDENTIEL]\n"
        "- JAMAIS d'opinion, JAMAIS \"il est excellent/brillant/controverse\"\n"
        "- Si les sources sont insuffisantes, dis-le explicitement en 2 lignes\n"
        "- Toujours en francais"
    ),
}


def get_system_prompt(content_type="default"):
    """Selectionner le prompt systeme adapte au type de contenu.
    Injecte automatiquement la date du jour pour la coherence temporelle.
    v4.9f : person_notoriete utilise TEMPORAL_INSTRUCTION_NO_AGE
    (pas d'estimation d'age sans date de naissance).
    """
    base_prompt = CONTENT_PROMPTS.get(content_type, CONTENT_PROMPTS["default"])
    today_str = date.today().strftime("%Y-%m-%d")
    current_year = date.today().year
    example_age = 48 + (current_year - 2017)

    # v4.9f — person_notoriete : pas d'estimation d'age
    if content_type == "person_notoriete":
        temporal = TEMPORAL_INSTRUCTION_NO_AGE.format(
            today=today_str)
    else:
        temporal = TEMPORAL_INSTRUCTION.format(
            today=today_str,
            current_year=current_year,
            example_age=example_age)

    return base_prompt + temporal


# Retrocompatibilite : SYSTEM_PROMPT pointe vers le prompt default
SYSTEM_PROMPT = CONTENT_PROMPTS["default"]

# Prompt de retry si la synthese initiale est trop courte
RETRY_PROMPT = (
    "Ta synthese precedente ne fait que {char_count} caracteres. "
    "C'est insuffisant — une seule phrase n'est PAS une synthese. "
    "Developpe OBLIGATOIREMENT chaque section pour atteindre "
    "au minimum 1500 caracteres. Ajoute des details, du contexte historique, "
    "des enjeux et des developpements recents. "
    "Utilise TOUTES les sources fournies, pas seulement la premiere."
)


def _log(level, message):
    """Log prefixe pour le module synthesizer"""
    print(f"[CYBER-STRAT][SYNTHESIZER][{level}] {message}")


class GeoSynthesizer:
    """Synthetiseur geopolitique multi-sources.

    Pipeline :
    1. Construction du prompt utilisateur a partir des sources
    2. Tentative de synthese via Ollama (fallback chain : mistral > gemma2 > llama3.2)
    3. Si Ollama indisponible : synthese heuristique locale (extraction de phrases)
    4. Validation de la synthese produite
    """

    # Cache disponibilite Ollama — evite de re-tester a chaque requete
    _ollama_available_cache = None   # True/False
    _ollama_cache_ts = 0             # timestamp du dernier check
    _OLLAMA_CACHE_TTL = 120          # secondes avant re-check

    def __init__(self):
        """Initialisation du synthetiseur"""
        _log("INFO", "GeoSynthesizer initialise")
        if GROQ_API_KEY:
            _log("INFO", f"Groq configure: {GROQ_API_KEY[:8]}...")
        else:
            _log("ERROR", "GROQ_API_KEY absente — verifier override.conf. Ollama uniquement")

    # ─────────────────────────────────────────────────────────────────────
    # METHODE PRINCIPALE
    # ─────────────────────────────────────────────────────────────────────

    def synthesize(self, query, entity_type="concept", sources=None,
                   source_texts=None, lang="fr", content_type="default",
                   extra_context=""):
        """Synthetiser un resume depuis des sources multiples.

        Parametres :
            query         : str   — le mot-clef recherche
            entity_type   : str   — "person", "country", "organization", "concept"
            sources       : list[dict] — chaque source a les cles "domain", "text", "title"
            source_texts  : list[str]  — alternative simplifiee (liste de textes bruts)
            lang          : str   — langue de sortie ("fr" par defaut)
            content_type  : str   — type de contenu detecte (fiction, person, country, etc.)
            extra_context : str   — contexte supplementaire injecte dans le USER prompt
                                    (ex: activite editoriale Reflets.info)

        Retourne :
            dict avec "summary", "method", "model", "char_count"
        """
        _log("INFO",
            f"Synthese demandee pour: '{query}' "
            f"(type: {entity_type}, content: {content_type}, lang: {lang})"
            f"{' [extra_context]' if extra_context else ''}")

        # Normaliser les sources : accepter soit sources (list[dict]) soit source_texts (list[str])
        if sources is None and source_texts is not None:
            sources = [{"domain": "source", "text": t, "title": query} for t in source_texts if t]
        if sources is None:
            sources = []

        if not sources:
            _log("WARN", f"Aucune source fournie pour '{query}'")
            return {
                "summary": f"Aucune source disponible pour synthetiser des informations sur \"{query}\".",
                "method": "none",
                "model": None,
                "char_count": 0,
            }

        # Selectionner le prompt systeme adapte au contenu
        system_prompt = get_system_prompt(content_type)
        _log("INFO", f"Prompt systeme selectionne: {content_type}")

        # Construire le prompt utilisateur a partir des sources
        user_prompt = self._build_user_prompt(
            query, entity_type, sources, lang, extra_context)

        # PRIORITE 1 : Tentative Groq (rapide, puissant)
        if GROQ_API_KEY:
            groq_result = self._call_groq(system_prompt, user_prompt)
            if groq_result:
                summary = groq_result

                # Retry Groq si synthese trop courte (1 seul retry)
                if len(summary) < MIN_SUMMARY_CHARS:
                    _log("WARN",
                        "Synthese Groq trop courte (%d chars < %d) "
                        "— retry avec instruction renforcee"
                        % (len(summary), MIN_SUMMARY_CHARS))
                    retry_user = (
                        RETRY_PROMPT.format(char_count=len(summary))
                        + "\n\nTa synthese precedente :\n" + summary
                        + "\n\nSources originales :\n" + user_prompt
                    )
                    retry_result = self._call_groq(
                        system_prompt, retry_user)
                    if (retry_result
                            and len(retry_result) > len(summary)):
                        summary = retry_result
                        _log("INFO",
                            "Retry Groq reussi: %d chars"
                            % len(summary))

                # Troncature de securite
                if len(summary) > MAX_SUMMARY_CHARS:
                    truncated = summary[:MAX_SUMMARY_CHARS]
                    last_period = max(truncated.rfind(". "), truncated.rfind(".\n"))
                    if last_period > MAX_SUMMARY_CHARS * 0.8:
                        summary = truncated[:last_period + 1]
                    else:
                        summary = truncated[:MAX_SUMMARY_CHARS - 3] + "..."

                method = f"groq:{GROQ_MODEL}"
                _log("INFO", f"Synthese Groq reussie ({len(summary)} chars)")

                validation = self.validate_summary(summary)
                _log("INFO",
                    f"Validation: {validation['char_count']} chars, "
                    f"limite={'OK' if validation['within_limit'] else 'DEPASSE'}, "
                    f"statut={'OK' if validation['has_status_line'] else 'ABSENT'}")

                return {
                    "summary": summary,
                    "method": method,
                    "model": GROQ_MODEL,
                    "char_count": len(summary),
                }

        # PRIORITE 2 : Fallback Ollama (fallback chain)
        summary, model_used = self._synthesize_ollama(system_prompt, user_prompt)

        if summary:
            # Retry automatique si synthese trop courte (maximum 1 retry)
            if model_used and len(summary) < MIN_SUMMARY_CHARS:
                _log("INFO", f"Synthese trop courte ({len(summary)} chars < {MIN_SUMMARY_CHARS}), retry...")
                retry_user = RETRY_PROMPT.format(char_count=len(summary)) + "\n\nTa synthese precedente :\n" + summary + "\n\nSources originales :\n" + user_prompt
                retry_summary = self._call_ollama(model_used, system_prompt, retry_user)
                if retry_summary and len(retry_summary) > len(summary):
                    summary = retry_summary
                    _log("INFO", f"Retry reussi: {len(summary)} chars")

            # Troncature de securite (coupe a la derniere phrase avant MAX_SUMMARY_CHARS)
            if len(summary) > MAX_SUMMARY_CHARS:
                truncated = summary[:MAX_SUMMARY_CHARS]
                last_period = max(truncated.rfind(". "), truncated.rfind(".\n"))
                if last_period > MAX_SUMMARY_CHARS * 0.8:
                    summary = truncated[:last_period + 1]
                else:
                    summary = truncated[:MAX_SUMMARY_CHARS - 3] + "..."
            method = f"ollama:{model_used}"
            _log("INFO", f"Synthese Ollama reussie via {model_used} ({len(summary)} chars)")
        else:
            # Fallback heuristique (Ollama indisponible)
            _log("INFO", "Fallback synthese heuristique (Ollama indisponible)")
            summary = self._heuristic_synthesis(query, sources)
            method = "heuristic"
            model_used = None

        # Validation
        validation = self.validate_summary(summary)
        _log("INFO",
            f"Validation: {validation['char_count']} chars, "
            f"limite={'OK' if validation['within_limit'] else 'DEPASSE'}, "
            f"statut={'OK' if validation['has_status_line'] else 'ABSENT'}")

        return {
            "summary": summary,
            "method": method,
            "model": model_used,
            "char_count": len(summary),
        }

    # ─────────────────────────────────────────────────────────────────────
    # CONSTRUCTION DU PROMPT (v4.0 — budget elargi pour synthese riche)
    # ─────────────────────────────────────────────────────────────────────

    # Budgets v4.9f — cible : USER prompt <= 12000 chars total
    MAX_USER_CHARS = 12000
    MAX_SOURCE_CHARS = 3000   # par source (v4.9f, etait 1500)
    MAX_SOURCES = 6

    def _build_user_prompt(self, query, entity_type, sources, lang="fr",
                           extra_context=""):
        """Construire le USER prompt — donnees uniquement, zero instruction.

        v4.0 : budget 6000 chars. 5 blocs, ordre strict :
        1. SUJET : {query}                             (80c max)
        2. IDENTITE compacte                           (300c max)
        3. ELU officiel si present                     (200c max)
        4. Reflets si present                          (150c max)
        5. Sources : 6 x 1500c max                     (~9000c brut, tronque)

        Les instructions sont UNIQUEMENT dans system_prompt.
        """
        parts = []

        # 1. Sujet (80c max)
        parts.append("SUJET : %s" % query[:80])

        # 2. Identite compacte (300c max)
        if extra_context:
            id_line = self._extract_identity_line(extra_context)
            if id_line:
                parts.append(id_line[:300])

        # 3. Bloc ELU si present dans extra_context (200c max)
        if extra_context and "DONNEES OFFICIELLES (RNE" in extra_context:
            elu_start = extra_context.find("DONNEES OFFICIELLES (RNE")
            elu_end = extra_context.find("\n\n", elu_start)
            if elu_end < 0:
                elu_end = len(extra_context)
            elu_block = extra_context[elu_start:elu_end].strip()
            elu_compact = elu_block.replace("\n", " | ")
            parts.append(elu_compact[:200])

        # 3b. Bloc Wikidata si present (250c max, v3.3)
        if extra_context and "WIKIDATA :" in extra_context:
            wd_start = extra_context.find("WIKIDATA :")
            wd_end = extra_context.find("\n\n", wd_start)
            if wd_end < 0:
                wd_end = min(wd_start + 300, len(extra_context))
            wd_block = extra_context[wd_start:wd_end].strip()
            parts.append(wd_block[:250])

        # 4. Reflets si present (150c max, titres)
        if extra_context and "ACTIVITE EDITORIALE" in extra_context:
            ref_start = extra_context.find("ACTIVITE EDITORIALE")
            ref_text = extra_context[ref_start:]
            titles = []
            for line in ref_text.split("\n"):
                line = line.strip()
                if line.startswith("- ") and len(titles) < 3:
                    t = line.split(" — ")[0] if " — " in line else line
                    titles.append(t)
            if titles:
                parts.append("REFLETS: " + " ; ".join(titles))

        # 5. Sources (6 x 1500c max)
        if sources:
            src_parts = []
            for src in sources[:self.MAX_SOURCES]:
                text = (src.get("text") or src.get("extract")
                        or src.get("summary") or "")
                if not text or not text.strip():
                    continue
                domain = src.get("domain", "?")
                src_parts.append("[%s] %s" % (domain, text[:self.MAX_SOURCE_CHARS]))
            if src_parts:
                parts.append("SOURCES:\n" + "\n---\n".join(src_parts))

        user_content = "\n".join(parts)

        # Garde-fou absolu
        if len(user_content) > self.MAX_USER_CHARS:
            user_content = user_content[:self.MAX_USER_CHARS]
            _log("WARN", "USER prompt tronque a %d chars" % self.MAX_USER_CHARS)

        _log("INFO", "USER_PROMPT_CHARS: %d" % len(user_content))
        _log("INFO",
            "USER_PROMPT_PREVIEW: %s..."
            % user_content[:500].replace("\n", " | "))
        return user_content

    def _extract_identity_line(self, extra_context):
        """Extraire une ligne d'identite compacte depuis extra_context.
        Format : IDENTITE : Nom -- Profession (Lieu)
        """
        result_parts = []
        for line in extra_context.split("\n"):
            line = line.strip()
            if not line:
                continue
            # Ignorer les regles
            if any(line.startswith(skip) for skip in (
                    "REGLE ABSOLUE", "NE PAS DERIVER", "SUJET DE LA",
                    "IDENTITE CONFIRMEE", "DONNEES OFFICIELLES",
                    "ACTIVITE EDITORIALE", "->", "La personne")):
                continue
            # Extraire les infos cles
            if line.startswith("- Nom :"):
                result_parts.insert(0, line[7:].strip())
            elif line.startswith("- Profession"):
                result_parts.append(line.split(":")[1].strip() if ":" in line else "")
            elif line.startswith("- Localisation"):
                result_parts.append("(%s)" % (line.split(":")[1].strip() if ":" in line else ""))
            elif line.startswith("- Contexte"):
                ctx = line.split(":", 1)[1].strip() if ":" in line else ""
                if ctx:
                    result_parts.append(ctx[:80])
        if result_parts:
            return "IDENTITE : " + " -- ".join(p for p in result_parts if p)
        return ""

    # ─────────────────────────────────────────────────────────────────────
    # SYNTHESE GROQ (PRIORITE 1)
    # ─────────────────────────────────────────────────────────────────────

    def _call_groq(self, system_prompt, user_prompt):
        """Appeler Groq API via urllib.request.

        v4.0 : max_tokens=2000, timeout=8s, log GROQ_RESPONSE_MS.
        Zero import requests — urllib.request uniquement.
        Retourne le texte de synthese ou None si echec.
        """
        import time as _time
        _log("INFO", "Tentative synthese via Groq API...")
        _log("INFO", "SYSTEM_PROMPT_CHARS: %d" % len(system_prompt))
        _log("INFO", "USER_PROMPT_CHARS: %d" % len(user_prompt))
        t0 = _time.time()
        try:
            payload = json.dumps({
                "model": GROQ_MODEL,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt},
                ],
                "max_tokens": 2000,
                "temperature": OLLAMA_TEMPERATURE,
            }).encode("utf-8")

            req = urllib.request.Request(
                GROQ_URL,
                data=payload,
                headers={
                    "Content-Type": "application/json",
                    "Authorization": "Bearer %s" % GROQ_API_KEY,
                    "User-Agent": "CyberStrat/1.0",
                },
            )

            with urllib.request.urlopen(req, timeout=GROQ_TIMEOUT) as resp:
                elapsed_ms = int((_time.time() - t0) * 1000)
                data = json.loads(resp.read().decode("utf-8"))
                choices = data.get("choices", [])
                if choices:
                    content = choices[0].get("message", {}).get("content", "")
                    _log("INFO", "GROQ_RESPONSE_MS: %d" % elapsed_ms)
                    if content and len(content) > 100:
                        _log("INFO", "Groq: reponse recue (%d chars)" % len(content))
                        return content.strip()
                    else:
                        _log("WARN", "Groq: reponse trop courte (%d chars)" % len(content))
                        return None
                else:
                    _log("WARN", "Groq: pas de choices dans la reponse")
                    return None

        except urllib.error.HTTPError as e:
            elapsed_ms = int((_time.time() - t0) * 1000)
            body = e.read().decode("utf-8", errors="replace")[:200]
            _log("WARN", "Groq HTTP %d (%dms): %s" % (e.code, elapsed_ms, body))
            return None
        except Exception as e:
            elapsed_ms = int((_time.time() - t0) * 1000)
            _log("WARN", "Echec Groq (%dms): %s — fallback Ollama" % (elapsed_ms, e))
            return None

    # ─────────────────────────────────────────────────────────────────────
    # SYNTHESE OLLAMA (FALLBACK CHAIN)
    # ─────────────────────────────────────────────────────────────────────

    def _synthesize_ollama(self, system_prompt, user_prompt):
        """Tenter chaque modele de la fallback chain.

        Ordre : gemma2:2b (defini dans ollama_config.py)
        v3.2 fix : timeout=3s, cache disponibilite 120s.
        Retourne (texte, nom_du_modele) ou (None, None) si tous echouent.
        """
        import time as _time
        t0 = _time.time()
        # Verifier d'abord si Ollama est accessible (cache 120s)
        if not self._ollama_available():
            elapsed_ms = int((_time.time() - t0) * 1000)
            _log("WARN", "Ollama non accessible (%dms), skip fallback chain" % elapsed_ms)
            return None, None

        for model in OLLAMA_FALLBACK_CHAIN:
            try:
                _log("INFO", "Tentative synthese avec %s..." % model)
                result = self._call_ollama(model, system_prompt, user_prompt)
                if result and len(result) > 100:
                    elapsed_ms = int((_time.time() - t0) * 1000)
                    _log("INFO", "OLLAMA_RESPONSE_MS: %d (model=%s)" % (elapsed_ms, model))
                    return result, model
                else:
                    _log("WARN", "Reponse trop courte de %s: %d chars" % (
                        model, len(result) if result else 0))
            except Exception as e:
                _log("WARN", "Echec %s: %s" % (model, e))
                continue

        # Tous les modeles ont echoue — invalider le cache pour eviter
        # de re-tenter pendant 120s (Ollama repond a /api/tags mais
        # ne peut pas faire d'inference)
        elapsed_ms = int((_time.time() - t0) * 1000)
        GeoSynthesizer._ollama_available_cache = False
        GeoSynthesizer._ollama_cache_ts = _time.time()
        _log("WARN", "Tous les modeles echoue (%dms) — cache invalide 120s" % elapsed_ms)
        return None, None

    def _call_ollama(self, model, system_prompt, user_prompt):
        """Appeler Ollama via /api/chat (PAS /api/generate).

        Zero API Claude — regle absolue.
        Utilise urllib.request (zero import requests).
        """
        payload = json.dumps({
            "model": model,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            "stream": False,
            "options": {
                "temperature": OLLAMA_TEMPERATURE,
                "num_predict": OLLAMA_NUM_PREDICT,
            },
        }).encode("utf-8")

        req = urllib.request.Request(
            f"{OLLAMA_URL}/api/chat",
            data=payload,
            headers={"Content-Type": "application/json"},
        )

        with urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            # /api/chat retourne la reponse dans message.content
            message = data.get("message", {})
            return message.get("content", "")

    def _ollama_available(self):
        """Verifier si Ollama est accessible sur le port configure.

        v3.2 fix : cache le resultat pendant 120s pour eviter de
        re-tester a chaque requete quand Ollama est down.
        """
        import time as _time
        now = _time.time()
        # Utiliser le cache si encore valide
        if (GeoSynthesizer._ollama_available_cache is not None
                and (now - GeoSynthesizer._ollama_cache_ts)
                < self._OLLAMA_CACHE_TTL):
            return GeoSynthesizer._ollama_available_cache
        try:
            req = urllib.request.Request("%s/api/tags" % OLLAMA_URL)
            with urllib.request.urlopen(req, timeout=2) as resp:
                available = resp.status == 200
        except Exception:
            available = False
        GeoSynthesizer._ollama_available_cache = available
        GeoSynthesizer._ollama_cache_ts = now
        if not available:
            _log("INFO", "Ollama indisponible — cache 120s active")
        return available

    # ─────────────────────────────────────────────────────────────────────
    # FALLBACK HEURISTIQUE
    # ─────────────────────────────────────────────────────────────────────

    def _heuristic_synthesis(self, query, sources):
        """Synthese sans LLM : phrases pertinentes extraites des sources.

        Prefixe : [SYNTHESE AUTO -- LLM INDISPONIBLE]
        Utilise quand Ollama est down ou que tous les modeles echouent.

        Strategie d'extraction intelligente :
        1. Priorise les phrases contenant le nom recherche
        2. Fallback sur les premieres phrases si aucune mention directe
        Tronque a MAX_SUMMARY_CHARS (4500) caracteres.
        """
        parts = ["[SYNTHESE AUTO -- LLM INDISPONIBLE]"]
        query_lower = query.lower()
        name_parts = [p.lower() for p in query.split() if len(p) > 2]

        for src in sources[:4]:
            text = src.get("text", "")
            if not text or not text.strip():
                continue
            # Decouper en phrases (split sur ". " pour eviter les faux positifs)
            raw_sentences = text.replace("\n", " ").split(". ")
            sentences = [s.strip() for s in raw_sentences if len(s.strip()) > 30]

            # Phase 1 : phrases contenant le nom complet ou le nom de famille
            relevant = []
            for s in sentences:
                s_lower = s.lower()
                if query_lower in s_lower:
                    relevant.append(s)
                elif len(name_parts) >= 2 and name_parts[-1] in s_lower:
                    relevant.append(s)
                if len(relevant) >= 3:
                    break

            if relevant:
                fragment = ". ".join(relevant)
                if not fragment.endswith("."):
                    fragment += "."
                parts.append(fragment)
            else:
                # Phase 2 : fallback 2 premieres phrases
                fallback = sentences[:2]
                if fallback:
                    fragment = ". ".join(fallback)
                    if not fragment.endswith("."):
                        fragment += "."
                    parts.append(fragment)

        result = "\n\n".join(parts)
        return result[:MAX_SUMMARY_CHARS]

    # ─────────────────────────────────────────────────────────────────────
    # VALIDATION
    # ─────────────────────────────────────────────────────────────────────

    def validate_summary(self, summary):
        """Verifier la conformite de la synthese.

        Retourne un dict avec :
            char_count   : nombre de caracteres
            within_limit : True si <= 4500 chars (MAX_SUMMARY_CHARS)
            has_status_line : True si contient "STATUT:"
            min_density  : True si >= 2500 chars (MIN_SUMMARY_CHARS)
        """
        return {
            "char_count": len(summary),
            "within_limit": len(summary) <= MAX_SUMMARY_CHARS,
            "has_status_line": "STATUT:" in summary,
            "min_density": len(summary) >= MIN_SUMMARY_CHARS,
        }

    # ─────────────────────────────────────────────────────────────────────
    # EXTRACTION D'ENTITES (NER simplifie)
    # ─────────────────────────────────────────────────────────────────────

    def extract_entities(self, text):
        """Extraction d'entites nommees via Ollama.

        Utilise le LLM pour identifier personnes, organisations, lieux, evenements.
        Retourne une liste de dicts {"entity", "type", "context"}.
        Fallback : liste vide si Ollama indisponible.
        """
        if not text or not text.strip():
            return []

        system_prompt = (
            "Tu es un extracteur d'entites nommees specialise en geopolitique.\n"
            "Extrais les entites du texte fourni.\n"
            "Reponds UNIQUEMENT en JSON valide, sous la forme d'une liste :\n"
            '[{"entity": "nom", "type": "PERSON|ORG|GPE|EVENT", "context": "breve description"}]\n'
            "Types : PERSON (personne), ORG (organisation), GPE (pays/ville), EVENT (evenement)\n"
            "Maximum 10 entites. Pas de commentaire, uniquement le JSON."
        )

        user_prompt = f"Texte a analyser :\n\n{text[:2000]}"

        # Tenter via Ollama
        result_text, model_used = self._synthesize_ollama(system_prompt, user_prompt)

        if not result_text:
            _log("WARN", "NER: Ollama indisponible, retour liste vide")
            return []

        # Parser le JSON retourne par le LLM
        try:
            # Nettoyer la reponse (le LLM peut ajouter du texte autour du JSON)
            json_start = result_text.find("[")
            json_end = result_text.rfind("]") + 1
            if json_start >= 0 and json_end > json_start:
                json_str = result_text[json_start:json_end]
                entities = json.loads(json_str)
                _log("INFO", f"NER: {len(entities)} entites extraites via {model_used}")
                return entities
            else:
                _log("WARN", "NER: Pas de JSON valide dans la reponse LLM")
                return []
        except (json.JSONDecodeError, ValueError) as e:
            _log("WARN", f"NER: Echec parsing JSON: {e}")
            return []


# ─────────────────────────────────────────────────────────────────────────
# TEST AUTONOME
# ─────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print("=" * 60)
    print("[CYBER-STRAT] Test du module synthesizer.py")
    print("=" * 60)

    synth = GeoSynthesizer()

    # Test 1 : Verification Ollama
    print("\n--- Test 1 : Disponibilite Ollama ---")
    available = synth._ollama_available()
    print(f"Ollama accessible : {available}")

    # Test 2 : Synthese avec sources simulees
    print("\n--- Test 2 : Synthese (sources simulees) ---")
    test_sources = [
        {
            "domain": "lemonde.fr",
            "title": "Article test Le Monde",
            "text": "Emmanuel Macron a prononce un discours sur la defense europeenne. "
                    "Le president francais a insiste sur la necessite d'une autonomie strategique. "
                    "Cette declaration intervient dans un contexte de tensions internationales accrues.",
        },
        {
            "domain": "rfi.fr",
            "title": "Article test RFI",
            "text": "La France renforce sa position diplomatique en Afrique de l'Ouest. "
                    "Les relations franco-africaines traversent une periode de redefinition. "
                    "Plusieurs pays du Sahel ont pris leurs distances avec Paris.",
        },
    ]

    result = synth.synthesize(
        query="Emmanuel Macron",
        entity_type="person",
        sources=test_sources,
        lang="fr",
    )

    print(f"Methode  : {result['method']}")
    print(f"Modele   : {result['model']}")
    print(f"Chars    : {result['char_count']}")
    print(f"Resume   :\n{result['summary'][:500]}...")

    # Test 3 : Validation
    print("\n--- Test 3 : Validation ---")
    validation = synth.validate_summary(result["summary"])
    for key, value in validation.items():
        print(f"  {key}: {value}")

    # Test 4 : Synthese sans sources (cas limite)
    print("\n--- Test 4 : Synthese sans sources ---")
    result_empty = synth.synthesize(query="Entite inconnue", sources=[])
    print(f"Methode : {result_empty['method']}")
    print(f"Resume  : {result_empty['summary']}")

    print("\n" + "=" * 60)
    print("[CYBER-STRAT] Tests termines")
    print("=" * 60)
