"""MBIOTaskLLM — Assistant conversationnel LLM intégré au processeur MBIO.

Utilise l'API Anthropic (Claude) avec streaming SSE token-par-token.
Outil registry déclaratif via décorateur @tool.
Sessions multi-utilisateur persistées par pickle.
"""

import json
import os
import queue
import threading
import time
import uuid
import fnmatch
from dataclasses import dataclass, field
from io import BytesIO

import httpx

from .task import MBIOTask
from .xmlconfig import XMLConfig

CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages'
CLAUDE_COUNT_TOKENS_URL = 'https://api.anthropic.com/v1/messages/count_tokens'
_RATELIMIT_REFRESH_INTERVAL = 60.0  # secondes entre chaque refresh rate-limit sans trafic
_ANTHROPIC_VERSION = '2023-06-01'
_SESSION_EXPIRY = 86400   # 24h inactivité
_MAX_SESSIONS_DEFAULT = 20


# ---------------------------------------------------------------------------
# Décorateur @tool
# ---------------------------------------------------------------------------

def tool(description, params=None, confirm=False):
    """Marque une méthode comme tool LLM.

    description : texte visible par Claude dans tool_schema
    params      : dict {name: {type, description, ...}} au format JSON Schema
    confirm     : True → demande confirmation utilisateur avant exécution
    """
    def decorator(fn):
        fn._is_tool = True
        fn._tool_description = description
        fn._tool_params = params or {}
        fn._tool_confirm = confirm
        return fn
    return decorator


# ---------------------------------------------------------------------------
# Session
# ---------------------------------------------------------------------------

@dataclass
class LLMSession:
    session_id: str
    created: float = field(default_factory=time.time)
    last_activity: float = field(default_factory=time.time)
    history: list = field(default_factory=list)
    output_queue: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=500))
    pending_confirms: dict = field(default_factory=dict)
    processing: bool = False
    title: str = ''
    audit: list = field(default_factory=list)
    downloads: list = field(default_factory=list)  # chemins absolus des fichiers générés pour cette session
    last_processed_len: int = 0  # longueur de history au moment du dernier dispatch
    stream_id: int = 0           # incrémenté à chaque nouveau message — invalide les événements du thread précédent
    abort_current: bool = False  # signale au thread en cours de s'arrêter dès que possible


# ---------------------------------------------------------------------------
# Tâche principale
# ---------------------------------------------------------------------------

class MBIOTaskLLM(MBIOTask):

    def initName(self):
        return 'llm'

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def onInit(self):
        self._generation = str(uuid.uuid4())  # régénéré à chaque poweron() — permet au browser de détecter un reset
        self._sessions = {}          # {session_id: LLMSession}
        self._sessions_lock = threading.Lock()
        self._thread_locals = threading.local()  # session courante par thread de traitement
        self._apikey = ''
        self._apikey_xml = ''        # clé lue depuis config XML (fallback si persistence absente)
        self._apikey_rejected = False  # True si l'API a retourné 401 (clé révoquée/invalide)
        self._ratelimit = {}         # infos rate limit extraites des headers Anthropic
        self._model = 'claude-haiku-4-5'
        self._use_xmldoc_summary=False
        self._max_tokens = 1024
        self._history_turns = 20
        self._max_sessions = _MAX_SESSIONS_DEFAULT
        self._system_context = ''
        self._suggestions = []       # [{'label': str, 'prompt': str}]
        self._welcome_message = 'Bonjour, je suis votre assistant IA Digimat\nQue puis-je faire pour vous ?'
        self._config_summary = ''    # résumé compact du config.xml injecté dans le system prompt
        self._extra_contexts = {}    # {key: {'content': str, 'ts': float}} — persistés
        self._downloads_dir = None   # répertoire local pour les fichiers générés
        self._t_cleanup = 0.0
        self._t_ratelimit_refresh = 0.0
        self._downloads_ttl = 7 * 86400  # 7 jours avant nettoyage automatique

        self.valueDigital('comerr', default=False)
        self.value('sessions_count', default=0)
        self.value('total_requests', default=0)

    def onLoad(self, xml: XMLConfig):
        self._apikey_xml = xml.get('apikey', '')
        self._apikey = self._apikey_xml
        self._model = xml.get('model', self._model)
        self._use_xmldoc_summary=xml.getBool('xmldoc')
        self._max_tokens = xml.getInt('max_tokens', self._max_tokens)
        self._history_turns = xml.getInt('history_turns', self._history_turns)
        self._max_sessions = xml.getInt('max_sessions', _MAX_SESSIONS_DEFAULT)
        ttl_days = xml.getInt('downloads_ttl_days', 7)
        self._downloads_ttl = max(1, ttl_days) * 86400

        self._welcome_message = xml.get('welcome', self._welcome_message)
        wnode = xml.child('welcome')
        if wnode is not None and wnode.text():
            self._welcome_message = wnode.text().strip()

        ctx_node = xml.child('systemcontext')
        if ctx_node is not None:
            self._system_context = (ctx_node.text() or '').strip()

        for sug in xml.children('suggestion') or []:
            label = sug.get('label', '')
            prompt = sug.get('prompt', '')
            if label and prompt:
                self._suggestions.append({'label': label, 'prompt': prompt})

        self.logger.info('LLMAssistant "%s": model=%s max_tokens=%d history_turns=%d max_sessions=%d' % (
            self.name, self._model, self._max_tokens, self._history_turns, self._max_sessions))

        if self._suggestions:
            self.logger.info('LLMAssistant "%s": %d suggestion(s) configurée(s)' % (self.name, len(self._suggestions)))

        if not self._system_context:
            self.logger.warning('LLMAssistant "%s": aucun SystemContext configuré' % self.name)

        # Vérification dépendance httpx
        try:
            import httpx as _httpx_check  # noqa: F401
        except ImportError:
            self.logger.error('LLMAssistant "%s": module httpx manquant — pip install httpx' % self.name)

    def _init_downloads_dir(self):
        """Crée et retourne le répertoire local pour les fichiers générés."""
        mbio = self.getMBIO()
        root = (mbio.rootPath() if mbio else None) or '.'
        d = os.path.join(root, 'llm_downloads')
        try:
            os.makedirs(d, exist_ok=True)
            return d
        except Exception as e:
            self.logger.error('LLMAssistant: impossible de créer llm_downloads: %s' % e)
            return None

    @staticmethod
    def _sanitize_filename(filename):
        """Normalise un nom de fichier : ASCII pur, caractères sûrs uniquement.

        - Translitère les accents (é→e, à→a…) via unicodedata
        - Remplace tout caractère non alphanumérique (hors ._-) par _
        - Réduit les _ consécutifs
        - Limite à 120 caractères (préfixe UUID exclu)
        """
        import unicodedata
        import re
        nfkd = unicodedata.normalize('NFKD', filename)
        ascii_name = nfkd.encode('ascii', 'ignore').decode('ascii')
        safe = re.sub(r'[^\w.\-]', '_', ascii_name)
        safe = re.sub(r'_+', '_', safe).strip('_')
        return safe[:120] or 'download'

    def _create_download(self, filename, data_bytes):
        """Sauvegarde data_bytes dans downloads_dir, retourne l'URL de téléchargement."""
        if not self._downloads_dir:
            return None
        safe_name = self._sanitize_filename(filename)
        uid = uuid.uuid4().hex[:8]
        stored_name = '%s_%s' % (uid, safe_name)
        fpath = os.path.join(self._downloads_dir, stored_name)
        try:
            with open(fpath, 'wb') as f:
                f.write(data_bytes)
            self.logger.info('LLMAssistant: fichier généré %s (%d bytes)' % (stored_name, len(data_bytes)))
            # Enregistrer dans la session active (thread-local)
            session = getattr(self._thread_locals, 'session', None)
            if session is not None:
                session.downloads.append(fpath)
            from urllib.parse import quote
            return '/api/v1/llm/files?name=%s' % quote(stored_name, safe='')
        except Exception as e:
            self.logger.error('LLMAssistant: erreur écriture fichier: %s' % e)
            return None

    def get_download_file_path(self, stored_name):
        """Retourne le chemin absolu d'un fichier download, ou None si inexistant."""
        if not self._downloads_dir:
            return None
        # Sécurité : interdire les traversées de répertoire
        stored_name = os.path.basename(stored_name)
        fpath = os.path.join(self._downloads_dir, stored_name)
        if os.path.isfile(fpath):
            return fpath
        return None

    def _cleanup_downloads(self):
        """Supprime les fichiers de téléchargement plus vieux que _downloads_ttl."""
        if not self._downloads_dir:
            return
        now = time.time()
        try:
            for fname in os.listdir(self._downloads_dir):
                fpath = os.path.join(self._downloads_dir, fname)
                try:
                    if os.path.isfile(fpath) and now - os.path.getmtime(fpath) > self._downloads_ttl:
                        os.remove(fpath)
                        self.logger.debug('LLMAssistant: download expiré supprimé: %s' % fname)
                except Exception:
                    pass
        except Exception:
            pass

    def _delete_session_downloads(self, session):
        """Supprime les fichiers générés pour une session (appelé lors du clear)."""
        deleted = 0
        for fpath in list(session.downloads):
            try:
                if os.path.isfile(fpath):
                    os.remove(fpath)
                    deleted += 1
            except Exception:
                pass
        session.downloads.clear()
        if deleted:
            self.logger.info('LLMAssistant [%s]: %d fichier(s) supprimé(s) au clear' % (
                session.session_id[:8], deleted))

    def _build_config_summary(self):
        """Lit config.xml.txt depuis le répertoire du module et retourne un extrait compact.

        Même fichier que cb_getxmlconfigdoc dans wsmonitor.py.
        Limite à _CONFIG_SUMMARY_MAX_CHARS pour ne pas saturer le contexte.
        """
        import os
        _CONFIG_SUMMARY_MAX_CHARS = 6000    # ~1 500 tokens

        module_dir = os.path.dirname(os.path.abspath(__file__))
        fpath = os.path.join(module_dir, 'config.xml.txt')
        try:
            with open(fpath, 'r', encoding='utf-8', errors='replace') as f:
                content = f.read()
        except (OSError, IOError):
            self.logger.debug('LLMAssistant: config.xml.txt absent, résumé config non injecté')
            return ''

        if len(content) <= _CONFIG_SUMMARY_MAX_CHARS:
            summary = content
        else:
            summary = content[:_CONFIG_SUMMARY_MAX_CHARS] + '\n... (tronqué)'

        self.logger.info('LLMAssistant: config.xml.txt injecté (%d/%d chars)' % (
            len(summary), len(content)))
        return summary

    def has_valid_apikey(self):
        """Retourne True si la clé API est configurée, valide et non rejetée par l'API."""
        return bool(self._apikey and self._apikey.startswith('sk-ant-') and not self._apikey_rejected)

    def _refresh_ratelimit(self):
        """POST /v1/messages/count_tokens pour récupérer les headers rate-limit sans inférence.

        count_tokens ne consomme aucun output token mais retourne bien tous les headers
        anthropic-ratelimit-* nécessaires à la mise à jour de l'affichage.
        """
        if not self.has_valid_apikey():
            return
        try:
            resp = httpx.post(
                CLAUDE_COUNT_TOKENS_URL,
                headers={
                    'x-api-key': self._apikey,
                    'anthropic-version': _ANTHROPIC_VERSION,
                    'content-type': 'application/json',
                },
                json={
                    'model': self._model,
                    'messages': [{'role': 'user', 'content': '.'}],
                },
                timeout=10,
            )
            self._update_ratelimit(resp.headers)
        except Exception:
            pass  # silencieux — pas critique

    def _update_ratelimit(self, headers):
        """Extrait les headers rate-limit Anthropic et les stocke dans _ratelimit."""
        rl = {}
        for hdr, key in (
            ('anthropic-ratelimit-tokens-limit',             'tokens_limit'),
            ('anthropic-ratelimit-tokens-remaining',         'tokens_remaining'),
            ('anthropic-ratelimit-tokens-reset',             'tokens_reset'),
            ('anthropic-ratelimit-input-tokens-limit',       'input_tokens_limit'),
            ('anthropic-ratelimit-input-tokens-remaining',   'input_tokens_remaining'),
            ('anthropic-ratelimit-input-tokens-reset',       'input_tokens_reset'),
            ('anthropic-ratelimit-output-tokens-limit',      'output_tokens_limit'),
            ('anthropic-ratelimit-output-tokens-remaining',  'output_tokens_remaining'),
            ('anthropic-ratelimit-output-tokens-reset',      'output_tokens_reset'),
            ('anthropic-ratelimit-requests-limit',           'requests_limit'),
            ('anthropic-ratelimit-requests-remaining',       'requests_remaining'),
            ('anthropic-ratelimit-requests-reset',           'requests_reset'),
            ('retry-after',                                  'retry_after'),
        ):
            val = headers.get(hdr)
            if val is not None:
                rl[key] = val
        if rl:
            rl['ts'] = time.time()
            self._ratelimit = rl

    def get_ratelimit(self):
        """Retourne les infos de rate limit avec secondes avant reset calculées."""
        from datetime import datetime, timezone
        rl = dict(self._ratelimit)
        for key in ('tokens_reset', 'input_tokens_reset', 'output_tokens_reset', 'requests_reset'):
            if key in rl:
                try:
                    reset_dt = datetime.fromisoformat(rl[key].replace('Z', '+00:00'))
                    secs = (reset_dt - datetime.now(timezone.utc)).total_seconds()
                    rl[key + '_secs'] = max(0, round(secs))
                except Exception:
                    pass
        return rl

    def set_apikey(self, new_key):
        """Définit la clé API, la persiste et redémarre la tâche."""
        new_key = (new_key or '').strip()
        self._apikey = new_key
        self._apikey_rejected = False
        self.pickleWrite('apikey', new_key, force=True)
        self.logger.info('LLMAssistant "%s": clé API mise à jour via UI — redémarrage' % self.name)
        self.reset()

    def poweron(self):
        try:
            import httpx as _httpx_check  # noqa: F401
        except ImportError:
            self.logger.error('LLMAssistant "%s": démarrage impossible, module httpx manquant' % self.name)
            return False

        # Persistence > XML : la clé sauvegardée via UI prime sur la config
        saved_key = self.pickleRead('apikey')
        if saved_key:
            self._apikey = saved_key
            self.logger.info('LLMAssistant "%s": clé API chargée depuis la persistence' % self.name)

        self._apikey_rejected = False

        comerr = self.valueDigital('comerr')
        if not self._apikey:
            self.logger.warning('LLMAssistant "%s": apikey non configurée — en attente via UI' % self.name)
            if comerr is not None:
                comerr.updateValue(True)
        elif not self._apikey.startswith('sk-ant-'):
            self.logger.warning('LLMAssistant "%s": apikey ne commence pas par "sk-ant-"' % self.name)
            if comerr is not None:
                comerr.updateValue(True)
        else:
            if comerr is not None:
                comerr.updateValue(False)

        self._generation = str(uuid.uuid4())
        if self._use_xmldoc_summary:
            self._config_summary = self._build_config_summary()
        self._extra_contexts = self.pickleRead('llm_extra_contexts') or {}
        if self._extra_contexts:
            self.logger.info('LLMAssistant "%s": %d contexte(s) additionnel(s) restauré(s)' % (
                self.name, len(self._extra_contexts)))
        self._downloads_dir = self._init_downloads_dir()
        self._restore_sessions_index()
        self.logger.info('LLMAssistant "%s": prêt (%d session(s) restaurée(s))' % (
            self.name, len(self._sessions)))
        return True

    def run(self):
        now = time.time()
        if now - self._t_cleanup > 60.0:
            self._cleanup_expired_sessions()
            self._cleanup_downloads()
            self._t_cleanup = now
        if now - self._t_ratelimit_refresh > _RATELIMIT_REFRESH_INTERVAL:
            self._t_ratelimit_refresh = now
            threading.Thread(target=self._refresh_ratelimit, daemon=True).start()
        self._process_pending_messages()
        v = self.value('sessions_count')
        if v is not None:
            with self._sessions_lock:
                v.updateValue(len(self._sessions))
        return 0.5

    def poweroff(self):
        with self._sessions_lock:
            for session in self._sessions.values():
                self._save_session(session)

    # ------------------------------------------------------------------
    # Serialisation MBIOValue
    # ------------------------------------------------------------------

    def _value_to_dict(self, v, detail=False):
        """Sérialise une MBIOValue en dict JSON-safe.

        detail=False : champs essentiels (valeur, flags, description, unité, âge)
        detail=True  : tous les champs (config, persistentConfig, interne)
        """
        try:
            age = round(v.age(), 1)
        except Exception:
            age = None

        flags_decoded = {
            'error':        v.isError(),
            'manual':       v.isManual(),
            'override':     v.override,
            'disabled':     not v.isEnabled(),
            'remote_manual': v.isRemoteManual(),
            'commissionable': v.isCommissionable(),
            'marked':       v.isMarked(),
            'inverted':     v.isInverted(),
            'writable':     v.isWritable(),
            'managed':      v.isManaged() if v.isWritable() else False,
        }

        vtype = 'digital' if v.isDigital() else ('multistate' if v.isMultistate() else 'analog')

        d = {
            'key':         v.key,
            'name':        v.name,
            'value':       v.value,
            'valuestr':    v.valuestr(),
            'unit':        v.unitstr() or '',
            'type':        vtype,
            'flags':       v._flags or '',
            'flags_detail': flags_decoded,
            'description': v.description or '',
            'zone':        v.zone or '',
            'age_s':       age,
        }

        if v.isManual():
            d['manual_value'] = v._manual

        if v.isWritable() and v.isPendingSync():
            d['to_reach'] = getattr(v, '_toReachValue', None)

        if not detail:
            return d

        # Champs étendus (detail=True)
        d['default'] = v._default
        d['min']     = v._min
        d['max']     = v._max
        d['resolution'] = v._resolution
        d['reccount'] = v.reccount

        # config (métadonnées)
        cfg = {}
        for attr in ('iodescription', 'iomaptag', 'zone', 'iolow', 'ionormal', 'iohigh'):
            val = getattr(v.config, attr, None)
            if val:
                cfg[attr] = val
        d['config'] = cfg

        # persistentConfig
        pcfg = {}
        for attr in ('invert',):
            val = getattr(v.persistentConfig, attr, None)
            if val is not None:
                pcfg[attr] = val
        d['persistent_config'] = pcfg

        return d

    # ------------------------------------------------------------------
    # Mécanisme de confirmation générique
    # ------------------------------------------------------------------

    def _queue_confirm(self, session, description, fn, kwargs, extra_event=None):
        """Enregistre une action en attente de confirmation.

        Retourne (action_id, event_dict) à pousser dans output_queue.
        """
        action_id = str(uuid.uuid4())[:8]
        session.pending_confirms[action_id] = {
            'description': description,
            'fn': fn,
            'kwargs': kwargs,
        }
        event = {'type': 'confirm', 'action_id': action_id, 'description': description}
        if extra_event:
            event.update(extra_event)
        return action_id, event

    def execute_confirm(self, session_id, action_id, confirmed):
        """Exécute ou annule une action en attente de confirmation."""
        s = self._sessions.get(session_id)
        if s is None:
            return {'error': 'Session introuvable'}
        confirm_data = s.pending_confirms.pop(action_id, None)
        if confirm_data is None:
            return {'error': 'Action introuvable ou expirée'}

        if not confirmed:
            s.output_queue.put({'type': 'confirm_result', 'action_id': action_id, 'status': 'cancelled'})
            return {'status': 'cancelled'}

        try:
            result = confirm_data['fn'](**confirm_data['kwargs'])
        except Exception as e:
            result = {'error': str(e)}

        s.output_queue.put({'type': 'confirm_result', 'action_id': action_id, 'status': 'done', 'result': result})
        s.audit.append({
            'ts': time.time(),
            'type': 'confirm_executed',
            'tool': confirm_data.get('description', ''),
            'params': confirm_data.get('kwargs', {}),
            'result': result,
            'is_write': True,
        })
        return result

    # ------------------------------------------------------------------
    # Tool registry
    # ------------------------------------------------------------------

    def _build_history_window(self, session):
        """Retourne une fenêtre d'historique valide pour l'API Anthropic.

        Invariants garantis :
        - Commence par un message user avec content string (pas tool_result)
        - Alternance stricte user/assistant
        - Pas de sentinelles vides
        - Pas de tool_result orphelin (sans tool_use correspondant dans le message assistant précédent)
        - Pas de tool_use orphelin (sans tool_result correspondant dans le message user suivant)
        """
        window = [
            m for m in session.history[-(self._history_turns * 2):]
            if not (m.get('role') == 'assistant' and m.get('content') == [])
        ]

        # Fusion des messages consécutifs de même rôle (garde le dernier)
        merged = []
        for msg in window:
            if merged and merged[-1]['role'] == msg['role']:
                merged[-1] = msg
            else:
                merged.append(msg)

        # Avancer jusqu'au premier message user dont le content est une string ou une
        # liste image/text (pas un tool_result orphelin sans son tool_use précédent)
        while merged:
            first = merged[0]
            if first.get('role') == 'user':
                content = first.get('content')
                if isinstance(content, str):
                    break
                if isinstance(content, list) and any(
                    isinstance(b, dict) and b.get('type') in ('text', 'image')
                    for b in content
                ):
                    break
            merged.pop(0)

        # Vérification supplémentaire : si un message user contient des tool_result,
        # son prédécesseur assistant doit avoir les tool_use correspondants.
        # Sinon on avance jusqu'au prochain échange propre.
        i = 0
        while i < len(merged):
            msg = merged[i]
            if msg.get('role') == 'user' and isinstance(msg.get('content'), list):
                tool_result_ids = {
                    b.get('tool_use_id')
                    for b in msg['content']
                    if isinstance(b, dict) and b.get('type') == 'tool_result'
                }
                if tool_result_ids:
                    prev = merged[i - 1] if i > 0 else None
                    tool_use_ids = set()
                    if prev and prev.get('role') == 'assistant' and isinstance(prev.get('content'), list):
                        tool_use_ids = {
                            b.get('id')
                            for b in prev['content']
                            if isinstance(b, dict) and b.get('type') == 'tool_use'
                        }
                    if not tool_result_ids.issubset(tool_use_ids):
                        # tool_result orphelin : supprimer depuis le début jusqu'ici inclus
                        merged = merged[i + 1:]
                        i = 0
                        continue
            i += 1

        # Vérification : chaque message assistant avec tool_use doit être suivi
        # d'un message user avec les tool_result correspondants.
        # Si ce n'est pas le cas (interruption pendant l'exécution des outils),
        # supprimer la paire corrompue et redémarrer depuis le début.
        i = 0
        while i < len(merged):
            msg = merged[i]
            if msg.get('role') == 'assistant' and isinstance(msg.get('content'), list):
                tool_use_ids = {
                    b.get('id')
                    for b in msg['content']
                    if isinstance(b, dict) and b.get('type') == 'tool_use'
                }
                if tool_use_ids:
                    next_msg = merged[i + 1] if i + 1 < len(merged) else None
                    next_result_ids = set()
                    if next_msg and next_msg.get('role') == 'user' and isinstance(next_msg.get('content'), list):
                        next_result_ids = {
                            b.get('tool_use_id')
                            for b in next_msg['content']
                            if isinstance(b, dict) and b.get('type') == 'tool_result'
                        }
                    if not tool_use_ids.issubset(next_result_ids):
                        # tool_use orphelin mid-historique : supprimer depuis le début jusqu'ici inclus
                        self.logger.warning('LLM [%s] tool_use orphelin mid-historique pos %d — nettoyage' % (
                            session.session_id[:8], i))
                        merged = merged[i + 1:]
                        i = 0
                        continue
            i += 1

        # Éliminer les tool_use orphelins en fin d'historique :
        # si le dernier message assistant contient des tool_use mais n'est pas suivi
        # d'un message user avec les tool_result correspondants.
        while len(merged) >= 1:
            last = merged[-1]
            if last.get('role') == 'assistant' and isinstance(last.get('content'), list):
                tool_use_ids = {
                    b.get('id')
                    for b in last['content']
                    if isinstance(b, dict) and b.get('type') == 'tool_use'
                }
                if tool_use_ids:
                    # Vérifier si le message suivant (user) couvre tous ces tool_use
                    # Il n'y a pas de suivant (c'est le dernier) → orphelin → retirer
                    self.logger.warning('LLM [%s] outil orphelin en fin d\'historique — nettoyage' % session.session_id[:8])
                    merged.pop()
                    continue
            break

        # S'assurer que le premier message est un user string
        while merged and not (merged[0].get('role') == 'user' and isinstance(merged[0].get('content'), str)):
            merged.pop(0)

        return merged

    def _build_tool_schemas(self):
        schemas = []
        for name in sorted(dir(self.__class__)):
            m = getattr(self.__class__, name, None)
            if m and getattr(m, '_is_tool', False):
                schemas.append({
                    'name': name,
                    'description': m._tool_description,
                    'input_schema': {
                        'type': 'object',
                        'properties': m._tool_params,
                        'required': list(m._tool_params.keys()),
                    }
                })
        return schemas

    def get_tools_info(self):
        """Retourne la liste des tools avec métadonnées pour l'UI."""
        result = []
        for name in sorted(dir(self.__class__)):
            m = getattr(self.__class__, name, None)
            if m and getattr(m, '_is_tool', False):
                result.append({
                    'name': name,
                    'description': m._tool_description,
                    'params': m._tool_params,
                    'confirm': m._tool_confirm,
                })
        return result

    # ------------------------------------------------------------------
    # System prompt
    # ------------------------------------------------------------------

    def _build_system_prompt(self):
        mbio = self.getMBIO()
        tasks = mbio.tasks.all() if mbio else []
        n_tasks = len(tasks)
        n_values = sum(len(list(t._values.all())) for t in tasks) if tasks else 0
        n_errors = 0
        for t in tasks:
            for v in t._values.all():
                if v.isError():
                    n_errors += 1

        lines = [
            'Tu es un assistant de supervision industrielle pour le système MBIO.',
            f'Le système comporte {n_tasks} tâches et {n_values} valeurs ({n_errors} en erreur).',
            'Réponds en français sauf demande contraire. Sois concis et factuel.',
            '',
            '## Règles de présentation',
            '- N\'utilise JAMAIS de caractères de dessin de boîte (┌─┐│├┤└┘●) ni d\'art ASCII.',
            '- Pour afficher un graphique, utilise TOUJOURS le tool get_trend (il génère un vrai graphe interactif).',
            '- Pour lister des valeurs, utilise list_values (il génère un tableau interactif).',
            '- Dans tes réponses texte, utilise uniquement du Markdown standard (titres, listes, gras, tableaux Markdown).',
            '',
            '## Flags des MBIOValue',
            '- error (E) : valeur en défaut de communication ou signalée invalide',
            '- manual (M) : valeur forcée manuellement, ne suit plus le process',
            '- override (*) : override interne par le programme',
            '- disabled (X) : valeur désactivée',
            '- remote_manual (R) : forcée par un système distant',
            '- commissionable (C) : peut être commissionnée (réglage terrain)',
            '- marked (K) : marquée pour surveillance/attention',
            '- inverted (I) : logique inversée (digital)',
            '- writable (W) : peut être écrite depuis le process',
            '- managed (W!) : writable mais aucun process ne la gère actuellement',
            '',
            '## Actions disponibles',
            '- get_value / get_value_detail : lecture valeur et flags',
            '- list_values : liste filtrée de valeurs',
            '- get_trend : historique de tendance (une valeur)',
            '- compare_trends : tendances multi-valeurs sur la même période (graphe comparatif)',
            '- get_trend_info : nombre de points disponibles dans le trendstore pour une MBIOValue (premier/dernier point, durée couverte)',
            '- get_statistics : statistiques détaillées sur une période (min/max/moy/écart-type/percentiles)',
            '- get_state_durations : durées ON/OFF et cycles pour une valeur digitale',
            '- get_regulation_quality : qualité de régulation mesure vs consigne (écart, temps hors tolérance)',
            '- get_system_overview : vue globale du système',
            '- set_manual : forcer une valeur en mode manuel',
            '- set_auto : relâcher le mode manuel (retour en automatique)',
            '- mark_value / unmark_value : marquer/démarquer une valeur',
            '',
            '## Outils BACnet — objets locaux',
            '- bacnet_list_objects : liste les objets BACnet locaux (AV/BV/MSV/Schedule) avec valeurs et priorités',
            '- bacnet_get_value : lit la valeur courante d\'un objet BACnet local avec son priority array',
            '- bacnet_list_schedules : liste les schedules locaux avec valeur courante et fiabilité',
            '- bacnet_get_schedule : programme hebdomadaire complet d\'un schedule local (plages horaires par jour)',
            '',
            '## Outils BACnet — trending (enregistrement)',
            '- bacnet_list_recorded : liste tous les objets BACnet actuellement enregistrés en tendance',
            '- bacnet_get_trend_info : nombre de points disponibles pour un objet BACnet (premier/dernier point, durée couverte)',
            '- bacnet_record : active l\'enregistrement de tendance pour un objet (local ou distant)',
            '- bacnet_unrecord : désactive l\'enregistrement (historique conservé)',
            '',
            '## Outils BACnet — devices distants',
            '- bacnet_list_remote_devices : liste les devices BACnet distants découverts sur le réseau',
            '- bacnet_list_remote_objects : liste les objets d\'un device distant (par deviceId)',
            '- bacnet_read_remote_object : lit valeur + priority array d\'un objet distant',
            '- bacnet_write_remote_object : écrit une valeur sur un objet commandable distant (ou release Null)',
            '',
            '## Contextes additionnels',
            '- list_contexts : liste les contextes additionnels enregistrés par l\'opérateur',
            '- save_context : enregistre ou met à jour un contexte nommé (persisté, injecté au démarrage)',
            '- delete_context : supprime un contexte additionnel',
            '',
            '## Web',
            '- web_search : recherche DuckDuckGo — retourne titres, URLs, extraits. Sans clé API.',
            '- web_fetch : télécharge et extrait le texte d\'une page HTML (doc en ligne, fiche technique).',
            '- web_fetch_pdf : télécharge un PDF et en extrait le texte (manuel, datasheet). '
            'Workflow typique : web_search → identifier URL PDF → web_fetch_pdf → répondre à la question.',
            '',
            '## Export de fichiers (téléchargement)',
            '- export_values_xlsx : exporte les valeurs MBIO dans un fichier Excel (.xlsx) téléchargeable',
            '- export_trend_xlsx : exporte des données de tendance dans un fichier Excel (une feuille par valeur)',
            '- export_statistics_xlsx : exporte un tableau comparatif de statistiques pour plusieurs valeurs',
            'Utilise ces outils quand l\'opérateur demande un rapport, un export, un fichier Excel, ou un téléchargement.',
            'Le résultat contient un lien de téléchargement cliquable. Les fichiers expirent automatiquement après 7 jours.',
        ]
        if self._system_context:
            lines.append('')
            lines.append('## Contexte site')
            lines.append(self._system_context)

        if self._config_summary:
            lines.append('')
            lines.append('## Documentation de configuration')
            lines.append(self._config_summary)

        if self._extra_contexts:
            lines.append('')
            lines.append('## Contextes additionnels (enregistrés par l\'opérateur)')
            for key, entry in sorted(self._extra_contexts.items()):
                ts_str = time.strftime('%Y-%m-%d %H:%M', time.localtime(entry.get('ts', 0)))
                lines.append(f'### {key}  _(enregistré le {ts_str})_')
                lines.append(entry.get('content', ''))

        return '\n'.join(lines)

    # ------------------------------------------------------------------
    # Tools — Lecture
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Lit la valeur courante et les flags essentiels d\'une MBIOValue par sa clé '
            '(ex: "cta1_setpoint"). Retourne: valeur, unité, type, flags, description, zone, âge.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé unique de la valeur (format task_name ou task:name)'},
        }
    )
    def get_value(self, key):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        return self._value_to_dict(v, detail=False)

    @tool(
        description=(
            'Retourne la fiche complète d\'une MBIOValue : tous les flags décodés, '
            'configuration interne (description, tag, labels, zone, iolow/ionormal/iohigh), '
            'persistentConfig (invert), default, min, max, résolution, nombre de records trend, '
            'valeur manuelle si active, toReachValue si écriture en cours.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé unique de la valeur'},
        }
    )
    def get_value_detail(self, key):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        return self._value_to_dict(v, detail=True)

    @tool(
        description=(
            'Liste les MBIOValues selon un pattern wildcard avec leurs flags. '
            'Filtres cumulables : errors_only, manual_only, marked_only. '
            'Retourne un tableau (rendu DataTable si > 5 entrées).'
        ),
        params={
            'pattern':     {'type': 'string',  'description': 'Pattern wildcard (ex: "cta1*", "*temp*"). Défaut: "*"'},
            'errors_only': {'type': 'boolean', 'description': 'Filtre sur les valeurs en erreur'},
            'manual_only': {'type': 'boolean', 'description': 'Filtre sur les valeurs en mode manuel'},
            'marked_only': {'type': 'boolean', 'description': 'Filtre sur les valeurs marquées'},
            'writable_only': {'type': 'boolean', 'description': 'Filtre sur les valeurs modifiables (writable)'},
            'limit':       {'type': 'integer', 'description': 'Nombre max de résultats (défaut 50)'},
        }
    )
    def list_values(self, pattern='*', errors_only=False, manual_only=False,
                    marked_only=False, writable_only=False, limit=50):
        mbio = self.getMBIO()
        if not mbio:
            return {'error': 'MBIO non disponible'}
        items = []
        # mbio.values() utilise v.match() — la méthode native MBIO (supporte prefix*, *suffix, *contains*)
        for v in mbio.values(pattern or '*'):
            if errors_only and not v.isError():
                continue
            if manual_only and not v.isManual():
                continue
            if marked_only and not v.isMarked():
                continue
            if writable_only and not v.isWritable():
                continue
            items.append({
                'key':         v.key,
                'value':       v.value,
                'valuestr':    v.valuestr(),
                'unit':        v.unitstr() or '',
                'type':        'digital' if v.isDigital() else ('multistate' if v.isMultistate() else 'analog'),
                'flags':       v._flags or '',
                'description': v.description or '',
                'zone':        v.zone or '',
                'age_s':       round(v.age(), 0),
            })
            if len(items) >= limit:
                break

        result = {'count': len(items), 'data': items}
        if len(items) > 5:
            result['_render'] = 'datatable'
        return result

    @tool(
        description='Récupère les données de tendance (historique) d\'une valeur.',
        params={
            'key':      {'type': 'string',  'description': 'Clé de la valeur'},
            'duration': {'type': 'string',  'description': 'Durée : "1h", "6h", "24h", "7d"'},
            'points':   {'type': 'integer', 'description': 'Nombre max de points (défaut 100)'},
        }
    )
    def get_trend(self, key, duration='1h', points=100):
        seconds = self._parse_duration(duration)

        mbio = self.getMBIO()
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}

        now = time.time()
        start_ts = now - seconds
        trend_data = []

        store = getattr(mbio, 'trendingStore', None)
        if store is not None:
            raw = store.get_history_sampled(key, max_points=int(points), since=start_ts, until=now)
            # raw = list of (ts, value, flags, unit)
            trend_data = [{'ts': p[0], 'value': p[1]} for p in raw]

        if not trend_data:
            return {
                'key': key,
                'unit': v.unitstr() or '',
                'duration': duration,
                'points': 0,
                'data': [],
                'stats': {},
                'error': 'Aucune donnée de tendance disponible pour cette valeur sur la période demandée.',
            }

        raw_values = [p['value'] for p in trend_data if p.get('value') is not None]
        stats = {}
        if raw_values:
            stats = {
                'min': min(raw_values),
                'max': max(raw_values),
                'avg': round(sum(raw_values) / len(raw_values), 3),
                'last': raw_values[-1],
            }

        return {
            'key': key,
            'unit': v.unitstr() or '',
            'duration': duration,
            'points': len(trend_data),
            'data': trend_data,
            'stats': stats,
            '_render': 'chart',
        }

    @tool(
        description='Retourne une vue d\'ensemble du système MBIO : tâches, valeurs, erreurs, uptime.',
        params={}
    )
    def get_system_overview(self):
        mbio = self.getMBIO()
        if not mbio:
            return {'error': 'MBIO non disponible'}
        tasks = list(mbio.tasks.all())
        n_values = 0
        n_errors = 0
        n_manual = 0
        n_marked = 0
        task_list = []
        for t in tasks:
            vals = list(t._values.all())
            n_v = len(vals)
            n_e = sum(1 for v in vals if v.isError())
            n_m = sum(1 for v in vals if v.isManual())
            n_k = sum(1 for v in vals if v.isMarked())
            n_values += n_v
            n_errors += n_e
            n_manual += n_m
            n_marked += n_k
            task_list.append({
                'name':    t.name,
                'state':   t.stateName() if hasattr(t, 'stateName') else str(t._state),
                'values':  n_v,
                'errors':  n_e,
                'manual':  n_m,
                'marked':  n_k,
            })
        uptime = mbio.uptime() if hasattr(mbio, 'uptime') else 0
        return {
            'tasks':    len(tasks),
            'values':   n_values,
            'errors':   n_errors,
            'manual':   n_manual,
            'marked':   n_marked,
            'uptime_s': uptime,
            'task_list': task_list,
        }

    # ------------------------------------------------------------------
    # Tools — Manipulation (avec confirmation)
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Force une MBIOValue en mode manuel à la valeur spécifiée. '
            'En mode manuel, la valeur ne suit plus le processus automatique. '
            'Fonctionne sur toute valeur (writable ou non). '
            'Pour les valeurs writable, envoie aussi la commande au terrain.'
        ),
        params={
            'key':   {'type': 'string', 'description': 'Clé de la valeur'},
            'value': {'type': 'number', 'description': 'Valeur à forcer en manuel'},
        }
    )
    def set_manual(self, key, value):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        previous = v.value
        v.manual(value)
        return {'status': 'done', 'key': key, 'value': value, 'previous': previous}

    @tool(
        description=(
            'Relâche le mode manuel d\'une MBIOValue et la remet en mode automatique. '
            'La valeur reprend le suivi du processus.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé de la valeur à remettre en auto'},
        }
    )
    def set_auto(self, key):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        if not v.isManual():
            return {'status': 'skipped', 'message': f'"{key}" n\'est pas en mode manuel'}
        v.auto()
        return {'status': 'done', 'key': key}

    # ------------------------------------------------------------------
    # Tools — Tendances avancées
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Compare les tendances de plusieurs valeurs sur la même période. '
            'Retourne un graphe multi-courbes. '
            'Idéal pour corréler mesure/consigne, température/vanne, etc.'
        ),
        params={
            'keys':     {'type': 'string',  'description': 'Clés séparées par des virgules (ex: "cta1_temp,cta1_setpoint")'},
            'duration': {'type': 'string',  'description': 'Durée : "1h", "6h", "24h", "7d"'},
            'points':   {'type': 'integer', 'description': 'Nombre max de points par courbe (défaut 100)'},
        }
    )
    def compare_trends(self, keys, duration='1h', points=100):
        mbio = self.getMBIO()
        if not mbio:
            return {'error': 'MBIO non disponible'}
        store = getattr(mbio, 'trendingStore', None)
        if store is None:
            return {'error': 'TrendingStore non disponible'}

        key_list = [k.strip() for k in keys.split(',') if k.strip()]
        if not key_list:
            return {'error': 'Aucune clé fournie'}
        if len(key_list) > 8:
            return {'error': 'Maximum 8 valeurs comparables simultanément'}

        seconds = self._parse_duration(duration)

        now = time.time()
        start_ts = now - seconds
        series = []
        missing = []

        for key in key_list:
            v = self._find_value(key)
            if v is None:
                missing.append(key)
                continue
            raw = store.get_history_sampled(key, max_points=int(points), since=start_ts, until=now)
            if not raw:
                missing.append(key)
                continue
            series.append({
                'key':    key,
                'unit':   v.unitstr() or '',
                'data':   [{'ts': p[0], 'value': p[1]} for p in raw],
            })

        if not series:
            return {'error': 'Aucune donnée de tendance disponible pour les clés demandées', 'missing': missing}

        result = {
            'duration': duration,
            'series':   series,
            '_render':  'chart_multi',
        }
        if missing:
            result['missing'] = missing
        return result

    @tool(
        description=(
            'Retourne le nombre de points disponibles dans le trendstore pour une MBIOValue, '
            'ainsi que les horodatages du premier et du dernier point.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé de la valeur MBIO'},
        }
    )
    def get_trend_info(self, key):
        mbio = self.getMBIO()
        store = getattr(mbio, 'trendingStore', None) if mbio else None
        if store is None:
            return {'error': 'TrendingStore non disponible'}
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        info = store.info(v.key)
        if info['count'] == 0:
            return {'key': v.key, 'count': 0, 'message': 'Aucun point enregistré pour cette valeur.'}
        first = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(info['firstTimestamp'])) if info['firstTimestamp'] else None
        last  = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(info['lastTimestamp']))  if info['lastTimestamp']  else None
        span_h = round((info['lastTimestamp'] - info['firstTimestamp']) / 3600, 1) if (info['firstTimestamp'] and info['lastTimestamp']) else 0
        return {
            'key':        v.key,
            'count':      info['count'],
            'first':      first,
            'last':       last,
            'span_hours': span_h,
            'last_value': info['lastValue'],
            'unit':       info['unit'] or '',
            'dirty':      info['dirty'],
        }

    @tool(
        description=(
            'Retourne le nombre de points disponibles dans le trendstore pour un objet BACnet enregistré, '
            'ainsi que les horodatages du premier et du dernier point.'
        ),
        params={
            'device_id': {'type': 'string',  'description': '"local" pour un objet local, sinon l\'ID numérique du device distant'},
            'obj_type':  {'type': 'string',  'description': 'Type d\'objet : "analog-value", "binary-value", etc.'},
            'instance':  {'type': 'integer', 'description': 'Numéro d\'instance de l\'objet BACnet'},
        }
    )
    def bacnet_get_trend_info(self, device_id, obj_type, instance):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        svc = getattr(getattr(t, '_bnet', None), '_service', None)
        if svc is None or not svc._trending:
            return {'error': 'Trending BACnet non disponible'}
        from .bacnetserver import TrendingEntry
        key = TrendingEntry.make_key(t.key, str(device_id), obj_type, int(instance))
        store = svc._trending   # BACnetTrendingStore

        recorded = store.is_recorded(key)
        count = store.point_count(key)
        if count == 0:
            return {
                'key': key, 'count': 0, 'recorded': recorded,
                'message': 'Aucun point enregistré pour cet objet%s.' % ('' if recorded else ' (non enregistré)'),
            }

        last_pt = store.last_point(key)   # (ts, value) ou None
        # Premier point : accès direct au deque (thread-safe via lock interne)
        first_ts = None
        try:
            with store._lock:
                dq = store._data.get(key)
                first_ts = dq[0][0] if dq else None
        except Exception:
            pass

        last_ts  = last_pt[0] if last_pt else None
        last_val = last_pt[1] if last_pt else None
        first = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(first_ts)) if first_ts else None
        last  = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last_ts))  if last_ts  else None
        span_h = round((last_ts - first_ts) / 3600, 1) if (first_ts and last_ts) else 0
        return {
            'key':        key,
            'device_id':  str(device_id),
            'obj_type':   obj_type,
            'instance':   int(instance),
            'count':      count,
            'first':      first,
            'last':       last,
            'span_hours': span_h,
            'last_value': last_val,
            'recorded':   recorded,
        }

    @tool(
        description=(
            'Calcule des statistiques détaillées sur l\'historique d\'une valeur : '
            'min/max avec horodatage, moyenne, écart-type, percentiles P10/P90, '
            'nombre de points. Utile pour caractériser le comportement d\'un point.'
        ),
        params={
            'key':      {'type': 'string', 'description': 'Clé de la valeur'},
            'duration': {'type': 'string', 'description': 'Durée : "1h", "6h", "24h", "7d"'},
        }
    )
    def get_statistics(self, key, duration='24h'):
        mbio = self.getMBIO()
        store = getattr(mbio, 'trendingStore', None) if mbio else None
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}

        seconds = self._parse_duration(duration)

        now = time.time()
        start_ts = now - seconds

        raw = []
        if store:
            raw = store.get_history(key, since=start_ts, until=now)  # tous les points, pas samplés

        if not raw:
            return {'error': 'Aucune donnée disponible sur la période', 'key': key, 'duration': duration}

        values = [p[1] for p in raw if p[1] is not None]
        timestamps = [p[0] for p in raw]
        n = len(values)
        if not values:
            return {'error': 'Aucune valeur valide sur la période'}

        mean = sum(values) / n
        variance = sum((x - mean) ** 2 for x in values) / n
        stddev = variance ** 0.5

        sorted_vals = sorted(values)
        p10 = sorted_vals[max(0, int(n * 0.10))]
        p90 = sorted_vals[min(n - 1, int(n * 0.90))]

        min_val = min(values)
        max_val = max(values)
        min_idx = values.index(min_val)
        max_idx = values.index(max_val)

        return {
            'key':       key,
            'unit':      v.unitstr() or '',
            'duration':  duration,
            'count':     n,
            'min':       round(min_val, 3),
            'min_ts':    timestamps[min_idx],
            'min_time':  time.strftime('%H:%M:%S', time.localtime(timestamps[min_idx])),
            'max':       round(max_val, 3),
            'max_ts':    timestamps[max_idx],
            'max_time':  time.strftime('%H:%M:%S', time.localtime(timestamps[max_idx])),
            'mean':      round(mean, 3),
            'stddev':    round(stddev, 3),
            'p10':       round(p10, 3),
            'p90':       round(p90, 3),
        }

    @tool(
        description=(
            'Pour une valeur digitale (ON/OFF), calcule les durées cumulées dans chaque état '
            'et le nombre de cycles ON→OFF. '
            'Utile pour connaître le temps de fonctionnement d\'un équipement.'
        ),
        params={
            'key':      {'type': 'string', 'description': 'Clé de la valeur digitale'},
            'duration': {'type': 'string', 'description': 'Durée : "1h", "6h", "24h", "7d"'},
        }
    )
    def get_state_durations(self, key, duration='24h'):
        mbio = self.getMBIO()
        store = getattr(mbio, 'trendingStore', None) if mbio else None
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        if not v.isDigital():
            return {'error': f'"{key}" n\'est pas une valeur digitale'}

        seconds = self._parse_duration(duration)

        now = time.time()
        start_ts = now - seconds

        raw = []
        if store:
            raw = store.get_history(key, since=start_ts, until=now)

        if not raw:
            return {'error': 'Aucune donnée disponible sur la période', 'key': key}

        duration_on = 0.0
        duration_off = 0.0
        cycles = 0
        prev_ts = start_ts
        prev_val = raw[0][1]

        for p in raw:
            ts, val = p[0], p[1]
            if val is None:
                prev_ts = ts
                continue
            dt = ts - prev_ts
            if prev_val:
                duration_on += dt
            else:
                duration_off += dt
            if prev_val and not val:
                cycles += 1
            prev_val = val
            prev_ts = ts

        # segment final jusqu'à maintenant
        dt = now - prev_ts
        if prev_val:
            duration_on += dt
        else:
            duration_off += dt

        total = duration_on + duration_off or 1

        def fmt(s):
            s = int(s)
            if s < 60:
                return '%ds' % s
            if s < 3600:
                return '%dm%02ds' % (s // 60, s % 60)
            return '%dh%02dm' % (s // 3600, (s % 3600) // 60)

        return {
            'key':          key,
            'duration':     duration,
            'on_seconds':   round(duration_on),
            'off_seconds':  round(duration_off),
            'on_formatted': fmt(duration_on),
            'off_formatted': fmt(duration_off),
            'on_percent':   round(duration_on / total * 100, 1),
            'cycles':       cycles,
        }

    @tool(
        description=(
            'Analyse l\'écart entre une mesure et sa consigne dans le temps. '
            'Calcule l\'écart moyen, max, le temps passé hors tolérance. '
            'Utile pour évaluer la qualité de régulation d\'une boucle.'
        ),
        params={
            'key_measure':  {'type': 'string', 'description': 'Clé de la valeur mesurée'},
            'key_setpoint': {'type': 'string', 'description': 'Clé de la consigne'},
            'duration':     {'type': 'string', 'description': 'Durée : "1h", "6h", "24h", "7d"'},
            'tolerance':    {'type': 'number', 'description': 'Tolérance acceptable (même unité). Défaut 1.0'},
        }
    )
    def get_regulation_quality(self, key_measure, key_setpoint, duration='1h', tolerance=1.0):
        mbio = self.getMBIO()
        store = getattr(mbio, 'trendingStore', None) if mbio else None
        if not store:
            return {'error': 'TrendingStore non disponible'}

        vm = self._find_value(key_measure)
        vs = self._find_value(key_setpoint)
        if vm is None:
            return {'error': f'Mesure "{key_measure}" introuvable'}
        if vs is None:
            return {'error': f'Consigne "{key_setpoint}" introuvable'}

        seconds = self._parse_duration(duration)

        now = time.time()
        start_ts = now - seconds
        points = 200

        raw_m = store.get_history_sampled(key_measure, max_points=points, since=start_ts, until=now)
        raw_s = store.get_history_sampled(key_setpoint, max_points=points, since=start_ts, until=now)

        if not raw_m or not raw_s:
            return {'error': 'Données insuffisantes pour l\'analyse'}

        # Interpolation simple : aligner les timestamps de la mesure sur la consigne
        def interp(raw, ts):
            if not raw:
                return None
            for i in range(len(raw) - 1):
                if raw[i][0] <= ts <= raw[i + 1][0]:
                    t0, v0 = raw[i][0], raw[i][1]
                    t1, v1 = raw[i + 1][0], raw[i + 1][1]
                    if t1 == t0:
                        return v0
                    return v0 + (v1 - v0) * (ts - t0) / (t1 - t0)
            if ts <= raw[0][0]:
                return raw[0][1]
            return raw[-1][1]

        deviations = []
        time_out = 0.0
        prev_ts = None

        for p in raw_m:
            ts, mval = p[0], p[1]
            if mval is None:
                continue
            sval = interp(raw_s, ts)
            if sval is None:
                continue
            dev = abs(mval - sval)
            deviations.append(dev)
            if prev_ts is not None:
                dt = ts - prev_ts
                if dev > tolerance:
                    time_out += dt
            prev_ts = ts

        if not deviations:
            return {'error': 'Impossible de calculer l\'écart (données insuffisantes)'}

        total_dur = raw_m[-1][0] - raw_m[0][0] or 1
        mean_dev = sum(deviations) / len(deviations)
        max_dev = max(deviations)

        return {
            'key_measure':    key_measure,
            'key_setpoint':   key_setpoint,
            'unit':           vm.unitstr() or '',
            'duration':       duration,
            'tolerance':      tolerance,
            'mean_deviation': round(mean_dev, 3),
            'max_deviation':  round(max_dev, 3),
            'time_out_s':     round(time_out),
            'out_percent':    round(time_out / total_dur * 100, 1),
            'quality':        'bonne' if mean_dev <= tolerance * 0.5 else ('acceptable' if mean_dev <= tolerance else 'mauvaise'),
        }

    # ------------------------------------------------------------------
    # ------------------------------------------------------------------
    # Tools — BACnet
    # ------------------------------------------------------------------

    def _find_bacnet_task(self):
        """Retourne la première tâche MBIOTaskBacnet trouvée, ou None."""
        mbio = self.getMBIO()
        if not mbio:
            return None
        for t in mbio.tasks.all():
            if t.__class__.__name__ == 'MBIOTaskBacnet':
                return t
        return None

    @tool(
        description=(
            'Liste tous les objets BACnet déclarés localement (AV, BV, MSV, Schedule) '
            'avec leur valeur courante, unité et priorité active. '
            'Retourne un tableau interactif.'
        ),
        params={
            'pattern': {'type': 'string', 'description': 'Filtre sur le nom (ex: "cta1*"). Défaut: tous'},
            'obj_type': {'type': 'string', 'description': 'Type : "analog-value", "binary-value", "multi-state-value", "schedule". Défaut: tous'},
        }
    )
    def bacnet_list_objects(self, pattern='', obj_type=''):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        objects = client.retrieveLocalObjects()
        if not objects:
            return {'count': 0, 'data': []}

        filtered = []
        pat = pattern.strip().lower()
        typ = obj_type.strip().lower()
        for o in objects:
            if pat and not fnmatch.fnmatch((o.get('name') or '').lower(), pat):
                continue
            if typ and typ not in (o.get('type') or '').lower():
                continue
            filtered.append({
                'name':     o.get('name', ''),
                'type':     o.get('type', ''),
                'instance': o.get('instance', ''),
                'value':    o.get('value'),
                'unit':     o.get('unit', ''),
                'writable': o.get('writable', False),
                'priority': o.get('active_priority'),
                'description': o.get('description', ''),
            })

        result = {'count': len(filtered), 'data': filtered}
        if len(filtered) > 5:
            result['_render'] = 'datatable'
        return result

    @tool(
        description='Lit la valeur courante d\'un objet BACnet local par son nom.',
        params={
            'name': {'type': 'string', 'description': 'Nom de l\'objet BACnet (ex: "cta1_setpoint")'},
        }
    )
    def bacnet_get_value(self, name):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        bnet = getattr(t, '_bnet', None)
        if bnet is None:
            return {'error': 'Interface BACnet non initialisée'}
        val = bnet.get_value(name)
        if val is None:
            return {'error': f'Objet BACnet "{name}" introuvable ou valeur nulle'}
        details = bnet.get_object_details(name) or {}
        priority_array = None
        try:
            priority_array = bnet.get_priority_array(name)
        except Exception:
            pass
        return {
            'name':     name,
            'value':    val,
            'unit':     details.get('unit', ''),
            'type':     details.get('type', ''),
            'instance': details.get('instance'),
            'priority_array': priority_array,
        }

    @tool(
        description=(
            'Liste les schedules BACnet locaux avec leur valeur courante '
            'et leur prochaine transition. '
            'Utile pour connaître l\'état des programmes horaires.'
        ),
        params={}
    )
    def bacnet_list_schedules(self):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        schedules = client.retrieveLocalSchedules()
        if not schedules:
            return {'count': 0, 'data': [], 'message': 'Aucun schedule déclaré'}
        data = []
        for s in schedules:
            data.append({
                'name':          s.get('name', ''),
                'instance':      s.get('instance', ''),
                'present_value': s.get('present_value'),
                'default_value': s.get('schedule_default'),
                'reliability':   s.get('reliability', ''),
                'out_of_service': s.get('out_of_service', False),
                'description':   s.get('description', ''),
            })
        result = {'count': len(data), 'data': data}
        if len(data) > 3:
            result['_render'] = 'datatable'
        return result

    @tool(
        description=(
            'Retourne le programme hebdomadaire complet d\'un schedule BACnet : '
            'plages horaires par jour de la semaine, valeur par défaut, exceptions.'
        ),
        params={
            'name': {'type': 'string', 'description': 'Nom du schedule BACnet'},
        }
    )
    def bacnet_get_schedule(self, name):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        data = client.getScheduleData(name)
        if not data:
            return {'error': f'Schedule "{name}" introuvable'}

        def _fmt_time(t):
            """Tuple/list [H, M, S, cs] → 'HH:MM'."""
            try:
                return '%02d:%02d' % (int(t[0]), int(t[1]))
            except (TypeError, IndexError):
                return str(t)

        def _fmt_value(v):
            """{'t':..., 'v':...} → valeur native."""
            if isinstance(v, dict):
                return v.get('v')
            return v

        def _fmt_default(v):
            """{'t':..., 'v':...} → représentation lisible."""
            if isinstance(v, dict):
                raw = v.get('v')
                t = v.get('t', '')
                if t == 'enum':
                    return bool(raw)
                return raw
            return v

        days = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
        weekly = data.get('weeklySchedule', [])
        summary = {}
        for i, day_slots in enumerate(weekly):
            day_name = days[i] if i < len(days) else str(i)
            summary[day_name] = [
                {'time': _fmt_time(s.get('time')), 'value': _fmt_value(s.get('value'))}
                for s in (day_slots or [])
            ]
        return {
            'name':             name,
            'default_value':    _fmt_default(data.get('scheduleDefault')),
            'present_value':    data.get('presentValue'),
            'weekly_summary':   summary,
            'exceptions_count': len(data.get('exceptionSchedule') or []),
        }

    @tool(
        description=(
            'Modifie le programme horaire d\'un schedule BACnet local. '
            'Peut modifier le programme hebdomadaire (plages horaires par jour de la semaine) '
            'et/ou la valeur par défaut appliquée hors des plages. '
            'Appeler bacnet_get_schedule d\'abord pour connaître la configuration actuelle. '
            'Format weekly : liste de 7 éléments (lundi → dimanche). '
            'Chaque jour est une liste de {time: "HH:MM", value: nombre}. '
            'Jour vide = [] (aucune plage). '
            'Exemple un jour : [{"time":"08:00","value":21},{"time":"22:00","value":17}]'
        ),
        params={
            'name': {
                'type': 'string',
                'description': 'Nom du schedule BACnet (ex: "cta1_schedule")',
            },
            'weekly': {
                'type': 'array',
                'description': (
                    'Programme hebdomadaire : 7 éléments (lundi→dimanche). '
                    'Chaque élément est une liste de {time: "HH:MM", value: nombre}. '
                    'Omettre pour ne pas modifier le programme.'
                ),
            },
            'default_value': {
                'type': 'number',
                'description': 'Valeur appliquée hors de toute plage horaire. Omettre pour ne pas modifier.',
            },
        },
    )
    def bacnet_write_schedule(self, name, weekly=None, default_value=None):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}

        if weekly is None and default_value is None:
            return {'error': 'Aucune modification spécifiée (weekly et default_value sont tous les deux absents)'}

        # Lire la config actuelle pour détecter le type de valeur (real/unsigned/enum)
        current = client.getScheduleData(name)
        if not current:
            return {'error': f'Schedule "{name}" introuvable'}

        current_default = current.get('scheduleDefault')
        val_type = 'real'
        if isinstance(current_default, dict):
            val_type = current_default.get('t', 'real')

        def _make_value(v):
            if val_type == 'unsigned':
                return {'t': 'unsigned', 'v': int(round(float(v)))}
            elif val_type == 'enum':
                return {'t': 'enum', 'v': int(bool(v))}
            else:
                return {'t': 'real', 'v': float(v)}

        def _parse_time(s):
            parts = str(s).split(':')
            h   = int(parts[0]) if len(parts) > 0 else 0
            m   = int(parts[1]) if len(parts) > 1 else 0
            sec = int(parts[2]) if len(parts) > 2 else 0
            return [h, m, sec, 0]

        # Convertir weekly au format interne (validation + sérialisation)
        serial_weekly = None
        if weekly is not None:
            if len(weekly) != 7:
                return {'error': f'weekly doit avoir exactement 7 éléments (un par jour lun→dim), reçu {len(weekly)}'}
            serial_weekly = []
            for day_slots in weekly:
                day = []
                for slot in (day_slots or []):
                    try:
                        day.append({
                            'time':  _parse_time(slot['time']),
                            'value': _make_value(slot['value']),
                        })
                    except (KeyError, ValueError, TypeError) as e:
                        return {'error': f'Slot invalide {slot!r} : {e}'}
                serial_weekly.append(day)

        serial_default = _make_value(default_value) if default_value is not None else None

        ok = client.writeScheduleData(name, weekly=serial_weekly, sched_default=serial_default)
        if not ok:
            return {'error': f'Échec de l\'écriture du schedule "{name}"'}

        _days = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
        result = {'status': 'ok', 'name': name}
        if serial_weekly is not None:
            result['weekly'] = {
                _days[i]: [
                    {'time': '%02d:%02d' % (s['time'][0], s['time'][1]), 'value': s['value'].get('v')}
                    for s in day
                ]
                for i, day in enumerate(serial_weekly)
            }
        if serial_default is not None:
            result['default_value'] = serial_default.get('v')
        return result

    # ------------------------------------------------------------------
    # Tools — Trending BACnet
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Active l\'enregistrement de tendance (trending) pour un objet BACnet local ou distant. '
            'Pour un objet local, utilise device_id="local". '
            'Pour un objet distant, device_id est l\'identifiant numérique du device BACnet. '
            'obj_type : "analog-value", "binary-value", "multi-state-value", etc. '
            'instance : numéro d\'instance de l\'objet. '
            'name : nom de l\'objet (requis pour les objets locaux, utilisé pour l\'affichage pour les distants).'
        ),
        params={
            'device_id':  {'type': 'string',  'description': '"local" pour un objet local, sinon l\'ID numérique du device BACnet distant'},
            'obj_type':   {'type': 'string',  'description': 'Type d\'objet : "analog-value", "binary-value", "multi-state-value", etc.'},
            'instance':   {'type': 'integer', 'description': 'Numéro d\'instance de l\'objet BACnet'},
            'name':       {'type': 'string',  'description': 'Nom de l\'objet (ex: "cta1_setpoint"). Requis pour local, informatif pour distant.'},
        }
    )
    def bacnet_record(self, device_id, obj_type, instance, name=''):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}

        already = client.isRecorded(t.key, str(device_id), obj_type, int(instance))
        if already:
            return {'status': 'already_recorded', 'message': f'L\'objet {obj_type}:{instance} est déjà en cours d\'enregistrement.'}

        ok = client.recordObject(t.key, str(device_id), obj_type, int(instance), name or '')
        if not ok:
            return {'error': 'Échec de l\'activation du trending (trending non disponible sur cette tâche ?)'}

        # Charger les données persistées si existantes
        try:
            entry = client.getTrendingEntry(t.key, str(device_id), obj_type, int(instance))
            if entry and hasattr(t, '_trending_init_persistence'):
                t._trending_init_persistence()
                if hasattr(t, '_trend_loaded_keys') and entry.key not in t._trend_loaded_keys:
                    t._trend_loaded_keys.add(entry.key)
                    if hasattr(t, '_trending_load_one'):
                        t._trending_load_one(entry)
            if hasattr(t, '_trend_index_save'):
                t._trend_index_save()
        except Exception:
            pass

        return {
            'status': 'recorded',
            'device_id': str(device_id),
            'obj_type':  obj_type,
            'instance':  int(instance),
            'name':      name,
            'message':   f'Enregistrement activé pour {obj_type}:{instance} (device={device_id}).',
        }

    @tool(
        description=(
            'Désactive l\'enregistrement de tendance (trending) pour un objet BACnet. '
            'Les données historiques sont conservées mais plus aucun nouveau point n\'est enregistré. '
            'Paramètres identiques à bacnet_record.'
        ),
        params={
            'device_id': {'type': 'string',  'description': '"local" pour un objet local, sinon l\'ID numérique du device distant'},
            'obj_type':  {'type': 'string',  'description': 'Type d\'objet : "analog-value", "binary-value", "multi-state-value", etc.'},
            'instance':  {'type': 'integer', 'description': 'Numéro d\'instance de l\'objet BACnet'},
        }
    )
    def bacnet_unrecord(self, device_id, obj_type, instance):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}

        if not client.isRecorded(t.key, str(device_id), obj_type, int(instance)):
            return {'status': 'not_recorded', 'message': f'L\'objet {obj_type}:{instance} n\'était pas en cours d\'enregistrement.'}

        client.unrecordObject(t.key, str(device_id), obj_type, int(instance))
        try:
            if hasattr(t, '_trend_index_save'):
                t._trend_index_save()
        except Exception:
            pass

        return {
            'status': 'unrecorded',
            'device_id': str(device_id),
            'obj_type':  obj_type,
            'instance':  int(instance),
            'message':   f'Enregistrement désactivé pour {obj_type}:{instance} (device={device_id}). Historique conservé.',
        }

    @tool(
        description='Liste tous les objets BACnet actuellement en cours d\'enregistrement de tendance.',
        params={}
    )
    def bacnet_list_recorded(self):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}

        svc = getattr(getattr(t, '_bnet', None), '_service', None)
        if svc is None or not svc._trending:
            return {'count': 0, 'data': [], 'message': 'Trending non disponible sur cette tâche.'}

        entries = svc._trending.get_entries()
        items = []
        for key, entry in entries.items():
            items.append({
                'name':       entry.name,
                'device_id':  entry.device_id,
                'obj_type':   entry.obj_type,
                'instance':   entry.instance,
                'description': entry.description or '',
                'local':      entry.is_local,
            })

        result = {'count': len(items), 'data': items}
        if len(items) > 5:
            result['_render'] = 'datatable'
        return result

    # Tools — Devices BACnet distants
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Liste les devices BACnet distants découverts sur le réseau (depuis le cache). '
            'Retourne deviceId, adresse IP, nom, vendeur.'
        ),
        params={
            'refresh': {'type': 'boolean', 'description': 'True pour redécouvrir le réseau (lent, ~10s). Défaut: False (cache)'},
        }
    )
    def bacnet_list_remote_devices(self, refresh=False):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        try:
            devices = client.retrieveDevices(cached=not refresh)
        except Exception as e:
            return {'error': f'Erreur découverte BACnet : {e}'}
        if not devices:
            return {'count': 0, 'data': [], 'message': 'Aucun device BACnet découvert'}
        data = [
            {
                'deviceId': d.get('deviceId'),
                'address':  d.get('address', ''),
                'name':     d.get('name', ''),
                'vendor':   d.get('vendor', ''),
                'model':    d.get('model', ''),
                'firmware': d.get('firmware', ''),
            }
            for d in devices
        ]
        result = {'count': len(data), 'data': data}
        if len(data) > 4:
            result['_render'] = 'datatable'
        return result

    @tool(
        description=(
            'Liste les objets BACnet d\'un device distant (AV, BV, AI, AO, BI, BO, MSV, Schedule…) '
            'avec valeur courante, unité et description. '
            'Identifier le device par son deviceId (entier) ou son adresse IP.'
        ),
        params={
            'device_id':  {'type': 'integer', 'description': 'deviceId BACnet du device distant'},
            'obj_type':   {'type': 'string',  'description': 'Filtre sur le type : "AV","BV","AI","AO","BI","BO","MSV","schedule". Défaut: tous'},
            'pattern':    {'type': 'string',  'description': 'Filtre wildcard sur le nom de l\'objet (ex: "cta1*"). Défaut: tous'},
        }
    )
    def bacnet_list_remote_objects(self, device_id, obj_type='', pattern=''):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        dev = client.device(int(device_id))
        if dev is None:
            return {'error': f'Device {device_id} introuvable (essayez bacnet_list_remote_devices)'}
        try:
            objects = dev.retrieveObjects(cached=True) or []
        except Exception as e:
            return {'error': f'Erreur lecture objets du device {device_id} : {e}'}

        typ = obj_type.strip().upper()
        pat = pattern.strip().lower()
        TYPE_MAP = {
            'AV': 'analog-value', 'BV': 'binary-value',
            'AI': 'analog-input',  'AO': 'analog-output',
            'BI': 'binary-input',  'BO': 'binary-output',
            'MSV': 'multi-state-value', 'SCHEDULE': 'schedule',
        }
        type_filter = TYPE_MAP.get(typ, typ.lower() if typ else '')

        filtered = []
        for o in objects:
            if type_filter and type_filter not in o.get('type', '').lower():
                continue
            if pat and not fnmatch.fnmatch((o.get('name') or '').lower(), pat):
                continue
            filtered.append({
                'type':        o.get('type', ''),
                'instance':    o.get('instance'),
                'name':        o.get('name', ''),
                'value':       o.get('value'),
                'unit':        o.get('unit', ''),
                'description': o.get('description', ''),
            })

        result = {'count': len(filtered), 'device_id': device_id, 'data': filtered}
        if len(filtered) > 5:
            result['_render'] = 'datatable'
        return result

    @tool(
        description=(
            'Lit la valeur courante et le priority array d\'un objet BACnet distant. '
            'Spécifier le deviceId, le type d\'objet (ex: "analog-value") et l\'instance (entier).'
        ),
        params={
            'device_id': {'type': 'integer', 'description': 'deviceId BACnet du device distant'},
            'obj_type':  {'type': 'string',  'description': 'Type d\'objet : "analog-value", "binary-value", "analog-output", etc.'},
            'instance':  {'type': 'integer', 'description': 'Numéro d\'instance de l\'objet'},
        }
    )
    def bacnet_read_remote_object(self, device_id, obj_type, instance):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        dev = client.device(int(device_id))
        if dev is None:
            return {'error': f'Device {device_id} introuvable'}
        obj_id = (obj_type, int(instance))
        try:
            value = dev.read(obj_id)
        except Exception as e:
            return {'error': f'Erreur lecture : {e}'}
        priority_array = None
        try:
            priority_array = dev.retrievePriorityArray(obj_id)
        except Exception:
            pass
        return {
            'device_id':     device_id,
            'obj_type':      obj_type,
            'instance':      instance,
            'value':         value,
            'priority_array': priority_array,
        }

    @tool(
        description=(
            'Écrit une valeur sur un objet BACnet commandable d\'un device distant '
            '(analog-output, binary-output, analog-value, binary-value, multi-state-value…). '
            'Utiliser release=True pour libérer une priorité (écrire Null). '
            'La priorité BACnet par défaut est 16 (la plus basse).'
        ),
        params={
            'device_id': {'type': 'integer', 'description': 'deviceId BACnet du device distant'},
            'obj_type':  {'type': 'string',  'description': 'Type d\'objet BACnet (ex: "analog-output")'},
            'instance':  {'type': 'integer', 'description': 'Numéro d\'instance'},
            'value':     {'type': 'number',  'description': 'Valeur à écrire (ignorée si release=True)'},
            'priority':  {'type': 'integer', 'description': 'Priorité BACnet 1-16 (défaut: 16)'},
            'release':   {'type': 'boolean', 'description': 'True pour libérer la priorité (écrire Null)'},
        }
    )
    def bacnet_write_remote_object(self, device_id, obj_type, instance, value=0, priority=16, release=False):
        t = self._find_bacnet_task()
        if t is None:
            return {'error': 'Aucune tâche BACnet trouvée'}
        try:
            client = t.client()
        except Exception as e:
            return {'error': f'Client BACnet indisponible : {e}'}
        dev = client.device(int(device_id))
        if dev is None:
            return {'error': f'Device {device_id} introuvable'}
        obj_id = (obj_type, int(instance))
        try:
            if release:
                ok = dev.release(obj_id, priority=int(priority))
                action = 'release'
                written = None
            else:
                ok = dev.write(obj_id, value, priority=int(priority))
                action = 'write'
                written = value
        except Exception as e:
            return {'error': f'Erreur écriture BACnet : {e}'}
        if ok:
            return {
                'status':    'ok',
                'action':    action,
                'device_id': device_id,
                'obj_type':  obj_type,
                'instance':  instance,
                'value':     written,
                'priority':  priority,
            }
        return {'error': f'Écriture refusée par le device {device_id}'}

    # ------------------------------------------------------------------
    # Tools — Web
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Recherche sur internet via DuckDuckGo. '
            'Retourne une liste de résultats (titre, URL, extrait). '
            'Utilisé pour trouver des documentations constructeur, fiches techniques, manuels PDF. '
            'Conseils : inclure le nom du fabricant + modèle + "datasheet" ou "manuel" ou "modbus registers". '
            'Après une recherche, utiliser web_fetch ou web_fetch_pdf sur les URLs trouvées.'
        ),
        params={
            'query':       {'type': 'string',  'description': 'Requête de recherche (en anglais de préférence pour plus de résultats)'},
            'max_results': {'type': 'integer', 'description': 'Nombre max de résultats (défaut 8, max 20)'},
        }
    )
    def web_search(self, query, max_results=8):
        import re
        from html.parser import HTMLParser
        from urllib.parse import urlencode, unquote

        query = str(query).strip()
        if not query:
            return {'error': 'Requête vide'}
        max_results = max(1, min(int(max_results), 20))

        class _DDGParser(HTMLParser):
            """Parse la page HTML DuckDuckGo lite pour extraire titre/url/snippet."""
            def __init__(self):
                super().__init__()
                self.results = []
                self._cur = {}
                self._in_title = False
                self._in_snippet = False
                self._depth_a = 0

            def handle_starttag(self, tag, attrs):
                attrs = dict(attrs)
                cls = attrs.get('class', '')
                if tag == 'a' and 'result__a' in cls:
                    href = attrs.get('href', '')
                    # DDG redirige via uddg= param
                    m = re.search(r'uddg=([^&]+)', href)
                    if m:
                        href = unquote(m.group(1))
                    self._cur['url'] = href
                    self._in_title = True
                elif tag == 'a' and 'result__snippet' in cls:
                    self._in_snippet = True

            def handle_endtag(self, tag):
                if tag == 'a':
                    if self._in_title:
                        self._in_title = False
                        if self._cur.get('url') and self._cur.get('title'):
                            self.results.append(dict(self._cur))
                            self._cur = {}
                    elif self._in_snippet:
                        self._in_snippet = False

            def handle_data(self, data):
                if self._in_title:
                    self._cur['title'] = self._cur.get('title', '') + data
                elif self._in_snippet:
                    self._cur['snippet'] = self._cur.get('snippet', '') + data

        try:
            resp = httpx.post(
                'https://html.duckduckgo.com/html/',
                data={'q': query, 'kl': 'fr-fr'},
                headers={
                    'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
                    'Accept': 'text/html',
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                follow_redirects=True,
                timeout=15,
            )
            resp.raise_for_status()
        except httpx.TimeoutException:
            return {'error': 'Timeout recherche DuckDuckGo (15s)'}
        except Exception as e:
            return {'error': f'Erreur réseau : {e}'}

        parser = _DDGParser()
        try:
            parser.feed(resp.text)
        except Exception as e:
            return {'error': f'Erreur parsing résultats : {e}'}

        results = parser.results[:max_results]
        if not results:
            return {'query': query, 'count': 0, 'results': [],
                    'message': 'Aucun résultat trouvé. Essayez des termes différents (en anglais).'}

        # Marquer les URLs PDF
        for r in results:
            r['is_pdf'] = r.get('url', '').lower().endswith('.pdf')
            r['title'] = r.get('title', '').strip()
            r['snippet'] = r.get('snippet', '').strip()

        return {
            'query':   query,
            'count':   len(results),
            'results': results,
        }

    @tool(
        description=(
            'Télécharge un fichier PDF depuis une URL et en extrait le texte. '
            'Utilisé après web_search pour lire le contenu d\'un manuel ou d\'une fiche technique PDF. '
            'Retourne le texte extrait page par page.'
        ),
        params={
            'url':       {'type': 'string',  'description': 'URL directe du fichier PDF (doit se terminer par .pdf ou être un PDF direct)'},
            'max_chars': {'type': 'integer', 'description': 'Nombre max de caractères retournés (défaut 8000, max 30000)'},
            'max_pages': {'type': 'integer', 'description': 'Nombre max de pages à extraire (défaut 20)'},
        }
    )
    def web_fetch_pdf(self, url, max_chars=8000, max_pages=20):
        from io import BytesIO

        url = str(url).strip()
        if not url.startswith(('http://', 'https://')):
            return {'error': 'URL invalide — doit commencer par http:// ou https://'}

        max_chars = max(500, min(int(max_chars), 30000))
        max_pages = max(1, min(int(max_pages), 50))

        try:
            from pypdf import PdfReader
        except ImportError:
            return {'error': 'Module pypdf non installé. Exécuter : pip install pypdf'}

        try:
            resp = httpx.get(url, follow_redirects=True, timeout=30,
                             headers={'User-Agent': 'Mozilla/5.0 (compatible; MBIOAssistant/1.0)'})
            resp.raise_for_status()
        except httpx.TimeoutException:
            return {'error': 'Timeout téléchargement PDF (30s)'}
        except httpx.HTTPStatusError as e:
            return {'error': f'Erreur HTTP {e.response.status_code}'}
        except Exception as e:
            return {'error': f'Erreur réseau : {e}'}

        ct = resp.headers.get('content-type', '').lower()
        if 'html' in ct:
            return {'error': 'L\'URL pointe vers une page HTML, pas un PDF. Cherchez un lien direct vers le .pdf'}

        try:
            reader = PdfReader(BytesIO(resp.content))
        except Exception as e:
            return {'error': f'Impossible de lire le PDF : {e}'}

        n_pages = len(reader.pages)
        pages_text = []
        chars_total = 0
        pages_done = 0
        for i, page in enumerate(reader.pages):
            if pages_done >= max_pages:
                break
            try:
                txt = page.extract_text() or ''
            except Exception:
                txt = ''
            txt = txt.strip()
            if txt:
                pages_text.append(f'--- Page {i + 1} ---\n{txt}')
                chars_total += len(txt)
                pages_done += 1
            if chars_total >= max_chars:
                break

        full_text = '\n\n'.join(pages_text)
        truncated = len(full_text) > max_chars or pages_done < n_pages
        if len(full_text) > max_chars:
            full_text = full_text[:max_chars]

        if not full_text.strip():
            return {
                'url': url, 'pages_total': n_pages,
                'error': 'Aucun texte extractible. Le PDF est probablement scanné (image). '
                         'L\'extraction OCR n\'est pas supportée.',
            }

        return {
            'url':          url,
            'pages_total':  n_pages,
            'pages_read':   pages_done,
            'chars':        len(full_text),
            'truncated':    truncated,
            'content':      full_text,
        }

    @tool(
        description=(
            'Télécharge le contenu textuel d\'une URL (page web, documentation constructeur, fiche technique). '
            'Extrait le texte lisible en supprimant le HTML. '
            'Utile pour consulter une documentation dont l\'URL est connue. '
            'Ne pas utiliser pour des APIs JSON — préférer une URL de page HTML.'
        ),
        params={
            'url':        {'type': 'string',  'description': 'URL complète à télécharger (https://...)'},
            'max_chars':  {'type': 'integer', 'description': 'Nombre max de caractères retournés (défaut 6000, max 20000)'},
        }
    )
    def web_fetch(self, url, max_chars=6000):
        import re
        from html.parser import HTMLParser

        url = str(url).strip()
        if not url.startswith(('http://', 'https://')):
            return {'error': 'URL invalide — doit commencer par http:// ou https://'}

        max_chars = max(500, min(int(max_chars), 20000))

        class _TextExtractor(HTMLParser):
            SKIP_TAGS = {'script', 'style', 'head', 'noscript', 'nav', 'footer', 'header'}

            def __init__(self):
                super().__init__()
                self.parts = []
                self._skip = 0

            def handle_starttag(self, tag, attrs):
                if tag.lower() in self.SKIP_TAGS:
                    self._skip += 1

            def handle_endtag(self, tag):
                if tag.lower() in self.SKIP_TAGS:
                    self._skip = max(0, self._skip - 1)
                elif tag.lower() in ('p', 'div', 'br', 'li', 'h1', 'h2', 'h3', 'h4', 'tr'):
                    self.parts.append('\n')

            def handle_data(self, data):
                if not self._skip:
                    self.parts.append(data)

            def get_text(self):
                text = ''.join(self.parts)
                # Collapse multiple blank lines
                text = re.sub(r'\n{3,}', '\n\n', text)
                return text.strip()

        try:
            resp = httpx.get(url, follow_redirects=True, timeout=15,
                             headers={'User-Agent': 'Mozilla/5.0 (compatible; MBIOAssistant/1.0)'})
            resp.raise_for_status()
        except httpx.TimeoutException:
            return {'error': 'Timeout : le serveur n\'a pas répondu dans les 15 secondes'}
        except httpx.HTTPStatusError as e:
            return {'error': f'Erreur HTTP {e.response.status_code} : {url}'}
        except Exception as e:
            return {'error': f'Erreur réseau : {e}'}

        ct = resp.headers.get('content-type', '').lower()
        if 'html' in ct or ct == '':
            parser = _TextExtractor()
            try:
                parser.feed(resp.text)
                text = parser.get_text()
            except Exception:
                text = re.sub(r'<[^>]+>', ' ', resp.text)
                text = re.sub(r'\s+', ' ', text).strip()
        elif 'pdf' in ct:
            return {'error': 'Les fichiers PDF ne peuvent pas être lus directement. Cherchez une version HTML de la documentation.'}
        else:
            text = resp.text

        truncated = len(text) > max_chars
        if truncated:
            text = text[:max_chars]

        return {
            'url':        url,
            'chars':      len(text),
            'truncated':  truncated,
            'content':    text,
        }

    @tool(
        description=(
            'Recherche les horaires d\'ouverture hebdomadaires d\'un établissement (restaurant, '
            'magasin, administration, etc.) en interrogeant OpenStreetMap puis des sources web. '
            'Retourne les plages horaires jour par jour si trouvées. '
            'IMPORTANT : ces données proviennent de sources publiques libres (OpenStreetMap, pages web) '
            'dont le contenu peut être incomplet, obsolète ou dans un format non reconnu. '
            'Les résultats doivent être vérifiés avant toute utilisation critique. '
            'Mentionner systématiquement cette limitation à l\'utilisateur.'
        ),
        params={
            'place':    {'type': 'string', 'description': 'Nom de l\'établissement (ex: "Mairie de Lyon", "Boulangerie Paul Paris 1er")'},
            'location': {'type': 'string', 'description': 'Ville ou adresse pour affiner la recherche (optionnel)'},
        }
    )
    def web_get_opening_hours(self, place, location=''):
        import json as _json
        import re as _re

        _DAY_ORDER = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche']
        # Codes schema.org / OSM → français
        _CODE_FR = {
            'Mo': 'lundi', 'Tu': 'mardi', 'We': 'mercredi', 'Th': 'jeudi',
            'Fr': 'vendredi', 'Sa': 'samedi', 'Su': 'dimanche',
            'Monday': 'lundi', 'Tuesday': 'mardi', 'Wednesday': 'mercredi', 'Thursday': 'jeudi',
            'Friday': 'vendredi', 'Saturday': 'samedi', 'Sunday': 'dimanche',
        }

        def _fmt(schedule):
            """Formate un dict {jour: [slots]} en dict {jour: str}."""
            out = {}
            for d in _DAY_ORDER:
                slots = sorted(set(schedule.get(d, [])))
                out[d] = ', '.join(slots) if slots else 'fermé'
            return out

        # ── OSM helpers ──────────────────────────────────────────────────────────

        def _expand_days(tok):
            """'Mo-Fr' ou 'Sa' → liste de jours français."""
            rm = _re.match(r'^([A-Za-z]{2})-([A-Za-z]{2})$', tok)
            if rm:
                a, b = _CODE_FR.get(rm.group(1)), _CODE_FR.get(rm.group(2))
                if a and b:
                    return _DAY_ORDER[_DAY_ORDER.index(a):_DAY_ORDER.index(b) + 1]
            fr = _CODE_FR.get(tok)
            return [fr] if fr else []

        def _parse_osm_hours(oh_str):
            """Parse le format OSM opening_hours standard.
            Ex: 'Mo-Fr 09:00-19:00; Sa 09:00-18:00; Su off'
                'Mo-Fr 09:00-12:00,14:00-19:00'
            """
            schedule = {d: [] for d in _DAY_ORDER}
            for rule in oh_str.split(';'):
                rule = rule.strip()
                if not rule or rule == '24/7':
                    if rule == '24/7':
                        for d in _DAY_ORDER:
                            schedule[d].append('00:00–24:00')
                    continue
                closed = bool(_re.search(r'\boff\b|\bclosed\b', rule, _re.IGNORECASE))
                # Extraire tous les créneaux HH:MM-HH:MM
                slots = _re.findall(r'(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})', rule)
                # Partie jours = texte avant le premier créneau (ou tout si fermé)
                day_part = _re.split(r'\d{1,2}:\d{2}', rule)[0].strip().rstrip(',').strip()
                # Parser les tokens de jours (séparés par espaces/virgules)
                day_keys = []
                for tok in _re.split(r'[\s,]+', day_part):
                    tok = tok.strip()
                    if tok and not _re.match(r'^(off|closed|open)$', tok, _re.IGNORECASE):
                        day_keys.extend(_expand_days(tok))
                if not day_keys:
                    continue
                for d in day_keys:
                    if closed or not slots:
                        pass  # fermé → liste vide
                    else:
                        for o, c in slots:
                            slot = f'{o}–{c}'
                            if slot not in schedule[d]:
                                schedule[d].append(slot)
            return schedule

        def _try_osm():
            """Interroge Nominatim + Overpass pour récupérer opening_hours."""
            q = f'{place} {location}'.strip()
            try:
                r = httpx.get('https://nominatim.openstreetmap.org/search',
                              params={'q': q, 'format': 'json', 'limit': 8},
                              headers={'User-Agent': 'MBIO/1.0 digimat.mbio'},
                              timeout=10)
                candidates = r.json() if r.status_code == 200 else []
            except Exception:
                return None

            type_map = {'node': 'node', 'way': 'way', 'relation': 'rel'}
            for c in candidates:
                osm_type = type_map.get(c.get('osm_type', ''))
                osm_id   = c.get('osm_id')
                if not osm_type or not osm_id:
                    continue
                try:
                    q2 = f'[out:json];{osm_type}({osm_id});out tags;'
                    r2 = httpx.get('https://overpass-api.de/api/interpreter',
                                   params={'data': q2}, timeout=15)
                    if r2.status_code != 200:
                        continue
                    elems = r2.json().get('elements', [])
                    if not elems:
                        continue
                    tags = elems[0].get('tags', {})
                    oh   = tags.get('opening_hours') or tags.get('opening_hours:signed')
                    if oh:
                        sched = _parse_osm_hours(oh)
                        name  = tags.get('name', place)
                        return {'place': name, 'source': 'OpenStreetMap', 'osm_hours': oh,
                                'schedule': _fmt(sched)}
                except Exception:
                    continue
            return None

        # ── JSON-LD / HTML scraping helpers ─────────────────────────────────────

        def _parse_jsonld(html):
            results = []
            for raw in _re.findall(
                    r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
                    html, _re.DOTALL | _re.IGNORECASE):
                try:
                    results.append(_json.loads(raw.strip()))
                except Exception:
                    pass
            return results

        def _flatten_jsonld(blocks):
            nodes = []
            for b in blocks:
                if isinstance(b, list):
                    nodes.extend(b)
                elif isinstance(b, dict):
                    if '@graph' in b:
                        nodes.extend(b['@graph'] if isinstance(b['@graph'], list) else [b['@graph']])
                    else:
                        nodes.append(b)
            return nodes

        def _extract_hours_from_node(node):
            hours = node.get('openingHours') or node.get('openingHoursSpecification')
            if not hours:
                return None
            schedule = {d: [] for d in _DAY_ORDER}
            if isinstance(hours, str):
                hours = [hours]
            # Cas 1 : liste de strings schema.org "Mo Tu We 08:00-18:00"
            if isinstance(hours, list) and hours and isinstance(hours[0], str):
                for entry in hours:
                    tm = _re.search(r'(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})', entry)
                    if not tm:
                        continue
                    opens, closes = tm.group(1), tm.group(2)
                    days_str = entry[:tm.start()].strip()
                    for tok in _re.split(r'[\s,]+', days_str):
                        for d in _expand_days(tok.strip()):
                            if d in schedule:
                                schedule[d].append(f'{opens}–{closes}')
                return schedule
            # Cas 2 : liste de dicts OpeningHoursSpecification
            if isinstance(hours, list) and hours and isinstance(hours[0], dict):
                for spec in hours:
                    days   = spec.get('dayOfWeek', [])
                    opens  = spec.get('opens', '')
                    closes = spec.get('closes', '')
                    if isinstance(days, str):
                        days = [days]
                    for day_uri in days:
                        fr = _CODE_FR.get(day_uri.split('/')[-1])
                        if fr and opens and closes:
                            schedule[fr].append(f'{opens[:5]}–{closes[:5]}')
                return schedule
            return None

        def _fetch_html(url):
            try:
                r = httpx.get(url, follow_redirects=True, timeout=12, headers={
                    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
                                  'AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36',
                    'Accept-Language': 'fr-FR,fr;q=0.9',
                })
                return r.text if r.status_code == 200 else None
            except Exception:
                return None

        # ── Main flow ────────────────────────────────────────────────────────────

        # 1. OpenStreetMap (source la plus fiable — données structurées)
        osm = _try_osm()
        if osm:
            return osm

        # 2. Recherche web + scraping JSON-LD
        query = f'{place} {location} horaires ouverture'.strip()
        search_results = self.web_search(query, max_results=6)
        if isinstance(search_results, dict) and 'error' in search_results:
            return search_results
        result_list = search_results.get('results', []) if isinstance(search_results, dict) else []
        urls = [r['url'] for r in result_list
                if r.get('url') and 'google.com' not in r['url'] and 'bing.com' not in r['url']]

        for url in urls[:5]:
            html = _fetch_html(url)
            if not html:
                continue
            blocks = _parse_jsonld(html)
            nodes  = _flatten_jsonld(blocks)
            for node in nodes:
                sched = _extract_hours_from_node(node)
                if sched and any(sched.values()):
                    return {
                        'place':    node.get('name', '') or place,
                        'source':   url,
                        'schedule': _fmt(sched),
                    }

        return {
            'error': f'Horaires introuvables pour "{place}" (OSM : aucun résultat, web : {len(urls)} URL(s) scrapée(s) sans données structurées).',
            'suggestion': 'Essayez avec le nom exact et la ville (ex: "Manor Chavannes-près-Renens").',
        }

    # ------------------------------------------------------------------
    # Tools — Contextes additionnels persistés
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Enregistre ou met à jour un contexte additionnel nommé. '
            'Le contenu est persisté en pickle et automatiquement injecté dans le system prompt '
            'à chaque démarrage. Utile pour mémoriser des informations site-spécifiques : '
            'responsables, particularités d\'installation, consignes d\'exploitation, etc.'
        ),
        params={
            'key':     {'type': 'string', 'description': 'Nom court du contexte (ex: "responsables", "particularites_cta1")'},
            'content': {'type': 'string', 'description': 'Contenu textuel à mémoriser (Markdown accepté)'},
        }
    )
    def save_context(self, key, content):
        key = key.strip().lower().replace(' ', '_')
        if not key or not content.strip():
            return {'error': 'Clé et contenu requis'}
        self._extra_contexts[key] = {'content': content.strip(), 'ts': time.time()}
        self.pickleWrite('llm_extra_contexts', self._extra_contexts)
        self.logger.info('LLM context saved: %s (%d chars)' % (key, len(content)))
        return {'status': 'ok', 'key': key, 'chars': len(content),
                'message': f'Contexte "{key}" enregistré et actif immédiatement.'}

    @tool(
        description=(
            'Liste tous les contextes additionnels enregistrés avec leur clé, '
            'date d\'enregistrement et aperçu du contenu.'
        ),
        params={}
    )
    def list_contexts(self):
        if not self._extra_contexts:
            return {'count': 0, 'data': [], 'message': 'Aucun contexte additionnel enregistré.'}
        data = []
        for key, entry in sorted(self._extra_contexts.items()):
            ts_str = time.strftime('%Y-%m-%d %H:%M', time.localtime(entry.get('ts', 0)))
            content = entry.get('content', '')
            data.append({
                'key':     key,
                'updated': ts_str,
                'chars':   len(content),
                'preview': content[:120] + ('…' if len(content) > 120 else ''),
            })
        return {'count': len(data), 'data': data}

    @tool(
        description='Supprime un contexte additionnel enregistré.',
        params={
            'key': {'type': 'string', 'description': 'Clé du contexte à supprimer'},
        }
    )
    def delete_context(self, key):
        key = key.strip().lower().replace(' ', '_')
        if key not in self._extra_contexts:
            return {'error': f'Contexte "{key}" introuvable. Utilisez list_contexts pour voir les clés disponibles.'}
        del self._extra_contexts[key]
        self.pickleWrite('llm_extra_contexts', self._extra_contexts)
        self.logger.info('LLM context deleted: %s' % key)
        return {'status': 'ok', 'message': f'Contexte "{key}" supprimé.'}

    # ------------------------------------------------------------------
    # Tools — Export de documents
    # ------------------------------------------------------------------

    def _xlsx_header_style(self):
        """Retourne un style d'en-tête XLSX (fond bleu, texte blanc, gras)."""
        from openpyxl.styles import PatternFill, Font, Alignment
        fill = PatternFill('solid', fgColor='1F4E79')
        font = Font(bold=True, color='FFFFFF', size=10)
        align = Alignment(horizontal='center', vertical='center', wrap_text=True)
        return fill, font, align

    def _xlsx_autofit(self, ws, min_width=8, max_width=50):
        """Ajuste la largeur des colonnes au contenu."""
        for col in ws.columns:
            best = min_width
            for cell in col:
                if cell.value:
                    best = max(best, min(len(str(cell.value)) + 2, max_width))
            ws.column_dimensions[col[0].column_letter].width = best

    def _xlsx_meta_sheet(self, wb, title):
        """Ajoute une feuille 'Infos' avec les métadonnées du document."""
        ws = wb.create_sheet('Infos', 0)
        mbio = self.getMBIO()
        rows = [
            ('Document',    title),
            ('Généré le',   time.strftime('%Y-%m-%d %H:%M:%S')),
            ('Système',     getattr(mbio, 'name', '') if mbio else ''),
            ('Source',      'Assistant IA MBIO — %s' % self._model),
        ]
        from openpyxl.styles import Font
        for r, (k, v) in enumerate(rows, 1):
            ws.cell(r, 1, k).font = Font(bold=True)
            ws.cell(r, 2, str(v))
        ws.column_dimensions['A'].width = 16
        ws.column_dimensions['B'].width = 40

    @tool(
        description=(
            'Exporte les valeurs MBIO courantes vers un fichier XLSX téléchargeable. '
            'Colonnes : clé, valeur, unité, description, flags, âge. '
            'Retourne un lien de téléchargement.'
        ),
        params={
            'pattern': {'type': 'string',  'description': 'Filtre wildcard (ex: "cta1*"). Défaut: toutes les valeurs'},
            'title':   {'type': 'string',  'description': 'Titre du document. Défaut: "Valeurs MBIO"'},
            'errors_only': {'type': 'boolean', 'description': 'True pour n\'exporter que les valeurs en erreur'},
        }
    )
    def export_values_xlsx(self, pattern='*', title='Valeurs MBIO', errors_only=False):
        try:
            import openpyxl
            from openpyxl.styles import PatternFill, Font, Alignment
        except ImportError:
            return {'error': 'Module openpyxl manquant'}
        if not self._downloads_dir:
            return {'error': 'Répertoire de téléchargement non disponible'}

        mbio = self.getMBIO()
        if not mbio:
            return {'error': 'MBIO non disponible'}

        values = list(mbio.values(pattern) or [])
        if errors_only:
            values = [v for v in values if v.isError()]
        if not values:
            return {'error': 'Aucune valeur trouvée pour le filtre "%s"' % pattern}

        wb = openpyxl.Workbook()
        self._xlsx_meta_sheet(wb, title)
        ws = wb.create_sheet('Valeurs')
        fill, font, align = self._xlsx_header_style()

        headers = ['Clé', 'Valeur', 'Unité', 'Description', 'Flags', 'Âge (s)', 'Zone']
        for c, h in enumerate(headers, 1):
            cell = ws.cell(1, c, h)
            cell.fill = fill; cell.font = font; cell.alignment = align
        ws.row_dimensions[1].height = 20

        for r, v in enumerate(values, 2):
            flags = []
            if v.isError():    flags.append('E')
            if v.isManual():   flags.append('M')
            if v.override:     flags.append('*')
            if not v.isEnabled(): flags.append('X')
            try:    age = round(v.age(), 0)
            except: age = ''
            ws.cell(r, 1, str(v.key))
            ws.cell(r, 2, v.value)
            ws.cell(r, 3, str(v.unit or ''))
            ws.cell(r, 4, str(v.description or ''))
            ws.cell(r, 5, ' '.join(flags))
            ws.cell(r, 6, age)
            ws.cell(r, 7, str(getattr(v, 'zone', '') or ''))

        self._xlsx_autofit(ws)
        ws.freeze_panes = 'A2'

        buf = BytesIO()
        wb.save(buf)
        filename = '%s_%s.xlsx' % (title[:40], time.strftime('%Y%m%d_%H%M'))
        url = self._create_download(filename, buf.getvalue())
        if not url:
            return {'error': 'Erreur lors de la sauvegarde du fichier'}
        return {
            '_render': 'download',
            'url': url, 'filename': filename,
            'description': '%d valeur(s) exportée(s)' % len(values),
        }

    @tool(
        description=(
            'Exporte les tendances de plusieurs valeurs MBIO sur une période vers un fichier XLSX. '
            'Une feuille par valeur + une feuille de synthèse des statistiques. '
            'Retourne un lien de téléchargement.'
        ),
        params={
            'keys':     {'type': 'string', 'description': 'Clés séparées par virgule (ex: "cta1_temp,cta1_consigne")'},
            'duration': {'type': 'string', 'description': 'Durée : "1h", "24h", "7d". Défaut: "24h"'},
            'title':    {'type': 'string', 'description': 'Titre du document. Défaut: "Tendances MBIO"'},
        }
    )
    def export_trend_xlsx(self, keys, duration='24h', title='Tendances MBIO'):
        try:
            import openpyxl
            from openpyxl.styles import Font
        except ImportError:
            return {'error': 'Module openpyxl manquant'}
        if not self._downloads_dir:
            return {'error': 'Répertoire de téléchargement non disponible'}

        mbio = self.getMBIO()
        if not mbio or not getattr(mbio, 'trendingStore', None):
            return {'error': 'Trending store non disponible'}

        secs = self._parse_duration(duration, default=86400)
        since = time.time() - secs

        key_list = [k.strip() for k in keys.split(',') if k.strip()]
        if not key_list:
            return {'error': 'Aucune clé fournie'}

        wb = openpyxl.Workbook()
        self._xlsx_meta_sheet(wb, title)

        fill, hfont, halign = self._xlsx_header_style()
        stats_rows = []
        exported = 0

        for key in key_list:
            try:
                rows = mbio.trendingStore.get_history_sampled(key, max_points=2000,
                                                               since=since, until=None) or []
            except Exception:
                rows = []
            if not rows:
                continue

            safe_key = key.replace(':', '_').replace('/', '_')[:28]
            ws = wb.create_sheet(safe_key)
            for c, h in enumerate(['Timestamp', 'Valeur', 'Unité'], 1):
                cell = ws.cell(1, c, h)
                cell.fill = fill; cell.font = hfont; cell.alignment = halign
            ws.freeze_panes = 'A2'

            vals = []
            for r, row in enumerate(rows, 2):
                ts, val, flags, unit = row[0], row[1], row[2] if len(row) > 2 else 0, row[3] if len(row) > 3 else ''
                ts_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))
                ws.cell(r, 1, ts_str)
                ws.cell(r, 2, val)
                ws.cell(r, 3, str(unit or ''))
                if val is not None:
                    try: vals.append(float(val))
                    except Exception: pass

            self._xlsx_autofit(ws)
            exported += 1
            if vals:
                stats_rows.append({
                    'key': key, 'count': len(vals),
                    'min': round(min(vals), 3), 'max': round(max(vals), 3),
                    'avg': round(sum(vals) / len(vals), 3),
                })

        if not exported:
            return {'error': 'Aucune donnée de tendance trouvée pour les clés demandées'}

        # Feuille synthèse
        if stats_rows:
            ws_s = wb.create_sheet('Synthèse', 1)
            for c, h in enumerate(['Clé', 'Points', 'Min', 'Max', 'Moyenne'], 1):
                cell = ws_s.cell(1, c, h)
                cell.fill = fill; cell.font = hfont; cell.alignment = halign
            for r, s in enumerate(stats_rows, 2):
                ws_s.cell(r, 1, s['key'])
                ws_s.cell(r, 2, s['count'])
                ws_s.cell(r, 3, s['min'])
                ws_s.cell(r, 4, s['max'])
                ws_s.cell(r, 5, s['avg'])
            self._xlsx_autofit(ws_s)

        buf = BytesIO()
        wb.save(buf)
        filename = '%s_%s.xlsx' % (title[:40], time.strftime('%Y%m%d_%H%M'))
        url = self._create_download(filename, buf.getvalue())
        if not url:
            return {'error': 'Erreur lors de la sauvegarde du fichier'}
        return {
            '_render': 'download',
            'url': url, 'filename': filename,
            'description': '%d valeur(s), %s de données' % (exported, duration),
        }

    @tool(
        description=(
            'Exporte un tableau comparatif de statistiques (min/max/moy/écart-type) '
            'pour plusieurs valeurs sur une période vers un fichier XLSX. '
            'Retourne un lien de téléchargement.'
        ),
        params={
            'keys':     {'type': 'string', 'description': 'Clés séparées par virgule'},
            'duration': {'type': 'string', 'description': 'Durée : "1h", "24h", "7d". Défaut: "24h"'},
            'title':    {'type': 'string', 'description': 'Titre du document. Défaut: "Statistiques MBIO"'},
        }
    )
    def export_statistics_xlsx(self, keys, duration='24h', title='Statistiques MBIO'):
        try:
            import openpyxl
            from openpyxl.styles import Font, PatternFill
        except ImportError:
            return {'error': 'Module openpyxl manquant'}
        if not self._downloads_dir:
            return {'error': 'Répertoire de téléchargement non disponible'}

        mbio = self.getMBIO()
        if not mbio or not getattr(mbio, 'trendingStore', None):
            return {'error': 'Trending store non disponible'}

        secs = self._parse_duration(duration, default=86400)
        since = time.time() - secs

        key_list = [k.strip() for k in keys.split(',') if k.strip()]
        if not key_list:
            return {'error': 'Aucune clé fournie'}

        wb = openpyxl.Workbook()
        self._xlsx_meta_sheet(wb, title)
        ws = wb.create_sheet('Statistiques')
        fill, hfont, halign = self._xlsx_header_style()

        headers = ['Clé', 'Points', 'Min', 'Max', 'Moyenne', 'Écart-type', 'P10', 'P50 (médiane)', 'P90', 'Unité']
        for c, h in enumerate(headers, 1):
            cell = ws.cell(1, c, h)
            cell.fill = fill; cell.font = hfont; cell.alignment = halign
        ws.freeze_panes = 'A2'

        row_n = 2
        for key in key_list:
            try:
                rows = mbio.trendingStore.get_history_sampled(key, max_points=5000,
                                                               since=since, until=None) or []
            except Exception:
                rows = []
            unit = rows[0][3] if rows and len(rows[0]) > 3 else ''
            vals = []
            for row in rows:
                if row[1] is not None:
                    try: vals.append(float(row[1]))
                    except Exception: pass

            ws.cell(row_n, 1, key)
            if not vals:
                ws.cell(row_n, 2, 0)
                row_n += 1
                continue

            vals.sort()
            n = len(vals)
            mean = sum(vals) / n
            variance = sum((x - mean) ** 2 for x in vals) / n
            std = variance ** 0.5

            def percentile(p):
                idx = (p / 100) * (n - 1)
                lo, hi = int(idx), min(int(idx) + 1, n - 1)
                return round(vals[lo] + (vals[hi] - vals[lo]) * (idx - lo), 3)

            ws.cell(row_n, 2, n)
            ws.cell(row_n, 3, round(min(vals), 3))
            ws.cell(row_n, 4, round(max(vals), 3))
            ws.cell(row_n, 5, round(mean, 3))
            ws.cell(row_n, 6, round(std, 3))
            ws.cell(row_n, 7, percentile(10))
            ws.cell(row_n, 8, percentile(50))
            ws.cell(row_n, 9, percentile(90))
            ws.cell(row_n, 10, str(unit or ''))
            row_n += 1

        self._xlsx_autofit(ws)
        buf = BytesIO()
        wb.save(buf)
        filename = '%s_%s.xlsx' % (title[:40], time.strftime('%Y%m%d_%H%M'))
        url = self._create_download(filename, buf.getvalue())
        if not url:
            return {'error': 'Erreur lors de la sauvegarde du fichier'}
        return {
            '_render': 'download',
            'url': url, 'filename': filename,
            'description': '%d valeur(s) analysée(s) sur %s' % (len(key_list), duration),
        }

    # Tools — Flags (sans confirmation : actions réversibles / visuelles)
    # ------------------------------------------------------------------

    @tool(
        description=(
            'Marque une MBIOValue (flag K). Les valeurs marquées apparaissent dans /valuesmarked '
            'et permettent de suivre des points d\'intérêt. Action réversible, sans confirmation.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé de la valeur à marquer'},
        }
    )
    def mark_value(self, key):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        v.mark(True)
        return {'status': 'done', 'key': key, 'marked': True}

    @tool(
        description=(
            'Démarque une MBIOValue (retire le flag K). '
            'Action réversible, sans confirmation.'
        ),
        params={
            'key': {'type': 'string', 'description': 'Clé de la valeur à démarquer'},
        }
    )
    def unmark_value(self, key):
        v = self._find_value(key)
        if v is None:
            return {'error': f'Valeur "{key}" introuvable'}
        v.mark(False)
        return {'status': 'done', 'key': key, 'marked': False}

    # ------------------------------------------------------------------
    # Helpers internes
    # ------------------------------------------------------------------

    @staticmethod
    def _parse_duration(duration, default=3600):
        """Parse une durée textuelle en secondes. '1h'→3600, '24h'→86400, '7d'→604800, '30m'→1800."""
        _UNITS = {'h': 3600, 'd': 86400, 'm': 60, 's': 1}
        try:
            d = str(duration).strip().lower()
            return int(d[:-1]) * _UNITS.get(d[-1], 1)
        except (ValueError, IndexError):
            return default

    def _find_value(self, key):
        """Trouve une MBIOValue par sa clé (format task:name ou task_name)."""
        mbio = self.getMBIO()
        if not mbio:
            return None
        # Essai direct
        v = mbio.value(key)
        if v is not None:
            return v
        # Essai avec remplacement colon → underscore
        v = mbio.value(key.replace(':', '_'))
        return v

    # ------------------------------------------------------------------
    # Exécution tool
    # ------------------------------------------------------------------

    def _execute_tool(self, session, name, inputs):
        m = getattr(self, name, None)
        if m is None or not getattr(m, '_is_tool', False):
            return {'error': f'Tool inconnu: {name}'}
        try:
            self._thread_locals.session = session
            fn = m.__func__ if hasattr(m, '__func__') else type(self).__dict__.get(name)
            needs_confirm = getattr(fn, '_tool_confirm', False) if fn else False
            if needs_confirm:
                result = m(**inputs, _session=session)
            else:
                result = m(**inputs)
        except Exception as e:
            self.logger.exception(f'LLM tool {name}')
            result = {'error': str(e)}

        # Audit (exclut les actions pending — elles sont auditées à la confirmation)
        if not (isinstance(result, dict) and result.get('status') == 'pending'):
            session.audit.append({
                'ts':       time.time(),
                'type':     'tool',
                'tool':     name,
                'params':   inputs,
                'result':   result,
                'is_write': name in ('set_manual', 'set_auto'),
            })
        return result

    # ------------------------------------------------------------------
    # Traitement des messages (boucle run)
    # ------------------------------------------------------------------

    def _process_pending_messages(self):
        with self._sessions_lock:
            sessions = list(self._sessions.values())
        for session in sessions:
            if session.processing:
                continue
            # Déclenche uniquement si de nouveaux messages ont été ajoutés
            # depuis le dernier traitement (évite les boucles infinies après erreur)
            if len(session.history) <= session.last_processed_len:
                continue
            last = session.history[-1]
            if last.get('role') != 'user':
                continue
            # Accepter string (texte seul) ou list (texte + images)
            content = last.get('content', '')
            if not isinstance(content, (str, list)):
                continue
            session.processing = True
            session.last_processed_len = len(session.history)
            session.last_activity = time.time()
            t = threading.Thread(
                target=self._process_message,
                args=(session,),
                daemon=True,
            )
            t.start()

    def _process_message(self, session):
        """Thread dédié : appelle Claude API en streaming, exécute tools, pousse SSE."""
        # Log du message entrant
        last_user = next(
            (m['content'] for m in reversed(session.history)
             if m.get('role') == 'user' and isinstance(m.get('content'), str)),
            ''
        )
        sid = session.session_id[:8]
        preview = last_user[:120].replace('\n', ' ')
        self.logger.info('LLM [%s] >>> %s%s' % (sid, preview, '...' if len(last_user) > 120 else ''))

        try:
            v_total = self.value('total_requests')
            if v_total is not None:
                v_total.updateValue((v_total.value or 0) + 1)
            self._do_process_message(session)
        except Exception:
            self.logger.exception('LLM [%s] exception _process_message' % sid)
            session.output_queue.put({'type': 'error', 'message': 'Erreur interne du serveur LLM'})
        finally:
            session.processing = False
            self._save_session(session)

    def _do_process_message(self, session):
        if not self._apikey:
            session.output_queue.put({'type': 'error', 'message': 'Clé API non configurée'})
            return

        sid = session.session_id[:8]
        my_stream_id = session.stream_id   # génération de ce thread
        session.abort_current = False       # on prend le relais, reset du flag

        usage = {}
        stop_reason = None
        loop_n = 0

        def _is_stale():
            return session.stream_id != my_stream_id or session.abort_current

        def _put(event):
            """Envoie un événement SSE seulement si ce thread est encore la génération courante."""
            if not _is_stale():
                session.output_queue.put(event)

        # Calculer une seule fois par message : stable durant toute la boucle tool-use
        system_prompt = self._build_system_prompt()
        tool_schemas  = self._build_tool_schemas()
        comerr        = self.valueDigital('comerr')

        while True:
            if _is_stale():
                self.logger.info('LLM [%s] stream_id %d abandonné (nouveau message reçu)' % (sid, my_stream_id))
                return

            loop_n += 1
            assistant_content = []
            tool_calls_pending = []

            history_window = self._build_history_window(session)

            # Log de la fenêtre d'historique envoyée à l'API
            roles_summary = ' → '.join(m.get('role', '?') for m in history_window)
            self.logger.debug('LLM [%s] history window: %d msgs [%s]' % (
                sid, len(history_window), roles_summary))
            if not history_window:
                self.logger.error('LLM [%s] history_window vide ! history=%d' % (sid, len(session.history)))
                session.output_queue.put({'type': 'error', 'message': 'Historique vide (erreur interne)'})
                return

            payload = {
                'model': self._model,
                'max_tokens': self._max_tokens,
                'stream': True,
                'system': system_prompt,
                'tools': tool_schemas,
                'messages': history_window,
            }

            try:
                self.logger.debug('LLM [%s] POST %s model=%s max_tokens=%d' % (
                    sid, CLAUDE_API_URL, self._model, self._max_tokens))
                with httpx.stream(
                    'POST',
                    CLAUDE_API_URL,
                    headers={
                        'x-api-key': self._apikey,
                        'anthropic-version': _ANTHROPIC_VERSION,
                        'content-type': 'application/json',
                    },
                    json=payload,
                    timeout=60,
                ) as resp:
                    self.logger.debug('LLM [%s] HTTP response: %d' % (sid, resp.status_code))
                    self._update_ratelimit(resp.headers)
                    if resp.status_code != 200:
                        body = resp.read().decode('utf-8', errors='replace')
                        if comerr is not None:
                            comerr.updateValue(True)
                        # Extraire le message lisible depuis le JSON d'erreur Anthropic
                        user_msg = f'Erreur API {resp.status_code}'
                        try:
                            err_json = json.loads(body)
                            api_msg = err_json.get('error', {}).get('message', '')
                            if api_msg:
                                if resp.status_code == 429:
                                    user_msg = f'Limite de débit atteinte — réessayez dans quelques secondes. ({api_msg[:120]})'
                                elif resp.status_code == 401:
                                    self._apikey_rejected = True
                                    user_msg = 'Clé API invalide ou non autorisée.'
                                elif resp.status_code == 529:
                                    user_msg = 'API Claude surchargée — réessayez dans quelques instants.'
                                else:
                                    user_msg = f'Erreur API {resp.status_code} : {api_msg[:200]}'
                        except Exception:
                            user_msg = f'Erreur API {resp.status_code} : {body[:200]}'
                        self.logger.error('LLM [%s] API %d: %s' % (sid, resp.status_code, body[:200]))
                        _put({'type': 'error', 'message': user_msg})
                        return
                    if comerr is not None:
                        comerr.updateValue(False)
                    stop_reason, usage = self._stream_response(
                        resp, session, assistant_content, tool_calls_pending, _put)
            except httpx.TimeoutException:
                if comerr is not None:
                    comerr.updateValue(True)
                self.logger.error('LLM [%s] timeout API Claude' % sid)
                _put({'type': 'error', 'message': 'Timeout API Claude'})
                return
            except Exception as e:
                self.logger.exception('LLM [%s] erreur stream' % sid)
                _put({'type': 'error', 'message': str(e)})
                return

            if _is_stale():
                self.logger.info('LLM [%s] stream_id %d abandonné après API call' % (sid, my_stream_id))
                return

            # Ne pas modifier l'historique si le stream s'est terminé en erreur
            # (évite les tool_use orphelins qui corrompent les appels suivants)
            if stop_reason == 'error':
                self.logger.warning('LLM [%s] stream error — historique non modifié' % sid)
                return

            if assistant_content:
                session.history.append({'role': 'assistant', 'content': assistant_content})

            if stop_reason == 'end_turn' or not tool_calls_pending:
                break

            # Exécuter les tools
            tool_results = []
            for tc in tool_calls_pending:
                if _is_stale():
                    # Annuler le message assistant qu'on vient d'appender (contient des
                    # tool_use sans tool_result correspondants → corromprait l'historique)
                    if session.history and session.history[-1].get('role') == 'assistant':
                        session.history.pop()
                        self.logger.info('LLM [%s] undo assistant message (abort pendant tools)' % sid)
                    return
                params_str = ', '.join('%s=%r' % (k, v) for k, v in tc['input'].items())
                self.logger.info('LLM [%s] tool %s(%s)' % (sid, tc['name'], params_str))

                _put({
                    'type': 'tool_start',
                    'tool_use_id': tc['id'],
                    'name': tc['name'],
                    'input': tc['input'],
                })
                result = self._execute_tool(session, tc['name'], tc['input'])

                if isinstance(result, dict):
                    if 'error' in result:
                        self.logger.warning('LLM [%s] tool %s → error: %s' % (sid, tc['name'], result['error']))
                    elif 'status' in result:
                        self.logger.info('LLM [%s] tool %s → %s' % (sid, tc['name'], result['status']))
                    elif 'count' in result:
                        self.logger.info('LLM [%s] tool %s → %d résultat(s)' % (sid, tc['name'], result['count']))
                    else:
                        self.logger.info('LLM [%s] tool %s → ok' % (sid, tc['name']))

                _put({
                    'type': 'tool_end',
                    'tool_use_id': tc['id'],
                    'name': tc['name'],
                    'result': result,
                })

                if isinstance(result, dict) and '_render' in result:
                    _put({
                        'type': 'render',
                        'render_type': result['_render'],
                        'data': result,
                    })

                tool_results.append({
                    'type': 'tool_result',
                    'tool_use_id': tc['id'],
                    'content': json.dumps(result),
                })

            session.history.append({'role': 'user', 'content': tool_results})

        if _is_stale():
            return

        reply_text = ''
        for block in assistant_content:
            if isinstance(block, dict) and block.get('type') == 'text':
                reply_text += block.get('text', '')
        reply_preview = reply_text[:200].replace('\n', ' ')
        inp = usage.get('input_tokens', 0)
        out = usage.get('output_tokens', 0)
        self.logger.info('LLM [%s] <<< %s%s  [in:%d out:%d loops:%d]' % (
            sid, reply_preview, '...' if len(reply_text) > 200 else '', inp, out, loop_n))

        rl = self.get_ratelimit()
        session.audit.append({
            'ts':       time.time(),
            'type':     'usage',
            'tool':     '',
            'params':   {},
            'result':   {'input_tokens': inp, 'output_tokens': out, 'total': inp + out,
                         'ratelimit': rl},
            'is_write': False,
        })

        _put({'type': 'done', 'usage': usage, 'ratelimit': rl})

    def _stream_response(self, resp, session, assistant_content, tool_calls_pending, _put=None):
        """Parse le stream SSE Claude API et pousse les events vers le client browser."""
        if _put is None:
            _put = session.output_queue.put
        stop_reason = 'end_turn'
        usage = {}
        current_tool = None
        sid = session.session_id[:8]

        line_count = 0
        for raw_line in resp.iter_lines():
            line_count += 1
            if line_count <= 5 or not raw_line.startswith('data:'):
                self.logger.debug('LLM [%s] SSE line %d: %s' % (sid, line_count, raw_line[:120]))
            if not raw_line.startswith('data:'):
                continue
            raw = raw_line[5:].strip()
            if not raw or raw == '[DONE]':
                continue
            try:
                ev = json.loads(raw)
            except Exception:
                self.logger.warning('LLM [%s] JSON parse error line %d: %s' % (sid, line_count, raw[:80]))
                continue

            etype = ev.get('type', '')
            self.logger.debug('LLM [%s] SSE event: %s' % (sid, etype))

            if etype == 'error':
                err = ev.get('error', {})
                msg = err.get('message', str(err))
                self.logger.error('LLM [%s] stream error event: %s' % (sid, msg))
                _put({'type': 'error', 'message': msg})
                return 'error', usage

            if etype == 'content_block_start':
                blk = ev.get('content_block', {})
                if blk.get('type') == 'text':
                    assistant_content.append({'type': 'text', 'text': ''})
                elif blk.get('type') == 'tool_use':
                    current_tool = {
                        'id': blk['id'],
                        'name': blk['name'],
                        'input_buf': '',
                    }

            elif etype == 'content_block_delta':
                delta = ev.get('delta', {})
                if delta.get('type') == 'text_delta':
                    txt = delta.get('text', '')
                    if txt and assistant_content and assistant_content[-1].get('type') == 'text':
                        assistant_content[-1]['text'] += txt
                    _put({'type': 'token', 'content': txt})
                elif delta.get('type') == 'input_json_delta' and current_tool is not None:
                    current_tool['input_buf'] += delta.get('partial_json', '')

            elif etype == 'content_block_stop':
                if current_tool is not None:
                    try:
                        inputs = json.loads(current_tool['input_buf']) if current_tool['input_buf'] else {}
                    except Exception:
                        inputs = {}
                    tool_calls_pending.append({
                        'id': current_tool['id'],
                        'name': current_tool['name'],
                        'input': inputs,
                    })
                    assistant_content.append({
                        'type': 'tool_use',
                        'id': current_tool['id'],
                        'name': current_tool['name'],
                        'input': inputs,
                    })
                    current_tool = None

            elif etype == 'message_delta':
                stop_reason = ev.get('delta', {}).get('stop_reason', stop_reason)
                usage = ev.get('usage', usage)

            elif etype == 'message_stop':
                break

        return stop_reason, usage

    # ------------------------------------------------------------------
    # Sessions
    # ------------------------------------------------------------------

    def get_or_create_session(self, session_id):
        with self._sessions_lock:
            if session_id in self._sessions:
                return self._sessions[session_id]
            if len(self._sessions) >= self._max_sessions:
                oldest_id = min(self._sessions, key=lambda k: self._sessions[k].last_activity)
                self._save_session(self._sessions[oldest_id])
                del self._sessions[oldest_id]
            s = LLMSession(session_id=session_id)
            self._sessions[session_id] = s
            return s

    def get_sessions_info(self):
        with self._sessions_lock:
            result = []
            for s in self._sessions.values():
                result.append({
                    'session_id':   s.session_id,
                    'created':      s.created,
                    'last_activity': s.last_activity,
                    'title':        s.title,
                    'messages':     len([m for m in s.history if m.get('role') == 'user']),
                    'processing':   s.processing,
                })
        return result

    def get_generation(self):
        """Retourne le numéro de génération (incrémenté à chaque poweron).
        Permet au browser de détecter un reset de la tâche."""
        return self._generation

    @staticmethod
    def _strip_images_for_pickle(history):
        """Remplace les blocs image par '[image]' avant sérialisation pickle.
        Évite de stocker de gros base64 sur disque — les images ne survivent pas au redémarrage.
        """
        result = []
        for msg in history:
            content = msg.get('content')
            if isinstance(content, list) and any(
                isinstance(b, dict) and b.get('type') == 'image' for b in content
            ):
                new_content = []
                for block in content:
                    if isinstance(block, dict) and block.get('type') == 'image':
                        new_content.append({'type': 'text', 'text': '[image]'})
                    else:
                        new_content.append(block)
                # Simplifier en string si un seul bloc texte restant
                if len(new_content) == 1 and new_content[0].get('type') == 'text':
                    result.append({**msg, 'content': new_content[0]['text']})
                else:
                    result.append({**msg, 'content': new_content})
            else:
                result.append(msg)
        return result

    def _save_session(self, session):
        try:
            data = {
                'session_id': session.session_id,
                'created':    session.created,
                'updated':    time.time(),
                'title':      session.title,
                'messages':   self._strip_images_for_pickle(session.history),
                'audit':      session.audit[-200:],
            }
            self.pickleWrite(f'llm_conv_{session.session_id[:8]}', data, force=True)
        except Exception:
            self.logger.exception('LLM _save_session')

    def _restore_sessions_index(self):
        try:
            names = self.pickleList('llm_conv_*') or []
            for name in names:
                data = self.pickleRead(name)
                if not data or 'session_id' not in data:
                    continue
                sid = data['session_id']
                s = LLMSession(
                    session_id=sid,
                    created=data.get('created', time.time()),
                    last_activity=data.get('updated', time.time()),
                    title=data.get('title', ''),
                )
                s.history = data.get('messages', [])
                s.audit = data.get('audit', [])
                with self._sessions_lock:
                    self._sessions[sid] = s
        except Exception:
            self.logger.exception('LLM _restore_sessions_index')

    def _cleanup_expired_sessions(self):
        now = time.time()
        with self._sessions_lock:
            expired = [sid for sid, s in self._sessions.items()
                       if now - s.last_activity > _SESSION_EXPIRY and not s.processing]
            for sid in expired:
                self._save_session(self._sessions[sid])
                del self._sessions[sid]

    # ------------------------------------------------------------------
    # API publique pour wsmonitor_llm
    # ------------------------------------------------------------------

    @property
    def suggestions(self):
        return list(self._suggestions)

    def post_message(self, session_id, message, images=None):
        """Enregistre un message utilisateur dans la session (sera traité dans run()).

        images : liste de dicts {media_type, data} (base64 sans prefix data:...).
        """
        s = self.get_or_create_session(session_id)
        if not s.title:
            s.title = message[:50]
        # Nouvelle génération : invalider le thread en cours (s'il existe) et la queue résiduelle
        s.stream_id += 1
        s.abort_current = True
        while not s.output_queue.empty():
            try:
                s.output_queue.get_nowait()
            except Exception:
                break
        # Contenu multi-part si images présentes
        if images:
            content = [
                {'type': 'image', 'source': {'type': 'base64',
                                              'media_type': img.get('media_type', 'image/jpeg'),
                                              'data': img['data']}}
                for img in images
            ]
            content.append({'type': 'text', 'text': message})
        else:
            content = message
        s.history.append({'role': 'user', 'content': content})
        s.last_activity = time.time()
        return s
