"""Gateway: Central entry point and agent loop.

The Gateway:
  - Receives messages from all channels
  - Manages sessions
  - Orchestrates the PGE cycle (Plan -> Gate -> Execute -> Replan)
  - Returns responses to channels
  - Starts and stops all subsystems

Bible reference: §9.1 (Gateway), §3.4 (Complete cycle)
"""

from __future__ import annotations

import asyncio
import contextlib
import hashlib
import json as _json
import re
import signal
import threading
import time
from typing import TYPE_CHECKING, Any, ClassVar

from jarvis.config import JarvisConfig, load_config
from jarvis.core.agent_router import RouteDecision
from jarvis.gateway.phases import (
    apply_phase,
    declare_advanced_attrs,
    declare_agents_attrs,
    declare_compliance_attrs,
    declare_core_attrs,
    declare_memory_attrs,
    declare_pge_attrs,
    declare_security_attrs,
    declare_tools_attrs,
    init_advanced,
    init_agents,
    init_compliance,
    init_core,
    init_memory,
    init_pge,
    init_security,
    init_tools,
)
from jarvis.mcp.client import JarvisMCPClient
from jarvis.models import (
    ActionPlan,
    AgentResult,
    AuditEntry,
    GateDecision,
    GateStatus,
    IncomingMessage,
    Message,
    MessageRole,
    OutgoingMessage,
    SessionContext,
    ToolResult,
    WorkingMemory,
)
from jarvis.utils.logging import get_logger, setup_logging

if TYPE_CHECKING:
    from jarvis.channels.base import Channel
    from jarvis.core.message_queue import DurableMessageQueue
    from jarvis.models import SubAgentConfig

log = get_logger(__name__)

# Presearch result markers — used to detect empty/failed search results
_PRESEARCH_NO_RESULTS = "Keine Ergebnisse"
_PRESEARCH_NO_ENGINE = "Keine Suchengine"

# ── Tool-Status-Map für Progress-Feedback ────────────────────────

_TOOL_STATUS_MAP: dict[str, str] = {
    "web_search": "Searching the web...",
    "web_news_search": "Searching news...",
    "search_and_read": "Researching online...",
    "web_fetch": "Fetching webpage...",
    "read_file": "Reading file...",
    "write_file": "Writing file...",
    "edit_file": "Editing file...",
    "exec_command": "Running command...",
    "run_python": "Running Python code...",
    "search_memory": "Searching knowledge...",
    "save_to_memory": "Saving knowledge...",
    "document_export": "Creating document...",
    "media_analyze_image": "Analyzing image...",
    "media_transcribe_audio": "Transcribing audio...",
    "media_extract_text": "Extracting text...",
    "media_tts": "Generating speech...",
    "vault_search": "Searching vault...",
    "vault_write": "Writing to vault...",
    "analyze_code": "Analyzing code...",
    "list_directory": "Listing directory...",
    "browser_navigate": "Navigating browser...",
    "browser_screenshot": "Taking screenshot...",
}


class Gateway:
    """Central entry point. Connects all Jarvis subsystems. [B§9.1]"""

    # Session TTL: sessions older than 24 hours are considered stale
    _SESSION_TTL_SECONDS: float = 24 * 60 * 60  # 24h
    # Minimum interval between stale-session cleanup sweeps
    _CLEANUP_INTERVAL_SECONDS: float = 60 * 60  # 1h

    def __init__(self, config: JarvisConfig | None = None) -> None:
        """Initialisiert das Gateway mit PGE-Trinität, MCP-Client und Memory."""
        self._config = config or load_config()
        self._channels: dict[str, Channel] = {}
        self._sessions: dict[str, SessionContext] = {}
        self._working_memories: dict[str, WorkingMemory] = {}
        self._session_last_accessed: dict[str, float] = {}
        self._last_session_cleanup: float = time.monotonic()
        self._session_lock = threading.Lock()
        self._running = False
        self._context_pipeline = None
        self._message_queue: DurableMessageQueue | None = None
        self._background_tasks: set[asyncio.Task[Any]] = set()

        # Declare all subsystem attributes via phase modules
        apply_phase(self, declare_core_attrs(self._config))
        apply_phase(self, declare_security_attrs(self._config))
        apply_phase(self, declare_tools_attrs(self._config))
        apply_phase(self, declare_memory_attrs(self._config))
        apply_phase(self, declare_pge_attrs(self._config))
        apply_phase(self, declare_agents_attrs(self._config))
        apply_phase(self, declare_compliance_attrs(self._config))
        apply_phase(self, declare_advanced_attrs(self._config))

    async def initialize(self) -> None:
        """Initialisiert alle Subsysteme in der richtigen Reihenfolge.

        Dependency graph (→ = depends on):
          core        (independent)
          security    → core (_llm)
          memory      → security (_audit_logger)
          tools       → memory, core (_memory_manager, _interop)
          pge         → core, security, tools (_llm, _model_router, _mcp_client, ...)
          agents      → memory, tools, security (_memory_manager, _mcp_client, ...)

        Independent phases are run in parallel via asyncio.gather where possible.
        """
        # 1. Logging
        setup_logging(
            level=self._config.log_level,
            log_dir=self._config.logs_dir,
        )
        log.info("gateway_init_start", version=self._config.version)

        # 2. Verzeichnisse sicherstellen
        self._config.ensure_directories()
        self._config.ensure_default_files()

        # --- Phase A: Core (independent) ---
        core_result = await init_core(self._config)
        llm_ok = core_result.pop("__llm_ok", False)
        apply_phase(self, core_result)

        # --- Phase B: Security (depends on core for _llm) ---
        security_result = await init_security(self._config, llm_backend=self._llm)
        apply_phase(self, security_result)

        # --- Phase C: Memory (depends on security for _audit_logger) ---
        memory_result = await init_memory(self._config, audit_logger=self._audit_logger)
        apply_phase(self, memory_result)

        # --- Phase D: Tools (depends on memory + core) ---
        mcp_client = JarvisMCPClient(self._config)
        tools_result = await init_tools(
            self._config,
            mcp_client=mcp_client,
            memory_manager=self._memory_manager,
            interop=self._interop,
            handle_message=self.handle_message,
        )
        apply_phase(self, tools_result)

        # --- Phase D.1: Context Pipeline (depends on memory + tools) ---
        try:
            from jarvis.core.context_pipeline import ContextPipeline

            cp_config = getattr(self._config, "context_pipeline", None)
            if cp_config is None:
                from jarvis.config import ContextPipelineConfig

                cp_config = ContextPipelineConfig()
            if cp_config.enabled:
                self._context_pipeline = ContextPipeline(cp_config)
                self._context_pipeline.set_memory_manager(self._memory_manager)
                if hasattr(self, "_vault_tools") and self._vault_tools:
                    self._context_pipeline.set_vault_tools(self._vault_tools)
                log.info("context_pipeline_initialized")
        except Exception:
            log.debug("context_pipeline_init_skipped", exc_info=True)

        # --- Phase D.2: Message Queue (optional, durable message buffering) ---
        if self._config.queue.enabled:
            try:
                from jarvis.core.message_queue import DurableMessageQueue as _Dmq

                queue_path = self._config.jarvis_home / "memory" / "message_queue.db"
                self._message_queue = _Dmq(
                    queue_path,
                    max_size=self._config.queue.max_size,
                    max_retries=self._config.queue.max_retries,
                    ttl_hours=self._config.queue.ttl_hours,
                )
                # Trigger lazy DB init
                _ = self._message_queue.conn
                log.info("message_queue_initialized", db=str(queue_path))
            except Exception:
                log.warning("message_queue_init_failed", exc_info=True)

        # --- Phase E: PGE + Agents in parallel (both depend on phases A-D) ---
        pge_coro = init_pge(
            self._config,
            llm=self._llm,
            model_router=self._model_router,
            mcp_client=self._mcp_client,
            runtime_monitor=self._runtime_monitor,
            audit_logger=self._audit_logger,
            memory_manager=self._memory_manager,
            cost_tracker=self._cost_tracker,
        )
        agents_coro = init_agents(
            self._config,
            memory_manager=self._memory_manager,
            mcp_client=self._mcp_client,
            audit_logger=self._audit_logger,
            jarvis_home=self._config.jarvis_home,
            handle_message=self.handle_message,
            heartbeat_config=self._config.heartbeat,
        )
        pge_result, agents_result = await asyncio.gather(pge_coro, agents_coro)
        apply_phase(self, pge_result)
        apply_phase(self, agents_result)

        # Wire Orchestrator runner (Sub-Agent execution via handle_message)
        if getattr(self, "_orchestrator", None):
            try:

                async def _agent_runner(
                    config: SubAgentConfig,
                    agent_name: str,
                ) -> AgentResult:
                    msg = IncomingMessage(
                        channel="sub_agent",
                        user_id=f"agent:{agent_name}",
                        text=config.task,
                        metadata={
                            "agent_type": config.agent_type.value,
                            "parent_agent": agent_name,
                            "max_iterations": config.max_iterations,
                            "depth": config.depth + 1,
                        },
                    )
                    try:
                        response = await asyncio.wait_for(
                            self.handle_message(msg),
                            timeout=config.timeout_seconds,
                        )
                        return AgentResult(
                            response=response.text,
                            success=True,
                            model_used=config.model,
                        )
                    except TimeoutError:
                        return AgentResult(
                            response="",
                            success=False,
                            error=f"Sub-Agent Timeout nach {config.timeout_seconds}s",
                        )
                    except Exception as exc:
                        return AgentResult(
                            response="",
                            success=False,
                            error=str(exc),
                        )

                self._orchestrator.set_runner(_agent_runner)
                log.info("orchestrator_runner_wired")
            except Exception:
                log.debug("orchestrator_runner_wiring_skipped", exc_info=True)

        # --- Phase F: Advanced (depends on PGE + tools) ---
        advanced_result = await init_advanced(
            self._config,
            task_telemetry=self._task_telemetry,
            error_clusterer=self._error_clusterer,
            task_profiler=self._task_profiler,
            cost_tracker=self._cost_tracker,
            run_recorder=self._run_recorder,
            gatekeeper=self._gatekeeper,
        )
        apply_phase(self, advanced_result)

        # Wire DAG WorkflowEngine with MCP client + Gatekeeper
        if getattr(self, "_dag_workflow_engine", None):
            try:
                self._dag_workflow_engine._mcp_client = self._mcp_client
                self._dag_workflow_engine._gatekeeper = self._gatekeeper
            except Exception:
                log.debug("dag_workflow_engine_wiring_skipped", exc_info=True)

        # Wire prompt_evolution to planner (created in advanced, after PGE)
        if getattr(self, "_prompt_evolution", None) and getattr(self, "_planner", None):
            self._planner._prompt_evolution = self._prompt_evolution

        # Wire prompt_evolution LLM client (meta-prompt generation)
        if getattr(self, "_prompt_evolution", None) and self._llm and self._model_router:
            try:

                async def _pe_llm_call(prompt: str) -> str:
                    model = self._model_router.select_model("planning", "high")
                    resp = await self._llm.chat(
                        model=model,
                        messages=[{"role": "user", "content": prompt}],
                        temperature=0.8,
                    )
                    return resp.get("message", {}).get("content", "")

                self._prompt_evolution._llm_client = _pe_llm_call
            except Exception:
                log.debug("prompt_evolution_llm_wiring_skipped", exc_info=True)

        # Wire prompt_evolution interval from config
        if getattr(self, "_prompt_evolution", None):
            try:
                self._prompt_evolution.set_evolution_interval_hours(
                    self._config.prompt_evolution.evolution_interval_hours
                )
            except Exception:
                log.debug("prompt_evolution_interval_config_skipped", exc_info=True)

        # --- Phase G: Compliance validation ---
        compliance_attrs = {
            k: getattr(self, f"_{k}", None)
            for k in (
                "compliance_framework",
                "decision_log",
                "remediation_tracker",
                "economic_governor",
                "compliance_exporter",
                "impact_assessor",
                "explainability",
            )
        }
        await init_compliance(self._config, **compliance_attrs)

        # Governance-Cron-Job registrieren (taeglich um 02:00)
        if self._cron_engine and hasattr(self, "_governance_agent") and self._governance_agent:
            try:
                from jarvis.cron.jobs import governance_analysis

                self._cron_engine.add_system_job(
                    name="governance_analysis",
                    schedule="0 2 * * *",
                    callback=governance_analysis,
                    args=[self],
                )
            except Exception:
                log.debug("governance_cron_registration_skipped", exc_info=True)

        # Prompt-Evolution-Cron-Job registrieren
        if self._cron_engine and getattr(self, "_prompt_evolution", None):
            try:
                from jarvis.cron.jobs import prompt_evolution_check

                interval_h = self._config.prompt_evolution.evolution_interval_hours
                cron_expr = (
                    f"0 */{interval_h} * * *" if interval_h < 24 else f"0 {interval_h % 24} * * *"
                )
                self._cron_engine.add_system_job(
                    name="prompt_evolution_check",
                    schedule=cron_expr,
                    callback=prompt_evolution_check,
                    args=[self],
                )
            except Exception:
                log.debug("prompt_evolution_cron_registration_skipped", exc_info=True)

        log.info(
            "gateway_init_complete",
            llm_available=llm_ok,
            tools=self._mcp_client.get_tool_list(),
            cron_jobs=self._cron_engine.job_count if self._cron_engine else 0,
        )

        # Audit: System-Start protokollieren
        if self._audit_logger:
            self._audit_logger.log_system(
                "startup",
                description=(
                    f"Jarvis gestartet (LLM={llm_ok}, "
                    f"Tools={len(self._mcp_client.get_tool_list())})"
                ),
            )

        # CORE.md: Tool/Skill-Inventar aktualisieren
        try:
            self._sync_core_inventory()
        except Exception:
            log.debug("core_inventory_sync_failed", exc_info=True)

    def _sync_core_inventory(self) -> None:
        """Aktualisiert den INVENTAR-Abschnitt in CORE.md mit aktuellen Tools/Skills.

        Verwendet ToolRegistryDB fuer datenbankgestuetzte, lokalisierte und
        rollenbasierte Tool-Abschnitte. Faellt auf die alte statische Methode
        zurueck, wenn die DB nicht verfuegbar ist.
        """
        core_path = self._config.core_memory_file
        if not core_path or not core_path.exists():
            return

        content = core_path.read_text(encoding="utf-8")
        language = getattr(self._config, "language", "de")

        # Versuche DB-gestuetzte Generierung
        tool_count = 0
        try:
            from jarvis.mcp.tool_registry_db import (
                _SECTION_HEADERS,
                ToolRegistryDB,
                _ProcedureEntry,
                deduplicate_procedures,
            )

            db_path = self._config.jarvis_home / "tool_registry.db"
            registry_db = ToolRegistryDB(db_path)

            # Tools aus MCP-Client synchronisieren
            if self._mcp_client:
                registry_db.sync_from_mcp(self._mcp_client)

            tool_count = registry_db.tool_count()
            registry_db.close()
        except Exception:
            log.debug("tool_registry_db_failed_falling_back", exc_info=True)
            # Fallback: legacy method just to validate MCP is alive
            if self._sync_core_inventory_legacy() is None:
                return
            tool_count = 0

        # Skill-Liste zusammenstellen
        skill_lines: list[str] = []
        if hasattr(self, "_skill_registry") and self._skill_registry:
            try:
                for slug, skill in self._skill_registry._skills.items():
                    status = "active" if skill.enabled else "inactive"
                    skill_lines.append(f"- **{skill.name}** (`{slug}`) -- {status}")
            except Exception:
                log.debug("core_inventory_skills_failed", exc_info=True)
        if not skill_lines:
            skill_lines = ["- (no skills registered)"]

        # Prozedur-Liste mit Deduplizierung
        proc_lines: list[str] = []
        if self._memory_manager:
            try:
                from jarvis.mcp.tool_registry_db import (
                    _ProcedureEntry,
                    deduplicate_procedures,
                )

                procedural = self._memory_manager.procedural
                raw_procs = [
                    _ProcedureEntry(
                        name=meta.name,
                        total_uses=meta.total_uses,
                        trigger_keywords=list(meta.trigger_keywords),
                    )
                    for meta in procedural.list_procedures()
                ]
                proc_lines = deduplicate_procedures(
                    raw_procs,
                    language=language,
                )
            except Exception:
                log.debug("core_inventory_procedures_dedup_failed", exc_info=True)
                # Fallback: einfache Liste
                try:
                    procedural = self._memory_manager.procedural
                    for meta in procedural.list_procedures():
                        uses = f"{meta.total_uses}x" if meta.total_uses else "0x"
                        kw = ", ".join(meta.trigger_keywords[:3]) if meta.trigger_keywords else ""
                        suffix = f" [{kw}]" if kw else ""
                        proc_lines.append(f"- `{meta.name}` ({uses} used){suffix}")
                except Exception:
                    log.debug("core_inventory_procedures_failed", exc_info=True)

        if not proc_lines:
            proc_lines = ["- (no procedures stored)"]

        # Lokalisierte Header
        try:
            from jarvis.mcp.tool_registry_db import _SECTION_HEADERS

            headers = _SECTION_HEADERS.get(language, _SECTION_HEADERS["en"])
        except Exception:
            headers = {
                "inventory_title": "INVENTORY (auto-updated)",
                "skills_title": "Installed Skills ({count})",
                "procedures_title": "Learned Procedures ({count})",
            }

        inv_title = headers["inventory_title"]
        skills_title = headers["skills_title"].format(count=len(skill_lines))
        procs_title = headers["procedures_title"].format(count=len(proc_lines))

        # Tool descriptions are injected directly into the Planner prompt
        # via {tools_section} — no need to duplicate them in CORE.md
        tool_ref = (
            f"*{tool_count} Tools registriert (werden direkt in den Planner-Prompt injiziert)*"
        )

        inventory = (
            f"## {inv_title}\n\n"
            + tool_ref
            + "\n\n"
            + f"### {skills_title}\n"
            + "\n".join(skill_lines)
            + "\n\n"
            + f"### {procs_title}\n"
            + "\n".join(proc_lines)
        )

        # Bestehenden INVENTAR/INVENTORY-Abschnitt ersetzen oder am Ende anhaengen
        marker_candidates = [
            "## INVENTAR (auto-aktualisiert)",
            "## INVENTAR (automatisch aktualisiert)",
            "## INVENTORY (auto-updated)",
            f"## {inv_title}",
        ]
        marker_start = None
        for marker in marker_candidates:
            if marker in content:
                marker_start = marker
                break

        if marker_start:
            pattern = re.escape(marker_start) + r".*?(?=\n## (?!INVENT|清单)|\Z)"
            content = re.sub(pattern, inventory, content, flags=re.DOTALL)
        else:
            content = content.rstrip() + "\n\n---\n\n" + inventory + "\n"

        core_path.write_text(content, encoding="utf-8")
        log.info(
            "core_inventory_synced",
            tools=tool_count,
            skills=len(skill_lines),
            procedures=len(proc_lines),
        )

    def _sync_core_inventory_legacy(self) -> str | None:
        """Alte statische Tool-Liste als Fallback (ohne DB).

        Returns:
            Formatierter Tool-Abschnitt oder None bei Fehler.
        """
        tool_schemas = self._mcp_client.get_tool_schemas() if self._mcp_client else {}
        if not tool_schemas:
            return None

        tool_lines: list[str] = []
        for name in sorted(tool_schemas):
            schema = tool_schemas[name]
            desc = schema.get("description", "")
            props = schema.get("inputSchema", {}).get("properties", {})
            required = set(schema.get("inputSchema", {}).get("required", []))
            if props:
                parts = []
                for k, v in props.items():
                    typ = v.get("type", "?")
                    req = " *" if k in required else ""
                    parts.append(f"{k}: {typ}{req}")
                param_str = ", ".join(parts)
                tool_lines.append(f"- `{name}({param_str})` -- {desc}")
            else:
                tool_lines.append(f"- `{name}()` -- {desc}")

        tool_count = len(tool_schemas)
        return (
            f"### Registered Tools ({tool_count})\n"
            + "Parameters marked with * are required.\n\n"
            + "\n".join(tool_lines)
        )

    def register_channel(self, channel: Channel) -> None:
        """Registriert einen Kommunikationskanal."""
        self._channels[channel.name] = channel
        log.info("channel_registered", channel=channel.name)

    async def start(self) -> None:
        """Startet den Gateway und alle Channels + Cron."""
        self._running = True

        # Signal-Handler für Graceful Shutdown
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGINT, signal.SIGTERM):
            with contextlib.suppress(NotImplementedError, OSError):
                loop.add_signal_handler(sig, lambda: asyncio.create_task(self.shutdown()))

        # Cron-Engine starten (wenn konfiguriert)
        if self._cron_engine and self._cron_engine.has_enabled_jobs:
            await self._cron_engine.start()
            log.info("cron_engine_started", jobs=self._cron_engine.job_count)

        # MCP-Server starten (OPTIONAL -- nur wenn Bridge aktiviert)
        if self._mcp_bridge and self._mcp_bridge.enabled:
            try:
                await self._mcp_bridge.start()
            except Exception as exc:
                log.warning("mcp_bridge_start_failed", error=str(exc))

        # A2A-Server starten (OPTIONAL)
        if self._a2a_adapter and self._a2a_adapter.enabled:
            try:
                await self._a2a_adapter.start()
                # A2A HTTP-Routes in WebUI-App registrieren
                for channel in self._channels.values():
                    if hasattr(channel, "app") and channel.app is not None:
                        try:
                            from jarvis.a2a.http_handler import A2AHTTPHandler

                            a2a_http = A2AHTTPHandler(self._a2a_adapter)
                            a2a_http.register_routes(channel.app)
                        except Exception as exc:
                            log.debug("a2a_http_routes_skip", error=str(exc))
            except Exception as exc:
                log.warning("a2a_adapter_start_failed", error=str(exc))

        # Auto-update: community skill sync if plugins.auto_update is enabled
        if getattr(self._config.plugins, "auto_update", False) or getattr(
            self._config.marketplace, "auto_update", False
        ):
            _task = asyncio.create_task(self._auto_update_skills(), name="auto-update-skills")
            self._background_tasks.add(_task)
            _task.add_done_callback(self._background_tasks.discard)

        # Channels starten
        tasks = []
        for channel in self._channels.values():
            task = asyncio.create_task(
                channel.start(self.handle_message),
                name=f"channel-{channel.name}",
            )
            tasks.append(task)

        if tasks:
            # Warte bis alle Channels beendet sind
            await asyncio.gather(*tasks, return_exceptions=True)
        else:
            log.warning("no_channels_registered")

    async def _auto_update_skills(self) -> None:
        """Background task: sync community registry if auto_update is enabled."""
        try:
            from jarvis.skills.community.sync import RegistrySync

            sync = RegistrySync(
                community_dir=self._config.jarvis_home / "skills" / "community",
                skill_registry=self._skill_registry if hasattr(self, "_skill_registry") else None,
            )
            result = await sync.sync_once()
            if result.success:
                log.info(
                    "auto_update_sync_done",
                    skills=result.registry_skills,
                    recalls=len(result.new_recalls),
                )
            else:
                log.warning("auto_update_sync_failed", errors=result.errors)
        except Exception as exc:
            log.debug("auto_update_skipped", reason=str(exc))

    async def shutdown(self) -> None:
        """Fährt den Gateway sauber herunter mit Session-Persistierung."""
        log.info("gateway_shutdown_start")
        self._running = False

        # Audit log BEFORE closing resources
        if self._audit_logger:
            self._audit_logger.log_system("shutdown", description="Jarvis heruntergefahren")

        # Cron-Engine stoppen
        if self._cron_engine:
            await self._cron_engine.stop()

        # Channels stoppen
        for channel in self._channels.values():
            try:
                await channel.stop()
            except Exception as exc:
                log.warning("channel_stop_error", channel=channel.name, error=str(exc))

        # Sessions persistieren
        if self._session_store:
            saved_count = 0
            for _key, session in self._sessions.items():
                try:
                    self._session_store.save_session(session)
                    # Chat-History speichern
                    wm = self._working_memories.get(session.session_id)
                    if wm and wm.chat_history:
                        self._session_store.save_chat_history(
                            session.session_id,
                            wm.chat_history,
                        )
                    saved_count += 1
                except Exception as exc:
                    log.warning(
                        "session_save_error",
                        session=session.session_id[:8],
                        error=str(exc),
                    )
            log.info("sessions_persisted", count=saved_count)
            self._session_store.close()

        # Memory-Manager schließen
        if hasattr(self, "_memory_manager") and self._memory_manager:
            try:
                await self._memory_manager.close()
            except Exception as exc:
                log.warning("memory_close_error", error=str(exc))

        # A2A-Adapter stoppen (optional)
        if self._a2a_adapter:
            try:
                await self._a2a_adapter.stop()
            except Exception:
                log.debug("a2a_adapter_stop_skipped", exc_info=True)

        # Browser-Agent stoppen (optional)
        if self._browser_agent:
            try:
                await self._browser_agent.stop()
            except Exception:
                log.debug("browser_agent_stop_skipped", exc_info=True)

        # MCP-Bridge stoppen (optional)
        if self._mcp_bridge:
            try:
                await self._mcp_bridge.stop()
            except Exception:
                log.debug("mcp_bridge_stop_skipped", exc_info=True)

        # CostTracker schliessen
        if hasattr(self, "_cost_tracker") and self._cost_tracker:
            try:
                self._cost_tracker.close()
            except Exception:
                log.debug("cost_tracker_close_skipped", exc_info=True)

        # RunRecorder schliessen
        if hasattr(self, "_run_recorder") and self._run_recorder:
            try:
                self._run_recorder.close()
            except Exception:
                log.debug("run_recorder_close_skipped", exc_info=True)

        # GovernanceAgent schliessen
        if hasattr(self, "_governance_agent") and self._governance_agent:
            try:
                self._governance_agent.close()
            except Exception:
                log.debug("governance_agent_close_skipped", exc_info=True)

        # Gatekeeper Audit-Buffer flushen (keine Einträge verlieren)
        if self._gatekeeper:
            try:
                self._gatekeeper._flush_audit_buffer()
            except Exception:
                log.debug("gatekeeper_flush_skipped", exc_info=True)

        # UserPreferenceStore schließen
        if hasattr(self, "_user_pref_store") and self._user_pref_store:
            try:
                self._user_pref_store.close()
            except Exception:
                log.debug("user_pref_store_close_skipped", exc_info=True)

        # MCP-Client trennen
        if self._mcp_client:
            await self._mcp_client.disconnect_all()

        # Ollama-Client schließen
        if self._llm:
            await self._llm.close()

        log.info("gateway_shutdown_complete")

    async def execute_workflow(self, workflow_yaml: str) -> dict[str, Any]:
        """Execute a workflow via the DAG WorkflowEngine.

        Parses a YAML workflow definition and runs it through the wired
        WorkflowEngine. Returns the WorkflowRun as a dictionary.

        Args:
            workflow_yaml: YAML string defining the workflow.

        Returns:
            Dict with workflow run results.

        Raises:
            RuntimeError: If DAG WorkflowEngine is not available.
        """
        engine = getattr(self, "_dag_workflow_engine", None)
        if engine is None:
            raise RuntimeError("DAG WorkflowEngine ist nicht verfügbar")

        from jarvis.core.workflow_schema import WorkflowDefinition

        workflow = WorkflowDefinition.from_yaml(workflow_yaml)

        errors = engine.validate(workflow)
        if errors:
            return {"success": False, "errors": errors}

        run = await engine.execute(workflow)
        return run.model_dump(mode="json")

    async def execute_action_plan_as_workflow(self, plan: ActionPlan) -> dict[str, Any]:
        """Execute an ActionPlan through the DAG WorkflowEngine.

        Bridges PGE-style ActionPlans with the full WorkflowEngine.
        Useful for complex multi-step plans that benefit from the
        engine's checkpoint/resume, retry strategies, and status callbacks.

        Args:
            plan: PGE ActionPlan to execute.

        Returns:
            Dict with workflow run results.

        Raises:
            RuntimeError: If DAG WorkflowEngine is not available.
        """
        engine = getattr(self, "_dag_workflow_engine", None)
        if engine is None:
            raise RuntimeError("DAG WorkflowEngine ist nicht verfügbar")

        from jarvis.core.workflow_adapter import action_plan_to_workflow

        workflow = action_plan_to_workflow(
            plan,
            max_parallel=getattr(self._config.executor, "max_parallel_tools", 4),
        )

        errors = engine.validate(workflow)
        if errors:
            return {"success": False, "errors": errors}

        run = await engine.execute(workflow)
        return run.model_dump(mode="json")

    def reload_components(
        self,
        *,
        prompts: bool = False,
        policies: bool = False,
        config: bool = False,
        core_memory: bool = False,
        skills: bool = False,
    ) -> dict:
        """Reload-Koordinator für Live-Updates vom UI."""
        reloaded = []
        if prompts and self._planner:
            self._planner.reload_prompts()
            reloaded.append("prompts")
        if policies and self._gatekeeper:
            self._gatekeeper.reload_policies()
            reloaded.append("policies")
        if core_memory:
            core_path = self._config.core_memory_path
            if core_path.exists():
                try:
                    text = core_path.read_text(encoding="utf-8")
                    for wm in self._working_memories.values():
                        wm.core_memory_text = text
                    reloaded.append("core_memory")
                except Exception:
                    log.debug("reload_core_memory_failed", exc_info=True)
        if skills and self._skill_registry:
            try:
                skill_dirs = [
                    self._config.jarvis_home / "data" / "procedures",
                    self._config.jarvis_home / self._config.plugins.skills_dir,
                ]
                self._skill_registry.load_from_directories(skill_dirs)
                reloaded.append("skills")
            except Exception:
                log.warning("skills_reload_failed", exc_info=True)
        if config:
            # Reload config.yaml from disk
            try:
                new_config = load_config(self._config.config_file)
                self._config = new_config
            except Exception:
                log.debug("config_file_reload_failed", exc_info=True)
                new_config = self._config

            # Live-update i18n locale from config
            try:
                import os

                from jarvis.i18n import set_locale

                _lang = (
                    os.environ.get("JARVIS_LANGUAGE")
                    or os.environ.get("COGNITHOR_LANGUAGE")
                    or new_config.language
                )
                set_locale(_lang)
            except Exception:
                log.debug("i18n_locale_reload_failed", exc_info=True)

            # Live-update Executor runtime parameters
            if self._executor and hasattr(self._executor, "reload_config"):
                try:
                    self._executor.reload_config(new_config)
                except Exception:
                    log.debug("executor_config_reload_failed", exc_info=True)

            # Live-update ModelRouter with new config + schedule model list refresh
            if self._model_router and hasattr(self._model_router, "_config"):
                try:
                    self._model_router._config = new_config
                    # Schedule async re-initialization to refresh _available_models
                    import asyncio

                    try:
                        loop = asyncio.get_running_loop()
                        _task = loop.create_task(self._model_router.initialize())
                        self._background_tasks.add(_task)
                        _task.add_done_callback(self._background_tasks.discard)
                    except RuntimeError:
                        pass  # no loop — model list refresh skipped
                    log.info("model_router_config_reloaded")
                except Exception:
                    log.debug("model_router_config_reload_failed", exc_info=True)

            # Recreate UnifiedLLMClient if backend type changed
            if self._llm is not None:
                old_backend = getattr(self._llm, "backend_type", "ollama")
                new_backend = new_config.llm_backend_type
                if old_backend != new_backend:
                    try:
                        from jarvis.core.unified_llm import UnifiedLLMClient

                        old_llm = self._llm
                        self._llm = UnifiedLLMClient.create(new_config)
                        # Update references in Planner/Executor
                        if self._planner and hasattr(self._planner, "_ollama"):
                            self._planner._ollama = self._llm
                        if self._executor and hasattr(self._executor, "_ollama"):
                            self._executor._ollama = self._llm
                        # Close old client
                        import asyncio

                        try:
                            loop = asyncio.get_running_loop()
                            _task = loop.create_task(old_llm.close())
                            self._background_tasks.add(_task)
                            _task.add_done_callback(self._background_tasks.discard)
                        except RuntimeError:
                            pass
                        log.info(
                            "llm_backend_switched",
                            old=old_backend,
                            new=new_backend,
                        )
                    except Exception:
                        log.warning("llm_backend_switch_failed", exc_info=True)

            # Live-update Planner with new config
            if self._planner and hasattr(self._planner, "_config"):
                try:
                    self._planner._config = new_config
                except Exception:
                    log.debug("planner_config_reload_failed", exc_info=True)

            # Live-update WebTools runtime parameters
            web_tools = None
            if self._mcp_client:
                handler = self._mcp_client.get_handler("web_search")
                if handler is not None:
                    web_tools = getattr(handler, "__self__", None)
            if web_tools and hasattr(web_tools, "reload_config"):
                try:
                    web_tools.reload_config(new_config)
                except Exception:
                    log.debug("web_tools_config_reload_failed", exc_info=True)

            reloaded.append("config")
        log.info("gateway_components_reloaded", components=reloaded)
        return {"reloaded": reloaded}

    async def handle_message(self, msg: IncomingMessage) -> OutgoingMessage:
        """Verarbeitet eine eingehende Nachricht. [B§3.4]

        Orchestriert den PGE-Zyklus (Plan → Gate → Execute → Replan).

        Returns:
            OutgoingMessage mit der Jarvis-Antwort.
        """
        _handle_start = time.monotonic()

        # --- Sub-Agent depth guard ---
        # The _agent_runner passes depth in msg.metadata. Enforce max depth
        # to prevent infinite recursive sub-agent delegation.
        _depth = msg.metadata.get("depth", 0) if msg.metadata else 0
        _max_depth = getattr(self._config.security, "max_sub_agent_depth", 3)
        if _depth > _max_depth:
            log.warning(
                "sub_agent_depth_exceeded",
                depth=_depth,
                max_depth=_max_depth,
                channel=msg.channel,
                user_id=msg.user_id,
            )
            return OutgoingMessage(
                channel=msg.channel,
                text=(
                    f"Sub-Agent Rekursion abgebrochen: "
                    f"Tiefe {_depth} überschreitet "
                    f"Maximum {_max_depth}."
                ),
                is_final=True,
            )

        # Prometheus: Zähle eingehende Requests
        self._record_metric("requests_total", 1, channel=msg.channel)

        # User-Feedback erkennen und speichern (vor PGE-Zyklus)
        if getattr(self, "_session_analyzer", None):
            try:
                signal = self._session_analyzer._extract_feedback_signal(msg.text)
                if signal is not None:
                    fb_type, detail = signal
                    sid = msg.session_id if hasattr(msg, "session_id") else ""
                    self._session_analyzer.record_user_feedback(
                        session_id=sid,
                        message_id=getattr(msg, "message_id", ""),
                        feedback_type=fb_type,
                        detail=detail,
                    )
                    log.info("user_feedback_recorded", type=fb_type)
            except Exception:
                log.debug("user_feedback_detection_failed", exc_info=True)

        # Phase 1: Agent-Routing, Session, WM, Skills, Workspace
        (
            route_decision,
            session,
            wm,
            active_skill,
            agent_workspace,
            agent_name,
        ) = await self._resolve_agent_route(msg)

        # Phase 2: Profiler, Budget, Run-Recording, Policy-Snapshot
        run_id, budget_response = await self._prepare_execution_context(
            msg,
            session,
            wm,
            route_decision,
        )
        if budget_response is not None:
            return budget_response

        # Phase 2.3+2.5: Parallel ausführen (#43 Optimierung)
        # Context-Pipeline, Coding-Klassifizierung und Presearch sind unabhängig
        # voneinander und können parallel laufen.

        # Tool-Schemas (gefiltert nach Agent-Rechten) — synchron, schnell
        tool_schemas = self._mcp_client.get_tool_schemas() if self._mcp_client else {}
        if route_decision and route_decision.agent.has_tool_restrictions:
            tool_schemas = route_decision.agent.filter_tools(tool_schemas)

        # Subsystem checks
        if self._planner is None or self._gatekeeper is None or self._executor is None:
            raise RuntimeError("Gateway.initialize() must be called before handle_message()")

        async def _run_context_pipeline():
            if self._context_pipeline is not None:
                try:
                    ctx_result = await self._context_pipeline.enrich(msg.text, wm)
                    if not ctx_result.skipped:
                        log.info(
                            "context_enriched",
                            memories=len(ctx_result.memory_results),
                            vault=len(ctx_result.vault_snippets),
                            episodes=len(ctx_result.episode_snippets),
                            ms=f"{ctx_result.duration_ms:.1f}",
                        )
                except Exception:
                    log.warning("context_pipeline_failed", exc_info=True)

        async def _run_coding_classification():
            _is_coding = False
            _coding_model = ""
            _coding_complexity = "simple"
            try:
                _is_coding, _coding_complexity = await self._classify_coding_task(msg.text)
                if _is_coding and self._model_router:
                    if _coding_complexity == "complex":
                        _coding_model = self._model_router._config.models.coder.name
                    else:
                        _coding_model = self._model_router._config.models.coder_fast.name
                    # NOTE: Do NOT call set_coding_override() here — asyncio.create_task()
                    # runs in a copied context, so ContextVar changes are invisible to the
                    # parent. The override is applied in the parent after await (line below).
                    log.info(
                        "coding_task_detected", complexity=_coding_complexity, model=_coding_model
                    )
            except Exception:
                log.debug("coding_classification_skipped", exc_info=True)
            return _is_coding, _coding_model, _coding_complexity

        async def _run_presearch():
            return await self._maybe_presearch(msg, wm)

        import asyncio as _aio

        _ctx_task = _aio.create_task(_run_context_pipeline())
        _coding_task = _aio.create_task(_run_coding_classification())
        _presearch_task = _aio.create_task(_run_presearch())

        await _ctx_task  # Muss vor PGE fertig sein (modifiziert wm)
        is_coding, coding_model, coding_complexity = await _coding_task
        presearch_results = await _presearch_task

        # Apply coding override in parent context (ContextVar must be set here,
        # not inside the create_task — asyncio tasks get a copied context)
        if coding_model and self._model_router:
            self._model_router.set_coding_override(coding_model)

        # ── Sentiment Detection (Modul 3) ──
        try:
            from jarvis.core.sentiment import (
                Sentiment,
                detect_sentiment,
                get_sentiment_system_message,
            )

            sentiment_result = detect_sentiment(msg.text)
            if sentiment_result.sentiment != Sentiment.NEUTRAL:
                hint = get_sentiment_system_message(sentiment_result.sentiment)
                if hint:
                    wm.add_message(
                        Message(
                            role=MessageRole.SYSTEM,
                            content=hint,
                            channel=msg.channel,
                        )
                    )
                    log.info(
                        "sentiment_detected",
                        sentiment=sentiment_result.sentiment,
                        confidence=sentiment_result.confidence,
                        trigger=sentiment_result.trigger_phrase[:50],
                    )
        except Exception:
            log.debug("sentiment_detection_skipped", exc_info=True)

        # ── User Preferences (Modul 4) ──
        if hasattr(self, "_user_pref_store") and self._user_pref_store is not None:
            try:
                pref = self._user_pref_store.record_interaction(msg.user_id, len(msg.text))
                verbosity_hint = pref.verbosity_hint
                if verbosity_hint:
                    wm.add_message(
                        Message(
                            role=MessageRole.SYSTEM,
                            content=verbosity_hint,
                            channel=msg.channel,
                        )
                    )
            except Exception:
                log.debug("user_preferences_skipped", exc_info=True)

        all_results: list[ToolResult] = []
        all_plans: list[ActionPlan] = []
        all_audit: list[AuditEntry] = []

        # Hilfsfunktion: ToolEnforcer-State sicher aufraemen
        def _cleanup_skill_state() -> None:
            """Setzt active_skill und ToolEnforcer Call-Counter zurueck."""
            if hasattr(self._gatekeeper, "set_active_skill"):
                self._gatekeeper.set_active_skill(None)
            if (
                active_skill is not None
                and hasattr(self._gatekeeper, "_tool_enforcer")
                and self._gatekeeper._tool_enforcer is not None
                and hasattr(active_skill, "skill")
                and active_skill.skill is not None
            ):
                self._gatekeeper._tool_enforcer.reset_call_count(active_skill.skill.slug)

        try:
            if presearch_results:
                # Direktantwort aus Suchergebnissen generieren (PGE-Bypass)
                final_response = await self._answer_from_presearch(msg.text, presearch_results)
                if not final_response:
                    # Fallback: normaler PGE-Loop wenn Antwort-Generierung fehlschlug
                    if active_skill is not None and hasattr(self._gatekeeper, "set_active_skill"):
                        self._gatekeeper.set_active_skill(
                            active_skill.skill if hasattr(active_skill, "skill") else None,
                        )
                    final_response, all_results, all_plans, all_audit = await self._run_pge_loop(
                        msg,
                        session,
                        wm,
                        tool_schemas,
                        route_decision,
                        agent_workspace,
                        run_id,
                    )
                else:
                    log.info("presearch_bypass_used", response_chars=len(final_response))
            else:
                # Phase 3: PGE-Loop (regulaerer Ablauf)
                # Community-Skill ToolEnforcer: Aktiven Skill an Gatekeeper weiterreichen
                if active_skill is not None and hasattr(self._gatekeeper, "set_active_skill"):
                    self._gatekeeper.set_active_skill(
                        active_skill.skill if hasattr(active_skill, "skill") else None,
                    )
                final_response, all_results, all_plans, all_audit = await self._run_pge_loop(
                    msg,
                    session,
                    wm,
                    tool_schemas,
                    route_decision,
                    agent_workspace,
                    run_id,
                )
        finally:
            _cleanup_skill_state()

        # Coding-Override aufraeumen
        if self._model_router:
            self._model_router.clear_coding_override()

        # User- und Antwort-Nachricht in Working Memory speichern (nach PGE-Loop)
        wm.add_message(Message(role=MessageRole.USER, content=msg.text, channel=msg.channel))

        # Wichtige Tool-Ergebnisse als TOOL-Messages in Chat-History persistieren,
        # damit Folge-Requests den vollen Kontext haben (z.B. Vision-Text für PDF-Export)
        self._persist_key_tool_results(wm, all_results)

        wm.add_message(Message(role=MessageRole.ASSISTANT, content=final_response))

        # Phase 4: Reflexion, Skill-Tracking, Telemetry, Profiler, Run-Recording
        agent_result = AgentResult(
            response=final_response,
            plans=all_plans,
            tool_results=all_results,
            audit_entries=all_audit,
            total_iterations=session.iteration_count,
            total_duration_ms=int((time.monotonic() - _handle_start) * 1000),
            model_used=coding_model
            if is_coding
            else (
                self._model_router.select_model("planning", "high") if self._model_router else ""
            ),
            success=not any(r.is_error for r in all_results) if all_results else True,
        )
        await self._run_post_processing(session, wm, agent_result, active_skill, run_id)

        # Phase 5: Session persistieren
        await self._persist_session(session, wm)

        # Prometheus: Request-Dauer und Token-Metriken
        _duration_ms = (time.monotonic() - _handle_start) * 1000
        self._record_metric("request_duration_ms", _duration_ms, channel=msg.channel)
        _model_used = agent_result.model_used or ""
        if _model_used:
            self._record_metric("tokens_used_total", 1, model=_model_used, role="request")

        # Attachments aus Tool-Ergebnissen extrahieren (z.B. document_export)
        attachments = self._extract_attachments(all_results)

        return OutgoingMessage(
            channel=msg.channel,
            text=final_response,
            session_id=session.session_id,
            is_final=True,
            attachments=attachments,
        )

    # ── handle_message sub-methods ────────────────────────────────

    async def _resolve_agent_route(
        self,
        msg: IncomingMessage,
    ) -> tuple[RouteDecision | None, SessionContext, WorkingMemory, Any, Any, str]:
        """Phase 1: Agent-Routing, Session, Working Memory, Skills, Workspace."""
        route_decision = None
        agent_workspace = None
        agent_name = "jarvis"

        if self._agent_router is not None:
            target_agent = msg.metadata.get("target_agent")
            if target_agent:
                target_profile = self._agent_router.get_agent(target_agent)
                if target_profile:
                    route_decision = RouteDecision(
                        agent=target_profile,
                        confidence=1.0,
                        reason=f"Explicit target: {target_agent}",
                    )
                    log.info(
                        "agent_explicit_target",
                        agent=target_agent,
                        source=msg.metadata.get("cron_job", "delegation"),
                    )

            if route_decision is None:
                from jarvis.core.bindings import MessageContext as _MsgCtx

                msg_context = _MsgCtx.from_incoming(msg)
                route_decision = self._agent_router.route(
                    msg.text,
                    context=msg_context,
                )

            agent_name = route_decision.agent.name

        session = self._get_or_create_session(msg.channel, msg.user_id, agent_name)
        session.touch()
        session.reset_iteration()

        wm = self._get_or_create_working_memory(session)
        wm.clear_for_new_request()

        if self._audit_logger:
            self._audit_logger.log_user_input(
                msg.channel,
                msg.text[:100],
                agent_name=agent_name,
            )

        if route_decision and route_decision.agent.system_prompt:
            wm.add_message(
                Message(
                    role=MessageRole.SYSTEM,
                    content=route_decision.agent.system_prompt,
                    channel=msg.channel,
                )
            )

        # Gap Detection: Erkennung expliziter Tool-/Skill-Erstellungswünsche
        if hasattr(self, "_skill_generator") and self._skill_generator:
            _lower = msg.text.lower()
            _tool_request_triggers = (
                "erstelle ein tool",
                "erstelle einen skill",
                "baue ein tool",
                "create a tool",
                "build a tool",
                "neues tool",
                "neuer skill",
                "tool erstellen",
                "skill erstellen",
                "ich brauche ein tool",
                "kannst du ein tool",
                "mach ein tool",
            )
            for trigger in _tool_request_triggers:
                if trigger in _lower:
                    self._skill_generator.gap_detector.report_user_request(
                        msg.text[:200],
                        context=msg.text,
                    )
                    break

        active_skill = None
        if self._skill_registry is not None:
            try:
                tool_list = self._mcp_client.get_tool_list() if self._mcp_client else []
                active_skill = self._skill_registry.inject_into_working_memory(
                    msg.text,
                    wm,
                    available_tools=tool_list,
                )
                # Gap Detection: Melde wenn kein Skill zur Anfrage passt
                if (
                    active_skill is None
                    and hasattr(self, "_skill_generator")
                    and self._skill_generator
                ):
                    self._skill_generator.gap_detector.report_no_skill_match(msg.text)
            except Exception as exc:
                log.debug("skill_match_error", error=str(exc))

        if self._agent_router is not None and route_decision:
            agent_workspace = self._agent_router.resolve_agent_workspace(
                route_decision.agent.name,
                self._config.workspace_dir,
            )
            log.debug(
                "agent_workspace_resolved",
                agent=route_decision.agent.name,
                workspace=str(agent_workspace),
                shared=route_decision.agent.shared_workspace,
            )

        return route_decision, session, wm, active_skill, agent_workspace, agent_name

    async def _prepare_execution_context(
        self,
        msg: IncomingMessage,
        session: SessionContext,
        wm: WorkingMemory,
        route_decision: RouteDecision | None,
    ) -> tuple[str | None, OutgoingMessage | None]:
        """Phase 2: Profiler, Budget, Run-Recording, Policy-Snapshot.

        Returns:
            (run_id, budget_response) -- budget_response is not None if budget exceeded.
        """
        if hasattr(self, "_task_profiler") and self._task_profiler:
            try:
                self._task_profiler.start_task(
                    session_id=session.session_id,
                    task_description=msg.text[:200],
                )
            except Exception:
                log.debug("task_profiler_start_failed", exc_info=True)

        if hasattr(self, "_cost_tracker") and self._cost_tracker:
            try:
                budget = self._cost_tracker.check_budget()
                if not budget.ok:
                    return None, OutgoingMessage(
                        channel=msg.channel,
                        text=f"Budget-Limit erreicht: {budget.warning}",
                        session_id=session.session_id,
                        is_final=True,
                    )
            except Exception:
                log.debug("budget_check_failed", exc_info=True)

        run_id = None
        if hasattr(self, "_run_recorder") and self._run_recorder:
            try:
                run_id = self._run_recorder.start_run(
                    session_id=session.session_id,
                    user_message=msg.text[:500],
                    operation_mode=str(getattr(self._config, "resolved_operation_mode", "")),
                )
            except Exception:
                log.debug("run_recorder_start_failed", exc_info=True)

        if run_id and self._run_recorder and self._gatekeeper:
            try:
                policies = self._gatekeeper.get_policies()
                if policies:
                    self._run_recorder.record_policy_snapshot(
                        run_id, {"rules": [r.model_dump() for r in policies]}
                    )
            except Exception:
                log.debug("run_recorder_policy_snapshot_failed", exc_info=True)

        return run_id, None

    def _make_status_callback(
        self,
        channel_name: str,
        session_id: str,
    ) -> Any:
        """Creates a fire-and-forget status callback for the current channel.

        Returns an async callable (status_type: str, text: str) -> None.
        """

        async def _send_status(status_type: str, text: str) -> None:
            channel = self._channels.get(channel_name)
            if channel is None:
                return
            try:
                from jarvis.channels.base import StatusType

                try:
                    st = StatusType(status_type) if isinstance(status_type, str) else status_type
                except ValueError:
                    st = StatusType.PROCESSING
                await asyncio.wait_for(
                    channel.send_status(session_id, st, text),
                    timeout=2.0,
                )
            except Exception:
                log.debug("status_send_failed", exc_info=True)  # fire-and-forget

        return _send_status

    async def _run_pge_loop(
        self,
        msg: IncomingMessage,
        session: SessionContext,
        wm: WorkingMemory,
        tool_schemas: dict[str, Any],
        route_decision: RouteDecision | None,
        agent_workspace: Any,
        run_id: str | None,
    ) -> tuple[str, list[ToolResult], list[ActionPlan], list[AuditEntry]]:
        """Phase 3: Plan → Gate → Execute Loop.

        Returns:
            (final_response, all_results, all_plans, all_audit)
        """
        all_results: list[ToolResult] = []
        all_plans: list[ActionPlan] = []
        all_audit: list[AuditEntry] = []
        final_response = ""
        _consecutive_no_tool_iters = 0  # Detect stuck replan loops
        _max_no_tool_iters = 2  # After 2 iters without tool execution, stop

        # Status callback for progress feedback
        _status_cb = self._make_status_callback(msg.channel, session.session_id)

        while not session.iterations_exhausted and self._running:
            session.iteration_count += 1

            # Token-Budget prüfen und ggf. kompaktieren
            self._check_and_compact(wm, session)

            log.info(
                "agent_loop_iteration",
                iteration=session.iteration_count,
                session=session.session_id[:8],
                chat_messages=len(wm.chat_history),
                token_estimate=wm.token_count,
            )

            # Status: Thinking
            await _status_cb("thinking", "Thinking...")

            # Planner
            if session.iteration_count == 1:
                plan = await self._planner.plan(
                    user_message=msg.text,
                    working_memory=wm,
                    tool_schemas=tool_schemas,
                )
            else:
                plan = await self._planner.replan(
                    original_goal=msg.text,
                    results=all_results,
                    working_memory=wm,
                    tool_schemas=tool_schemas,
                )

            all_plans.append(plan)

            if run_id and self._run_recorder:
                try:
                    self._run_recorder.record_plan(run_id, plan)
                except Exception:
                    log.debug("run_recorder_plan_failed", exc_info=True)

            # JSON parse failed even after retry — inform user instead of
            # silently returning garbled LLM output as a "direct response"
            if getattr(plan, "parse_failed", False):
                log.warning(
                    "pge_plan_parse_failed",
                    iteration=session.iteration_count,
                    confidence=plan.confidence,
                )
                final_response = (
                    "I couldn't process the language model's response correctly. "
                    "Please try again or rephrase your request."
                )
                break

            # Direkte Antwort — but detect REPLAN text masquerading as response
            if not plan.has_actions and plan.direct_response:
                _resp = plan.direct_response.strip()
                # If the LLM returned REPLAN reasoning instead of a real answer
                # or a JSON plan, it's stuck — don't echo it to the user.
                _is_replan_text = (
                    _resp.startswith("REPLAN")
                    or _resp.startswith("KORRIGIERTER PLAN")
                    or _resp.startswith("BETROFFENE SCHRITTE")
                    or _resp.startswith("AKTUALISIERTE RISIKOBEWERTUNG")
                    or "REPLAN-GRUND" in _resp[:200]
                    or "CORRECTED PLAN" in _resp[:200]
                )
                if _is_replan_text:
                    _consecutive_no_tool_iters += 1
                    log.warning(
                        "pge_replan_text_as_response",
                        iteration=session.iteration_count,
                        no_tool_streak=_consecutive_no_tool_iters,
                        preview=_resp[:100],
                    )
                    # On first iteration with no tool results, the LLM is
                    # hallucinating REPLAN text for a conversational message.
                    # Don't retry — immediately formulate a direct response.
                    if session.iteration_count == 1 and not all_results:
                        await _status_cb("finishing", "Composing response...")
                        final_response = await self._planner.formulate_response(
                            user_message=msg.text,
                            results=[],
                            working_memory=wm,
                        )
                        break
                    # Allow max 2 replan-text retries, then break
                    if (
                        _consecutive_no_tool_iters < _max_no_tool_iters
                        and session.iteration_count < session.max_iterations
                    ):
                        continue
                    # Stuck — never send raw REPLAN text to the user
                    if all_results and any(r.success for r in all_results):
                        await _status_cb("finishing", "Composing response...")
                        final_response = await self._planner.formulate_response(
                            user_message=msg.text,
                            results=all_results,
                            working_memory=wm,
                        )
                    else:
                        final_response = (
                            "I'm stuck in a planning loop and can't make progress. "
                            "Please try rephrasing your request more concretely — e.g. "
                            "'Write a Pac-Man main.py' instead of 'Create a game'."
                        )
                    break

                # If we already have successful tool results but the replan
                # returned text instead of JSON, formulate a proper response
                if all_results and any(r.success for r in all_results):
                    await _status_cb("finishing", "Composing response...")
                    final_response = await self._planner.formulate_response(
                        user_message=msg.text,
                        results=all_results,
                        working_memory=wm,
                    )
                    break

                final_response = plan.direct_response
                break

            if not plan.has_actions:
                # If there are prior successful results, summarize them
                if all_results and any(r.success for r in all_results):
                    await _status_cb("finishing", "Composing response...")
                    final_response = await self._planner.formulate_response(
                        user_message=msg.text,
                        results=all_results,
                        working_memory=wm,
                    )
                    break
                final_response = (
                    "I couldn't create a plan for this. Could you rephrase your question?"
                )
                break

            # Gatekeeper
            decisions = self._gatekeeper.evaluate_plan(plan.steps, session)

            for step, decision in zip(plan.steps, decisions, strict=False):
                params_hash = hashlib.sha256(
                    _json.dumps(step.params, sort_keys=True, default=str).encode()
                ).hexdigest()
                all_audit.append(
                    AuditEntry(
                        session_id=session.session_id,
                        action_tool=step.tool,
                        action_params_hash=params_hash,
                        decision_status=decision.status,
                        decision_reason=decision.reason,
                    )
                )

            # Approvals
            approved_decisions = await self._handle_approvals(
                plan.steps,
                decisions,
                session,
                msg.channel,
            )

            all_blocked = all(d.status == GateStatus.BLOCK for d in approved_decisions)
            if all_blocked:
                for step, decision in zip(plan.steps, approved_decisions, strict=False):
                    block_count = session.record_block(step.tool)
                    if block_count >= 3:
                        escalation = await self._planner.generate_escalation(
                            tool=step.tool,
                            reason=decision.reason,
                            working_memory=wm,
                        )
                        final_response = escalation
                        break
                else:
                    try:
                        from jarvis.utils.error_messages import all_actions_blocked_message

                        final_response = all_actions_blocked_message(plan.steps, approved_decisions)
                    except Exception:
                        final_response = "All planned actions were blocked by the Gatekeeper."
                break

            # Status: Tool-specific progress message
            for step in plan.steps:
                tool_status = _TOOL_STATUS_MAP.get(step.tool, f"Running {step.tool}...")
                await _status_cb("executing", tool_status)
                break  # Only send the first tool's status

            # Set status callback on executor for retry visibility
            self._executor.set_status_callback(_status_cb)

            # Executor
            if route_decision and route_decision.agent.name != "jarvis":
                self._executor.set_agent_context(
                    workspace_dir=str(agent_workspace) if agent_workspace else None,
                    sandbox_overrides=route_decision.agent.get_sandbox_config(),
                    agent_name=route_decision.agent.name,
                    session_id=session.session_id,
                )
            else:
                self._executor.set_agent_context(session_id=session.session_id)

            try:
                results = await self._executor.execute(plan.steps, approved_decisions)
            finally:
                self._executor.clear_agent_context()

            if run_id and self._run_recorder:
                try:
                    self._run_recorder.record_gate_decisions(run_id, approved_decisions)
                    self._run_recorder.record_tool_results(run_id, results)
                except Exception:
                    log.debug("run_recorder_results_failed", exc_info=True)

            all_results.extend(results)

            for result in results:
                all_audit.append(
                    AuditEntry(
                        session_id=session.session_id,
                        action_tool=result.tool_name,
                        action_params_hash="",
                        decision_status=GateStatus.ALLOW,
                        decision_reason=f"executed success={result.success}",
                        execution_result="ok"
                        if result.success
                        else result.error_message or "error",
                    )
                )
                # Prometheus: Tool-Aufruf-Metriken
                self._record_metric("tool_calls_total", 1, tool_name=result.tool_name)
                if hasattr(result, "duration_ms") and result.duration_ms:
                    self._record_metric(
                        "tool_duration_ms",
                        result.duration_ms,
                        tool_name=result.tool_name,
                    )
                if result.is_error:
                    self._record_metric(
                        "errors_total",
                        1,
                        channel=msg.channel,
                        error_type="tool_error",
                    )

            for result in results:
                wm.add_tool_result(result)

            has_errors = any(r.is_error for r in results)
            has_success = any(r.success for r in results)

            # Track consecutive iterations without any tool execution
            if results:
                _consecutive_no_tool_iters = 0
            else:
                _consecutive_no_tool_iters += 1
                if _consecutive_no_tool_iters >= _max_no_tool_iters:
                    log.warning("pge_stuck_no_tools", iterations=session.iteration_count)
                    if all_results and any(r.success for r in all_results):
                        await _status_cb("finishing", "Composing response...")
                        final_response = await self._planner.formulate_response(
                            user_message=msg.text,
                            results=all_results,
                            working_memory=wm,
                        )
                    else:
                        final_response = (
                            "I'm stuck in a planning loop without making progress. "
                            "Please try a more specific request — e.g. "
                            "'Write a Pac-Man main.py' instead of 'Create a game'."
                        )
                    break

            # Pruefen ob der Plan MEHRERE Schritte hatte (Multi-Step-Task)
            _current_plan = all_plans[-1] if all_plans else None
            _is_multi_step = (
                _current_plan is not None
                and hasattr(_current_plan, "steps")
                and len(_current_plan.steps) > 1
            )

            # Coding-Tools: Nicht sofort breaken -- Replan entscheidet
            # ob weitere Schritte noetig sind (Code testen, analysieren, fixen)
            _coding_tools = {
                "run_python",
                "exec_command",
                "write_file",
                "edit_file",
                "analyze_code",
            }
            used_coding_tool = any(r.tool_name in _coding_tools for r in results)

            # ── Break conditions ─────────────────────────────────────────
            # Single-step non-coding tasks: respond immediately after success
            if has_success and not has_errors and not used_coding_tool and not _is_multi_step:
                await _status_cb("finishing", "Composing response...")
                final_response = await self._planner.formulate_response(
                    user_message=msg.text,
                    results=all_results,
                    working_memory=wm,
                )
                break

            # Multi-step / coding tasks: let replan decide if more steps needed.
            # Caps scale with user's max_iterations setting to prevent infinite loops
            # while respecting the configured iteration budget.
            _successful_iters = sum(1 for r in all_results if r.success)
            # Scale coding cap: 80% of max_iterations (min 4, reserve room for formulate)
            _max_coding_iters = max(4, int(session.max_iterations * 0.8))
            _max_coding_iters = min(_max_coding_iters, session.max_iterations - 1)
            # Scale success threshold: ~30% of max_iterations (min 3)
            _success_threshold = max(3, int(session.max_iterations * 0.3))
            if (
                has_success
                and (used_coding_tool or _is_multi_step)
                and (
                    session.iteration_count >= _max_coding_iters
                    or _successful_iters >= _success_threshold
                )
            ):
                await _status_cb("finishing", "Composing response...")
                final_response = await self._planner.formulate_response(
                    user_message=msg.text,
                    results=all_results,
                    working_memory=wm,
                )
                break
                # Otherwise: continue to replan for more steps (normal)

            # Failure-Threshold: give planner room for alternative strategies
            _failure_threshold = max(3, session.max_iterations // 2)
            if not has_success and session.iteration_count >= _failure_threshold:
                await _status_cb("finishing", "Composing response...")
                final_response = await self._planner.formulate_response(
                    user_message=msg.text,
                    results=all_results,
                    working_memory=wm,
                )
                break

        if session.iterations_exhausted and not final_response:
            final_response = (
                "I've reached the maximum number of processing steps "
                "without fully completing the task. "
                "Please try a more specific request or break the task "
                "into smaller steps — happy to help!"
            )

        return final_response, all_results, all_plans, all_audit

    async def _run_post_processing(
        self,
        session: SessionContext,
        wm: WorkingMemory,
        agent_result: AgentResult,
        active_skill: Any,
        run_id: str | None,
    ) -> None:
        """Phase 4: Reflection, Skill-Tracking, Telemetry, Profiler, Run-Recording."""
        if self._reflector and self._reflector.should_reflect(agent_result):
            try:
                reflection = await self._reflector.reflect(session, wm, agent_result)
                agent_result.reflection = reflection
                log.info(
                    "reflection_done",
                    session=session.session_id[:8],
                    score=reflection.success_score,
                )
                # Apply reflection to memory tiers (episodic, semantic, procedural)
                if self._memory_manager:
                    try:
                        counts = await self._reflector.apply(reflection, self._memory_manager)
                        log.info(
                            "reflection_applied",
                            session=session.session_id[:8],
                            episodic=counts.get("episodic", 0),
                            semantic=counts.get("semantic", 0),
                            procedural=counts.get("procedural", 0),
                        )
                    except Exception as apply_exc:
                        log.error("reflection_apply_error", error=str(apply_exc))
                if run_id and self._run_recorder:
                    try:
                        self._run_recorder.record_reflection(run_id, reflection)
                    except Exception:
                        log.debug("run_recorder_reflection_failed", exc_info=True)
            except Exception as exc:
                log.error("reflection_error", error=str(exc))

        if active_skill and self._skill_registry:
            try:
                success = agent_result.success
                score = (
                    agent_result.reflection.success_score
                    if agent_result.reflection
                    else (0.8 if success else 0.3)
                )
                self._skill_registry.record_usage(
                    active_skill.skill.slug,
                    success=success,
                    score=score,
                )
                # Failure-Pattern in Prozedur speichern (für Lerneffekt)
                if not success and self._memory_manager and active_skill.procedure_name:
                    try:
                        error_summary = (
                            agent_result.error[:200] if agent_result.error else "unknown"
                        )
                        self._memory_manager.procedural.add_failure_pattern(
                            active_skill.procedure_name,
                            error_summary,
                        )
                    except Exception:
                        log.debug("procedure_failure_pattern_save_failed", exc_info=True)

                # Gap Detection: Melde niedrige Erfolgsrate
                if not success and hasattr(self, "_skill_generator") and self._skill_generator:
                    try:
                        skill_obj = active_skill.skill
                        if skill_obj.total_uses >= 3 and skill_obj.total_uses > 0:
                            success_rate = skill_obj.success_count / skill_obj.total_uses
                            if success_rate < 0.4:
                                self._skill_generator.gap_detector.report_low_success_rate(
                                    skill_obj.slug,
                                    success_rate,
                                )
                    except Exception:
                        log.debug("skill_gap_detection_failed", exc_info=True)
            except Exception:
                log.debug("skill_usage_tracking_skipped", exc_info=True)

        if hasattr(self, "_task_telemetry") and self._task_telemetry:
            try:
                all_results = agent_result.tool_results
                tools_used = [r.tool_name for r in all_results]
                error_type = ""
                error_msg = ""
                for r in all_results:
                    if r.is_error:
                        error_type = r.error_type or ""
                        error_msg = r.content[:200]
                        break
                self._task_telemetry.record_task(
                    session_id=session.session_id,
                    success=agent_result.success,
                    duration_ms=float(agent_result.total_duration_ms),
                    tool_calls=tools_used,
                    error_type=error_type,
                    error_message=error_msg,
                )
            except Exception:
                log.debug("task_telemetry_record_failed", exc_info=True)

        if hasattr(self, "_task_profiler") and self._task_profiler:
            try:
                score = (
                    agent_result.reflection.success_score
                    if agent_result.reflection
                    else (0.8 if agent_result.success else 0.3)
                )
                self._task_profiler.finish_task(
                    session_id=session.session_id,
                    success_score=score,
                )
            except Exception:
                log.debug("task_profiler_finish_failed", exc_info=True)

        if run_id and hasattr(self, "_run_recorder") and self._run_recorder:
            try:
                self._run_recorder.finish_run(
                    run_id,
                    success=agent_result.success,
                    final_response=agent_result.response[:500],
                )
            except Exception:
                log.debug("run_recorder_finish_failed", exc_info=True)

        # Prompt-Evolution: Record session reward for A/B testing
        if getattr(self, "_prompt_evolution", None) and self._planner:
            try:
                version_id = getattr(self._planner, "_current_prompt_version_id", None)
                if version_id:
                    reward_score = (
                        agent_result.reflection.success_score
                        if agent_result.reflection
                        else (0.8 if agent_result.success else 0.3)
                    )
                    self._prompt_evolution.record_session(
                        session_id=session.session_id,
                        prompt_version_id=version_id,
                        reward=reward_score,
                    )
            except Exception:
                log.debug("prompt_evolution_record_failed", exc_info=True)

        # Session-Analyse: Failure-Clustering und Feedback-Loop
        if getattr(self, "_session_analyzer", None):
            try:
                improvements = await self._session_analyzer.analyze_session(
                    session_id=session.session_id,
                    agent_result=agent_result,
                    reflection=agent_result.reflection,
                )
                for imp in improvements:
                    log.info(
                        "session_improvement_proposed",
                        action=imp.action_type,
                        target=imp.target,
                        priority=imp.priority,
                    )
            except Exception:
                log.debug("session_analysis_failed", exc_info=True)

        # Self-Learning: Process actionable skill gaps (auto-generate new tools)
        if hasattr(self, "_skill_generator") and self._skill_generator:
            try:
                generated = await self._skill_generator.process_all_gaps(
                    skill_registry=self._skill_registry
                    if hasattr(self, "_skill_registry")
                    else None,
                )
                newly_registered = False
                for skill in generated:
                    log.info(
                        "skill_auto_generated",
                        name=skill.name,
                        status=skill.status.value,
                        version=skill.version,
                    )
                    if skill.status.value == "registered":
                        newly_registered = True
                # CORE.md aktualisieren wenn neue Skills registriert wurden
                if newly_registered:
                    try:
                        self._sync_core_inventory()
                    except Exception:
                        log.debug("core_inventory_sync_after_skill_gen_failed", exc_info=True)
            except Exception:
                log.debug("skill_gap_processing_failed", exc_info=True)

    async def _persist_session(
        self,
        session: SessionContext,
        wm: WorkingMemory,
    ) -> None:
        """Phase 5: Session persistieren."""
        if self._session_store:
            try:
                self._session_store.save_session(session)
                self._session_store.save_chat_history(
                    session.session_id,
                    wm.chat_history,
                )
            except Exception as exc:
                log.warning("session_persist_error", error=str(exc))

    # =========================================================================
    # Agent-zu-Agent Delegation
    # =========================================================================

    async def execute_delegation(
        self,
        from_agent: str,
        to_agent: str,
        task: str,
        session: SessionContext,
        parent_wm: WorkingMemory,
    ) -> str:
        """Führt eine echte Agent-zu-Agent-Delegation aus.

        Der delegierte Agent bekommt:
          - Eigenen System-Prompt
          - Eigenen Workspace (isoliert)
          - Eigene Sandbox-Config
          - Eigene Tool-Filterung
          - Die Aufgabe als User-Nachricht

        Das Ergebnis fließt als Text zurück zum aufrufenden Agenten.

        Args:
            from_agent: Name des delegierenden Agenten.
            to_agent: Name des Ziel-Agenten.
            task: Die delegierte Aufgabe.
            session: Aktuelle Session.
            parent_wm: Working Memory des Eltern-Agenten.

        Returns:
            Ergebnis-Text der Delegation.
        """
        if not self._agent_router:
            return f"Agent router unavailable. Delegation to {to_agent} failed."

        # Delegation erstellen und validieren
        delegation = self._agent_router.create_delegation(from_agent, to_agent, task)
        if delegation is None:
            return (
                f"Delegation from {from_agent} to {to_agent} not allowed. "
                f"I'll handle the task myself."
            )

        target = delegation.target_profile
        if not target:
            return f"Agent {to_agent} not found."

        log.info(
            "delegation_executing",
            from_=from_agent,
            to=to_agent,
            task=task[:200],
            depth=delegation.depth,
        )

        # Eigene Working Memory für delegierten Agenten
        sub_wm = WorkingMemory(session_id=session.session_id)

        # System-Prompt des Ziel-Agenten injizieren
        if target.system_prompt:
            sub_wm.add_message(
                Message(
                    role=MessageRole.SYSTEM,
                    content=target.system_prompt,
                )
            )

        # Aufgabe als User-Nachricht
        sub_wm.add_message(
            Message(
                role=MessageRole.USER,
                content=task,
            )
        )

        # Workspace des Ziel-Agenten auflösen
        target_workspace = self._agent_router.resolve_agent_workspace(
            to_agent,
            self._config.workspace_dir,
        )

        # Tool-Schemas für Ziel-Agenten filtern
        tool_schemas = self._mcp_client.get_tool_schemas() if self._mcp_client else {}
        if target.has_tool_restrictions:
            tool_schemas = target.filter_tools(tool_schemas)

        # Planner mit Ziel-Agent-Kontext aufrufen
        if self._planner is None:
            raise RuntimeError("Planner nicht initialisiert -- Delegation nicht möglich")

        plan = await self._planner.plan(
            user_message=task,
            working_memory=sub_wm,
            tool_schemas=tool_schemas,
        )

        # Direkte Antwort?
        if not plan.has_actions and plan.direct_response:
            delegation.result = plan.direct_response
            delegation.success = True
            return plan.direct_response

        if not plan.has_actions:
            delegation.result = "Kein Plan erstellt."
            delegation.success = False
            return delegation.result

        # Gatekeeper prüfen
        if self._gatekeeper is None:
            raise RuntimeError("Gatekeeper nicht initialisiert -- Delegation nicht möglich")
        decisions = self._gatekeeper.evaluate_plan(plan.steps, session)

        # APPROVE/BLOCK-Entscheidungen in Delegationen blockieren (kein HITL moeglich)
        blocked = [d for d in decisions if d.status in (GateStatus.APPROVE, GateStatus.BLOCK)]
        if blocked:
            reasons = "; ".join(d.reason for d in blocked[:3])
            delegation.result = f"Delegation blockiert: {reasons}"
            delegation.success = False
            return delegation.result

        # Executor mit Ziel-Agent-Kontext
        assert self._executor is not None
        self._executor.set_agent_context(
            workspace_dir=str(target_workspace),
            sandbox_overrides=target.get_sandbox_config(),
            agent_name=target.name,
        )

        try:
            results = await self._executor.execute(plan.steps, decisions)
        finally:
            self._executor.clear_agent_context()

        # Ergebnis formulieren
        if any(r.success for r in results):
            response = await self._planner.formulate_response(
                user_message=task,
                results=results,
                working_memory=sub_wm,
            )
            delegation.result = response
            delegation.success = True
        else:
            delegation.result = "Delegation failed: no successful actions."
            delegation.success = False

        log.info(
            "delegation_complete",
            from_=from_agent,
            to=to_agent,
            success=delegation.success,
            result_len=len(delegation.result or ""),
        )

        return delegation.result or ""

    # =========================================================================
    # Private Methoden
    # =========================================================================

    # Tools deren Ergebnisse für Folge-Requests in der Chat-History bleiben sollen.
    # Ohne diese Persistierung geht der Kontext (z.B. extrahierter Text aus Bildern)
    # bei clear_for_new_request() verloren.
    _CONTEXT_TOOLS: frozenset[str] = frozenset(
        {
            "media_analyze_image",
            "media_extract_text",
            "media_transcribe_audio",
            "analyze_code",
            "run_python",
            "web_search",
            "web_fetch",
            "search_and_read",
        }
    )
    # Maximale Zeichenzahl für persistierte Tool-Ergebnisse in Chat-History
    _CONTEXT_RESULT_LIMIT: int = 4000

    def _persist_key_tool_results(
        self,
        wm: WorkingMemory,
        results: list[ToolResult],
    ) -> None:
        """Persistiert wichtige Tool-Ergebnisse als TOOL-Messages in der Chat-History.

        Damit behält der Planner bei Folge-Requests den vollen Kontext,
        z.B. extrahierter Text aus Bildern, Analyse-Ergebnisse, Suchergebnisse.
        """
        for result in results:
            if not result.success:
                continue
            if result.tool_name not in self._CONTEXT_TOOLS:
                continue
            if not result.content.strip():
                continue

            content = result.content[: self._CONTEXT_RESULT_LIMIT]
            if len(result.content) > self._CONTEXT_RESULT_LIMIT:
                content += "\n[... gekürzt]"

            wm.add_message(
                Message(
                    role=MessageRole.TOOL,
                    content=content,
                    name=result.tool_name,
                )
            )
            log.debug(
                "tool_result_persisted",
                tool=result.tool_name,
                chars=len(content),
            )

    # Tools whose results are file paths that should be attached to the response
    _ATTACHMENT_TOOLS: frozenset[str] = frozenset(
        {
            "document_export",
        }
    )
    # File extensions considered valid attachments
    _ATTACHMENT_EXTENSIONS: frozenset[str] = frozenset(
        {
            ".pdf",
            ".docx",
            ".doc",
            ".xlsx",
            ".csv",
            ".png",
            ".jpg",
            ".jpeg",
            ".gif",
        }
    )

    # ── Prometheus Metric Recording ──────────────────────────────

    def _record_metric(self, name: str, value: float, **labels: str) -> None:
        """Zeichnet eine Metrik auf (wenn MonitoringHub oder TelemetryHub verfügbar).

        Schreibt in beide Subsysteme wenn vorhanden:
          - MonitoringHub.metrics (MetricCollector) -- für Dashboard + Prometheus
          - TelemetryHub.metrics (MetricsProvider)  -- für OTLP + Prometheus
        """
        # MetricCollector (gateway/monitoring.py)
        hub = getattr(self, "_monitoring_hub", None)
        if hub is not None:
            collector = getattr(hub, "metrics", None)
            if collector is not None:
                try:
                    collector.increment(name, value, **labels)
                except Exception:
                    log.debug("metric_collector_failed", metric=name, exc_info=True)

        # MetricsProvider (telemetry/metrics.py) via TelemetryHub
        telemetry = getattr(self, "_telemetry_hub", None)
        if telemetry is not None:
            provider = getattr(telemetry, "metrics", None)
            if provider is not None:
                try:
                    # Determine metric type based on name
                    if name.endswith("_ms"):
                        provider.histogram(name, value, **labels)
                    else:
                        provider.counter(name, value, **labels)
                except Exception:
                    log.debug("metric_provider_failed", metric=name, exc_info=True)

    def _extract_attachments(self, results: list[ToolResult]) -> list[str]:
        """Extrahiert Dateipfade aus Tool-Ergebnissen für den Anhang-Versand.

        Prüft ob das Tool-Ergebnis einen gültigen Dateipfad enthält und ob
        die Datei existiert.
        """
        from pathlib import Path

        attachments: list[str] = []
        for result in results:
            if not result.success:
                continue
            if result.tool_name not in self._ATTACHMENT_TOOLS:
                continue
            # content enthält den Dateipfad
            candidate = result.content.strip()
            if not candidate:
                continue
            try:
                path = Path(candidate)
                if (
                    path.exists()
                    and path.is_file()
                    and path.suffix.lower() in self._ATTACHMENT_EXTENSIONS
                ):
                    attachments.append(str(path))
                    log.info("attachment_detected", tool=result.tool_name, path=str(path))
            except (ValueError, OSError):
                continue
        return attachments

    # ── Automatische Vor-Suche für Faktenfragen ────────────────────

    # Regex-Patterns für Faktenfragen (Wann/Wo/Wer/Was + Verb)
    _FACT_QUESTION_PATTERNS: ClassVar[list[re.Pattern[str]]] = [
        re.compile(
            r"\b(wann|wo|wer|was|wie viele|welche[rsmn]?)\b"
            r".{3,}"
            r"(hat|haben|ist|sind|wurde|wurden"
            r"|war|waren|gibt|gab|passiert|geschehen"
            r"|entführ|verhaft|angegriff|getötet"
            r"|gestorben|gewählt|gestürzt"
            r"|finde[nt]?|stattfinde[nt]?|statt"
            r"|spiele[nt]?|laufe[nt]?|läuft"
            r"|komm[ent]?|beginne[nt]?|beginn"
            r"|anfange[nt]?|fängt|endet"
            r"|aufgetreten|gestartet|eröffnet"
            r"|erschien|veröffentlich)",
            re.IGNORECASE,
        ),
        re.compile(
            r"\b(when|where|who|what|how many|which)\b.{3,}(did|has|have|was|were|is|are|happened)",
            re.IGNORECASE,
        ),
        re.compile(
            r"\b(stimmt es|ist es wahr|hat .+ wirklich)\b",
            re.IGNORECASE,
        ),
    ]

    # Begriffe die KEINE Faktenfrage signalisieren (Smalltalk, Meinungen)
    _SKIP_PRESEARCH_PATTERNS: ClassVar[list[re.Pattern[str]]] = [
        re.compile(
            r"\b(meinst du|findest du|was denkst du|erkläre mir|was ist ein|definier)",
            re.IGNORECASE,
        ),
        # Trailing \b entfernt: "erstell" muss auch "erstelle/erstellst/erstellen" matchen
        re.compile(
            r"\b(schreib|erstell|generier|mach|öffne|lösch|speicher|such im memory)", re.IGNORECASE
        ),
    ]

    def _is_fact_question(self, text: str) -> bool:
        """Prüft ob eine Nachricht eine Faktenfrage ist, die Web-Recherche braucht."""
        # Zu kurz → wahrscheinlich kein Fakten-Query
        if len(text) < 15:
            return False

        # Skip-Patterns prüfen (Befehle, Meinungen, Erklärungen)
        for skip_pat in self._SKIP_PRESEARCH_PATTERNS:
            if skip_pat.search(text):
                return False

        # Faktenfrage-Patterns prüfen
        return any(fact_pat.search(text) for fact_pat in self._FACT_QUESTION_PATTERNS)

    async def _classify_coding_task(self, user_message: str) -> tuple[bool, str]:
        """Klassifiziert ob eine Nachricht eine Coding-Aufgabe ist und deren Komplexitaet.

        Nutzt einen schnellen LLM-Call mit dem Executor-Modell.

        Returns:
            (is_coding, complexity) -- complexity ist "simple" oder "complex"
        """
        if not self._model_router or not self._llm:
            return False, "simple"

        classify_prompt = (
            "Klassifiziere die folgende Nachricht:\n"
            "1. Ist es eine Coding/Programmier-Aufgabe? (ja/nein)\n"
            "2. Wenn ja: Ist es einfach (einzelne Funktion, kleines Fix, Snippet)\n"
            "   oder komplex (Multi-File, Architektur, Refactoring, neues Feature)?\n\n"
            'Antworte NUR mit einem JSON: {"coding": true/false, "complexity": "simple"/"complex"}'
        )

        model = self._model_router.select_model("simple_tool_call", "low")

        try:
            response = await self._llm.chat(
                model=model,
                messages=[
                    {"role": "system", "content": classify_prompt},
                    {"role": "user", "content": user_message[:500]},
                ],
                temperature=0.1,
                format_json=True,
            )

            text = response.get("message", {}).get("content", "")
            # JSON aus Antwort extrahieren
            import json as _json_mod

            # <think>...</think> Bloecke entfernen (qwen3)
            text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
            data = _json_mod.loads(text)
            is_coding = bool(data.get("coding", False))
            complexity = data.get("complexity", "simple")
            if complexity not in ("simple", "complex"):
                complexity = "simple"
            return is_coding, complexity

        except Exception as exc:
            log.debug("coding_classify_failed", error=str(exc)[:200])
            return False, "simple"

    @staticmethod
    def _resolve_relative_dates(text: str) -> str:
        """Ersetzt relative Zeitangaben durch konkrete Datumsangaben.

        'morgen' → '01.03.2026', 'heute' → '28.02.2026', etc.
        """
        from datetime import datetime, timedelta

        now = datetime.now()
        today = now.date()

        # Mapping: (regex_pattern, Datum-Offset oder Callback)
        replacements: list[tuple[str, str]] = [
            (r"\bheute\b", today.strftime("%d.%m.%Y")),
            (r"\bmorgen\b", (today + timedelta(days=1)).strftime("%d.%m.%Y")),
            (r"\bübermorgen\b", (today + timedelta(days=2)).strftime("%d.%m.%Y")),
            (r"\bgestern\b", (today - timedelta(days=1)).strftime("%d.%m.%Y")),
            (r"\bvorgestern\b", (today - timedelta(days=2)).strftime("%d.%m.%Y")),
        ]

        # Wochentags-basierte Auflösung: "nächsten Montag", "am Freitag", etc.
        _wochentage = {
            "montag": 0,
            "dienstag": 1,
            "mittwoch": 2,
            "donnerstag": 3,
            "freitag": 4,
            "samstag": 5,
            "sonntag": 6,
        }
        for tag_name, weekday_num in _wochentage.items():
            days_ahead = (weekday_num - today.weekday()) % 7
            if days_ahead == 0:
                days_ahead = 7  # "am Montag" = nächster Montag
            target = today + timedelta(days=days_ahead)
            replacements.append(
                (
                    rf"\b(?:nächsten?\s+|am\s+|kommenden?\s+)?{tag_name}\b",
                    target.strftime("%d.%m.%Y"),
                ),
            )

        # "nächste Woche" / "diese Woche" / "dieses Wochenende"
        replacements.append(
            (
                r"\bnächste(?:r|s|n)?\s+woche\b",
                f"Woche ab {(today + timedelta(days=7 - today.weekday())).strftime('%d.%m.%Y')}",
            ),
        )
        replacements.append(
            (
                r"\bdiese(?:s|r|n)?\s+wochenende\b",
                f"{(today + timedelta(days=5 - today.weekday())).strftime('%d.%m.%Y')}",
            ),
        )

        for pattern, replacement in replacements:
            text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)

        return text

    async def _maybe_presearch(self, msg: IncomingMessage, wm: WorkingMemory) -> str | None:
        """Führt automatisch eine Web-Suche durch wenn die Nachricht eine Faktenfrage ist.

        Returns:
            Suchergebnis-Text wenn Ergebnisse gefunden wurden, sonst None.
        """
        if not self._is_fact_question(msg.text):
            return None

        # WebTools-Instanz finden
        web_tools = None
        if self._mcp_client:
            web_tools = getattr(self._mcp_client, "_web_tools", None)
            if web_tools is None:
                # Fallback: WebTools aus registrierten Handlern extrahieren
                handler = self._mcp_client.get_handler("web_search")
                if handler is not None:
                    # Handler ist eine gebundene Methode von WebTools
                    web_tools = getattr(handler, "__self__", None)

        if web_tools is None:
            log.debug("presearch_skip_no_webtools")
            return None

        # Suchanfrage als Keywords formulieren (nicht als Frage)
        query = msg.text.strip()
        # Befehls-Suffixe abschneiden ("Recherchiere das online", etc.)
        # Längere Phrasen zuerst, damit "bitte such" vor "such" matcht
        for splitter in (
            "recherchiere das",
            "recherchiere",
            "recherchier",
            "bitte such",
            "such das",
            "such online",
            "finde heraus",
            "schau nach",
            "google",
        ):
            idx = query.lower().find(splitter)
            if idx > 10:  # Nur abschneiden wenn genug Fragetext davor steht
                query = query[:idx].strip()
                break
        query = query.rstrip("?!.").strip()
        # Frageworte entfernen für bessere Suchergebnisse
        for prefix in (
            "wann hat",
            "wann haben",
            "wann wurde",
            "wann war",
            "wo hat",
            "wo haben",
            "wo wurde",
            "wo war",
            "wer hat",
            "wer ist",
            "was ist mit",
            "was hat",
            "stimmt es dass",
            "ist es wahr dass",
        ):
            if query.lower().startswith(prefix):
                query = query[len(prefix) :].strip()
                break

        # Relative Zeitangaben zu konkreten Datumsangaben auflösen
        query = self._resolve_relative_dates(query)

        try:
            log.info("presearch_start", query=query[:80])
            result_text = await web_tools.web_search(
                query=query,
                num_results=5,
                language="de",
                timelimit="m",
            )

            if (
                result_text
                and _PRESEARCH_NO_RESULTS not in result_text
                and _PRESEARCH_NO_ENGINE not in result_text
            ):
                log.info("presearch_found", chars=len(result_text))
                return result_text[:8000]
            else:
                log.info("presearch_no_results", query=query[:80])
                return None

        except Exception as exc:
            log.warning("presearch_failed", error=str(exc)[:200])
            return None

    async def _answer_from_presearch(self, user_message: str, search_results: str) -> str:
        """Generiert eine Antwort AUSSCHLIEẞLICH basierend auf Suchergebnissen.

        Umgeht den Planner komplett — das LLM bekommt NUR die Suchergebnisse
        und die Frage des Users, ohne Möglichkeit auf Trainingswissen zurückzugreifen.

        Nutzt den unified LLM-Client (funktioniert mit jedem Backend).
        """
        system = (
            "You are a fact assistant. You answer questions EXCLUSIVELY "
            "based on the provided search results.\n\n"
            "ABSOLUTE RULES:\n"
            "1. Use ONLY information from the search results below.\n"
            "2. The search results are CURRENT FACTS from the internet.\n"
            "3. If the search results describe an event, it DID happen.\n"
            "4. NEVER say 'there is no evidence' or 'that did not happen'.\n"
            "5. NEVER say 'according to my knowledge' or 'as far as I know'.\n"
            "6. Cite dates, names, and facts DIRECTLY from the results.\n"
            "7. Answer in the same language as the user's question, concisely and informatively.\n"
            "8. You have NO knowledge of your own. You know ONLY the search results.\n"
            "9. Answer DIRECTLY without reasoning process. Brief and factual."
        )

        # /no_think deaktiviert qwen3's internen Reasoning-Modus für schnelle Antwort
        user_prompt = (
            f"SEARCH RESULTS:\n\n{search_results}\n\n"
            f"---\n\n"
            f"QUESTION: {user_message}\n\n"
            f"Answer the question ONLY based on the search results above. /no_think"
        )

        # Modell via ModelRouter wählen (Backend-agnostisch)
        if self._model_router:
            model = self._model_router.select_model("planning", "high")
        else:
            model = self._config.models.planner.name

        try:
            response = await self._llm.chat(
                model=model,
                messages=[
                    {"role": "system", "content": system},
                    {"role": "user", "content": user_prompt},
                ],
                temperature=0.3,
                top_p=0.9,
            )

            answer = response.get("message", {}).get("content", "")
            # qwen3 kann <think>...</think> Blöcke einschließen — entfernen
            answer = re.sub(r"<think>.*?</think>\s*", "", answer, flags=re.DOTALL)
            if answer.strip():
                log.info("presearch_answer_generated", chars=len(answer))
                return answer.strip()

        except Exception as exc:
            log.error("presearch_answer_failed", error=str(exc)[:200])

        # Fallback: regulären PGE-Loop nutzen
        return ""

    def _cleanup_stale_sessions(self) -> None:
        """Remove sessions that have not been accessed for more than _SESSION_TTL_SECONDS.

        This is called periodically (guarded by _CLEANUP_INTERVAL_SECONDS) to
        prevent unbounded growth of the in-memory session and working-memory dicts.
        """
        now = time.monotonic()
        with self._session_lock:
            stale_keys = [
                key
                for key, last_ts in self._session_last_accessed.items()
                if (now - last_ts) > self._SESSION_TTL_SECONDS
            ]
            for key in stale_keys:
                session = self._sessions.pop(key, None)
                if session:
                    self._working_memories.pop(session.session_id, None)
                self._session_last_accessed.pop(key, None)
        if stale_keys:
            log.info("stale_sessions_cleaned", count=len(stale_keys))
        self._last_session_cleanup = now

    def _maybe_cleanup_sessions(self) -> None:
        """Trigger stale session cleanup if enough time has passed since the last sweep."""
        now = time.monotonic()
        if (now - self._last_session_cleanup) >= self._CLEANUP_INTERVAL_SECONDS:
            self._cleanup_stale_sessions()

    def _get_or_create_session(
        self,
        channel: str,
        user_id: str,
        agent_name: str = "jarvis",
    ) -> SessionContext:
        """Lädt oder erstellt eine Session für Channel+User+Agent.

        Per-Agent-Isolation: Jeder Agent hat seine eigene Session.
        Das verhindert dass Working Memories vermischt werden.

        Reihenfolge:
          0. Periodic stale-session cleanup
          1. Im RAM-Cache nachschauen
          2. Aus SQLite laden (Session-Persistenz)
          3. Neue Session erstellen
        """
        # 0. Periodically clean up stale sessions
        self._maybe_cleanup_sessions()

        key = f"{channel}:{user_id}:{agent_name}"

        with self._session_lock:
            # 1. RAM-Cache
            if key in self._sessions:
                self._session_last_accessed[key] = time.monotonic()
                return self._sessions[key]

            # 2. SQLite-Persistenz
            if self._session_store:
                stored = self._session_store.load_session(channel, user_id, agent_name)
                if stored and stored.agent_name == agent_name:
                    self._sessions[key] = stored
                    self._session_last_accessed[key] = time.monotonic()
                    log.info(
                        "session_restored",
                        session=stored.session_id[:8],
                        channel=channel,
                        agent=agent_name,
                        messages=stored.message_count,
                    )
                    return stored

            # 3. Neue Session
            session = SessionContext(
                user_id=user_id,
                channel=channel,
                agent_name=agent_name,
                max_iterations=self._config.security.max_iterations,
            )
            self._sessions[key] = session
            self._session_last_accessed[key] = time.monotonic()

        # Persistieren (außerhalb Lock, blockiert nicht andere Sessions)
        if self._session_store:
            self._session_store.save_session(session)

        log.info(
            "session_created",
            session=session.session_id[:8],
            channel=channel,
            agent=agent_name,
        )
        return session

    def _get_or_create_working_memory(self, session: SessionContext) -> WorkingMemory:
        """Lädt oder erstellt Working Memory für eine Session.

        Bei existierenden Sessions wird die Chat-History aus SQLite geladen.
        """
        with self._session_lock:
            if session.session_id in self._working_memories:
                return self._working_memories[session.session_id]

        # Außerhalb Lock erstellen (I/O-Operationen blockieren nicht andere Sessions)
        wm = WorkingMemory(
            session_id=session.session_id,
            max_tokens=self._config.models.planner.context_window,
        )

        # Core Memory laden (wenn vorhanden)
        core_path = self._config.core_memory_path
        if core_path.exists():
            try:
                wm.core_memory_text = core_path.read_text(encoding="utf-8")
            except Exception as exc:
                log.warning("core_memory_load_failed", error=str(exc))

        # Chat-History aus SessionStore wiederherstellen
        if self._session_store:
            try:
                history = self._session_store.load_chat_history(
                    session.session_id,
                    limit=20,
                )
                if history:
                    wm.chat_history = history
                    log.info(
                        "chat_history_restored",
                        session=session.session_id[:8],
                        messages=len(history),
                    )
            except Exception as exc:
                log.warning("chat_history_load_failed", error=str(exc))

        with self._session_lock:
            # Double-check: anderer Thread könnte schneller gewesen sein
            if session.session_id not in self._working_memories:
                self._working_memories[session.session_id] = wm
            return self._working_memories[session.session_id]

    def _check_and_compact(self, wm: WorkingMemory, session: SessionContext) -> None:
        """Prüft Token-Budget und kompaktiert Chat-History wenn nötig.

        Nutzt den WorkingMemoryManager für sprachbewusste Token-Schätzung
        und FIFO-Entfernung alter Nachrichten.
        """
        from jarvis.memory.working import WorkingMemoryManager

        mem_cfg = self._config.memory
        mgr = WorkingMemoryManager(config=mem_cfg, max_tokens=wm.max_tokens)
        mgr._memory = wm  # Manager auf aktuelle WM zeigen

        if mgr.needs_compaction:
            result = mgr.compact()
            if result.messages_removed > 0:
                log.info(
                    "auto_compaction",
                    session=session.session_id[:8],
                    messages_removed=result.messages_removed,
                    tokens_freed=result.tokens_freed,
                    usage_after=f"{mgr.usage_ratio:.0%}",
                )

    async def _handle_approvals(
        self,
        steps: list[Any],
        decisions: list[GateDecision],
        session: SessionContext,
        channel_name: str,
    ) -> list[GateDecision]:
        """Holt User-Bestätigungen für ORANGE-Aktionen ein.

        Returns:
            Aktualisierte Liste von Entscheidungen (APPROVE → ALLOW oder BLOCK).
        """
        channel = self._channels.get(channel_name)
        if channel is None:
            return decisions

        result = list(decisions)  # Kopie

        for i, (step, decision) in enumerate(zip(steps, decisions, strict=False)):
            if decision.status != GateStatus.APPROVE:
                continue

            # User fragen
            approved = await channel.request_approval(
                session_id=session.session_id,
                action=step,
                reason=decision.reason,
            )

            if approved:
                result[i] = GateDecision(
                    status=GateStatus.ALLOW,
                    reason=f"User-Bestätigung für: {decision.reason}",
                    risk_level=decision.risk_level,
                    original_action=step,
                    policy_name=f"{decision.policy_name}:user_approved",
                )
                log.info("user_approved_action", tool=step.tool)
            else:
                result[i] = GateDecision(
                    status=GateStatus.BLOCK,
                    reason=f"User-Ablehnung für: {decision.reason}",
                    risk_level=decision.risk_level,
                    original_action=step,
                    policy_name=f"{decision.policy_name}:user_rejected",
                )
                log.info("user_rejected_action", tool=step.tool)

        return result
