"""WhatsApp channel via Baileys Node.js bridge.

Production-grade process supervision modeled after Evolution API and OpenClaw:

1. TWO-TIER HEALTH: liveness (/health — is HTTP up?) + readiness (/status —
   is WA socket open?). The health loop acts on BOTH, not just liveness.

2. DISCONNECTED CIRCUIT BREAKER: if the bridge is HTTP-responsive but WA
   disconnected for >5 minutes, kill and restart. No more zombie bridges.

3. PERSISTED FAILURE STATE: cumulative failures tracked in a JSON state file
   that survives process restarts. The parent reads bridge exit codes and
   state to decide on cooldowns.

4. BAN PROTECTION COOLDOWN: if the bridge exits with code 2 (ban risk) or
   cumulative failures exceed the threshold, the parent enters a 30-minute
   cooldown where it refuses to restart the bridge.

5. CRASH, DON'T ZOMBIE: the bridge process.exit(1) when reconnects exhaust.
   The health loop detects the dead process and restarts cleanly.
"""

from __future__ import annotations

import asyncio
import collections
import contextlib
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from pathlib import Path

import aiosqlite
import httpx

from agntspace.channels.base import Channel, ChannelMessage

log = logging.getLogger(__name__)

BRIDGE_DIR = Path(__file__).parent / "whatsapp"
DEFAULT_BRIDGE_PORT = 8787

# --- Supervision constants ---------------------------------------------------

# How often to poll the bridge (seconds)
_HEALTH_INTERVAL = 30

# After this many consecutive disconnected (503) checks, kill and restart.
# 10 checks * 30s = 5 minutes of persistent disconnection.
_DISCONNECTED_CIRCUIT_BREAKER = 10

# After this many consecutive HTTP failures, kill and restart.
_HTTP_FAILURE_THRESHOLD = 3  # arch:deterministic — circuit breaker constant, not strategy

# Cooldown after ban-risk exit (bridge exit code 2). Seconds.
_BAN_COOLDOWN_SECONDS = 30 * 60  # 30 minutes

# Max total restarts within one hour before we enter cooldown
_MAX_RESTARTS_PER_HOUR = 8

# Post-restart settling time (seconds) — give bridge time to init
_RESTART_SETTLE_SECONDS = 15


class WhatsAppChannel(Channel):
    """WhatsApp channel using Baileys via Node.js bridge."""

    name = "whatsapp"

    def __init__(self, bridge_port: int = DEFAULT_BRIDGE_PORT, mode: str = "personal") -> None:
        self._port = bridge_port
        self._mode = mode
        self._base_url = f"http://127.0.0.1:{bridge_port}"
        self._connected = False
        self._bridge_process: subprocess.Popen | None = None  # type: ignore[type-arg]
        self._bridge_stdout_thread: threading.Thread | None = None
        # Ring buffer of recent bridge stdout lines. Used when the bridge dies
        # so the died-bridge handler can still surface the last output even
        # though the drain thread has already consumed the pipe.
        self._bridge_log_tail: collections.deque[str] = collections.deque(maxlen=200)
        self._message_callback: Callable | None = None
        self._health_task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._db: aiosqlite.Connection | None = None

        # Supervision state (persisted in _state_file for cross-restart tracking)
        self._restarts_this_hour: list[float] = []  # timestamps of restarts
        self._cooldown_until: float = 0.0  # monotonic time when cooldown expires
        self._last_bridge_exit_code: int | None = None

    def set_db(self, db: aiosqlite.Connection) -> None:
        """Wire database for mode persistence."""
        self._db = db

    @property
    def _state_file(self) -> Path | None:
        """Path to the bridge state file (written by bridge.mjs, read by us).

        Lives under the vault's per-channel dir (``<vault>/agntspace/
        channels/whatsapp/data/``) so each vault owns its own WA state.
        """
        try:
            from agntspace.infra.config import get_settings
            return Path(get_settings().whatsapp_dir) / "data" / "whatsapp-bridge-state.json"
        except Exception:
            return None

    def _read_bridge_state(self) -> dict:
        """Read the persisted bridge state file (written by bridge.mjs)."""
        path = self._state_file
        if path and path.is_file():
            try:
                return json.loads(path.read_text())
            except Exception:
                pass
        return {}

    async def connect(self) -> None:
        """Start the Baileys bridge process and health check loop."""
        # Cancel any existing health task before reconnecting
        if self._health_task and not self._health_task.done():
            self._health_task.cancel()
            self._health_task = None

        # Kill any existing bridge on this port first
        self._kill_existing_bridge()

        # Restore persisted mode (survives server restarts)
        persisted = await self._load_persisted_mode()
        if persisted and persisted in ("personal", "dedicated"):
            self._mode = persisted
            log.info("Restored WhatsApp mode from DB: %s", self._mode)

        # Install npm deps if needed
        node_modules = BRIDGE_DIR / "node_modules"
        if not node_modules.is_dir():
            log.info("Installing WhatsApp bridge dependencies...")
            npm_exe = shutil.which("npm")
            if not npm_exe:
                raise FileNotFoundError(
                    "Cannot find 'npm' on PATH. Install Node.js "
                    "(https://nodejs.org/) and restart the server."
                )
            npm_result = subprocess.run(
                [npm_exe, "install"],
                cwd=str(BRIDGE_DIR),
                capture_output=True,
                timeout=120,
            )
            if npm_result.returncode != 0:
                err_out = (npm_result.stderr or npm_result.stdout or b"").decode()[:300]
                raise ConnectionError(f"npm install failed for WhatsApp bridge: {err_out}")

        # Start bridge
        self._start_bridge_process()

        # Wait for bridge to be ready (HTTP server starts before WhatsApp connects)
        for attempt in range(15):
            await asyncio.sleep(2)
            try:
                # Check if bridge process died (crash, Bad MAC, npm error)
                if self._bridge_process and self._bridge_process.poll() is not None:
                    exit_code = self._bridge_process.returncode
                    self._last_bridge_exit_code = exit_code
                    # Drain thread has already consumed stdout into the ring
                    # buffer; use the tail for the diagnostic.
                    output = "\n".join(self._bridge_log_tail)
                    log.error("WhatsApp bridge died (exit=%d): %s", exit_code, output[-500:])

                    if exit_code == 2:
                        # Ban risk — enter cooldown
                        self._cooldown_until = time.monotonic() + _BAN_COOLDOWN_SECONDS
                        log.warning(
                            "WhatsApp bridge reported ban risk. Cooldown for %d minutes.",
                            _BAN_COOLDOWN_SECONDS // 60,
                        )
                        break

                    msg = f"WhatsApp bridge failed to start (exit={exit_code}): {output[:200]}"
                    raise ConnectionError(msg) from None

                async with httpx.AsyncClient() as client:
                    resp = await client.get(f"{self._base_url}/status", timeout=3)
                    data = resp.json()
                    if data.get("connected"):
                        self._connected = True
                        log.debug("WhatsApp bridge ready (attempt %d)", attempt + 1)
                        self._health_task = asyncio.create_task(self._health_loop())
                        return
                    if data.get("qr_available"):
                        # QR is showing — bridge is alive, user needs to scan
                        self._connected = False
                        log.info("WhatsApp QR code available — scan in Settings")
                        self._health_task = asyncio.create_task(self._health_loop())
                        return
                    # Bridge HTTP is up but WhatsApp not yet connected — keep waiting
                    log.debug("WhatsApp bridge HTTP up, waiting for connection/QR...")
            except ConnectionError:
                raise
            except Exception:
                continue

        # Timeout — but start health loop anyway so it can recover later
        log.warning("WhatsApp bridge did not become ready in 30s — health loop will monitor")
        self._health_task = asyncio.create_task(self._health_loop())

    async def disconnect(self) -> None:
        """Stop the bridge process and health check."""
        if self._health_task:
            self._health_task.cancel()
            self._health_task = None
        if self._bridge_process:
            with contextlib.suppress(Exception):
                self._bridge_process.terminate()
                self._bridge_process.wait(timeout=5)
            self._bridge_process = None
        self._connected = False
        log.debug("WhatsApp bridge stopped")

    async def send_message(self, chat_id: str, text: str) -> None:
        async with httpx.AsyncClient() as client:
            await client.post(
                f"{self._base_url}/send",
                json={"to": chat_id, "text": text},
                timeout=10,
            )

    async def send_notification(self, chat_id: str, text: str) -> None:
        await self.send_message(chat_id, f"*Notification:* {text}")

    @property
    def is_connected(self) -> bool:
        return self._connected

    async def get_qr_code(self) -> str | None:
        try:
            async with httpx.AsyncClient() as client:
                resp = await client.get(f"{self._base_url}/qr", timeout=5)
                return resp.json().get("qr")
        except Exception:
            return None

    async def get_status(self) -> dict:
        try:
            async with httpx.AsyncClient() as client:
                resp = await client.get(f"{self._base_url}/status", timeout=5)
                data = resp.json()
                # Enrich with supervision state
                data["cooldown_active"] = time.monotonic() < self._cooldown_until
                if data["cooldown_active"]:
                    data["cooldown_remaining_s"] = int(self._cooldown_until - time.monotonic())
                data["restarts_this_hour"] = len(self._restarts_this_hour)
                data["last_exit_code"] = self._last_bridge_exit_code
                return data
        except Exception:
            return {
                "connected": False,
                "qr_available": False,
                "bridge": "not running",
                "cooldown_active": time.monotonic() < self._cooldown_until,
                "last_exit_code": self._last_bridge_exit_code,
            }

    async def _persist_mode(self, mode: str) -> None:
        """Save mode to database so it survives restarts."""
        if self._db:
            try:
                await self._db.execute(
                    "INSERT OR REPLACE INTO settings (key, value) VALUES ('whatsapp_mode', ?)",
                    (mode,),
                )
                await self._db.commit()
            except Exception:
                log.debug("Failed to persist WhatsApp mode")

    async def _load_persisted_mode(self) -> str | None:
        """Load mode from database."""
        if self._db:
            try:
                async with self._db.execute(
                    "SELECT value FROM settings WHERE key = 'whatsapp_mode'"
                ) as cur:
                    row = await cur.fetchone()
                    return row[0] if row else None
            except Exception:
                return None
        return None

    async def set_mode(self, mode: str) -> str:
        """Switch bridge mode at runtime. Returns the new mode."""
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{self._base_url}/mode",
                json={"mode": mode},
                timeout=5,
            )
            data = resp.json()
            if resp.status_code == 200:
                self._mode = data["mode"]
                await self._persist_mode(self._mode)
                log.info("WhatsApp mode switched to: %s", self._mode)
            return data.get("mode", self._mode)

    async def handle_incoming(self, payload: dict) -> str | None:
        sender = payload.get("sender", "")
        text = payload.get("text", "")
        audio_base64 = payload.get("audio_base64")
        audio_mimetype = payload.get("audio_mimetype", "audio/ogg")
        image_base64 = payload.get("image_base64")
        image_mimetype = payload.get("image_mimetype", "image/jpeg")
        document_name = payload.get("document_name")
        document_base64 = payload.get("document_base64")
        document_mimetype = payload.get("document_mimetype")
        video_base64 = payload.get("video_base64")
        video_mimetype = payload.get("video_mimetype", "video/mp4")
        sticker_base64 = payload.get("sticker_base64")
        sticker_mimetype = payload.get("sticker_mimetype", "image/webp")

        if not sender:
            return None

        # Transcribe voice message if present
        if audio_base64 and not text:
            try:
                from agntspace.infra.config import get_settings
                from agntspace.llm.transcribe import transcribe_audio

                settings = get_settings()
                api_key = settings.openai_api_key or None
                text = await transcribe_audio(audio_base64, audio_mimetype, api_key=api_key)
                log.info("Voice message transcribed from %s: %s...", sender, text[:60])
            except Exception:
                log.exception("Voice transcription failed")
                text = "[Voice message could not be transcribed]"

        # Describe image via vision LLM (requires cloud mode with vision-capable model)
        if image_base64 and not text:
            from agntspace.infra.config import get_settings
            settings = get_settings()
            if settings.llm.mode == "local":
                text = "[Image received — I can't view images in local mode, only when connected to a cloud AI provider]"
            else:
                try:
                    from agntspace.llm.base import ImageContent

                    model = f"{settings.llm.cloud_provider}/{settings.llm.cloud_model}"

                    from agntspace.llm.provider import UniversalProvider

                    llm = UniversalProvider(model=model, api_key=settings.anthropic_api_key or settings.openai_api_key)
                    img = ImageContent(base64_data=image_base64, media_type=image_mimetype)
                    resp = await llm.complete_vision(
                        prompt="Describe this image concisely. What is it showing?",
                        images=[img],
                        max_tokens=300,
                    )
                    text = f"[Image received] {resp.content}"
                    log.info("Image described from %s: %s...", sender, text[:80])
                except Exception:
                    log.exception("Image vision analysis failed")
                    text = "[Image received but could not be analyzed]"
        elif image_base64 and text:
            # Image with caption — analyze both together
            from agntspace.infra.config import get_settings
            settings = get_settings()
            if settings.llm.mode == "local":
                text = f"[Image received with caption: \"{text}\" — I can't view images in local mode]"
            else:
                try:
                    from agntspace.llm.base import ImageContent

                    model = f"{settings.llm.cloud_provider}/{settings.llm.cloud_model}"

                    from agntspace.llm.provider import UniversalProvider

                    llm = UniversalProvider(model=model, api_key=settings.anthropic_api_key or settings.openai_api_key)
                    img = ImageContent(base64_data=image_base64, media_type=image_mimetype)
                    resp = await llm.complete_vision(
                        prompt=f'The sender attached this image with caption: "{text}". Describe what the image shows and respond to the caption.',
                        images=[img],
                        max_tokens=300,
                    )
                    text = f"[Image with caption] {resp.content}"
                    log.info("Image+caption analyzed from %s: %s...", sender, text[:80])
                except Exception:
                    log.exception("Image+caption analysis failed")
                    text = f"[Image received] {text}"

        # Handle document — extract text content if available
        if document_base64 and document_name:
            try:
                extracted = await self._extract_document_text(document_base64, document_mimetype, document_name)
                if extracted:
                    prefix = f"[Document: {document_name}]\n\n"
                    if text:
                        text = f"{prefix}Caption: {text}\n\nContent:\n{extracted}"
                    else:
                        text = f"{prefix}{extracted}"
                    log.info("Document extracted from %s: %s (%d chars)", sender, document_name, len(extracted))
                else:
                    text = text or f"[Document received: {document_name}] Could not extract text content."
            except Exception:
                log.exception("Document extraction failed for %s", document_name)
                text = text or f"[Document received: {document_name}] Extraction failed."
        elif document_name and not text:
            text = f"[Document received: {document_name}] No content available."

        # Handle video — describe first frame via vision LLM
        if video_base64 and not image_base64:
            try:
                desc = await self._describe_video(video_base64, video_mimetype, text)
                caption = text or ""
                dur = payload.get("video_duration", 0)
                text = f"[Video received ({dur}s)] {desc}"
                if caption:
                    text = f"[Video with caption: \"{caption}\"] {desc}"
                log.info("Video described from %s: %s...", sender, text[:80])
            except Exception:
                log.exception("Video description failed")
                text = text or "[Video received but could not be analyzed]"

        # Handle sticker — describe via vision LLM (treat as image)
        if sticker_base64 and not text and not image_base64:
            try:
                from agntspace.infra.config import get_settings
                from agntspace.llm.base import ImageContent

                settings = get_settings()
                model = f"{settings.llm.cloud_provider}/{settings.llm.cloud_model}"

                from agntspace.llm.provider import UniversalProvider

                llm = UniversalProvider(model=model, api_key=settings.anthropic_api_key or settings.openai_api_key)
                # Convert webp sticker to image content
                img = ImageContent(base64_data=sticker_base64, media_type=sticker_mimetype)
                resp = await llm.complete_vision(
                    prompt="This is a sticker/emoji image. Briefly describe what it shows and what emotion or reaction it expresses.",
                    images=[img],
                    max_tokens=150,
                )
                text = f"[Sticker] {resp.content}"
                log.info("Sticker described from %s: %s...", sender, text[:60])
            except Exception:
                log.exception("Sticker description failed")
                text = "[Sticker received]"

        if not text:
            return None

        msg = ChannelMessage(
            channel="whatsapp",
            sender=sender,
            text=text,
            metadata={
                "chat_id": sender,
                "is_voice": bool(audio_base64),
                "is_image": bool(image_base64),
                "is_document": bool(document_base64),
                "is_video": bool(video_base64),
                "is_sticker": bool(sticker_base64),
                "document_name": document_name,
                "from_me": payload.get("from_me", False),
                "channel_mode": self._mode,
            },
        )
        if self._message_callback:
            return await self._message_callback(msg)
        return None

    # -----------------------------------------------------------------------
    # Health loop — production-grade process supervision
    # -----------------------------------------------------------------------

    async def _health_loop(self) -> None:
        """Periodically check bridge health, auto-restart if dead or zombie.

        Two-tier monitoring:
        - Liveness: is the process running? Is the HTTP server responding?
        - Readiness: is the WA socket in open state? (HTTP 200 vs 503)

        Circuit breaker: if the bridge is HTTP-responsive but WA disconnected
        for >5 minutes (DISCONNECTED_CIRCUIT_BREAKER * HEALTH_INTERVAL), kill
        and restart. This prevents the "always dead" zombie state.

        Ban protection: if we've restarted too many times in one hour, enter
        a 30-minute cooldown where we refuse to restart.
        """
        consecutive_http_failures = 0
        consecutive_disconnected = 0  # bridge alive but WA not connected (503)

        while True:
            await asyncio.sleep(_HEALTH_INTERVAL)

            # --- Cooldown check -------------------------------------------
            if time.monotonic() < self._cooldown_until:
                remaining = int(self._cooldown_until - time.monotonic())
                if remaining % 300 < _HEALTH_INTERVAL:  # log every ~5 min
                    log.info(
                        "WhatsApp in cooldown (%d min remaining) — not restarting",
                        remaining // 60,
                    )
                continue

            try:
                # --- Tier 1: Process liveness ---------------------------------
                if self._bridge_process and self._bridge_process.poll() is not None:
                    exit_code = self._bridge_process.returncode
                    self._last_bridge_exit_code = exit_code
                    log.warning(
                        "WhatsApp bridge process exited (code %s)", exit_code,
                    )

                    if exit_code == 2:
                        # Ban risk — cooldown
                        self._cooldown_until = time.monotonic() + _BAN_COOLDOWN_SECONDS
                        self._connected = False
                        log.warning(
                            "WhatsApp bridge reported ban risk (exit=2). "
                            "Cooldown for %d minutes.",
                            _BAN_COOLDOWN_SECONDS // 60,
                        )
                        continue

                    # Normal exit (exhausted reconnects) — restart
                    consecutive_http_failures = _HTTP_FAILURE_THRESHOLD
                    raise RuntimeError(f"Bridge process exited with code {exit_code}")

                # --- Tier 2: Readiness (HTTP status) --------------------------
                async with httpx.AsyncClient() as client:
                    resp = await client.get(f"{self._base_url}/status", timeout=5)
                    data = resp.json()

                    if resp.status_code == 200 and data.get("connected"):
                        # Fully healthy
                        if not self._connected:
                            log.info("WhatsApp connection restored")
                        self._connected = True
                        consecutive_http_failures = 0
                        consecutive_disconnected = 0
                        continue

                    # HTTP 503 — bridge alive but WA disconnected
                    consecutive_disconnected += 1
                    consecutive_http_failures = 0  # HTTP works, just not WA
                    self._connected = False

                    if consecutive_disconnected <= 2:
                        log.debug(
                            "WhatsApp: bridge running but not connected "
                            "(attempt %d/%d from bridge, check %d/%d from us)",
                            data.get("reconnect_attempts", "?"),
                            10,
                            consecutive_disconnected,
                            _DISCONNECTED_CIRCUIT_BREAKER,
                        )
                    elif consecutive_disconnected % 5 == 0:
                        log.warning(
                            "WhatsApp disconnected for %d checks (~%dm). "
                            "Circuit breaker at %d.",
                            consecutive_disconnected,
                            (consecutive_disconnected * _HEALTH_INTERVAL) // 60,
                            _DISCONNECTED_CIRCUIT_BREAKER,
                        )

                    # --- Circuit breaker: persistent disconnection -------------
                    if consecutive_disconnected >= _DISCONNECTED_CIRCUIT_BREAKER:
                        log.warning(
                            "WhatsApp disconnected circuit breaker tripped "
                            "(%d checks). Killing zombie bridge.",
                            consecutive_disconnected,
                        )
                        # Force the restart path below
                        consecutive_http_failures = _HTTP_FAILURE_THRESHOLD
                        raise RuntimeError(
                            f"Disconnected for {consecutive_disconnected} checks"
                        )

            except asyncio.CancelledError:
                return
            except Exception:
                consecutive_http_failures += 1
                if consecutive_http_failures <= _HTTP_FAILURE_THRESHOLD:
                    log.warning(
                        "WhatsApp health check failed (%d/%d)",
                        consecutive_http_failures,
                        _HTTP_FAILURE_THRESHOLD,
                    )

            # --- Restart decision -----------------------------------------
            if consecutive_http_failures >= _HTTP_FAILURE_THRESHOLD:
                await self._supervised_restart()
                consecutive_http_failures = 0
                consecutive_disconnected = 0

    async def _supervised_restart(self) -> None:
        """Restart the bridge with ban-protection guardrails.

        Tracks restarts-per-hour. If we exceed the threshold, enters a
        cooldown instead of restarting again (OpenClaw pattern).
        """
        now = time.monotonic()

        # Prune restart timestamps older than 1 hour
        one_hour_ago = now - 3600
        self._restarts_this_hour = [
            ts for ts in self._restarts_this_hour if ts > one_hour_ago
        ]

        if len(self._restarts_this_hour) >= _MAX_RESTARTS_PER_HOUR:
            self._cooldown_until = now + _BAN_COOLDOWN_SECONDS
            log.warning(
                "WhatsApp restarted %d times in the last hour (limit %d). "
                "Entering %d-minute cooldown to prevent ban.",
                len(self._restarts_this_hour),
                _MAX_RESTARTS_PER_HOUR,
                _BAN_COOLDOWN_SECONDS // 60,
            )
            self._connected = False
            return

        self._restarts_this_hour.append(now)

        log.warning(
            "WhatsApp bridge restarting (attempt %d this hour, max %d)...",
            len(self._restarts_this_hour),
            _MAX_RESTARTS_PER_HOUR,
        )
        self._connected = False

        try:
            self._kill_existing_bridge()
            self._start_bridge_process()
            # Give bridge time to start before next health check
            await asyncio.sleep(_RESTART_SETTLE_SECONDS)
        except Exception:
            log.exception("WhatsApp auto-restart failed")
            await asyncio.sleep(_HEALTH_INTERVAL)

    def _start_bridge_process(self) -> None:
        """Start the Node.js bridge subprocess."""
        from agntspace.infra.config import get_settings

        # ``AGNTSPACE_HOME`` is the bridge's base directory — it expects
        # to find (or create) ``data/whatsapp-session/``, ``data/
        # whatsapp-bridge-state.json``, and ``whatsapp-bridge.log``
        # underneath. We point it at the per-vault WhatsApp dir so a
        # different vault = different WA identity. The env var name is
        # kept for bridge.mjs compatibility — semantics remain "the
        # bridge's private writable root", value is per-vault now.
        whatsapp_dir = get_settings().whatsapp_dir
        whatsapp_dir.mkdir(parents=True, exist_ok=True)

        # Resolve node executable path — bare "node" fails on Windows when
        # the server process inherits a PATH that doesn't include Node.js
        # (e.g. running as a daemon/service).  shutil.which honours PATHEXT
        # on Windows and returns the full .exe path.
        node_exe = shutil.which("node")
        if not node_exe:
            raise FileNotFoundError(
                "Cannot find 'node' on PATH. Install Node.js "
                "(https://nodejs.org/) and restart the server."
            )

        env = {
            **dict(os.environ),
            "WHATSAPP_BRIDGE_PORT": str(self._port),
            "WHATSAPP_MODE": self._mode,
            "AGNTSPACE_HOME": str(whatsapp_dir),
        }
        self._bridge_process = subprocess.Popen(
            [node_exe, "bridge.mjs"],
            cwd=str(BRIDGE_DIR),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            env=env,
        )
        log.debug(
            "WhatsApp bridge started (PID: %d, mode: %s)",
            self._bridge_process.pid,
            self._mode,
        )
        # Drain stdout in a daemon thread so the bridge never blocks on a
        # full OS pipe buffer. On Windows, Node's console.log to a pipe is
        # synchronous — if we don't consume, the bridge hangs after a few KB
        # of output and stops forwarding messages even though /status still
        # responds.
        self._bridge_log_tail.clear()
        self._bridge_stdout_thread = threading.Thread(
            target=self._drain_bridge_stdout,
            args=(self._bridge_process,),
            daemon=True,
            name="whatsapp-bridge-stdout",
        )
        self._bridge_stdout_thread.start()

    def _drain_bridge_stdout(self, proc: subprocess.Popen) -> None:
        """Consume bridge stdout line by line so the OS pipe never fills."""
        stream = proc.stdout
        if stream is None:
            return
        try:
            for raw in iter(stream.readline, b""):
                line = raw.decode(errors="replace").rstrip()
                if not line:
                    continue
                self._bridge_log_tail.append(line)
                log.debug("whatsapp-bridge: %s", line)
        except Exception:  # pragma: no cover — defensive, never break the server
            log.exception("WhatsApp bridge stdout drain failed")

    def _kill_existing_bridge(self) -> None:
        """Kill any existing process on the bridge port (cross-platform)."""
        if self._bridge_process:
            with contextlib.suppress(Exception):
                self._bridge_process.terminate()
                self._bridge_process.wait(timeout=3)
            self._bridge_process = None

        for pid in self._find_pids_on_port(self._port):
            log.debug("Killing existing process on port %d (PID: %d)", self._port, pid)
            with contextlib.suppress(Exception):
                os.kill(pid, signal.SIGTERM)

    @staticmethod
    def _find_pids_on_port(port: int) -> list[int]:
        """Find PIDs of processes listening on a TCP port.

        Uses ``netstat -ano`` on Windows, ``lsof`` on macOS/Linux (with an
        ``ss`` fallback for minimal Linux containers). Returns an empty list
        on any failure — never raises.
        """
        pids: list[int] = []
        try:
            if sys.platform == "win32":
                result = subprocess.run(
                    ["netstat", "-ano"],
                    capture_output=True, text=True, timeout=5,
                )
                for line in result.stdout.splitlines():
                    if f"127.0.0.1:{port}" in line and "LISTENING" in line:
                        parts = line.split()
                        with contextlib.suppress(ValueError, IndexError):
                            pids.append(int(parts[-1]))
                return pids

            # macOS + Linux: lsof is the universal answer
            lsof = shutil.which("lsof")
            if lsof:
                result = subprocess.run(
                    [lsof, "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
                    capture_output=True, text=True, timeout=5,
                )
                for line in result.stdout.strip().splitlines():
                    with contextlib.suppress(ValueError):
                        pids.append(int(line.strip()))
                return pids

            # Linux fallback: ss (iproute2)
            ss = shutil.which("ss")
            if ss:
                result = subprocess.run(
                    [ss, "-ltnp", f"sport = :{port}"],
                    capture_output=True, text=True, timeout=5,
                )
                for line in result.stdout.splitlines():
                    for match in re.finditer(r"pid=(\d+)", line):
                        with contextlib.suppress(ValueError):
                            pids.append(int(match.group(1)))
        except Exception:
            log.debug("port-pid lookup failed for %d", port, exc_info=True)
        return pids

    async def _extract_document_text(
        self, base64_data: str, mimetype: str | None, filename: str,
    ) -> str | None:
        """Extract text from a document (PDF, txt, docx, csv, html)."""
        import base64
        from pathlib import Path

        raw = base64.b64decode(base64_data)
        suffix = Path(filename).suffix.lower()

        # Plain text formats
        if suffix in (".txt", ".md", ".csv", ".html", ".htm", ".json", ".xml", ".yaml", ".yml"):
            text = raw.decode("utf-8", errors="replace")
            return text[:8000]  # Truncate for LLM context

        # PDF
        if suffix == ".pdf" or (mimetype and "pdf" in mimetype):
            try:
                import io
                try:
                    import pdfplumber
                    with pdfplumber.open(io.BytesIO(raw)) as pdf:
                        pages = [p.extract_text() or "" for p in pdf.pages[:20]]
                    return "\n\n".join(pages)[:8000]
                except ImportError:
                    pass
                try:
                    from PyPDF2 import PdfReader
                    reader = PdfReader(io.BytesIO(raw))
                    pages = [p.extract_text() or "" for p in reader.pages[:20]]
                    return "\n\n".join(pages)[:8000]
                except ImportError:
                    log.warning("No PDF library available (install pdfplumber or PyPDF2)")
                    return f"[PDF document: {filename}, {len(raw)} bytes — install pdfplumber for text extraction]"
            except Exception:
                log.exception("PDF extraction failed")
                return None

        # DOCX
        if suffix == ".docx" or (mimetype and "wordprocessingml" in (mimetype or "")):
            try:
                import io
                import xml.etree.ElementTree as ET
                import zipfile

                with zipfile.ZipFile(io.BytesIO(raw)) as zf:
                    doc_xml = zf.read("word/document.xml")
                root = ET.fromstring(doc_xml)
                ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
                paragraphs = [
                    "".join(node.text or "" for node in p.iter(f"{{{ns['w']}}}t"))
                    for p in root.iter(f"{{{ns['w']}}}p")
                ]
                return "\n".join(p for p in paragraphs if p)[:8000]
            except Exception:
                log.exception("DOCX extraction failed")
                return None

        # Unsupported format
        log.info("Unsupported document format: %s (%s)", filename, mimetype)
        return None

    async def _describe_video(
        self, base64_data: str, mimetype: str, caption: str | None = None,
    ) -> str:
        """Describe a video by analyzing its content via LLM."""
        from agntspace.infra.config import get_settings

        settings = get_settings()

        # Try to extract a frame using ffmpeg if available
        try:
            import base64 as b64
            import subprocess
            import tempfile
            from pathlib import Path

            raw = b64.b64decode(base64_data)
            with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
                f.write(raw)
                video_path = f.name

            frame_path = video_path + ".jpg"
            result = subprocess.run(
                ["ffmpeg", "-i", video_path, "-ss", "00:00:01", "-frames:v", "1", "-y", frame_path],
                capture_output=True, timeout=10,
            )

            if result.returncode == 0 and Path(frame_path).exists():
                frame_data = Path(frame_path).read_bytes()
                frame_b64 = b64.b64encode(frame_data).decode()

                from agntspace.llm.base import ImageContent
                from agntspace.llm.provider import UniversalProvider

                model = f"{settings.llm.cloud_provider}/{settings.llm.cloud_model}"
                llm = UniversalProvider(model=model, api_key=settings.anthropic_api_key or settings.openai_api_key)
                img = ImageContent(base64_data=frame_b64, media_type="image/jpeg")

                prompt = "This is a frame from a video. Describe what it shows."
                if caption:
                    prompt = f'This is a frame from a video with caption: "{caption}". Describe what the video shows.'

                resp = await llm.complete_vision(prompt=prompt, images=[img], max_tokens=200)

                # Cleanup
                Path(video_path).unlink(missing_ok=True)
                Path(frame_path).unlink(missing_ok=True)
                return resp.content

            Path(video_path).unlink(missing_ok=True)
            Path(frame_path).unlink(missing_ok=True)
        except FileNotFoundError:
            pass  # ffmpeg not installed
        except Exception:
            log.debug("Video frame extraction failed", exc_info=True)

        # Fallback: describe based on metadata
        import base64 as b64
        size_kb = len(b64.b64decode(base64_data)) // 1024
        return f"A video was sent ({size_kb}KB, {mimetype}). No frame extraction available — install ffmpeg for video analysis."
