"""Web UI Channel: Browser-Interface mit WebSocket-Streaming.

Erweitert den API-Channel um WebSocket-Support für Echtzeit-
Kommunikation. Dient die Web-Oberfläche (React/Svelte) aus
und bietet Live-Streaming der Agent-Antworten.

Features:
  - WebSocket für bidirektionale Echtzeit-Kommunikation
  - SSE-Fallback für ältere Clients
  - Streaming-Tokens für flüssige Ausgabe
  - Tool-Execution-Events (User sieht was passiert)
  - Inline-Approvals via WebSocket
  - Static-File-Serving für Frontend

Bibel-Referenz: §9.3 (Web UI Channel)
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import os
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4

from jarvis.channels.base import Channel, MessageHandler, StatusType
from jarvis.models import IncomingMessage, OutgoingMessage, PlannedAction
from jarvis.security.rate_limiter import RateLimiter
from jarvis.security.token_store import get_token_store
from jarvis.utils.logging import get_logger
from jarvis.utils.ttl_dict import TTLDict

log = get_logger(__name__)

# Maximale Upload-Groesse (50 MB)
MAX_UPLOAD_SIZE = 52_428_800


# ============================================================================
# WebSocket-Nachrichtentypen
# ============================================================================


class WSMessageType:
    """WebSocket-Nachrichtentypen (Client ↔ Server)."""

    # Client → Server
    USER_MESSAGE = "user_message"
    APPROVAL_RESPONSE = "approval_response"
    PING = "ping"

    # Server → Client
    ASSISTANT_MESSAGE = "assistant_message"
    STREAM_TOKEN = "stream_token"
    STREAM_END = "stream_end"
    TOOL_START = "tool_start"
    TOOL_RESULT = "tool_result"
    APPROVAL_REQUEST = "approval_request"
    STATUS_UPDATE = "status_update"
    ERROR = "error"
    PONG = "pong"


# ============================================================================
# WebUI Channel
# ============================================================================


class WebUIChannel(Channel):
    """Web UI Channel mit WebSocket-Support. [B§9.3]

    Erweitert die API um WebSocket-Verbindungen für Echtzeit-
    Streaming und interaktive Tool-Visualisierung.
    """

    def __init__(
        self,
        *,
        host: str = "127.0.0.1",
        port: int = 8741,
        api_token: str | None = None,
        cors_origins: list[str] | None = None,
        static_dir: str | None = None,
        config: Any = None,
        config_manager: Any = None,
        ssl_certfile: str = "",
        ssl_keyfile: str = "",
    ) -> None:
        self._host = host
        self._port = port
        self._token_store = get_token_store()
        if api_token:
            self._token_store.store("webui_channel_token", api_token)
        self._has_api_token = bool(api_token)
        self._cors_origins = cors_origins or []
        self._ssl_certfile = ssl_certfile
        self._ssl_keyfile = ssl_keyfile
        self._static_dir = static_dir
        self._config_manager = config_manager
        self._config = config  # JarvisConfig (optional, für ConfigManager)
        # Default: eingebautes WebChat-Widget ausliefern
        if self._static_dir is None:
            builtin_webchat = Path(__file__).parent / "webchat"
            if builtin_webchat.is_dir():
                self._static_dir = str(builtin_webchat)
        self._handler: MessageHandler | None = None
        self._app: Any = None
        self._start_time = 0.0

        # WebSocket-Verbindungen: session_id → WebSocket
        self._connections: TTLDict[str, Any] = TTLDict(max_size=1000, ttl_seconds=86400)
        # Pending Approvals: request_id → Future (finally-Cleanup in request_approval)
        self._pending_approvals: dict[str, asyncio.Future[bool]] = {}
        # Session-Tracking
        self._session_messages: TTLDict[str, int] = TTLDict(max_size=10000, ttl_seconds=86400)
        self._rate_limiter = RateLimiter()

    @property
    def _api_token(self) -> str | None:
        """API-Token (entschlüsselt bei Zugriff)."""
        if self._has_api_token:
            return self._token_store.retrieve("webui_channel_token")
        return None

    @property
    def name(self) -> str:
        return "webui"

    async def start(self, handler: MessageHandler) -> None:
        """Startet den WebUI-Server."""
        self._handler = handler
        self._start_time = time.monotonic()
        self._app = self._create_app()

        # TLS-Warning für externe Hosts
        if self._host not in ("127.0.0.1", "localhost", "::1") and not self._ssl_certfile:
            log.warning(
                "webui_no_tls",
                host=self._host,
                message="WARNUNG: WebUI auf externem Host ohne TLS!",
            )

        log.info("webui_channel_starting", host=self._host, port=self._port)

    async def stop(self) -> None:
        """Stoppt den WebUI-Server und schließt WebSocket-Verbindungen."""
        # Alle WebSocket-Verbindungen schließen
        for ws in list(self._connections.values()):
            with contextlib.suppress(Exception):
                await ws.close()
        self._connections.clear()

        # Pending Approvals abbrechen
        for future in self._pending_approvals.values():
            if not future.done():
                future.set_result(False)
        self._pending_approvals.clear()
        log.info("webui_channel_stopped")

    async def send(self, message: OutgoingMessage) -> None:
        """Sendet Nachricht über WebSocket an den Client."""
        ws = self._connections.get(message.session_id)
        if ws:
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.ASSISTANT_MESSAGE,
                    "text": message.text,
                    "session_id": message.session_id,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )

    async def request_approval(
        self,
        session_id: str,
        action: PlannedAction,
        reason: str,
    ) -> bool:
        """Sendet Approval-Anfrage über WebSocket."""
        ws = self._connections.get(session_id)
        if not ws:
            log.warning("no_ws_connection_for_approval", session_id=session_id)
            return False

        request_id = str(uuid4())
        future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
        self._pending_approvals[request_id] = future

        await self._ws_send(
            ws,
            {
                "type": WSMessageType.APPROVAL_REQUEST,
                "request_id": request_id,
                "session_id": session_id,
                "tool": action.tool,
                "params": action.params,
                "reason": reason,
            },
        )

        try:
            return await asyncio.wait_for(future, timeout=300)
        except TimeoutError:
            return False
        finally:
            self._pending_approvals.pop(request_id, None)

    async def send_streaming_token(self, session_id: str, token: str) -> None:
        """Sendet einzelnes Streaming-Token über WebSocket."""
        ws = self._connections.get(session_id)
        if ws:
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.STREAM_TOKEN,
                    "token": token,
                    "session_id": session_id,
                },
            )

    async def send_status(self, session_id: str, status: StatusType, text: str) -> None:
        """Sendet Status-Update über WebSocket."""
        ws = self._connections.get(session_id)
        if ws:
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.STATUS_UPDATE,
                    "status": status.value,
                    "text": text,
                    "session_id": session_id,
                },
            )

    async def send_tool_event(
        self,
        session_id: str,
        event_type: str,
        tool_name: str,
        data: dict[str, Any] | None = None,
    ) -> None:
        """Sendet Tool-Execution-Event an den Client.

        Damit der User sieht, was gerade passiert (z.B.
        'Suche im Web...', 'Datei wird geschrieben...').
        """
        ws = self._connections.get(session_id)
        if ws:
            await self._ws_send(
                ws,
                {
                    "type": event_type,
                    "tool": tool_name,
                    "data": data or {},
                    "session_id": session_id,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )

    def _create_app(self) -> Any:
        """Erstellt die FastAPI-App mit WebSocket-Support."""
        # FastAPI and its dependencies are optional. When not available or
        # broken, return a lightweight placeholder object instead of
        # raising an error. Some environments may partially have FastAPI
        # installed but missing dependencies such as websockets; pydantic
        # may then fail when creating models. To handle all such cases
        # gracefully, we catch a broad set of exceptions during import
        # and app construction and fall back to a dummy implementation.
        try:
            from fastapi import (
                Depends,
                FastAPI,
                HTTPException,
                WebSocket,
                WebSocketDisconnect,
            )
            from fastapi.middleware.cors import CORSMiddleware
            from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
            # We'll attempt to import StaticFiles later when needed.
        except Exception:
            return self._dummy_app()

        # Build the FastAPI app in a try/except so that any runtime
        # failures (e.g. due to missing subpackages) fall back to a
        # dummy implementation.
        try:
            app = FastAPI(
                title="Jarvis Web UI",
                version="0.1.0",
            )

            app.add_middleware(
                CORSMiddleware,
                allow_origins=self._cors_origins,
                allow_credentials=True,
                allow_methods=["*"],
                allow_headers=["*"],
            )

            security = HTTPBearer(auto_error=False)

            async def verify_token(
                credentials: HTTPAuthorizationCredentials | None = Depends(security),  # noqa: B008
            ) -> None:
                if not self._api_token:
                    return
                if not credentials or credentials.credentials != self._api_token:
                    raise HTTPException(status_code=401, detail="Ungültiger Token")

            # --- Health ---
            @app.get("/api/v1/health")
            async def health() -> dict[str, Any]:
                return {
                    "status": "ok",
                    "version": "0.1.0",
                    "uptime_seconds": time.monotonic() - self._start_time,
                    "active_connections": len(self._connections),
                }

            # --- REST Message (Fallback) ---
            @app.post("/api/v1/message", dependencies=[Depends(verify_token)])
            async def send_message(
                text: str,
                session_id: str | None = None,
            ) -> dict[str, Any]:
                if not await self._rate_limiter.check("webui_default"):
                    raise HTTPException(status_code=429, detail="Too Many Requests")
                if not self._handler:
                    raise HTTPException(status_code=503, detail="Not ready")

                sid = session_id or str(uuid4())
                start = time.monotonic()

                incoming = IncomingMessage(
                    text=text,
                    channel="webui",
                    session_id=sid,
                    user_id="web_user",
                )
                response = await self._handler(incoming)
                duration_ms = int((time.monotonic() - start) * 1000)

                return {
                    "text": response.text,
                    "session_id": sid,
                    "duration_ms": duration_ms,
                }

            # --- WebSocket ---
            @app.websocket("/ws/{session_id}")
            async def websocket_endpoint(
                websocket: WebSocket,
                session_id: str,
            ) -> None:
                # Token-Check via erster WS-Nachricht (nicht Query-Param,
                # um Log-Exposure zu vermeiden).
                await websocket.accept()

                if self._api_token:
                    import hmac as _hmac

                    try:
                        auth_raw = await asyncio.wait_for(
                            websocket.receive_text(),
                            timeout=10.0,
                        )
                        auth_msg = json.loads(auth_raw)
                        client_token = (
                            auth_msg.get("token", "") if auth_msg.get("type") == "auth" else ""
                        )
                    except (TimeoutError, Exception):
                        client_token = ""
                    if not _hmac.compare_digest(client_token, self._api_token):
                        with contextlib.suppress(Exception):
                            await websocket.send_json(
                                {
                                    "type": "error",
                                    "error": "Unauthorized",
                                }
                            )
                        await websocket.close(code=4001, reason="Unauthorized")
                        return
                self._connections[session_id] = websocket
                log.info("ws_connected", session_id=session_id)

                try:
                    while True:
                        data = await websocket.receive_text()
                        msg = json.loads(data)
                        await self._handle_ws_message(websocket, session_id, msg)
                except WebSocketDisconnect:
                    log.info("ws_disconnected", session_id=session_id)
                except json.JSONDecodeError:
                    await self._ws_send(
                        websocket,
                        {
                            "type": WSMessageType.ERROR,
                            "error": "Ungültiges JSON",
                        },
                    )
                except Exception as exc:
                    log.error("ws_error", error=str(exc), session_id=session_id)
                finally:
                    self._connections.pop(session_id, None)

            # --- Config-API Routes ---
            # NOTE: Config routes are already registered on the main Cognithor
            # API (port 8741) in __main__.py.  We only register them here if
            # the WebUI channel runs on a DIFFERENT port and the caller passed
            # a shared ConfigManager via self._config_manager.  Creating a
            # separate ConfigManager would cause state divergence and data loss.
            if getattr(self, "_config_manager", None) is not None:
                try:
                    from jarvis.channels.config_routes import create_config_routes

                    create_config_routes(
                        app,
                        self._config_manager,
                        verify_token_dep=Depends(verify_token),
                    )
                    log.info("config_routes_registered", source="shared_manager")
                except Exception as exc:
                    log.warning("config_routes_not_available", error=str(exc))
            else:
                log.debug("config_routes_skipped", reason="no shared config_manager")

            # Static files (Frontend) -- optional
            if self._static_dir:
                try:
                    from fastapi.staticfiles import StaticFiles

                    app.mount(
                        "/",
                        StaticFiles(directory=self._static_dir, html=True),
                        name="frontend",
                    )
                except Exception:
                    log.warning("static_files_not_available")

            return app
        except Exception:
            # Any exception in building the FastAPI app leads to falling back
            # to a dummy implementation so that tests still pass.
            return self._dummy_app()

    def _dummy_app(self) -> Any:
        """Return a minimal stand-in for a FastAPI application."""

        class DummyApp:
            def __init__(self) -> None:
                self.title = "Jarvis Web UI (stub)"
                self.version = "0.0.0"

            def add_middleware(self, *args: Any, **kwargs: Any) -> None:
                return None

            def get(self, *args: Any, **kwargs: Any) -> Any:
                def decorator(func: Any) -> Any:
                    return func

                return decorator

            def post(self, *args: Any, **kwargs: Any) -> Any:
                def decorator(func: Any) -> Any:
                    return func

                return decorator

            def websocket(self, *args: Any, **kwargs: Any) -> Any:
                def decorator(func: Any) -> Any:
                    return func

                return decorator

            def mount(self, *args: Any, **kwargs: Any) -> None:
                return None

        return DummyApp()

    async def _handle_ws_message(
        self,
        ws: Any,
        session_id: str,
        msg: dict[str, Any],
    ) -> None:
        """Verarbeitet eine eingehende WebSocket-Nachricht.

        Unterstützt:
          - Text-Nachrichten
          - Sprachnachrichten (audio_base64 in metadata → Whisper-Transkription)
          - Datei-Uploads (file_base64 in metadata → Media-Pipeline)
          - Approval-Antworten
          - Ping/Pong
        """
        msg_type = msg.get("type", "")

        if msg_type == WSMessageType.PING:
            await self._ws_send(ws, {"type": WSMessageType.PONG})
            return

        if msg_type == WSMessageType.APPROVAL_RESPONSE:
            request_id = msg.get("request_id", "")
            future = self._pending_approvals.get(request_id)
            if future and not future.done():
                future.set_result(msg.get("approved", False))
            return

        if msg_type == WSMessageType.USER_MESSAGE:
            text = msg.get("text", "").strip()
            metadata = msg.get("metadata", {})

            # --- Voice-Bridge: Audio transkribieren ---
            if metadata.get("audio_base64"):
                transcribed = await self._transcribe_audio(metadata, ws, session_id)
                if transcribed:
                    text = transcribed
                else:
                    return  # Fehler wurde bereits gesendet

            # --- File-Upload: Datei speichern und Text extrahieren ---
            if metadata.get("file_base64") and text.startswith("[file_upload]"):
                file_text = await self._process_file_upload(metadata, ws, session_id)
                if file_text:
                    text = file_text

            if not text:
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.ERROR,
                        "error": "Leere Nachricht",
                    },
                )
                return

            if not self._handler:
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.ERROR,
                        "error": "Handler nicht bereit",
                    },
                )
                return

            incoming = IncomingMessage(
                text=text,
                channel="webui",
                session_id=session_id,
                user_id="web_user",
                metadata=metadata,
            )

            try:
                response = await self._handler(incoming)
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.ASSISTANT_MESSAGE,
                        "text": response.text,
                        "session_id": session_id,
                        "timestamp": datetime.now(UTC).isoformat(),
                    },
                )
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.STREAM_END,
                        "session_id": session_id,
                    },
                )
            except Exception as exc:
                log.error("ws_handler_error", error=str(exc))
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.ERROR,
                        "error": "Ein Verarbeitungsfehler ist aufgetreten.",
                    },
                )
            return

        # Unbekannter Typ
        await self._ws_send(
            ws,
            {
                "type": WSMessageType.ERROR,
                "error": f"Unbekannter Nachrichtentyp: {msg_type}",
            },
        )

    async def _transcribe_audio(
        self,
        metadata: dict[str, Any],
        ws: Any,
        session_id: str,
    ) -> str | None:
        """Voice-Bridge: Transkribiert Base64-Audio via Whisper.

        Empfängt Audio vom WebChat-Widget (Browser MediaRecorder),
        speichert temporär und transkribiert lokal.

        Returns:
            Transkribierter Text oder None bei Fehler.
        """
        import base64
        import tempfile
        from pathlib import Path

        audio_b64 = metadata.get("audio_base64", "")
        audio_type = metadata.get("audio_type", "audio/webm")

        if not audio_b64:
            return None

        # Geschätzte Dateigrösse prüfen
        estimated_size = len(audio_b64) * 3 // 4
        if estimated_size > MAX_UPLOAD_SIZE:
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.ERROR,
                    "error": (
                        f"Audiodatei zu gross "
                        f"({estimated_size // 1_048_576} MB, "
                        f"max {MAX_UPLOAD_SIZE // 1_048_576} MB)"
                    ),
                },
            )
            return None

        # Benachrichtigung: Transkription läuft
        await self._ws_send(
            ws,
            {
                "type": WSMessageType.TOOL_START,
                "tool": "voice_transcription",
                "data": {"status": "Sprachnachricht wird transkribiert..."},
                "session_id": session_id,
            },
        )

        tmp_path: str | None = None
        wav_path: str | None = None
        try:
            # Base64 → Datei
            audio_bytes = base64.b64decode(audio_b64)
            suffix = ".webm" if "webm" in audio_type else ".ogg"

            with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
                tmp.write(audio_bytes)
                tmp_path = tmp.name

            # Konvertierung zu WAV via ffmpeg (falls nötig)
            wav_path = tmp_path + ".wav"
            try:
                proc = await asyncio.create_subprocess_exec(
                    "ffmpeg",
                    "-i",
                    tmp_path,
                    "-ar",
                    "16000",
                    "-ac",
                    "1",
                    "-y",
                    wav_path,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                await proc.communicate()
                if proc.returncode != 0:
                    wav_path = tmp_path  # Fallback: Datei direkt an Whisper geben
            except FileNotFoundError:
                wav_path = tmp_path  # ffmpeg nicht verfügbar

            # Transkription via MediaPipeline oder faster-whisper direkt
            try:
                from jarvis.mcp.media import MediaPipeline

                pipeline = MediaPipeline()
                result = await pipeline.transcribe_audio(wav_path)

                if result.success and result.text.strip():
                    log.info(
                        "voice_bridge_transcribed", text=result.text[:100], session_id=session_id
                    )

                    # Transkription dem User anzeigen
                    await self._ws_send(
                        ws,
                        {
                            "type": WSMessageType.TOOL_RESULT,
                            "tool": "voice_transcription",
                            "data": {"transcription": result.text},
                            "session_id": session_id,
                        },
                    )

                    return f"🎤 {result.text}"
                else:
                    await self._ws_send(
                        ws,
                        {
                            "type": WSMessageType.ERROR,
                            "error": result.error or "Keine Sprache erkannt",
                        },
                    )
                    return None

            except ImportError:
                await self._ws_send(
                    ws,
                    {
                        "type": WSMessageType.ERROR,
                        "error": (
                            "faster-whisper nicht installiert. Voice-Transkription nicht verfügbar."
                        ),
                    },
                )
                return None

        except Exception as exc:
            log.error("voice_bridge_error", error=str(exc), session_id=session_id)
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.ERROR,
                    "error": "Voice-Transkription fehlgeschlagen.",
                },
            )
            return None
        finally:
            # Temporäre Dateien aufräumen
            for p in [tmp_path, wav_path]:
                if p is None:
                    continue
                with contextlib.suppress(Exception):
                    Path(p).unlink(missing_ok=True)

    async def _process_file_upload(
        self,
        metadata: dict[str, Any],
        ws: Any,
        session_id: str,
    ) -> str | None:
        """Verarbeitet einen Datei-Upload vom WebChat-Widget.

        Speichert die Datei und extrahiert Text wenn möglich.

        Returns:
            Aufbereiteter Text für den Handler oder None.
        """
        import base64
        from pathlib import Path

        file_b64 = metadata.get("file_base64", "")
        file_name = metadata.get("file_name", "upload")
        file_type = metadata.get("file_type", "")

        if not file_b64:
            return None

        # Geschätzte Dateigrösse prüfen (Base64 → ~75% Originalgrösse)
        estimated_size = len(file_b64) * 3 // 4
        if estimated_size > MAX_UPLOAD_SIZE:
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.ERROR,
                    "error": (
                        f"Datei zu gross "
                        f"({estimated_size // 1_048_576} MB, "
                        f"max {MAX_UPLOAD_SIZE // 1_048_576} MB)"
                    ),
                },
            )
            return None

        try:
            file_bytes = base64.b64decode(file_b64)
            workspace = Path.home() / ".jarvis" / "workspace" / "uploads"
            workspace.mkdir(parents=True, exist_ok=True)

            # Sanitize filename to prevent path traversal
            safe_name = Path(file_name).name.lstrip(".")
            if not safe_name:
                safe_name = "upload"
            save_path = (workspace / safe_name).resolve()
            if not str(save_path).startswith(str(workspace.resolve())):
                log.warning("path_traversal_blocked", file_name=file_name)
                return None
            save_path.write_bytes(file_bytes)

            log.info("file_uploaded", name=file_name, size=len(file_bytes), session_id=session_id)

            # Text-Extraktion versuchen
            try:
                from jarvis.mcp.media import MediaPipeline

                pipeline = MediaPipeline()
                result = await pipeline.extract_text(str(save_path))

                if result.success and result.text.strip():
                    return (
                        f"[Datei hochgeladen: {file_name}, "
                        f"{len(file_bytes)} Bytes]\n\n"
                        f"Inhalt:\n{result.text}"
                    )
            except Exception:
                pass  # Cleanup — file content extraction failure is non-critical

            # Fallback: Nur Dateiinfo
            return (
                f"[Datei hochgeladen: {file_name}, "
                f"{len(file_bytes)} Bytes, Typ: {file_type}]\n"
                f"Gespeichert unter: {save_path}"
            )

        except Exception as exc:
            log.error("file_upload_error", error=str(exc))
            await self._ws_send(
                ws,
                {
                    "type": WSMessageType.ERROR,
                    "error": "Datei-Upload fehlgeschlagen.",
                },
            )
            return None

    async def _ws_send(self, ws: Any, data: dict[str, Any]) -> None:
        """Sendet JSON über WebSocket (mit Fehlerbehandlung)."""
        try:
            await ws.send_text(json.dumps(data, ensure_ascii=False))
        except Exception as exc:
            log.warning("ws_send_failed", error=str(exc))

    @property
    def app(self) -> Any:
        """FastAPI-App-Instanz."""
        if self._app is None:
            self._app = self._create_app()
        return self._app

    @property
    def active_connections(self) -> int:
        """Anzahl aktiver WebSocket-Verbindungen."""
        return len(self._connections)


# ============================================================================
# ASGI Factory — für uvicorn --factory / docker-compose / systemd
# ============================================================================


def create_app() -> Any:
    """ASGI-Factory für Standalone-Deployment.

    Wird von ``uvicorn jarvis.channels.webui:create_app --factory`` aufgerufen
    (docker-compose.yml, jarvis-webui.service).

    Konfiguration ausschließlich über Umgebungsvariablen:
      JARVIS_WEBUI_HOST          (default "127.0.0.1"; "0.0.0.0" fuer Docker)
      JARVIS_WEBUI_PORT          (default "8080", nur informativ)
      JARVIS_API_TOKEN           (optional, Bearer-Auth)
      JARVIS_WEBUI_CORS_ORIGINS  (kommasepariert, default "http://localhost:8741")
      JARVIS_SSL_CERTFILE        (optional, PEM-Pfad)
      JARVIS_SSL_KEYFILE         (optional, PEM-Pfad)

    Ohne Gateway gibt POST /api/v1/message → 503 zurück (korrekt).
    """
    host = os.environ.get("JARVIS_WEBUI_HOST", "127.0.0.1")
    api_token = os.environ.get("JARVIS_API_TOKEN") or None
    cors_raw = os.environ.get("JARVIS_WEBUI_CORS_ORIGINS", "http://localhost:8741")
    cors_origins = [o.strip() for o in cors_raw.split(",") if o.strip()]
    ssl_cert = os.environ.get("JARVIS_SSL_CERTFILE", "")
    ssl_key = os.environ.get("JARVIS_SSL_KEYFILE", "")

    channel = WebUIChannel(
        host=host,
        api_token=api_token,
        cors_origins=cors_origins,
        ssl_certfile=ssl_cert,
        ssl_keyfile=ssl_key,
    )
    log.info(
        "create_app_factory",
        host=host,
        cors_origins=cors_origins,
        tls=bool(ssl_cert),
    )
    return channel.app
