import asyncio
import inspect
import json
import logging
import mimetypes
import os
import re
import sqlite3
import time
from urllib.parse import quote_plus

import aiohttp
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.cache import cache

from core.clients import ClientBase, transport
from core.messaging import history, media_bridge, reply_sync
from core.models import Message, PersonIdentifier, PlatformChatLink
from core.security.attachments import (
    validate_attachment_metadata,
    validate_attachment_url,
)

try:
    from google.protobuf.json_format import MessageToDict
except Exception:  # pragma: no cover
    MessageToDict = None


class WhatsAppClient(ClientBase):
    """
    Async WhatsApp transport backed by Neonize.

    Design notes:
    - Runs in UR process.
    - Publishes runtime state to shared cache via transport.
    - Degrades gracefully when Neonize/session is unavailable.
    """

    def __init__(self, ur, loop, service="whatsapp"):
        super().__init__(ur, loop, service)
        self._task = None
        self._stopping = False
        self._client = None
        self._build_jid = None
        self._connected = False
        self._last_qr_payload = ""
        self._accounts = []
        self._chat_presence = None
        self._chat_presence_media = None
        self._last_pair_request = 0
        self._next_qr_probe_at = 0.0
        self._qr_handler_registered = False
        self._qr_handler_supported = False
        self._event_hook_callable = False
        self._last_send_error = ""
        self.reply_debug_chat = str(
            getattr(settings, "WHATSAPP_REPLY_DEBUG_CHAT", "120363402761690215")
        ).strip()

        self.enabled = bool(
            str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
            in {"1", "true", "yes", "on"}
        )
        self.client_name = (
            str(getattr(settings, "WHATSAPP_CLIENT_NAME", "gia_whatsapp")).strip()
            or "gia_whatsapp"
        )
        self.database_url = str(getattr(settings, "WHATSAPP_DATABASE_URL", "")).strip()
        safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp"
        # Use a persistent default path (under project mount) instead of /tmp so
        # link state and contact cache survive container restarts.
        default_db_dir = str(
            getattr(settings, "WHATSAPP_DB_DIR", "/var/tmp/whatsapp")
        ).strip()
        self.session_db = self.database_url or os.path.join(
            default_db_dir,
            f"{safe_name}_neonize_v2.db",
        )

        transport.register_runtime_client(self.service, self)
        prior_state = transport.get_runtime_state(self.service)
        prior_accounts = prior_state.get("accounts")
        if not isinstance(prior_accounts, list):
            prior_accounts = []
        self._publish_state(
            connected=False,
            warning=(
                "WhatsApp runtime is disabled by settings." if not self.enabled else ""
            ),
            accounts=prior_accounts,
            last_event="init",
            session_db=self.session_db,
        )
        # Reduce third-party library logging noise (neonize/grpc/protobuf).
        try:
            # Common noisy libraries used by Neonize/WhatsApp stacks
            logging.getLogger("neonize").setLevel(logging.WARNING)
            logging.getLogger("grpc").setLevel(logging.WARNING)
            logging.getLogger("google").setLevel(logging.WARNING)
            logging.getLogger("protobuf").setLevel(logging.WARNING)
            logging.getLogger("whatsmeow").setLevel(logging.WARNING)
            logging.getLogger("whatsmeow.Client").setLevel(logging.WARNING)
            logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
        except Exception:
            pass

    def _publish_state(self, **updates):
        state = transport.update_runtime_state(self.service, **updates)
        accounts = state.get("accounts")
        if isinstance(accounts, list):
            self._accounts = accounts

    def start(self):
        if not self.enabled:
            self.log.info("whatsapp client disabled by settings")
            return
        if self._task is None:
            self.log.info("whatsapp neonize client starting")
            self._task = self.loop.create_task(self._run())

    async def _run(self):
        try:
            import neonize.aioze.client as wa_client_mod
            from neonize.aioze import events as wa_events
            from neonize.aioze.client import NewAClient

            try:
                from neonize.utils.enum import ChatPresence, ChatPresenceMedia
            except Exception:
                ChatPresence = None
                ChatPresenceMedia = None
            try:
                from neonize.utils import build_jid as wa_build_jid
            except Exception:
                wa_build_jid = None
        except Exception as exc:
            self._publish_state(
                connected=False,
                warning=f"Neonize not available: {exc}",
                accounts=[],
                last_event="neonize_import_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp neonize import failed: %s", exc)
            return

        # Neonize async module ships with its own global event loop object.
        # In this runtime we already have a live asyncio loop; bind Neonize's
        # globals to it so QR/pair callbacks and connect tasks actually execute.
        try:
            wa_events.event_global_loop = self.loop
            wa_client_mod.event_global_loop = self.loop
            self._publish_state(
                neonize_loop_bound=True,
                neonize_loop_type=str(type(self.loop).__name__),
                last_event="neonize_loop_bound",
            )
        except Exception as exc:
            self._publish_state(
                neonize_loop_bound=False,
                last_event="neonize_loop_bind_failed",
                last_error=str(exc),
            )
            self.log.warning("failed binding neonize loop: %s", exc)

        self._build_jid = wa_build_jid
        self._chat_presence = ChatPresence
        self._chat_presence_media = ChatPresenceMedia
        try:
            db_dir = os.path.dirname(self.session_db)
            if db_dir:
                os.makedirs(db_dir, exist_ok=True)
            if db_dir and not os.access(db_dir, os.W_OK):
                raise PermissionError(f"session db directory is not writable: {db_dir}")
        except Exception as exc:
            self._publish_state(
                connected=False,
                warning=f"WhatsApp DB path setup failed: {exc}",
                last_event="db_path_setup_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp db path setup failed: %s", exc)
            return
        self._client = self._build_client(NewAClient)
        if self._client is None:
            self._publish_state(
                connected=False,
                warning="Failed to initialize Neonize client.",
                accounts=[],
                last_event="client_init_failed",
                last_error="client_none",
            )
            return

        if not self._session_has_device():
            self._publish_state(
                connected=False,
                warning="No linked WhatsApp device. Waiting for QR pairing.",
                accounts=[],
                last_event="no_linked_device",
                pair_status="needs_pairing",
            )
            self.log.info(
                "whatsapp session db has no linked device — connect will "
                "start QR pairing flow"
            )

        self._register_event_handlers(wa_events)

        try:
            await self._maybe_await(self._client.connect())
            await self._after_connect_probe()
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            self._publish_state(
                connected=False,
                warning=f"WhatsApp connect failed: {exc}",
                accounts=[],
                last_event="connect_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp connect failed: %s", exc)
            return

        # Keep task alive so state/callbacks remain active.
        next_heartbeat_at = 0.0
        next_contacts_sync_at = 0.0
        while not self._stopping:
            now = time.time()
            if now >= next_heartbeat_at:
                self._publish_state(runtime_seen_at=int(now))
                next_heartbeat_at = now + 5.0
            await self._drain_runtime_commands()
            if now >= next_contacts_sync_at:
                await self._sync_contacts_from_client()
                next_contacts_sync_at = now + 30.0
            self._mark_qr_wait_timeout()
            await self._sync_pair_request()
            await self._probe_pending_qr(now)
            await asyncio.sleep(1)

    async def _sync_pair_request(self):
        state = transport.get_runtime_state(self.service)
        requested_at = int(state.get("pair_requested_at") or 0)
        if requested_at <= 0 or requested_at <= self._last_pair_request:
            return
        self._last_pair_request = requested_at
        self._publish_state(
            connected=False,
            pair_qr="",
            warning="Waiting for WhatsApp QR from Neonize.",
            last_event="pair_request_seen",
            pair_status="pending",
            last_error="",
            pair_reconnect_attempted_at=int(time.time()),
        )
        self._next_qr_probe_at = time.time()

        if self._client is None:
            return

        try:
            if hasattr(self._client, "disconnect"):
                await self._maybe_await(self._client.disconnect())
        except Exception as exc:
            self._publish_state(
                last_event="pair_disconnect_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp disconnect before pairing failed: %s", exc)

        try:
            await self._maybe_await(self._client.connect())
            self._publish_state(
                last_event="pair_refresh_connected",
                pair_refresh_connected_at=int(time.time()),
            )
            await self._after_connect_probe()
        except Exception as exc:
            self._publish_state(
                connected=False,
                warning=f"WhatsApp pairing refresh failed: {exc}",
                last_event="pair_refresh_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp pairing refresh failed: %s", exc)

    async def _probe_pending_qr(self, now_ts: float):
        state = transport.get_runtime_state(self.service)
        status = str(state.get("pair_status") or "").strip().lower()
        if status != "pending":
            return
        if str(state.get("pair_qr") or "").strip():
            return
        if now_ts < float(self._next_qr_probe_at or 0.0):
            return
        await self._after_connect_probe()
        latest = transport.get_runtime_state(self.service)
        if str(latest.get("pair_qr") or "").strip():
            self._next_qr_probe_at = now_ts + 30.0
            return
        error_text = str(latest.get("last_error") or "").strip().lower()
        # Neonize may report "client is nil" for a few seconds after connect.
        if "client is nil" in error_text:
            self._next_qr_probe_at = now_ts + 2.0
            return
        self._next_qr_probe_at = now_ts + 5.0

    async def _after_connect_probe(self):
        if self._client is None:
            return
        now_ts = int(time.time())
        try:
            check_connected = getattr(self._client, "is_connected", None)
            if check_connected is not None:
                connected_value = (
                    await self._maybe_await(check_connected())
                    if callable(check_connected)
                    else await self._maybe_await(check_connected)
                )
                if connected_value:
                    self._connected = True
                    account = await self._resolve_account_identifier()
                    account_list = [account] if account else list(self._accounts or [])
                    if not account_list:
                        account_list = [self.client_name]
                    self._publish_state(
                        connected=True,
                        accounts=account_list,
                        pair_status="connected",
                        last_event="connected_probe",
                        connected_at=now_ts,
                    )
        except Exception as exc:
            self._publish_state(
                last_event="connected_probe_failed",
                last_error=str(exc),
            )

        if self._connected:
            await self._sync_contacts_from_client()

        # Neonize does not always emit QR callbacks after reconnect. Try explicit
        # QR-link fetch when available to surface pair data to the UI.
        try:
            if hasattr(self._client, "get_contact_qr_link"):
                qr_link = await self._maybe_await(self._client.get_contact_qr_link())
                qr_payload = self._decode_qr_payload(qr_link)
                self._publish_state(last_qr_probe_at=now_ts)
                if qr_payload:
                    self._last_qr_payload = qr_payload
                    self._publish_state(
                        connected=False,
                        pair_qr=qr_payload,
                        warning="Scan QR in WhatsApp Linked Devices.",
                        last_event="qr_probe_success",
                        pair_status="qr_ready",
                        qr_received_at=now_ts,
                        qr_probe_result="ok",
                        last_error="",
                    )
                else:
                    self._publish_state(
                        last_event="qr_probe_empty",
                        qr_probe_result="empty",
                    )
        except Exception as exc:
            self._publish_state(
                last_event="qr_probe_failed",
                qr_probe_result="error",
                last_error=str(exc),
                last_qr_probe_at=now_ts,
            )

    def _register_event(self, event_cls, callback):
        if event_cls is None:
            return False
        if self._client is None:
            return False
        event_hook = getattr(self._client, "event", None)
        if not callable(event_hook):
            self._event_hook_callable = False
            return False
        self._event_hook_callable = True
        try:
            decorator = event_hook(event_cls)
            decorator(callback)
            return True
        except Exception as exc:
            self.log.warning(
                "whatsapp event registration failed (%s): %s",
                getattr(event_cls, "__name__", str(event_cls)),
                exc,
            )
            self._publish_state(
                last_event="event_registration_failed",
                last_error=str(exc),
            )
            return False

    def _register_qr_handler(self):
        if self._client is None:
            self._qr_handler_supported = False
            self._qr_handler_registered = False
            self._publish_state(
                qr_handler_supported=False,
                qr_handler_registered=False,
            )
            return
        if not hasattr(self._client, "qr"):
            self._qr_handler_supported = False
            self._qr_handler_registered = False
            self._publish_state(
                qr_handler_supported=False,
                qr_handler_registered=False,
                last_event="qr_api_missing",
            )
            return
        self._qr_handler_supported = True

        async def on_qr(client, raw_payload):
            qr_payload = self._decode_qr_payload(raw_payload)
            if not qr_payload:
                return
            self._last_qr_payload = qr_payload
            self._publish_state(
                connected=False,
                pair_qr=qr_payload,
                warning="Scan QR in WhatsApp Linked Devices.",
                last_event="qr_handler",
                pair_status="qr_ready",
                qr_received_at=int(time.time()),
                qr_probe_result="event",
                last_error="",
            )

        try:
            self._client.qr(on_qr)
            self._qr_handler_registered = True
            self._publish_state(
                qr_handler_supported=True,
                qr_handler_registered=True,
                last_event="qr_handler_registered",
            )
        except Exception as exc:
            self._qr_handler_registered = False
            self._publish_state(
                qr_handler_supported=True,
                qr_handler_registered=False,
                last_event="qr_handler_registration_failed",
                last_error=str(exc),
            )
            self.log.warning("whatsapp qr handler registration failed: %s", exc)

    def _decode_qr_payload(self, raw_payload):
        if raw_payload is None:
            return ""
        if isinstance(raw_payload, memoryview):
            raw_payload = raw_payload.tobytes()
        if isinstance(raw_payload, bytes):
            return raw_payload.decode("utf-8", errors="ignore").strip()
        if isinstance(raw_payload, (list, tuple)):
            for item in raw_payload:
                candidate = self._decode_qr_payload(item)
                if candidate:
                    return candidate
            return ""
        return str(raw_payload).strip()

    def _build_client(self, cls):
        # NewAClient first arg is the SQLite filename / DB string.
        try:
            return cls(self.session_db)
        except Exception as exc:
            self.log.warning(
                "whatsapp client init failed (%s): %s", self.session_db, exc
            )
            self._publish_state(
                last_event="client_init_exception",
                last_error=str(exc),
                session_db=self.session_db,
            )
            return None

    def _session_has_device(self):
        """Check whatsmeow_device table for a linked device row.

        Neonize's Go layer panics (SIGSEGV in GetMe) when connect() is called
        on a session with no linked device.  Querying the SQLite schema directly
        lets us skip connect() and avoid crashing the entire UR process.
        """
        if not os.path.exists(self.session_db):
            return False
        try:
            conn = sqlite3.connect(self.session_db)
            cursor = conn.cursor()
            cursor.execute(
                "SELECT COUNT(*) FROM sqlite_master "
                "WHERE type='table' AND name='whatsmeow_device'"
            )
            if cursor.fetchone()[0] == 0:
                conn.close()
                return False
            cursor.execute("SELECT COUNT(*) FROM whatsmeow_device")
            count = cursor.fetchone()[0]
            conn.close()
            return count > 0
        except Exception:
            return False

    def _register_event_handlers(self, wa_events):
        connected_ev = getattr(wa_events, "ConnectedEv", None)
        message_ev = getattr(wa_events, "MessageEv", None)
        receipt_ev = getattr(wa_events, "ReceiptEv", None)
        chat_presence_ev = getattr(wa_events, "ChatPresenceEv", None)
        presence_ev = getattr(wa_events, "PresenceEv", None)
        pair_ev = getattr(wa_events, "PairStatusEv", None)
        qr_ev = getattr(wa_events, "QREv", None)
        history_sync_ev = getattr(wa_events, "HistorySyncEv", None)
        offline_sync_completed_ev = getattr(wa_events, "OfflineSyncCompletedEv", None)

        self._register_qr_handler()
        support = {
            "connected_ev": bool(connected_ev),
            "pair_ev": bool(pair_ev),
            "qr_ev": bool(qr_ev),
            "message_ev": bool(message_ev),
            "receipt_ev": bool(receipt_ev),
            "history_sync_ev": bool(history_sync_ev),
            "offline_sync_completed_ev": bool(offline_sync_completed_ev),
        }
        event_names = [name for name in dir(wa_events) if name.endswith("Ev")]
        message_like_names = [
            name
            for name in event_names
            if "message" in name.lower() or name.lower().startswith("msg")
        ]
        self._publish_state(
            event_hook_callable=bool(getattr(self._client, "event", None)),
            event_support=support,
            event_message_candidates=message_like_names[:20],
            last_event="event_handlers_scanned",
        )

        if connected_ev is not None:

            async def on_connected(client, event: connected_ev):
                self._connected = True
                account = await self._resolve_account_identifier()
                self._publish_state(
                    connected=True,
                    warning="",
                    accounts=[account] if account else [self.client_name],
                    pair_qr="",
                    last_event="connected",
                    pair_status="connected",
                    connected_at=int(time.time()),
                    last_error="",
                )
                await self._sync_contacts_from_client()

            self._register_event(connected_ev, on_connected)

        async def on_message_any(client, event):
            await self._handle_message_event(event)

        registered_message_events = []
        if message_ev is not None:
            if self._register_event(message_ev, on_message_any):
                registered_message_events.append(
                    getattr(message_ev, "__name__", str(message_ev))
                )
        for attr in message_like_names:
            candidate = getattr(wa_events, attr, None)
            if candidate is None or candidate is message_ev:
                continue
            if self._register_event(candidate, on_message_any):
                registered_message_events.append(attr)
        if registered_message_events:
            self._publish_state(
                registered_message_events=registered_message_events[:20],
                last_event="message_events_registered",
            )

        if receipt_ev is not None:

            async def on_receipt(client, event: receipt_ev):
                await self._handle_receipt_event(event)

            self._register_event(receipt_ev, on_receipt)

        if chat_presence_ev is not None:

            async def on_chat_presence(client, event: chat_presence_ev):
                await self._handle_chat_presence_event(event)

            self._register_event(chat_presence_ev, on_chat_presence)

        if presence_ev is not None:

            async def on_presence(client, event: presence_ev):
                await self._handle_presence_event(event)

            self._register_event(presence_ev, on_presence)

        if pair_ev is not None:

            async def on_pair_status(client, event: pair_ev):
                qr_payload = self._extract_pair_qr(event)
                if qr_payload:
                    self._last_qr_payload = qr_payload
                    self._publish_state(
                        pair_qr=qr_payload,
                        warning="Scan QR in WhatsApp Linked Devices.",
                        last_event="pair_status_qr",
                        pair_status="qr_ready",
                        qr_received_at=int(time.time()),
                        qr_probe_result="event",
                        last_error="",
                    )
                status_raw = self._pluck(event, "Status")
                status_text = str(status_raw or "").strip().lower()
                if status_text in {"2", "success"}:
                    account = await self._resolve_account_identifier()
                    self._connected = True
                    self._publish_state(
                        connected=True,
                        warning="",
                        accounts=[account] if account else [self.client_name],
                        pair_qr="",
                        last_event="pair_status_success",
                        pair_status="connected",
                        connected_at=int(time.time()),
                        last_error="",
                    )
                    await self._sync_contacts_from_client()
                elif status_text in {"1", "error"}:
                    error_text = str(self._pluck(event, "Error") or "").strip()
                    self._publish_state(
                        warning=error_text or "WhatsApp pairing failed. Retry scan.",
                        last_event="pair_status_error",
                        pair_status="error",
                        last_error=error_text or "unknown_pair_error",
                    )

            self._register_event(pair_ev, on_pair_status)

        if qr_ev is not None:

            async def on_qr_event(client, event: qr_ev):
                # Once connected, ignore late/stale QR emissions so runtime state
                # does not regress from connected -> qr_ready.
                state = transport.get_runtime_state(self.service)
                if self._connected or bool(state.get("connected")):
                    return
                qr_payload = self._extract_pair_qr(event)
                if not qr_payload:
                    return
                self._last_qr_payload = qr_payload
                self._publish_state(
                    connected=False,
                    pair_qr=qr_payload,
                    warning="Scan QR in WhatsApp Linked Devices.",
                    last_event="qr_event",
                    pair_status="qr_ready",
                    qr_received_at=int(time.time()),
                    qr_probe_result="event",
                    last_error="",
                )

            self._register_event(qr_ev, on_qr_event)

        if history_sync_ev is not None:

            async def on_history_sync(client, event: history_sync_ev):
                await self._handle_history_sync_event(event)

            self._register_event(history_sync_ev, on_history_sync)

        if offline_sync_completed_ev is not None:

            async def on_offline_sync_completed(
                client, event: offline_sync_completed_ev
            ):
                self._publish_state(last_event="offline_sync_completed")
                await self._sync_contacts_from_client()

            self._register_event(offline_sync_completed_ev, on_offline_sync_completed)

    def _mark_qr_wait_timeout(self):
        state = transport.get_runtime_state(self.service)
        if str(state.get("pair_status") or "").strip().lower() != "pending":
            return
        requested_at = int(state.get("pair_requested_at") or 0)
        qr_received_at = int(state.get("qr_received_at") or 0)
        if requested_at <= 0 or qr_received_at > 0:
            return
        now = int(time.time())
        age = now - requested_at
        # Avoid spamming writes while still surfacing a clear timeout state.
        if age < 15 or (age % 10) != 0:
            return
        self._publish_state(
            last_event="pair_waiting_no_qr",
            warning=(
                "Waiting for WhatsApp QR from Neonize. " "No QR callback received yet."
            ),
        )

    async def _maybe_await(self, value):
        if asyncio.iscoroutine(value):
            return await value
        return value

    async def _call_client_method(self, method, *args, timeout: float | None = None):
        if method is None:
            return None
        if inspect.iscoroutinefunction(method):
            coro = method(*args)
        else:
            coro = asyncio.to_thread(method, *args)
        if timeout and timeout > 0:
            return await asyncio.wait_for(coro, timeout=timeout)
        return await coro

    async def _drain_runtime_commands(self):
        # Process a small burst each loop to keep sends responsive but avoid starvation.
        for _ in range(5):
            command = transport.pop_runtime_command(self.service)
            if not command:
                return
            await self._execute_runtime_command(command)

    async def _execute_runtime_command(self, command):
        command_id = str((command or {}).get("id") or "").strip()
        action = str((command or {}).get("action") or "").strip()
        payload = dict((command or {}).get("payload") or {})
        if not command_id:
            return
        self.log.debug(
            "whatsapp runtime command start: id=%s action=%s",
            command_id,
            action,
        )

        if action == "send_message_raw":
            recipient = str(payload.get("recipient") or "").strip()
            text = payload.get("text")
            attachments = payload.get("attachments") or []
            metadata = dict(payload.get("metadata") or {})
            send_timeout_s = 18.0
            try:
                # Include command_id so send_message_raw can observe cancel requests
                result = await asyncio.wait_for(
                    self.send_message_raw(
                        recipient=recipient,
                        text=text,
                        attachments=attachments,
                        command_id=command_id,
                        metadata=metadata,
                    ),
                    timeout=send_timeout_s,
                )
                if result is not False and result is not None:
                    transport.set_runtime_command_result(
                        self.service,
                        command_id,
                        {
                            "ok": True,
                            "timestamp": (
                                int(result)
                                if isinstance(result, int)
                                else int(time.time() * 1000)
                            ),
                        },
                    )
                    self.log.debug(
                        "whatsapp runtime command ok: id=%s action=%s",
                        command_id,
                        action,
                    )
                    return
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": str(
                            getattr(self, "_last_send_error", "")
                            or "runtime_send_failed"
                        ),
                    },
                )
                self.log.warning(
                    "whatsapp runtime command failed: id=%s action=%s error=%s",
                    command_id,
                    action,
                    str(getattr(self, "_last_send_error", "") or "runtime_send_failed"),
                )
                return
            except asyncio.TimeoutError:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": f"runtime_send_timeout:{int(send_timeout_s)}s",
                    },
                )
                self.log.warning(
                    "whatsapp runtime command timeout: id=%s action=%s timeout=%ss",
                    command_id,
                    action,
                    int(send_timeout_s),
                )
                return
            except Exception as exc:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": str(exc),
                    },
                )
                self.log.warning(
                    "whatsapp runtime command exception: id=%s action=%s error=%s",
                    command_id,
                    action,
                    exc,
                )
                return

        if action == "send_reaction":
            recipient = str(payload.get("recipient") or "").strip()
            emoji = str(payload.get("emoji") or "")
            target_message_id = str(payload.get("target_message_id") or "").strip()
            target_timestamp = int(payload.get("target_timestamp") or 0)
            remove = bool(payload.get("remove"))
            try:
                ok = await self.send_reaction(
                    recipient=recipient,
                    emoji=emoji,
                    target_message_id=target_message_id,
                    target_timestamp=target_timestamp,
                    remove=remove,
                )
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": bool(ok),
                        "timestamp": int(time.time() * 1000),
                        "error": "" if ok else "reaction_send_failed",
                    },
                )
                return
            except Exception as exc:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": str(exc),
                    },
                )
                return

        if action == "force_history_sync":
            target_identifier = str(payload.get("identifier") or "").strip()
            try:
                result = await self._force_history_sync(target_identifier)
            except Exception as exc:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": str(exc),
                    },
                )
                return
            transport.set_runtime_command_result(
                self.service,
                command_id,
                {
                    "ok": True,
                    **dict(result or {}),
                },
            )
            return

        if action == "notify_xmpp_sent":
            person_identifier_id = str(
                payload.get("person_identifier_id") or ""
            ).strip()
            text = str(payload.get("text") or "")
            if not person_identifier_id:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {"ok": False, "error": "missing_person_identifier_id"},
                )
                return
            try:
                identifier = await sync_to_async(
                    lambda: PersonIdentifier.objects.filter(id=person_identifier_id)
                    .select_related("user", "person")
                    .first()
                )()
                if identifier is None:
                    transport.set_runtime_command_result(
                        self.service,
                        command_id,
                        {"ok": False, "error": "person_identifier_not_found"},
                    )
                    return
                await self.ur.xmpp.client.send_from_external(
                    identifier.user,
                    identifier,
                    text,
                    True,
                    attachments=[],
                )
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {"ok": True, "timestamp": int(time.time() * 1000)},
                )
                return
            except Exception as exc:
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {"ok": False, "error": str(exc)},
                )
                return

        transport.set_runtime_command_result(
            self.service,
            command_id,
            {
                "ok": False,
                "error": f"unsupported_action:{action or '-'}",
            },
        )

    async def _force_history_sync(self, identifier: str = ""):
        started_at = int(time.time())
        self._publish_state(
            last_event="manual_history_sync_started",
            history_sync_running=True,
            history_sync_started_at=started_at,
            history_sync_target=identifier or "",
        )
        try:
            await self._sync_contacts_from_client()
            history_request = await self._request_on_demand_history(
                identifier=identifier,
                count=120,
            )
            self._publish_state(
                history_on_demand_requested=bool(history_request.get("requested")),
                history_on_demand_error=str(history_request.get("error") or ""),
                history_on_demand_anchor=str(history_request.get("anchor_key") or ""),
                history_on_demand_at=int(time.time()),
            )
            if history_request.get("requested"):
                # Give on-demand history chunks a brief window to arrive before command returns.
                await asyncio.sleep(3)
            # Best-effort probe: reconnect state and QR/connection probes can unlock
            # pending history sync callbacks in some runtime sessions.
            await self._after_connect_probe()
            sqlite_sync = await self._sync_history_from_sqlite(identifier=identifier)
            self._publish_state(
                history_sqlite_imported=int(sqlite_sync.get("imported", 0)),
                history_sqlite_scanned=int(sqlite_sync.get("scanned", 0)),
                history_sqlite_rows=int(sqlite_sync.get("rows", 0)),
                history_sqlite_table=str(sqlite_sync.get("table") or ""),
                history_sqlite_ts=int(time.time()),
            )
        finally:
            finished_at = int(time.time())
            self._publish_state(
                history_sync_running=False,
                history_sync_finished_at=finished_at,
                history_sync_duration_ms=int((finished_at - started_at) * 1000),
                last_event="manual_history_sync_finished",
            )
        state = transport.get_runtime_state(self.service)
        return {
            "started_at": started_at,
            "finished_at": int(
                state.get("history_sync_finished_at") or int(time.time())
            ),
            "duration_ms": int(state.get("history_sync_duration_ms") or 0),
            "contacts_sync_count": int(state.get("contacts_sync_count") or 0),
            "history_imported_messages": int(
                state.get("history_imported_messages") or 0
            ),
            "sqlite_imported_messages": int(state.get("history_sqlite_imported") or 0),
            "sqlite_scanned_messages": int(state.get("history_sqlite_scanned") or 0),
            "sqlite_table": str(state.get("history_sqlite_table") or ""),
            "sqlite_error": str(state.get("history_sqlite_error") or ""),
            "on_demand_requested": bool(state.get("history_on_demand_requested")),
            "on_demand_error": str(state.get("history_on_demand_error") or ""),
            "last_event": str(state.get("last_event") or ""),
        }

    async def _request_on_demand_history(self, identifier: str, count: int = 120):
        if not self._client:
            return {"requested": False, "error": "client_missing"}
        normalized = str(identifier or "").strip()
        if not normalized:
            return {"requested": False, "error": "identifier_missing"}

        state = transport.get_runtime_state(self.service)
        anchors = state.get("history_anchors") or {}
        anchor = None
        anchor_key = ""
        for candidate in self._normalize_identifier_candidates(normalized):
            row = anchors.get(str(candidate))
            if isinstance(row, dict) and row.get("msg_id"):
                anchor = row
                anchor_key = str(candidate)
                break
        if not anchor:
            anchor = await self._load_history_anchor_from_sqlite(identifier=normalized)
            if isinstance(anchor, dict) and anchor.get("msg_id"):
                anchor_key = str(anchor.get("anchor_key") or "")
            else:
                return {"requested": False, "error": "anchor_missing"}

        try:
            from neonize.builder import build_history_sync_request
            from neonize.proto.Neonize_pb2 import MessageInfo, MessageSource
        except Exception as exc:
            return {"requested": False, "error": f"history_builder_unavailable:{exc}"}

        try:
            chat_raw = str(anchor.get("chat_jid") or normalized).strip()
            sender_raw = str(anchor.get("sender_jid") or chat_raw).strip()
            if not chat_raw:
                return {"requested": False, "error": "anchor_chat_missing"}
            chat_jid = self._to_jid(chat_raw)
            sender_jid = self._to_jid(sender_raw)
            info = MessageInfo(
                MessageSource=MessageSource(
                    Chat=chat_jid,
                    Sender=sender_jid,
                    IsFromMe=bool(anchor.get("from_me")),
                    IsGroup=False,
                ),
                ID=str(anchor.get("msg_id") or ""),
                Timestamp=int(anchor.get("ts") or int(time.time() * 1000)),
            )
            request_msg = build_history_sync_request(
                info, max(10, min(int(count or 120), 500))
            )
            await self._maybe_await(self._client.send_message(chat_jid, request_msg))
            self._publish_state(
                last_event="history_on_demand_requested",
                last_error="",
            )
            return {"requested": True, "anchor_key": anchor_key}
        except Exception as exc:
            self._publish_state(
                last_event="history_on_demand_request_failed",
                last_error=str(exc),
            )
            return {"requested": False, "error": str(exc), "anchor_key": anchor_key}

    async def _load_history_anchor_from_sqlite(self, identifier: str):
        def _read_anchor():
            if not self.session_db or not os.path.exists(self.session_db):
                return {}
            try:
                conn = sqlite3.connect(self.session_db)
                conn.row_factory = sqlite3.Row
            except Exception:
                return {}
            try:
                cur = conn.cursor()
                table_rows = cur.execute(
                    "SELECT name FROM sqlite_master WHERE type='table'"
                ).fetchall()
                table_names = [str(row[0]) for row in table_rows if row and row[0]]
                if "whatsmeow_message_secrets" not in table_names:
                    return {}
                candidates = self._normalize_identifier_candidates(identifier)
                local_values = set()
                for value in candidates:
                    local = str(value or "").strip().split("@", 1)[0]
                    if local:
                        local_values.add(local)
                if not local_values:
                    return {}
                placeholders = ",".join("?" for _ in local_values)
                query = (
                    "SELECT our_jid, chat_jid, sender_jid, message_id "
                    'FROM "whatsmeow_message_secrets" '
                    f"WHERE substr(chat_jid, 1, instr(chat_jid, '@') - 1) IN ({placeholders}) "
                    "ORDER BY rowid DESC LIMIT 1"
                )
                row = cur.execute(query, tuple(local_values)).fetchone()
                if not row:
                    return {}
                our_jid = str(row["our_jid"] or "")
                own_user = our_jid.split("@", 1)[0].split(":", 1)[0].strip()
                sender_jid = str(row["sender_jid"] or "")
                sender_user = sender_jid.split("@", 1)[0].split(":", 1)[0].strip()
                from_me = bool(own_user and sender_user and own_user == sender_user)
                chat_jid = str(row["chat_jid"] or "")
                msg_id = str(row["message_id"] or "")
                if not chat_jid or not msg_id:
                    return {}
                anchor_key = chat_jid.split("@", 1)[0]
                return {
                    "chat_jid": chat_jid,
                    "sender_jid": sender_jid,
                    "msg_id": msg_id,
                    "from_me": from_me,
                    "ts": int(time.time() * 1000),
                    "anchor_key": anchor_key,
                }
            except Exception:
                return {}
            finally:
                conn.close()

        return await asyncio.to_thread(_read_anchor)

    async def _sync_history_from_sqlite(self, identifier: str = ""):
        def _extract_rows():
            if not self.session_db or not os.path.exists(self.session_db):
                return {"rows": [], "table": "", "error": "sqlite_missing"}
            try:
                conn = sqlite3.connect(self.session_db)
                conn.row_factory = sqlite3.Row
            except Exception as exc:
                return {"rows": [], "table": "", "error": f"sqlite_open_failed:{exc}"}
            try:
                cur = conn.cursor()
                table_rows = cur.execute(
                    "SELECT name FROM sqlite_master WHERE type='table'"
                ).fetchall()
                table_names = [str(row[0]) for row in table_rows if row and row[0]]
                preferred = [
                    "whatsmeow_messages",
                    "messages",
                    "message",
                ]
                selected = ""
                for candidate in preferred:
                    if candidate in table_names:
                        selected = candidate
                        break
                if not selected:
                    for name in table_names:
                        lowered = name.lower()
                        if "message" not in lowered:
                            continue
                        if any(
                            token in lowered
                            for token in (
                                "secret",
                                "contacts",
                                "contact",
                                "chat_settings",
                                "event_buffer",
                                "identity",
                                "pre_keys",
                                "sender_keys",
                                "session",
                                "version",
                                "privacy",
                                "lid_map",
                                "device",
                                "app_state",
                            )
                        ):
                            continue
                        if "contact" not in lowered:
                            selected = name
                            break
                if not selected:
                    return {
                        "rows": [],
                        "table": "",
                        "error": "messages_table_not_found",
                    }

                columns = [
                    str(row[1] or "")
                    for row in cur.execute(
                        f'PRAGMA table_info("{selected}")'
                    ).fetchall()
                ]
                if not columns:
                    return {"rows": [], "table": selected, "error": "no_columns"}

                def pick(options):
                    lowered = {col.lower(): col for col in columns}
                    for option in options:
                        if option in lowered:
                            return lowered[option]
                    for option in options:
                        for col in columns:
                            if option in col.lower():
                                return col
                    return ""

                text_col = pick(
                    [
                        "text",
                        "conversation",
                        "body",
                        "caption",
                        "content",
                        "message",
                        "msg",
                    ]
                )
                ts_col = pick(
                    [
                        "message_timestamp",
                        "messagetimestamp",
                        "timestamp",
                        "ts",
                        "time",
                    ]
                )
                from_me_col = pick(
                    [
                        "from_me",
                        "fromme",
                        "is_from_me",
                        "isfromme",
                        "outgoing",
                    ]
                )
                sender_col = pick(
                    [
                        "sender_jid",
                        "sender",
                        "participant",
                        "from_jid",
                        "from",
                    ]
                )
                chat_col = pick(
                    [
                        "chat_jid",
                        "remote_jid",
                        "their_jid",
                        "jid",
                        "chat",
                    ]
                )
                if not (text_col and ts_col and (sender_col or chat_col)):
                    return {
                        "rows": [],
                        "table": selected,
                        "error": "required_message_columns_missing",
                    }
                select_cols = [
                    col
                    for col in [text_col, ts_col, from_me_col, sender_col, chat_col]
                    if col
                ]
                quoted = ", ".join(f'"{col}"' for col in select_cols)
                order_expr = f'"{ts_col}" DESC' if ts_col else "ROWID DESC"
                sql = f'SELECT {quoted} FROM "{selected}" ORDER BY {order_expr} LIMIT 12000'
                try:
                    rows = cur.execute(sql).fetchall()
                except Exception as exc:
                    return {
                        "rows": [],
                        "table": selected,
                        "error": f"messages_query_failed:{exc}",
                    }
                parsed = []
                for row in rows:
                    row_map = {col: row[idx] for idx, col in enumerate(select_cols)}
                    text = str(row_map.get(text_col) or "").strip()
                    if not text:
                        continue
                    raw_sender = (
                        str(row_map.get(sender_col) or "").strip() if sender_col else ""
                    )
                    raw_chat = (
                        str(row_map.get(chat_col) or "").strip() if chat_col else ""
                    )
                    raw_from_me = row_map.get(from_me_col) if from_me_col else None
                    parsed.append(
                        {
                            "text": text,
                            "ts": row_map.get(ts_col),
                            "from_me": raw_from_me,
                            "sender": raw_sender,
                            "chat": raw_chat,
                        }
                    )
                return {"rows": parsed, "table": selected, "error": ""}
            finally:
                conn.close()

        extracted = await asyncio.to_thread(_extract_rows)
        rows = list(extracted.get("rows") or [])
        table_name = str(extracted.get("table") or "")
        error_text = str(extracted.get("error") or "").strip()
        if error_text:
            self._publish_state(
                last_event="sqlite_history_scan_failed",
                history_sqlite_error=error_text,
            )
            return {"imported": 0, "scanned": 0, "rows": 0, "table": table_name}
        if not rows:
            return {"imported": 0, "scanned": 0, "rows": 0, "table": table_name}

        target_candidates = self._normalize_identifier_candidates(identifier)
        target_local = str(identifier or "").strip().split("@", 1)[0]
        if target_local:
            target_candidates.update(
                self._normalize_identifier_candidates(target_local)
            )

        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(service="whatsapp")
        )
        if not identifiers:
            return {"imported": 0, "scanned": 0, "rows": len(rows), "table": table_name}

        by_candidate = {}
        for row in identifiers:
            values = self._normalize_identifier_candidates(row.identifier)
            for candidate in values:
                if not candidate:
                    continue
                by_candidate.setdefault(candidate, []).append(row)

        imported = 0
        scanned = 0
        session_cache = {}
        for row in rows:
            sender = self._jid_to_identifier(row.get("sender"))
            chat = self._jid_to_identifier(row.get("chat"))
            row_candidates = self._normalize_identifier_candidates(sender, chat)
            if not row_candidates:
                continue
            if target_candidates and not (row_candidates & target_candidates):
                continue
            matched_identifiers = {}
            for candidate in row_candidates:
                for pi in by_candidate.get(candidate, []):
                    matched_identifiers[int(pi.id)] = pi
            if not matched_identifiers:
                continue
            scanned += 1
            ts = self._normalize_timestamp(row.get("ts"))
            text = str(row.get("text") or "").strip()
            if not text:
                continue
            from_me_raw = row.get("from_me")
            from_me_text = str(from_me_raw or "").strip().lower()
            from_me = from_me_text in {"1", "true", "t", "yes", "y"}
            sender_uuid = "" if from_me else str(sender or chat or "")
            custom_author = "BOT" if from_me else None
            for pi in matched_identifiers.values():
                session = session_cache.get(int(pi.id))
                if session is None:
                    session = await history.get_chat_session(pi.user, pi)
                    session_cache[int(pi.id)] = session
                exists = await sync_to_async(
                    Message.objects.filter(
                        user=pi.user,
                        session=session,
                        ts=ts,
                        sender_uuid=sender_uuid,
                        text=text,
                        custom_author=custom_author,
                    ).exists
                )()
                if exists:
                    continue
                await sync_to_async(Message.objects.create)(
                    user=pi.user,
                    session=session,
                    ts=ts,
                    sender_uuid=sender_uuid,
                    text=text,
                    custom_author=custom_author,
                    delivered_ts=ts,
                )
                imported += 1

        self.log.info(
            "whatsapp sqlite history sync: table=%s scanned=%s imported=%s rows=%s target=%s",
            table_name or "-",
            scanned,
            imported,
            len(rows),
            identifier or "-",
        )
        return {
            "imported": imported,
            "scanned": scanned,
            "rows": len(rows),
            "table": table_name,
        }

    async def _resolve_account_identifier(self):
        if self._client is None:
            return ""
        if not hasattr(self._client, "get_me"):
            return self.client_name
        # Guard: Neonize Go GetMe() panics (SIGSEGV) when cli.LID is nil
        # on unlinked sessions.  Only safe to call after pairing completes
        # and the device row exists in the whatsmeow_device table.
        if not self._session_has_device():
            return self.client_name
        try:
            me = await self._maybe_await(self._client.get_me())
        except Exception:
            return self.client_name
        # Support both dict-like and object-like payloads.
        for path in (
            ("JID",),
            ("jid",),
        ):
            value = self._jid_to_identifier(self._pluck(me, *path))
            if value:
                return value
        for path in (
            ("JID", "User"),
            ("jid",),
            ("user",),
            ("ID",),
        ):
            value = self._pluck(me, *path)
            if value:
                return str(value)
        if hasattr(self._client, "get_all_devices"):
            try:
                devices = await self._maybe_await(self._client.get_all_devices())
                if devices:
                    jid = self._jid_to_identifier(self._pluck(devices[0], "JID"))
                    if jid:
                        return jid
            except Exception:
                pass
        return self.client_name

    def _pluck(self, obj, *path):
        current = obj
        for key in path:
            if current is None:
                return None
            if isinstance(current, dict):
                current = current.get(key)
                continue
            if hasattr(current, key):
                current = getattr(current, key)
                continue
            return None
        return current

    def _shape_keys(self, obj):
        if obj is None:
            return []
        if isinstance(obj, dict):
            return sorted(str(key) for key in obj.keys())
        if hasattr(obj, "__dict__"):
            return sorted(str(key) for key in vars(obj).keys())
        return []

    def _proto_to_dict(self, obj):
        if obj is None:
            return {}
        if isinstance(obj, dict):
            return obj
        # Neonize emits protobuf objects for inbound events. Convert them to a
        # plain dict so nested contextInfo reply fields are addressable.
        if MessageToDict is not None and hasattr(obj, "DESCRIPTOR"):
            try:
                return MessageToDict(
                    obj,
                    preserving_proto_field_name=True,
                    use_integers_for_enums=True,
                )
            except Exception:
                pass
        return {}

    def _chat_matches_reply_debug(self, chat: str) -> bool:
        target = str(self.reply_debug_chat or "").strip()
        value = str(chat or "").strip()
        if not target or not value:
            return False
        value_local = value.split("@", 1)[0]
        return value == target or value_local == target

    def _extract_reply_hints(self, obj, max_depth: int = 6):
        hints = []

        def walk(value, path="", depth=0):
            if depth > max_depth or value is None:
                return
            if isinstance(value, dict):
                for key, nested in value.items():
                    key_str = str(key)
                    next_path = f"{path}.{key_str}" if path else key_str
                    lowered = key_str.lower()
                    if any(
                        token in lowered
                        for token in ("stanza", "quoted", "reply", "context")
                    ):
                        if isinstance(nested, (str, int, float, bool)):
                            hints.append(
                                {
                                    "path": next_path,
                                    "value": str(nested)[:180],
                                }
                            )
                    walk(nested, next_path, depth + 1)
                return
            if isinstance(value, list):
                for idx, nested in enumerate(value):
                    walk(nested, f"{path}[{idx}]", depth + 1)

        walk(obj, "", 0)
        # Deduplicate by path/value for compact diagnostics.
        unique = []
        seen = set()
        for row in hints:
            key = (str(row.get("path") or ""), str(row.get("value") or ""))
            if key in seen:
                continue
            seen.add(key)
            unique.append(row)
        return unique[:40]

    def _normalize_timestamp(self, raw_value):
        if raw_value is None:
            return int(time.time() * 1000)
        try:
            value = int(raw_value)
        except Exception:
            return int(time.time() * 1000)
        # WhatsApp libs often emit seconds. Promote to ms.
        if value < 10**12:
            return value * 1000
        return value

    def _normalize_identifier_candidates(self, *values):
        out = set()
        state = transport.get_runtime_state(self.service)
        lid_map = state.get("lid_map") or {}
        for value in values:
            raw = self._jid_to_identifier(value)
            if not raw:
                continue
            out.add(raw)
            if "@" in raw:
                out.add(raw.split("@", 1)[0])
            digits = re.sub(r"[^0-9]", "", raw)
            if digits:
                out.add(digits)
                if not digits.startswith("+"):
                    out.add(f"+{digits}")
                if "@lid" in raw:
                    mapped = re.sub(r"[^0-9]", "", str(lid_map.get(digits) or ""))
                    if mapped:
                        out.add(mapped)
                        out.add(f"+{mapped}")
                        out.add(f"{mapped}@s.whatsapp.net")
        return out

    def _message_identifier_candidates(self, *, sender, chat, is_from_me):
        """
        Resolve the logical contact for a WhatsApp message event.

        Direct outbound messages must bind to the chat peer, not the sender,
        otherwise the user's own account identifier can fan out the same message
        into unrelated XMPP contact threads.
        """
        sender_value = self._jid_to_identifier(sender)
        chat_value = self._jid_to_identifier(chat)
        candidate_values = []

        if chat_value.endswith("@g.us"):
            candidate_values.append(chat)
        elif is_from_me:
            if chat_value:
                candidate_values.append(chat)
            elif sender_value:
                candidate_values.append(sender)
        else:
            if sender_value:
                candidate_values.append(sender)
            elif chat_value:
                candidate_values.append(chat)

        return self._normalize_identifier_candidates(*candidate_values)

    async def _sync_contacts_from_client(self):
        if self._client is None:
            return
        connected_now = await self._is_contact_sync_ready()
        if not connected_now:
            self._publish_state(
                last_event="contacts_sync_skipped_disconnected",
                contacts_source="disconnected",
            )
            return
        # NOTE: Neonize get_all_contacts has crashed some runtime builds with a Go panic.
        # Read contact-like rows directly from the session sqlite DB instead.
        contacts, source, lid_map = await self._sync_contacts_from_sqlite()
        groups, groups_source = await self._sync_groups_from_client()
        await self._upsert_groups(groups)
        now_ts = int(time.time())

        if contacts:
            self.log.debug(
                "whatsapp contacts synced: count=%s source=%s",
                len(contacts),
                source or "unknown",
            )
            self._publish_state(
                contacts=contacts,
                lid_map=lid_map,
                contacts_synced_at=now_ts,
                contacts_sync_count=len(contacts),
                last_event="contacts_synced",
                contacts_source=source or "unknown",
                last_error="",
            )
        else:
            self.log.debug("whatsapp contacts sync empty (%s)", source or "unknown")
            self._publish_state(
                last_event="contacts_sync_empty",
                contacts_source=source or "unknown",
            )

        if groups_source:
            event_name = "groups_synced" if groups else "groups_sync_empty"
            self._publish_state(
                groups=groups,
                groups_source=groups_source,
                groups_sync_count=len(groups),
                groups_synced_at=now_ts,
                last_event=event_name,
                last_error="" if groups else "",
            )

    async def _sync_contacts_from_sqlite(self):
        def _extract():
            if not self.session_db or not os.path.exists(self.session_db):
                return [], "sqlite_missing", {}
            try:
                conn = sqlite3.connect(self.session_db)
                conn.row_factory = sqlite3.Row
            except Exception:
                return [], "sqlite_open_failed", {}
            try:
                cur = conn.cursor()
                table_rows = cur.execute(
                    "SELECT name FROM sqlite_master WHERE type='table'"
                ).fetchall()
                table_names = [str(row[0]) for row in table_rows if row and row[0]]
                lid_map = {}
                if "whatsmeow_lid_map" in table_names:
                    try:
                        lid_rows = cur.execute(
                            'SELECT "lid", "pn" FROM "whatsmeow_lid_map" LIMIT 10000'
                        ).fetchall()
                    except Exception:
                        lid_rows = []
                    for lid, pn in lid_rows:
                        lid_key = re.sub(r"[^0-9]", "", str(lid or "").strip())
                        pn_value = re.sub(r"[^0-9]", "", str(pn or "").strip())
                        if lid_key and pn_value:
                            lid_map[lid_key] = pn_value
                # Prefer the canonical contacts table when available.
                if "whatsmeow_contacts" in table_names:
                    own_ids = set()
                    for value in (
                        transport.get_runtime_state(self.service).get("accounts") or []
                    ):
                        base = str(value or "").strip().split("@", 1)[0]
                        base = base.split(":", 1)[0]
                        if base:
                            own_ids.add(base.lower())
                    try:
                        for row in cur.execute(
                            'SELECT DISTINCT "our_jid" FROM "whatsmeow_contacts"'
                        ).fetchall():
                            base = (
                                str((row or [None])[0] or "").strip().split("@", 1)[0]
                            )
                            base = base.split(":", 1)[0]
                            if base:
                                own_ids.add(base.lower())
                    except Exception:
                        pass

                    out = []
                    seen = set()
                    try:
                        rows = cur.execute(
                            'SELECT "their_jid", "first_name", "full_name", "push_name", "business_name" '
                            'FROM "whatsmeow_contacts" LIMIT 5000'
                        ).fetchall()
                    except Exception:
                        rows = []
                    for (
                        their_jid,
                        first_name,
                        full_name,
                        push_name,
                        business_name,
                    ) in rows:
                        jid_value = str(their_jid or "").strip()
                        if (
                            "@s.whatsapp.net" not in jid_value
                            and "@lid" not in jid_value
                        ):
                            continue
                        identifier = jid_value.split("@", 1)[0].strip().split(":", 1)[0]
                        if "@lid" in jid_value:
                            mapped = lid_map.get(re.sub(r"[^0-9]", "", identifier))
                            if mapped:
                                identifier = mapped
                                jid_value = f"{mapped}@s.whatsapp.net"
                        if not identifier:
                            continue
                        if identifier.lower() in own_ids:
                            continue
                        if identifier in seen:
                            continue
                        seen.add(identifier)
                        display_name = (
                            str(push_name or "").strip()
                            or str(full_name or "").strip()
                            or str(business_name or "").strip()
                            or str(first_name or "").strip()
                        )
                        out.append(
                            {
                                "identifier": identifier,
                                "jid": jid_value,
                                "name": display_name,
                                "chat": "",
                                "seen_at": int(time.time()),
                            }
                        )
                        if len(out) >= 500:
                            break
                    if out:
                        return out, "sqlite_contacts", lid_map

                account_keys = {
                    str(value or "").strip().split("@", 1)[0].lower()
                    for value in (
                        transport.get_runtime_state(self.service).get("accounts") or []
                    )
                    if str(value or "").strip()
                }
                seen = set()
                out = []
                for table in table_names:
                    try:
                        columns = [
                            str(row[1] or "")
                            for row in cur.execute(
                                f'PRAGMA table_info("{table}")'
                            ).fetchall()
                        ]
                    except Exception:
                        continue
                    if not columns:
                        continue
                    jid_cols = [
                        col
                        for col in columns
                        if any(
                            token in col.lower()
                            for token in (
                                "jid",
                                "phone",
                                "chat",
                                "sender",
                                "recipient",
                                "participant",
                                "from",
                                "to",
                                "user",
                            )
                        )
                    ]
                    name_cols = [
                        col
                        for col in columns
                        if "name" in col.lower() or "push" in col.lower()
                    ]
                    if not jid_cols:
                        continue
                    select_cols = list(dict.fromkeys(jid_cols + name_cols))[:6]
                    quoted = ", ".join(f'"{col}"' for col in select_cols)
                    try:
                        rows = cur.execute(
                            f'SELECT {quoted} FROM "{table}" LIMIT 1000'
                        ).fetchall()
                    except Exception:
                        continue
                    for row in rows:
                        row_map = {col: row[idx] for idx, col in enumerate(select_cols)}
                        jid_value = ""
                        for col in jid_cols:
                            raw = str(row_map.get(col) or "").strip()
                            if "@s.whatsapp.net" in raw:
                                m = re.search(r"([0-9]{6,20})@s\.whatsapp\.net", raw)
                                jid_value = (
                                    f"{m.group(1)}@s.whatsapp.net"
                                    if m
                                    else raw.split()[0]
                                )
                                break
                            if raw.endswith("@lid"):
                                jid_value = raw
                                break
                            digits = re.sub(r"[^0-9]", "", raw)
                            if digits:
                                jid_value = f"{digits}@s.whatsapp.net"
                                break
                        if not jid_value:
                            continue
                        identifier = jid_value.split("@", 1)[0].strip()
                        if not identifier:
                            continue
                        if identifier.lower() in account_keys:
                            continue
                        if identifier in seen:
                            continue
                        seen.add(identifier)
                        display_name = ""
                        for col in name_cols:
                            candidate = str(row_map.get(col) or "").strip()
                            if candidate and candidate not in {"~", "-", "_"}:
                                display_name = candidate
                                break
                        out.append(
                            {
                                "identifier": identifier,
                                "jid": jid_value,
                                "name": display_name,
                                "chat": "",
                                "seen_at": int(time.time()),
                            }
                        )
                        if len(out) >= 500:
                            return out, "sqlite_tables", lid_map
                return out, "sqlite_tables", lid_map
            finally:
                conn.close()

        return await asyncio.to_thread(_extract)

    async def _upsert_groups(self, groups: list) -> None:
        if not groups:
            self.log.debug("[WA] _upsert_groups: no groups to persist")
            return

        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(service="whatsapp").select_related("user")
        )
        seen_user_ids: set = set()
        users = []
        for pi in identifiers:
            if pi.user_id not in seen_user_ids:
                seen_user_ids.add(pi.user_id)
                users.append(pi.user)
        if not users:
            self.log.debug("[WA] _upsert_groups: no PersonIdentifiers found — skipping")
            return

        upserted = 0
        for user in users:
            for group in groups:
                identifier = group.get("identifier") or ""
                name = group.get("name") or identifier
                jid = group.get("jid") or ""
                if "@newsletter" in jid or "@newsletter" in identifier:
                    continue
                await sync_to_async(PlatformChatLink.objects.update_or_create)(
                    user=user,
                    service="whatsapp",
                    chat_identifier=identifier,
                    defaults={
                        "person": None,
                        "person_identifier": None,
                        "is_group": True,
                        "chat_name": name,
                        "chat_jid": jid,
                    },
                )
                upserted += 1

        self.log.info(
            "[WA] upserted %d group rows (%d groups × %d users)",
            upserted,
            len(groups),
            len(users),
        )

    async def _sync_groups_from_client(self):
        if self._client is None:
            return [], "client_missing"
        getter = getattr(self._client, "get_joined_groups", None)
        if getter is None:
            return [], "get_joined_groups_missing"
        try:
            group_rows = await self._maybe_await(getter())
        except Exception as exc:
            self._publish_state(
                last_event="groups_sync_failed",
                last_error=str(exc),
            )
            return [], "get_joined_groups_failed"

        out = []
        now_ts = int(time.time())
        for group in group_rows or []:
            jid_value = self._jid_to_identifier(
                self._pluck(group, "JID") or self._pluck(group, "jid")
            )
            identifier = jid_value.split("@", 1)[0].strip() if jid_value else ""
            if not identifier:
                continue
            name = (
                str(self._pluck(group, "GroupName", "Name") or "").strip()
                or str(self._pluck(group, "GroupTopic", "Topic") or "").strip()
                or identifier
            )
            out.append(
                {
                    "identifier": identifier,
                    "jid": jid_value or f"{identifier}@g.us",
                    "name": name,
                    "chat": name,
                    "type": "group",
                    "seen_at": now_ts,
                }
            )
            if len(out) >= 500:
                break
        return out, "get_joined_groups"

    async def _is_contact_sync_ready(self) -> bool:
        if self._client is None:
            return False
        if self._connected:
            return True
        state = transport.get_runtime_state(self.service)
        if bool(state.get("connected")):
            return True
        pair_status = str(state.get("pair_status") or "").strip().lower()
        if pair_status == "connected":
            return True
        check_connected = getattr(self._client, "is_connected", None)
        if check_connected is None:
            return False
        try:
            value = (
                await self._maybe_await(check_connected())
                if callable(check_connected)
                else await self._maybe_await(check_connected)
            )
        except Exception:
            return False
        if value:
            self._connected = True
            self._publish_state(connected=True, warning="", pair_status="connected")
            return True
        # Guard: Neonize Go GetMe() panics (SIGSEGV) on unlinked sessions.
        if hasattr(self._client, "get_me") and self._session_has_device():
            try:
                me = await self._maybe_await(self._client.get_me())
                if me:
                    self._connected = True
                    self._publish_state(
                        connected=True, warning="", pair_status="connected"
                    )
                    return True
            except Exception:
                pass
        return False

    async def _handle_history_sync_event(self, event):
        started_at = time.time()
        self._publish_state(
            history_sync_running=True,
            history_sync_started_at=int(started_at),
            last_event="history_sync_started",
        )
        data = self._pluck(event, "Data") or self._pluck(event, "data")
        if data is None:
            self._publish_state(
                history_sync_running=False,
                history_sync_duration_ms=int((time.time() - started_at) * 1000),
            )
            return

        pushname_rows = (
            self._pluck(data, "pushnames") or self._pluck(data, "Pushnames") or []
        )
        pushname_map = {}
        for row in pushname_rows:
            raw_id = self._jid_to_identifier(
                self._pluck(row, "ID")
            ) or self._jid_to_identifier(self._pluck(row, "id"))
            if not raw_id:
                continue
            pushname = str(
                self._pluck(row, "pushname") or self._pluck(row, "Pushname") or ""
            ).strip()
            if not pushname:
                continue
            pushname_map[raw_id] = pushname
            pushname_map[raw_id.split("@", 1)[0]] = pushname

        conversation_rows = (
            self._pluck(data, "conversations")
            or self._pluck(data, "Conversations")
            or []
        )
        found = 0
        imported_messages = 0
        for row in conversation_rows:
            jid = ""
            for candidate in (
                self._pluck(row, "ID"),
                self._pluck(row, "id"),
                self._pluck(row, "pnJID"),
                self._pluck(row, "pnJid"),
                self._pluck(row, "newJID"),
                self._pluck(row, "newJid"),
                self._pluck(row, "oldJID"),
                self._pluck(row, "oldJid"),
            ):
                parsed = self._jid_to_identifier(candidate)
                if parsed:
                    jid = parsed
                    break
            if not jid or "@g.us" in jid or "@broadcast" in jid:
                continue
            identifier = jid.split("@", 1)[0].strip()
            if not identifier or not re.search(r"[0-9]{6,}", identifier):
                continue
            name = str(
                self._pluck(row, "displayName")
                or self._pluck(row, "DisplayName")
                or self._pluck(row, "name")
                or self._pluck(row, "Name")
                or self._pluck(row, "username")
                or self._pluck(row, "Username")
                or pushname_map.get(jid)
                or pushname_map.get(identifier)
                or ""
            ).strip()
            self._remember_contact(identifier, jid=jid, name=name)
            found += 1
            imported_messages += await self._import_history_messages_for_conversation(
                row=row,
                chat_jid=jid,
            )

        if found:
            state = transport.get_runtime_state(self.service)
            current_count = int(state.get("contacts_sync_count") or 0)
            self._publish_state(
                contacts_source="history_sync",
                contacts_synced_at=int(time.time()),
                contacts_sync_count=max(current_count, found),
                last_event="history_sync_contacts",
                last_error="",
            )
        self._publish_state(
            history_sync_running=False,
            history_sync_finished_at=int(time.time()),
            history_sync_duration_ms=int((time.time() - started_at) * 1000),
            history_imported_messages=imported_messages,
            last_event="history_sync_completed",
        )

    def _history_message_rows(self, conversation_row):
        candidates = (
            self._pluck(conversation_row, "messages"),
            self._pluck(conversation_row, "Messages"),
            self._pluck(conversation_row, "msgs"),
            self._pluck(conversation_row, "Msgs"),
        )
        for value in candidates:
            if isinstance(value, (list, tuple)):
                return list(value)
        return []

    def _history_message_text(self, msg):
        return str(
            self._pluck(msg, "message", "conversation")
            or self._pluck(msg, "message", "Conversation")
            or self._pluck(msg, "message", "extendedTextMessage", "text")
            or self._pluck(msg, "message", "ExtendedTextMessage", "Text")
            or self._pluck(msg, "message", "imageMessage", "caption")
            or self._pluck(msg, "message", "videoMessage", "caption")
            or self._pluck(msg, "message", "documentMessage", "caption")
            or self._pluck(msg, "Message", "conversation")
            or self._pluck(msg, "Message", "Conversation")
            or self._pluck(msg, "Message", "extendedTextMessage", "text")
            or self._pluck(msg, "Message", "ExtendedTextMessage", "Text")
            or self._pluck(msg, "conversation")
            or self._pluck(msg, "Conversation")
            or self._pluck(msg, "text")
            or self._pluck(msg, "Text")
            or ""
        ).strip()

    def _message_text(self, msg_obj, event_obj=None):
        """
        Extract user-visible text from diverse WhatsApp message payload shapes.
        """
        for candidate in self._iter_message_variants(msg_obj):
            for value in (
                self._pluck(candidate, "conversation"),
                self._pluck(candidate, "Conversation"),
                self._pluck(candidate, "extendedTextMessage", "text"),
                self._pluck(candidate, "ExtendedTextMessage", "Text"),
                self._pluck(candidate, "extended_text_message", "text"),
                self._pluck(candidate, "imageMessage", "caption"),
                self._pluck(candidate, "videoMessage", "caption"),
                self._pluck(candidate, "documentMessage", "caption"),
            ):
                text = str(value or "").strip()
                if text:
                    return text
        for value in (
            self._pluck(event_obj, "message", "conversation"),
            self._pluck(event_obj, "message", "extendedTextMessage", "text"),
            self._pluck(event_obj, "Message", "conversation"),
            self._pluck(event_obj, "Message", "extendedTextMessage", "text"),
            self._pluck(event_obj, "conversation"),
            self._pluck(event_obj, "text"),
        ):
            text = str(value or "").strip()
            if text:
                return text
        return ""

    def _history_message_ts(self, msg):
        raw_ts = (
            self._pluck(msg, "messageTimestamp")
            or self._pluck(msg, "MessageTimestamp")
            or self._pluck(msg, "timestamp")
            or self._pluck(msg, "Timestamp")
            or self._pluck(msg, "message", "messageTimestamp")
            or self._pluck(msg, "Message", "messageTimestamp")
            or int(time.time() * 1000)
        )
        return self._normalize_timestamp(raw_ts)

    def _history_message_from_me(self, msg):
        return bool(
            self._pluck(msg, "key", "fromMe")
            or self._pluck(msg, "Key", "FromMe")
            or self._pluck(msg, "messageKey", "fromMe")
            or self._pluck(msg, "MessageKey", "FromMe")
            or self._pluck(msg, "fromMe")
            or self._pluck(msg, "FromMe")
        )

    def _history_message_sender_jid(self, msg, fallback_chat_jid: str):
        sender = self._jid_to_identifier(
            self._pluck(msg, "key", "participant")
            or self._pluck(msg, "Key", "Participant")
            or self._pluck(msg, "messageKey", "participant")
            or self._pluck(msg, "MessageKey", "Participant")
            or self._pluck(msg, "participant")
            or self._pluck(msg, "Participant")
            or fallback_chat_jid
        )
        return str(sender or fallback_chat_jid or "").strip()

    async def _import_history_messages_for_conversation(
        self, row, chat_jid: str
    ) -> int:
        imported = 0
        msg_rows = self._history_message_rows(row)
        if not msg_rows:
            return imported
        chat_identifier = str(chat_jid or "").split("@", 1)[0].strip()
        if not chat_identifier:
            return imported
        candidate_values = self._normalize_identifier_candidates(
            chat_jid, chat_identifier
        )
        if not candidate_values:
            return imported
        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(
                service="whatsapp",
                identifier__in=list(candidate_values),
            )
        )
        if not identifiers:
            return imported

        for msg in msg_rows:
            ts = self._history_message_ts(msg)
            text = self._history_message_text(msg)
            from_me = self._history_message_from_me(msg)
            sender_jid = self._history_message_sender_jid(msg, chat_jid)
            for identifier in identifiers:
                session = await history.get_chat_session(identifier.user, identifier)
                exists = await sync_to_async(
                    Message.objects.filter(
                        user=identifier.user,
                        session=session,
                        ts=ts,
                        sender_uuid=(sender_jid if not from_me else ""),
                        text=text,
                        custom_author=("BOT" if from_me else None),
                    ).exists
                )()
                if exists:
                    continue
                await sync_to_async(Message.objects.create)(
                    user=identifier.user,
                    session=session,
                    ts=ts,
                    sender_uuid=(sender_jid if not from_me else ""),
                    text=text,
                    custom_author=("BOT" if from_me else None),
                    delivered_ts=ts,
                )
                imported += 1
        return imported

    def _remember_contact(self, identifier, *, jid="", name="", chat=""):
        cleaned = str(identifier or "").strip()
        if "@" in cleaned:
            cleaned = cleaned.split("@", 1)[0]
        if not cleaned:
            return
        state = transport.get_runtime_state(self.service)
        existing = state.get("contacts") or []
        rows = [item for item in existing if isinstance(item, dict)]
        merged = []
        seen = set()
        now_ts = int(time.time())
        row = {
            "identifier": cleaned,
            "jid": str(jid or "").strip(),
            "name": str(name or "").strip(),
            "chat": str(chat or "").strip(),
            "seen_at": now_ts,
        }
        merged.append(row)
        seen.add(cleaned)
        for item in rows:
            candidate = str(item.get("identifier") or item.get("jid") or "").strip()
            if not candidate or candidate in seen:
                continue
            seen.add(candidate)
            merged.append(item)
            if len(merged) >= 500:
                break
        self._publish_state(contacts=merged, last_contact_seen_at=now_ts)

    def _remember_history_anchor(
        self,
        *,
        identifier: str,
        chat_jid: str,
        msg_id: str,
        ts: int,
        from_me: bool,
        sender_jid: str = "",
    ):
        if not msg_id:
            return
        candidate_keys = self._normalize_identifier_candidates(identifier, chat_jid)
        if not candidate_keys:
            return
        state = transport.get_runtime_state(self.service)
        anchors = dict(state.get("history_anchors") or {})
        row = {
            "msg_id": str(msg_id),
            "ts": int(ts or int(time.time() * 1000)),
            "from_me": bool(from_me),
            "chat_jid": str(chat_jid or ""),
            "sender_jid": str(sender_jid or ""),
            "updated_at": int(time.time()),
        }
        for key in candidate_keys:
            anchors[str(key)] = row
        if len(anchors) > 400:
            recent = sorted(
                anchors.items(),
                key=lambda item: int((item[1] or {}).get("updated_at") or 0),
                reverse=True,
            )[:300]
            anchors = {k: v for k, v in recent}
        self._publish_state(history_anchors=anchors)

    def _jid_to_identifier(self, value):
        raw = str(value or "").strip()
        if not raw:
            return ""
        if not isinstance(value, str):
            user = self._pluck(value, "User") or self._pluck(value, "user")
            server = self._pluck(value, "Server") or self._pluck(value, "server")
            if user and server:
                return f"{user}@{server}"
            if user:
                return str(user)
        return raw

    def _iter_message_variants(self, message_obj, max_depth: int = 8):
        wrapper_paths = (
            ("deviceSentMessage", "message"),
            ("DeviceSentMessage", "Message"),
            ("ephemeralMessage", "message"),
            ("EphemeralMessage", "Message"),
            ("viewOnceMessage", "message"),
            ("ViewOnceMessage", "Message"),
            ("viewOnceMessageV2", "message"),
            ("ViewOnceMessageV2", "Message"),
            ("viewOnceMessageV2Extension", "message"),
            ("ViewOnceMessageV2Extension", "Message"),
            ("editedMessage", "message"),
            ("EditedMessage", "Message"),
        )
        queue = [(message_obj, 0)]
        seen = set()
        while queue:
            current, depth = queue.pop(0)
            if current is None:
                continue
            marker = id(current)
            if marker in seen:
                continue
            seen.add(marker)
            yield current
            if depth >= max_depth:
                continue
            for path in wrapper_paths:
                nested = self._pluck(current, *path)
                if nested is not None:
                    queue.append((nested, depth + 1))

    def _direct_media_payload(self, message_obj):
        media_fields = (
            "imageMessage",
            "videoMessage",
            "audioMessage",
            "documentMessage",
            "stickerMessage",
            "image_message",
            "video_message",
            "audio_message",
            "document_message",
            "sticker_message",
        )
        for field in media_fields:
            value = self._pluck(message_obj, field)
            if value:
                return value
        return None

    def _resolve_media_message(self, message_obj):
        for candidate in self._iter_message_variants(message_obj):
            if self._direct_media_payload(candidate):
                return candidate
        return None

    def _is_media_message(self, message_obj):
        return self._resolve_media_message(message_obj) is not None

    def _infer_media_content_type(self, message_obj):
        if self._pluck(message_obj, "imageMessage") or self._pluck(
            message_obj, "image_message"
        ):
            return "image/jpeg"
        if self._pluck(message_obj, "videoMessage") or self._pluck(
            message_obj, "video_message"
        ):
            return "video/mp4"
        if self._pluck(message_obj, "audioMessage") or self._pluck(
            message_obj, "audio_message"
        ):
            return "audio/ogg"
        if self._pluck(message_obj, "stickerMessage") or self._pluck(
            message_obj, "sticker_message"
        ):
            return "image/webp"
        return "application/octet-stream"

    def _extract_reaction_event(self, message_obj):
        node = (
            self._pluck(message_obj, "reactionMessage")
            or self._pluck(message_obj, "reaction_message")
            or self._pluck(
                message_obj, "ephemeralMessage", "message", "reactionMessage"
            )
            or self._pluck(
                message_obj, "ephemeral_message", "message", "reaction_message"
            )
            or self._pluck(message_obj, "viewOnceMessage", "message", "reactionMessage")
            or self._pluck(
                message_obj, "view_once_message", "message", "reaction_message"
            )
            or self._pluck(
                message_obj, "viewOnceMessageV2", "message", "reactionMessage"
            )
            or self._pluck(
                message_obj, "view_once_message_v2", "message", "reaction_message"
            )
            or self._pluck(
                message_obj,
                "viewOnceMessageV2Extension",
                "message",
                "reactionMessage",
            )
            or self._pluck(
                message_obj,
                "view_once_message_v2_extension",
                "message",
                "reaction_message",
            )
            or self._pluck(message_obj, "protocolMessage", "reactionMessage")
            or self._pluck(message_obj, "protocol_message", "reaction_message")
        )
        if not node:
            return None
        emoji = str(
            self._pluck(node, "text") or self._pluck(node, "emoji") or ""
        ).strip()
        target_msg_id = str(
            self._pluck(node, "key", "id")
            or self._pluck(node, "key", "ID")
            or self._pluck(node, "messageKey", "id")
            or self._pluck(node, "message_key", "id")
            or self._pluck(node, "targetMessageKey", "id")
            or self._pluck(node, "target_message_key", "id")
            or self._pluck(node, "stanzaId")
            or self._pluck(node, "stanza_id")
            or ""
        ).strip()
        target_ts = self._normalize_timestamp(
            self._pluck(node, "key", "messageTimestamp")
            or self._pluck(node, "targetMessageKey", "messageTimestamp")
            or self._pluck(node, "target_message_key", "message_timestamp")
            or self._pluck(node, "targetTimestamp")
            or self._pluck(node, "target_timestamp")
            or 0
        )
        explicit_remove = self._pluck(node, "remove") or self._pluck(node, "isRemove")
        if explicit_remove is None:
            explicit_remove = self._pluck(node, "is_remove")
        remove = (
            bool(explicit_remove) if explicit_remove is not None else bool(not emoji)
        )
        if not target_msg_id:
            return None
        return {
            "emoji": emoji,
            "target_message_id": target_msg_id,
            "remove": remove,
            "target_ts": int(target_ts or 0),
            "raw": (
                self._proto_to_dict(node) or dict(node or {})
                if isinstance(node, dict)
                else {}
            ),
        }

    async def _download_event_media(self, event):
        if not self._client:
            return []
        msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
        media_msg = self._resolve_media_message(msg_obj)
        if media_msg is None:
            return []
        if not hasattr(self._client, "download_any"):
            return []

        try:
            payload = await self._maybe_await(self._client.download_any(media_msg))
        except Exception as exc:
            self.log.warning("whatsapp media download failed: %s", exc)
            return []

        if isinstance(payload, memoryview):
            payload = payload.tobytes()
        if not isinstance(payload, (bytes, bytearray)):
            return []

        filename = self._pluck(
            media_msg, "documentMessage", "fileName"
        ) or self._pluck(
            media_msg, "document_message", "file_name"
        )
        content_type = (
            self._pluck(media_msg, "documentMessage", "mimetype")
            or self._pluck(media_msg, "document_message", "mimetype")
            or self._pluck(media_msg, "imageMessage", "mimetype")
            or self._pluck(media_msg, "image_message", "mimetype")
            or self._pluck(media_msg, "videoMessage", "mimetype")
            or self._pluck(media_msg, "video_message", "mimetype")
            or self._pluck(media_msg, "audioMessage", "mimetype")
            or self._pluck(media_msg, "audio_message", "mimetype")
            or self._infer_media_content_type(media_msg)
        )
        if not filename:
            ext = mimetypes.guess_extension(
                str(content_type or "").split(";", 1)[0].strip().lower()
            )
            filename = f"wa-{int(time.time())}{ext or '.bin'}"
        blob_key = media_bridge.put_blob(
            service="whatsapp",
            content=bytes(payload),
            filename=filename,
            content_type=content_type,
        )
        if not blob_key:
            return []
        return [
            {
                "blob_key": blob_key,
                "filename": filename,
                "content_type": content_type,
                "size": len(payload),
            }
        ]

    async def _handle_message_event(self, event):
        event_obj = self._proto_to_dict(event) or event
        msg_obj = self._pluck(event_obj, "message") or self._pluck(event_obj, "Message")
        if self._pluck(msg_obj, "protocolMessage") or self._pluck(
            msg_obj, "protocol_message"
        ):
            return
        text = self._message_text(msg_obj, event_obj)
        if not text:
            self.log.debug(
                "whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
                self._shape_keys(msg_obj),
                self._shape_keys(event_obj),
                str(type(event).__name__),
            )
        source = (
            self._pluck(event_obj, "Info", "MessageSource")
            or self._pluck(event_obj, "info", "message_source")
            or self._pluck(event_obj, "info", "messageSource")
            or self._pluck(event_obj, "info", "message_source")
            or self._pluck(event_obj, "info", "messageSource")
        )
        is_from_me = bool(
            self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
        )

        sender = self._jid_to_identifier(
            self._pluck(source, "Sender")
            or self._pluck(source, "sender")
            or self._pluck(source, "SenderAlt")
            or self._pluck(source, "senderAlt")
        )
        chat = self._jid_to_identifier(
            self._pluck(source, "Chat") or self._pluck(source, "chat")
        )
        raw_ts = (
            self._pluck(event_obj, "Info", "Timestamp")
            or self._pluck(event_obj, "info", "timestamp")
            or self._pluck(event_obj, "info", "message_timestamp")
            or self._pluck(event_obj, "Timestamp")
            or self._pluck(event_obj, "timestamp")
        )
        msg_id = str(
            self._pluck(event_obj, "Info", "ID")
            or self._pluck(event_obj, "info", "id")
            or self._pluck(event_obj, "ID")
            or self._pluck(event_obj, "id")
            or ""
        ).strip()
        ts = self._normalize_timestamp(raw_ts)

        reaction_payload = self._extract_reaction_event(msg_obj or event_obj)
        if reaction_payload:
            self.log.debug(
                "reaction-bridge whatsapp-inbound msg_id=%s target_id=%s emoji=%s remove=%s sender=%s chat=%s",
                msg_id or "-",
                str(reaction_payload.get("target_message_id") or "") or "-",
                str(reaction_payload.get("emoji") or "") or "-",
                bool(reaction_payload.get("remove")),
                sender or "-",
                chat or "-",
            )
            identifier_values = self._normalize_identifier_candidates(sender, chat)
            if not identifier_values:
                self.log.warning(
                    "reaction-bridge whatsapp-identifiers-miss sender=%s chat=%s",
                    sender or "-",
                    chat or "-",
                )
                return
            identifiers = await sync_to_async(list)(
                PersonIdentifier.objects.filter(
                    service="whatsapp",
                    identifier__in=list(identifier_values),
                )
            )
            for identifier in identifiers:
                try:
                    await history.apply_reaction(
                        identifier.user,
                        identifier,
                        target_message_id=str(
                            reaction_payload.get("target_message_id") or ""
                        ),
                        target_ts=int(reaction_payload.get("target_ts") or 0),
                        emoji=str(reaction_payload.get("emoji") or ""),
                        source_service="whatsapp",
                        actor=str(sender or chat or ""),
                        remove=bool(reaction_payload.get("remove")),
                        payload={
                            "event": "reaction",
                            "message_id": msg_id,
                            "raw": reaction_payload.get("raw") or {},
                        },
                    )
                except Exception as exc:
                    self.log.warning("whatsapp reaction local apply failed: %s", exc)
                try:
                    await self.ur.xmpp.client.apply_external_reaction(
                        identifier.user,
                        identifier,
                        source_service="whatsapp",
                        emoji=str(reaction_payload.get("emoji") or ""),
                        remove=bool(reaction_payload.get("remove")),
                        upstream_message_id=str(
                            reaction_payload.get("target_message_id") or ""
                        ),
                        upstream_ts=0,
                        actor=(sender or chat or ""),
                        payload={
                            "event": "reaction",
                            "message_id": msg_id,
                        },
                    )
                except Exception as exc:
                    self.log.warning("whatsapp reaction relay to XMPP failed: %s", exc)
                try:
                    await self.ur.presence_changed(
                        self.service,
                        identifier=identifier.identifier,
                        state="available",
                        confidence=0.9,
                        ts=int(ts or int(time.time() * 1000)),
                        payload={
                            "event": "reaction",
                            "inferred_from": "reaction",
                            "message_id": msg_id,
                        },
                    )
                except Exception:
                    pass
            return

        self._remember_contact(
            sender or chat,
            jid=sender,
            chat=chat,
        )
        self._remember_history_anchor(
            identifier=(chat or sender or ""),
            chat_jid=str(chat or sender or ""),
            msg_id=msg_id,
            ts=ts,
            from_me=is_from_me,
            sender_jid=str(sender or ""),
        )

        identifier_values = self._message_identifier_candidates(
            sender=sender,
            chat=chat,
            is_from_me=is_from_me,
        )
        if not identifier_values:
            return

        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(
                service="whatsapp",
                identifier__in=list(identifier_values),
            )
        )
        if not identifiers:
            return

        is_self_chat = bool(
            is_from_me
            and str(sender or "").strip()
            and str(chat or "").strip()
            and str(sender).strip() == str(chat).strip()
        )
        if is_self_chat and str(text or "").strip().startswith("."):
            responded_user_ids = set()
            reply_target = str(chat or sender or "").strip()
            for identifier in identifiers:
                if identifier.user_id in responded_user_ids:
                    continue
                responded_user_ids.add(identifier.user_id)
                replies = await self.ur.xmpp.client.execute_gateway_command(
                    sender_user=identifier.user,
                    body=text,
                    service=self.service,
                    channel_identifier=str(identifier.identifier or ""),
                    sender_identifier=str(identifier.identifier or ""),
                    local_message=None,
                    message_meta={
                        "whatsapp": {
                            "sender": str(sender or ""),
                            "chat": str(chat or ""),
                            "self_chat": True,
                        }
                    },
                )
                for line in replies:
                    await self.send_message_raw(reply_target, f"[>] {line}")
            return

        attachments = await self._download_event_media(event)
        xmpp_attachments = []
        if attachments:
            fetched = await asyncio.gather(
                *[transport.fetch_attachment(self.service, att) for att in attachments]
            )
            xmpp_attachments = [row for row in fetched if row]

        payload = {
            "sender": str(sender or ""),
            "chat": str(chat or ""),
            "raw_event": str(type(event).__name__),
        }

        for identifier in identifiers:
            uploaded_urls = await self.ur.xmpp.client.send_from_external(
                identifier.user,
                identifier,
                text,
                is_outgoing_message=is_from_me,
                attachments=xmpp_attachments,
                source_ref={
                    "upstream_message_id": str(msg_id or ""),
                    "upstream_author": str(sender or chat or ""),
                    "upstream_ts": int(ts or 0),
                },
            )
            display_text = text
            if (not display_text) and uploaded_urls:
                display_text = "\n".join(uploaded_urls)
            if (not display_text) and attachments:
                media_urls = [
                    self._blob_key_to_compose_url((att or {}).get("blob_key"))
                    for att in attachments
                ]
                media_urls = [url for url in media_urls if url]
                if media_urls:
                    display_text = "\n".join(media_urls)

            session = await history.get_chat_session(identifier.user, identifier)
            duplicate_exists = await sync_to_async(
                Message.objects.filter(
                    user=identifier.user,
                    session=session,
                    ts=ts,
                    sender_uuid=str(sender or chat or ""),
                    text=display_text,
                ).exists
            )()
            if duplicate_exists:
                continue
            reply_ref = reply_sync.extract_reply_ref(
                self.service,
                {
                    "contextInfo": self._pluck(msg_obj, "contextInfo")
                    or self._pluck(msg_obj, "ContextInfo")
                    or self._pluck(msg_obj, "extendedTextMessage", "contextInfo")
                    or self._pluck(msg_obj, "ExtendedTextMessage", "ContextInfo")
                    or self._pluck(msg_obj, "imageMessage", "contextInfo")
                    or self._pluck(msg_obj, "ImageMessage", "ContextInfo")
                    or self._pluck(msg_obj, "videoMessage", "contextInfo")
                    or self._pluck(msg_obj, "VideoMessage", "ContextInfo")
                    or self._pluck(
                        msg_obj,
                        "ephemeralMessage",
                        "message",
                        "extendedTextMessage",
                        "contextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "EphemeralMessage",
                        "Message",
                        "ExtendedTextMessage",
                        "ContextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "viewOnceMessage",
                        "message",
                        "extendedTextMessage",
                        "contextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "ViewOnceMessage",
                        "Message",
                        "ExtendedTextMessage",
                        "ContextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "viewOnceMessageV2",
                        "message",
                        "extendedTextMessage",
                        "contextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "ViewOnceMessageV2",
                        "Message",
                        "ExtendedTextMessage",
                        "ContextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "viewOnceMessageV2Extension",
                        "message",
                        "extendedTextMessage",
                        "contextInfo",
                    )
                    or self._pluck(
                        msg_obj,
                        "ViewOnceMessageV2Extension",
                        "Message",
                        "ExtendedTextMessage",
                        "ContextInfo",
                    )
                    or {},
                    "messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
                    or self._pluck(msg_obj, "MessageContextInfo")
                    or {},
                    "message": {
                        "extendedTextMessage": self._pluck(
                            msg_obj, "extendedTextMessage"
                        )
                        or self._pluck(msg_obj, "ExtendedTextMessage")
                        or {},
                        "imageMessage": self._pluck(msg_obj, "imageMessage") or {},
                        "ImageMessage": self._pluck(msg_obj, "ImageMessage") or {},
                        "videoMessage": self._pluck(msg_obj, "videoMessage") or {},
                        "VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
                        "documentMessage": self._pluck(msg_obj, "documentMessage")
                        or {},
                        "DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
                        or {},
                        "ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
                        or {},
                        "EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
                        or {},
                        "viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
                        or {},
                        "ViewOnceMessage": self._pluck(msg_obj, "ViewOnceMessage")
                        or {},
                        "viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
                        or {},
                        "ViewOnceMessageV2": self._pluck(msg_obj, "ViewOnceMessageV2")
                        or {},
                        "viewOnceMessageV2Extension": self._pluck(
                            msg_obj, "viewOnceMessageV2Extension"
                        )
                        or {},
                        "ViewOnceMessageV2Extension": self._pluck(
                            msg_obj, "ViewOnceMessageV2Extension"
                        )
                        or {},
                    },
                },
            )
            reply_debug = {}
            if self._chat_matches_reply_debug(chat):
                reply_debug = reply_sync.extract_whatsapp_reply_debug(
                    {
                        "contextInfo": self._pluck(msg_obj, "contextInfo") or {},
                        "messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
                        or {},
                        "message": {
                            "extendedTextMessage": self._pluck(
                                msg_obj, "extendedTextMessage"
                            )
                            or {},
                            "imageMessage": self._pluck(msg_obj, "imageMessage") or {},
                            "videoMessage": self._pluck(msg_obj, "videoMessage") or {},
                            "documentMessage": self._pluck(msg_obj, "documentMessage")
                            or {},
                            "ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
                            or {},
                            "viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
                            or {},
                            "viewOnceMessageV2": self._pluck(
                                msg_obj, "viewOnceMessageV2"
                            )
                            or {},
                            "viewOnceMessageV2Extension": self._pluck(
                                msg_obj, "viewOnceMessageV2Extension"
                            )
                            or {},
                        },
                    }
                )
                self.log.warning(
                    "wa-reply-debug chat=%s msg_id=%s reply_ref=%s debug=%s",
                    str(chat or ""),
                    str(msg_id or ""),
                    json.dumps(reply_ref, ensure_ascii=True),
                    json.dumps(reply_debug, ensure_ascii=True),
                )
            reply_target = await reply_sync.resolve_reply_target(
                identifier.user,
                session,
                reply_ref,
            )
            message_meta = reply_sync.apply_sync_origin(
                {},
                reply_sync.extract_origin_tag(payload),
            )
            if self._chat_matches_reply_debug(chat):
                info_obj = self._proto_to_dict(
                    self._pluck(event_obj, "Info")
                ) or self._pluck(event_obj, "Info")
                raw_obj = self._proto_to_dict(
                    self._pluck(event_obj, "Raw")
                ) or self._pluck(event_obj, "Raw")
                message_meta["wa_reply_debug"] = {
                    "reply_ref": reply_ref,
                    "reply_target_id": str(getattr(reply_target, "id", "") or ""),
                    "msg_id": str(msg_id or ""),
                    "chat": str(chat or ""),
                    "sender": str(sender or ""),
                    "msg_obj_keys": self._shape_keys(msg_obj),
                    "event_keys": self._shape_keys(event_obj),
                    "info_keys": self._shape_keys(info_obj),
                    "raw_keys": self._shape_keys(raw_obj),
                    "event_type": str(type(event).__name__),
                    "reply_hints_event": self._extract_reply_hints(event_obj),
                    "reply_hints_message": self._extract_reply_hints(msg_obj),
                    "reply_hints_info": self._extract_reply_hints(info_obj),
                    "reply_hints_raw": self._extract_reply_hints(raw_obj),
                    "debug": reply_debug,
                }
            local_message = await history.store_message(
                session=session,
                sender=str(sender or chat or ""),
                text=display_text,
                ts=ts,
                outgoing=is_from_me,
                source_service=self.service,
                source_message_id=str(msg_id or ""),
                source_chat_id=str(chat or sender or ""),
                reply_to=reply_target,
                reply_source_service=str(reply_ref.get("reply_source_service") or ""),
                reply_source_message_id=str(
                    reply_ref.get("reply_source_message_id") or ""
                ),
                message_meta=message_meta,
            )
            await self.ur.message_received(
                self.service,
                identifier=identifier,
                text=display_text,
                ts=ts,
                payload=payload,
                local_message=local_message,
            )

    async def _handle_receipt_event(self, event):
        source = (
            self._pluck(event, "MessageSource")
            or self._pluck(event, "info", "message_source")
            or self._pluck(event, "info", "messageSource")
        )
        sender = self._jid_to_identifier(
            self._pluck(source, "Sender") or self._pluck(source, "sender")
        )
        chat = self._jid_to_identifier(
            self._pluck(source, "Chat") or self._pluck(source, "chat")
        )
        timestamps = []
        raw_ids = (
            self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
        )
        if isinstance(raw_ids, (list, tuple, set)):
            for item in raw_ids:
                try:
                    value = int(item)
                    timestamps.append(value * 1000 if value < 10**12 else value)
                except Exception:
                    continue
        read_ts = self._normalize_timestamp(
            self._pluck(event, "Timestamp")
            or self._pluck(event, "timestamp")
            or int(time.time() * 1000)
        )
        receipt_type = str(self._pluck(event, "Type") or "").strip()
        self._remember_contact(
            sender or chat,
            jid=sender,
            chat=chat,
        )

        for candidate in self._normalize_identifier_candidates(sender, chat):
            await self.ur.message_read(
                self.service,
                identifier=candidate,
                message_timestamps=timestamps,
                read_ts=read_ts,
                read_by=sender or chat,
                payload={
                    "event": "receipt",
                    "type": receipt_type,
                    "sender": str(sender),
                    "chat": str(chat),
                },
            )

    async def _handle_chat_presence_event(self, event):
        source = (
            self._pluck(event, "MessageSource")
            or self._pluck(event, "message_source")
            or {}
        )
        sender = self._jid_to_identifier(
            self._pluck(source, "Sender") or self._pluck(source, "sender")
        )
        chat = self._jid_to_identifier(
            self._pluck(source, "Chat") or self._pluck(source, "chat")
        )
        state = self._pluck(event, "State") or self._pluck(event, "state")
        state_text = str(state or "").strip().lower()
        is_typing = state_text in {"1", "composing", "chat_presence_composing"}
        self._remember_contact(
            sender or chat,
            jid=sender,
            chat=chat,
        )

        for candidate in self._normalize_identifier_candidates(sender, chat):
            if is_typing:
                await self.ur.started_typing(
                    self.service,
                    identifier=candidate,
                    payload={
                        "presence": state_text,
                        "sender": str(sender),
                        "chat": str(chat),
                    },
                )
            else:
                await self.ur.stopped_typing(
                    self.service,
                    identifier=candidate,
                    payload={
                        "presence": state_text,
                        "sender": str(sender),
                        "chat": str(chat),
                    },
                )

    async def _handle_presence_event(self, event):
        sender = self._jid_to_identifier(
            self._pluck(event, "From", "User") or self._pluck(event, "from", "user")
        )
        is_unavailable = bool(
            self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
        )
        last_seen_raw = (
            self._pluck(event, "LastSeen")
            or self._pluck(event, "lastSeen")
            or self._pluck(event, "last_seen")
            or self._pluck(event, "Timestamp")
            or self._pluck(event, "timestamp")
            or 0
        )
        last_seen_ts = self._normalize_timestamp(last_seen_raw)
        self._remember_contact(sender, jid=sender)

        for candidate in self._normalize_identifier_candidates(sender):
            try:
                await self.ur.presence_changed(
                    self.service,
                    identifier=candidate,
                    state=("unavailable" if is_unavailable else "available"),
                    confidence=0.9 if not is_unavailable else 0.8,
                    ts=int(last_seen_ts or int(time.time() * 1000)),
                    payload={
                        "presence": ("offline" if is_unavailable else "online"),
                        "sender": str(sender),
                        "last_seen_ts": int(last_seen_ts or 0),
                    },
                )
            except Exception:
                pass
            if is_unavailable:
                await self.ur.stopped_typing(
                    self.service,
                    identifier=candidate,
                    payload={
                        "presence": "offline",
                        "sender": str(sender),
                        "last_seen_ts": int(last_seen_ts or 0),
                    },
                )

    def _extract_pair_qr(self, event):
        codes = self._pluck(event, "Codes") or self._pluck(event, "codes") or []
        decoded_codes = self._decode_qr_payload(codes)
        if decoded_codes:
            return decoded_codes

        for path in (
            ("qr",),
            ("qr_code",),
            ("code",),
            ("pair_code",),
            ("pairCode",),
            ("url",),
        ):
            value = self._pluck(event, *path)
            if value:
                return str(value)
        return ""

    def _to_jid(self, recipient):
        raw = str(recipient or "").strip()
        if not raw:
            return ""
        if "@" in raw:
            return raw
        # Group chats often arrive as bare numeric ids in compose/runtime
        # payloads; prefer known group mappings before defaulting to person JIDs.
        group_jid = self._resolve_group_jid(raw)
        if group_jid:
            return group_jid
        digits = re.sub(r"[^0-9]", "", raw)
        if digits:
            # Prefer direct JID formatting for phone numbers; Neonize build_jid
            # can trigger a usync lookup path that intermittently times out.
            return f"{digits}@s.whatsapp.net"
        if self._build_jid is not None:
            try:
                return self._build_jid(raw)
            except Exception:
                pass
        return raw

    def _resolve_group_jid(self, value: str) -> str:
        local = str(value or "").strip().split("@", 1)[0].strip()
        if not local:
            return ""

        # Runtime state is the cheapest source of truth for currently joined groups.
        state = transport.get_runtime_state(self.service) or {}
        for row in list(state.get("groups") or []):
            if not isinstance(row, dict):
                continue
            candidates = (
                row.get("identifier"),
                row.get("chat_identifier"),
                row.get("chat"),
                row.get("jid"),
                row.get("chat_jid"),
            )
            matched = False
            for candidate in candidates:
                candidate_local = (
                    str(self._jid_to_identifier(candidate) or "")
                    .split("@", 1)[0]
                    .strip()
                )
                if candidate_local and candidate_local == local:
                    matched = True
                    break
            if not matched:
                continue
            jid = str(
                self._jid_to_identifier(row.get("jid") or row.get("chat_jid") or "")
            ).strip()
            if jid and "@g.us" in jid:
                return jid
            return f"{local}@g.us"

        # DB fallback for compose pages that resolved from PlatformChatLink.
        try:
            link = (
                PlatformChatLink.objects.filter(
                    service="whatsapp",
                    chat_identifier=local,
                    is_group=True,
                )
                .order_by("-updated_at", "-id")
                .first()
            )
        except Exception:
            link = None
        if link is not None:
            jid = str(self._jid_to_identifier(link.chat_jid or "")).strip()
            if jid and "@g.us" in jid:
                return jid
            return f"{local}@g.us"

        # WhatsApp group ids are numeric and usually very long (commonly start
        # with 120...). Treat those as groups when no explicit mapping exists.
        digits = re.sub(r"[^0-9]", "", local)
        if (
            digits
            and digits == local
            and len(digits) >= 15
            and digits.startswith("120")
        ):
            return f"{digits}@g.us"
        return ""

    def _blob_key_to_compose_url(self, blob_key):
        key = str(blob_key or "").strip()
        if not key:
            return ""
        return f"/compose/media/blob/?key={quote_plus(key)}"

    async def _fetch_attachment_payload(self, attachment):
        blob_key = (attachment or {}).get("blob_key")
        if blob_key:
            row = media_bridge.get_blob(blob_key)
            if row:
                return row

        content = (attachment or {}).get("content")
        if isinstance(content, memoryview):
            content = content.tobytes()
        if isinstance(content, bytes):
            filename, content_type = validate_attachment_metadata(
                filename=(attachment or {}).get("filename") or "attachment.bin",
                content_type=(attachment or {}).get("content_type")
                or "application/octet-stream",
                size=len(content),
            )
            return {
                "content": content,
                "filename": filename,
                "content_type": content_type,
                "size": len(content),
            }

        url = (attachment or {}).get("url")
        if url:
            safe_url = validate_attachment_url(url)
            filename, content_type = validate_attachment_metadata(
                filename=(attachment or {}).get("filename")
                or safe_url.rstrip("/").split("/")[-1]
                or "attachment.bin",
                content_type=(attachment or {}).get("content_type")
                or "application/octet-stream",
                size=(attachment or {}).get("size"),
            )
            return {
                "url": safe_url,
                "filename": filename,
                "content_type": content_type,
                "size": (attachment or {}).get("size"),
            }
        return None

    async def send_message_raw(
        self,
        recipient,
        text=None,
        attachments=None,
        command_id: str | None = None,
        metadata: dict | None = None,
    ):
        self._last_send_error = ""
        if not self._client:
            self._last_send_error = "client_missing"
            return False
        jid_str = self._to_jid(recipient)
        if not jid_str:
            self._last_send_error = "recipient_invalid"
            return False
        # Prefer direct JID string for sends to avoid Neonize usync/device-list
        # lookups that can stall on some runtime sessions.
        jid = jid_str
        jid_obj = None
        try:
            if self._build_jid is not None:
                maybe_jid = None
                if "@" in jid_str:
                    local_part, server_part = jid_str.split("@", 1)
                    try:
                        maybe_jid = self._build_jid(local_part, server_part)
                    except TypeError:
                        maybe_jid = self._build_jid(jid_str)
                else:
                    maybe_jid = self._build_jid(jid_str)
                if hasattr(maybe_jid, "SerializeToString"):
                    jid_obj = maybe_jid
                else:
                    self.log.warning(
                        "whatsapp build_jid returned non-JID object, falling back to string: type=%s repr=%s",
                        type(maybe_jid).__name__,
                        repr(maybe_jid)[:100],
                    )
        except Exception as exc:
            self.log.warning(
                "whatsapp failed to build JID from %s, falling back to string: %s",
                jid_str,
                exc,
            )
        if not self._connected and hasattr(self._client, "connect"):
            try:
                await self._maybe_await(self._client.connect())
                self._connected = True
                self._publish_state(
                    connected=True,
                    last_event="send_reconnect_ok",
                    warning="",
                    last_error="",
                )
            except Exception as exc:
                self._last_send_error = f"reconnect_failed:{exc}"
                self._publish_state(
                    connected=False,
                    last_event="send_reconnect_failed",
                    last_error=str(exc),
                    warning=f"WhatsApp reconnect before send failed: {exc}",
                )

        sent_any = False
        sent_ts = 0
        metadata = dict(metadata or {})
        xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip()
        legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
        reply_to_upstream_message_id = str(
            metadata.get("reply_to_upstream_message_id") or ""
        ).strip()
        reply_to_participant = str(metadata.get("reply_to_participant") or "").strip()
        reply_to_remote_jid = str(metadata.get("reply_to_remote_jid") or "").strip()
        person_identifier = None
        if legacy_message_id:
            person_identifier = await sync_to_async(
                lambda: (
                    Message.objects.filter(id=legacy_message_id)
                    .select_related(
                        "session__identifier__user", "session__identifier__person"
                    )
                    .first()
                )
            )()
            if person_identifier is not None:
                person_identifier = getattr(
                    getattr(person_identifier, "session", None), "identifier", None
                )
                if (
                    person_identifier is not None
                    and str(getattr(person_identifier, "service", "") or "")
                    .strip()
                    .lower()
                    != "whatsapp"
                ):
                    person_identifier = None
        if person_identifier is None and (xmpp_source_id or legacy_message_id):
            candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
            if candidates:
                person_identifier = await sync_to_async(
                    lambda: PersonIdentifier.objects.filter(
                        service="whatsapp",
                        identifier__in=candidates,
                    )
                    .select_related("user", "person")
                    .first()
                )()

        def _extract_response_message_id(response):
            return str(
                self._pluck(response, "ID")
                or self._pluck(response, "id")
                or self._pluck(response, "Info", "ID")
                or self._pluck(response, "info", "id")
                or ""
            ).strip()

        async def _record_bridge(response, ts_value, body_hint=""):
            if person_identifier is None:
                return
            upstream_message_id = _extract_response_message_id(response)
            if legacy_message_id:
                try:
                    await history.save_bridge_ref(
                        person_identifier.user,
                        person_identifier,
                        source_service="whatsapp",
                        local_message_id=legacy_message_id,
                        local_ts=int(ts_value or int(time.time() * 1000)),
                        upstream_message_id=upstream_message_id,
                        upstream_author=str(recipient or ""),
                        upstream_ts=int(ts_value or 0),
                    )
                except Exception:
                    pass
            if not xmpp_source_id:
                return
            transport.record_bridge_mapping(
                user_id=person_identifier.user_id,
                person_id=person_identifier.person_id,
                service="whatsapp",
                xmpp_message_id=xmpp_source_id,
                xmpp_ts=int(metadata.get("xmpp_source_ts") or 0),
                upstream_message_id=upstream_message_id,
                upstream_ts=int(ts_value or 0),
                text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
                local_message_id=legacy_message_id,
            )

        for attachment in attachments or []:
            payload = await self._fetch_attachment_payload(attachment)
            if not payload:
                continue
            data = payload.get("content")
            source_url = str(payload.get("url") or "").strip()
            try:
                filename, mime = validate_attachment_metadata(
                    filename=payload.get("filename") or "attachment.bin",
                    content_type=payload.get("content_type")
                    or "application/octet-stream",
                    size=payload.get("size")
                    or (len(data) if isinstance(data, (bytes, bytearray)) else None),
                )
            except Exception as exc:
                self.log.warning("whatsapp blocked attachment: %s", exc)
                continue
            file_arg = (
                data
                if isinstance(data, (bytes, bytearray))
                else source_url
            )
            if not file_arg:
                continue
            mime = str(mime).lower()
            attachment_target = jid_obj if jid_obj is not None else jid
            send_method = "document"
            if mime.startswith("image/") and hasattr(self._client, "send_image"):
                send_method = "image"
            elif mime.startswith("video/") and hasattr(self._client, "send_video"):
                send_method = "video"
            elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
                send_method = "audio"

            if getattr(settings, "WHATSAPP_DEBUG", False):
                self.log.debug(
                    "whatsapp media send prep: method=%s mime=%s filename=%s size=%s",
                    send_method,
                    mime,
                    filename,
                    (
                        len(data)
                        if isinstance(data, (bytes, bytearray))
                        else (payload.get("size") or 0)
                    ),
                )

            try:
                if mime.startswith("image/") and hasattr(self._client, "send_image"):
                    response = await self._maybe_await(
                        self._client.send_image(attachment_target, file_arg, caption="")
                    )
                elif mime.startswith("video/") and hasattr(self._client, "send_video"):
                    response = await self._maybe_await(
                        self._client.send_video(attachment_target, file_arg, caption="")
                    )
                elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
                    response = await self._maybe_await(
                        self._client.send_audio(attachment_target, file_arg)
                    )
                elif hasattr(self._client, "send_document"):
                    response = await self._maybe_await(
                        self._client.send_document(
                            attachment_target,
                            file_arg,
                            filename=filename,
                            mimetype=mime,
                            caption="",
                        )
                    )
                else:
                    response = None
                sent_ts = max(
                    sent_ts,
                    self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
                )
                await _record_bridge(response, sent_ts, body_hint="attachment")
                sent_any = True
                if getattr(settings, "WHATSAPP_DEBUG", False):
                    self.log.debug(
                        "whatsapp media send ok: method=%s filename=%s ts=%s",
                        send_method,
                        filename,
                        self._normalize_timestamp(
                            self._pluck(response, "Timestamp") or 0
                        ),
                    )
            except Exception as exc:
                self.log.warning("whatsapp attachment send failed: %s", exc)

        if text:
            response = None
            last_error = None
            quoted_text_message = text
            if reply_to_upstream_message_id:
                try:
                    from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
                        ContextInfo,
                        ExtendedTextMessage,
                    )
                    from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
                        Message as WAProtoMessage,
                    )

                    context = ContextInfo(
                        stanzaID=reply_to_upstream_message_id,
                    )
                    participant_jid = self._to_jid(reply_to_participant)
                    remote_jid = self._to_jid(reply_to_remote_jid) or jid_str
                    if participant_jid:
                        context.participant = participant_jid
                    if remote_jid:
                        context.remoteJID = remote_jid
                    quoted_text_message = WAProtoMessage(
                        extendedTextMessage=ExtendedTextMessage(
                            text=str(text or ""),
                            contextInfo=context,
                        )
                    )
                except Exception as exc:
                    self.log.warning(
                        "whatsapp quoted-reply payload build failed: %s", exc
                    )
                    quoted_text_message = text
            # Prepare cancel key (if caller provided command_id)
            cancel_key = None
            try:
                if command_id:
                    cancel_key = transport._runtime_command_cancel_key(
                        self.service, str(command_id)
                    )
            except Exception:
                cancel_key = None

            for attempt in range(2):
                # Check for a cancellation marker set by transport.cancel_runtime_command
                try:
                    if cancel_key and cache.get(cancel_key):
                        self.log.info("whatsapp send cancelled via cancel marker")
                        self._last_send_error = "cancelled"
                        return False
                except Exception:
                    pass
                try:
                    send_target = jid_obj if jid_obj is not None else jid
                    # Log what we're about to send for debugging
                    if getattr(settings, "WHATSAPP_DEBUG", False):
                        self.log.debug(
                            f"send_message attempt {attempt+1}: target_type={type(send_target).__name__} text_type={type(text).__name__} text_len={len(text)}"
                        )
                    response = await self._call_client_method(
                        getattr(self._client, "send_message", None),
                        send_target,
                        quoted_text_message,
                        timeout=9.0,
                    )
                    sent_any = True
                    last_error = None
                    break
                except Exception as exc:
                    if getattr(settings, "WHATSAPP_DEBUG", False):
                        self.log.debug(
                            f"send_message attempt {attempt+1} failed: {type(exc).__name__}: {exc}"
                        )
                    last_error = exc

                error_text = (
                    f"{type(last_error).__name__}:{repr(last_error)}"
                    if last_error is not None
                    else ""
                ).lower()
                is_transient = (
                    "usync" in error_text
                    or "timed out" in error_text
                    or "timeout" in error_text
                    or "device list" in error_text
                    or "serializetostring" in error_text
                    or not error_text.strip()
                )
                if is_transient and attempt < 1:
                    # If runtime rejected string target, try to build protobuf JID for retry.
                    if (
                        jid_obj is None
                        and self._build_jid is not None
                        and "@" in jid_str
                    ):
                        local_part, server_part = jid_str.split("@", 1)
                        try:
                            maybe_retry_jid = self._build_jid(local_part, server_part)
                        except TypeError:
                            maybe_retry_jid = self._build_jid(jid_str)
                        except Exception:
                            maybe_retry_jid = None
                        if hasattr(maybe_retry_jid, "SerializeToString"):
                            jid_obj = maybe_retry_jid
                    if hasattr(self._client, "connect"):
                        try:
                            await self._maybe_await(self._client.connect())
                            self._connected = True
                            self._publish_state(
                                connected=True,
                                last_event="send_retry_reconnect_ok",
                                warning="",
                            )
                        except Exception as reconnect_exc:
                            self._publish_state(
                                connected=False,
                                last_event="send_retry_reconnect_failed",
                                last_error=str(reconnect_exc),
                            )
                    # Sleep but wake earlier if cancelled: poll small intervals
                    # Increase backoff time for device list queries
                    total_sleep = 0.8 * (attempt + 1)
                    slept = 0.0
                    while slept < total_sleep:
                        try:
                            if cancel_key and cache.get(cancel_key):
                                self.log.info(
                                    "whatsapp send cancelled during retry backoff"
                                )
                                self._last_send_error = "cancelled"
                                return False
                        except Exception:
                            pass
                        await asyncio.sleep(0.2)
                        slept += 0.2
                    continue
                break
            if last_error is not None and not sent_any:
                self.log.warning("whatsapp text send failed: %s", last_error)
                self._last_send_error = (
                    f"text_send_failed:{type(last_error).__name__}:{repr(last_error)}"
                )
                return False
            sent_ts = max(
                sent_ts,
                self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
            )
            await _record_bridge(response, sent_ts, body_hint=str(text or ""))

        if not sent_any:
            self._last_send_error = "no_payload_sent"
            return False
        self._last_send_error = ""
        return sent_ts or int(time.time() * 1000)

    async def start_typing(self, identifier):
        if not self._client:
            return False
        jid = self._to_jid(identifier)
        if not jid:
            return False
        if (
            hasattr(self._client, "send_chat_presence")
            and self._chat_presence is not None
            and self._chat_presence_media is not None
        ):
            try:
                await self._maybe_await(
                    self._client.send_chat_presence(
                        jid,
                        self._chat_presence.CHAT_PRESENCE_COMPOSING,
                        self._chat_presence_media.CHAT_PRESENCE_MEDIA_TEXT,
                    )
                )
                return True
            except Exception:
                pass
        if hasattr(self._client, "set_chat_presence"):
            try:
                await self._maybe_await(
                    self._client.set_chat_presence(jid, "composing")
                )
                return True
            except Exception:
                pass
        return False

    async def stop_typing(self, identifier):
        if not self._client:
            return False
        jid = self._to_jid(identifier)
        if not jid:
            return False
        if (
            hasattr(self._client, "send_chat_presence")
            and self._chat_presence is not None
            and self._chat_presence_media is not None
        ):
            try:
                await self._maybe_await(
                    self._client.send_chat_presence(
                        jid,
                        self._chat_presence.CHAT_PRESENCE_PAUSED,
                        self._chat_presence_media.CHAT_PRESENCE_MEDIA_TEXT,
                    )
                )
                return True
            except Exception:
                pass
        if hasattr(self._client, "set_chat_presence"):
            try:
                await self._maybe_await(self._client.set_chat_presence(jid, "paused"))
                return True
            except Exception:
                pass
        return False

    async def send_reaction(
        self,
        recipient,
        *,
        emoji,
        target_message_id="",
        target_timestamp=0,
        remove=False,
    ):
        if not self._client:
            return False
        jid = self._to_jid(recipient)
        if not jid:
            return False
        target_id = str(target_message_id or "").strip()
        if not target_id:
            return False

        reaction_emoji = "" if remove else str(emoji or "").strip()
        candidate_names = (
            "send_reaction",
            "react",
            "send_message_reaction",
            "reaction",
        )
        self.log.debug(
            "reaction-bridge whatsapp-send start recipient=%s target_id=%s emoji=%s remove=%s",
            recipient,
            target_id,
            reaction_emoji or "-",
            bool(remove),
        )
        for method_name in candidate_names:
            method = getattr(self._client, method_name, None)
            if method is None:
                continue
            attempts = [
                (jid, target_id, reaction_emoji),
                (jid, target_id, reaction_emoji, bool(remove)),
                (jid, reaction_emoji, target_id),
            ]
            for args in attempts:
                try:
                    response = await self._call_client_method(
                        method, *args, timeout=9.0
                    )
                    if response is not None:
                        self.log.debug(
                            "reaction-bridge whatsapp-send ok method=%s args_len=%s",
                            method_name,
                            len(args),
                        )
                        return True
                except Exception as exc:
                    self.log.debug(
                        "reaction-bridge whatsapp-send miss method=%s args_len=%s error=%s",
                        method_name,
                        len(args),
                        exc,
                    )
                    continue
        self.log.warning(
            "reaction-bridge whatsapp-send failed recipient=%s target_id=%s",
            recipient,
            target_id,
        )
        return False

    async def fetch_attachment(self, attachment_ref):
        blob_key = (attachment_ref or {}).get("blob_key")
        if blob_key:
            return media_bridge.get_blob(blob_key)
        return None

    def get_link_qr_png(self, device_name):
        _ = (device_name or "").strip()
        if not self._last_qr_payload:
            return None
        try:
            return transport._as_qr_png(self._last_qr_payload)
        except Exception:
            return None

    async def send_message_to_contact(self, contact_jid: str, text: str) -> bool:
        """Send a text message to a WhatsApp contact."""
        try:
            jid = self._to_jid(contact_jid)
            if not jid or self._client is None:
                return False
            # neonize.send_message() accepts either a Message protobuf or a plain string
            # If passing a string, it auto-converts to Message(conversation=text)
            response = self._client.send_message(jid, text)
            return response is not None
        except Exception as e:
            self.log.error(f"Failed to send WhatsApp message: {e}")
            return False

    # If you need to send a Message object explicitly:
    async def send_structured_message(self, contact_jid: str, message: Message) -> bool:
        """Send a structured Message protobuf to a WhatsApp contact."""
        try:
            jid = self._to_jid(contact_jid)
            if not jid or self._client is None:
                return False
            response = self._client.send_message(jid, message)
            return response is not None
        except Exception as e:
            self.log.error(f"Failed to send structured WhatsApp message: {e}")
            return False
