import asyncio
import base64
import json
import logging
import os
import re
import time
import uuid
from pathlib import Path
from urllib.parse import urlsplit

import aiohttp
from asgiref.sync import sync_to_async
from django.conf import settings
from django.utils.timezone import now
from slixmpp.componentxmpp import ComponentXMPP
from slixmpp.plugins.xep_0085.stanza import Active, Composing, Gone, Inactive, Paused
from slixmpp.plugins.xep_0356.permissions import MessagePermission
from slixmpp.stanza import Message
from slixmpp.xmlstream import register_stanza_plugin
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import StanzaPath
from slixmpp.xmlstream.stanzabase import ET

from core.clients import ClientBase, transport
from core.gateway.builtin import (
    dispatch_builtin_gateway_command,
    gateway_help_lines,
)
from core.messaging import ai, history, replies, reply_sync, utils
from core.models import (
    ChatSession,
    Manipulation,
    PatternMitigationAutoSettings,
    PatternMitigationCorrection,
    PatternMitigationGame,
    PatternMitigationPlan,
    PatternMitigationRule,
    Person,
    PersonIdentifier,
    User,
    UserXmppOmemoState,
    WorkspaceConversation,
)
from core.security.attachments import (
    validate_attachment_metadata,
    validate_attachment_url,
)
from core.util import logs

URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)
EMOJI_ONLY_PATTERN = re.compile(
    r"^[\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\u2640-\u2642\u2764]+$"
)
TOTP_BASE32_SECRET_RE = re.compile(r"^[A-Z2-7]{16,}$")
PUBSUB_NS = "http://jabber.org/protocol/pubsub"
OMEMO_OLD_NS = "eu.siacs.conversations.axolotl"
OMEMO_OLD_DEVICELIST_NODE = "eu.siacs.conversations.axolotl.devicelist"
XMPP_LOCALPART_ESCAPE_MAP = {
    "\\": r"\5c",
    " ": r"\20",
    '"': r"\22",
    "&": r"\26",
    "'": r"\27",
    "/": r"\2f",
    ":": r"\3a",
    "<": r"\3c",
    ">": r"\3e",
    "@": r"\40",
    "|": r"\7c",
}
XMPP_LOCALPART_UNESCAPE_MAP = {
    value: key for key, value in XMPP_LOCALPART_ESCAPE_MAP.items()
}
XMPP_LOCALPART_UNESCAPE_RE = re.compile(
    "|".join(
        sorted(
            (re.escape(key) for key in XMPP_LOCALPART_UNESCAPE_MAP.keys()),
            key=len,
            reverse=True,
        )
    )
)


def _clean_url(value):
    return str(value or "").strip().rstrip(".,);:!?\"'")


def _escape_xmpp_localpart(value):
    return "".join(XMPP_LOCALPART_ESCAPE_MAP.get(ch, ch) for ch in str(value or ""))


def _unescape_xmpp_localpart(value):
    text = str(value or "")
    if not text:
        return ""
    return XMPP_LOCALPART_UNESCAPE_RE.sub(
        lambda match: XMPP_LOCALPART_UNESCAPE_MAP.get(match.group(0), match.group(0)),
        text,
    )


def _normalized_person_lookup_token(value):
    return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())


def _resolve_person_from_xmpp_localpart(*, user, localpart_value):
    raw_value = _unescape_xmpp_localpart(localpart_value).strip()
    if not raw_value:
        return None

    # First try a straightforward case-insensitive name match.
    person = Person.objects.filter(user=user, name__iexact=raw_value).first()
    if person is not None:
        return person

    # Fall back to normalized matching so `test-account`, `test_account`,
    # and `test account` can resolve the same contact.
    normalized_target = _normalized_person_lookup_token(raw_value)
    if not normalized_target:
        return None
    for candidate in Person.objects.filter(user=user).only("id", "name"):
        if _normalized_person_lookup_token(candidate.name) == normalized_target:
            return candidate
    return None


def _signal_identifier_rank(identifier_value):
    identifier_text = str(identifier_value or "").strip()
    if not identifier_text:
        return 99
    if identifier_text.startswith("group."):
        return 0
    if identifier_text.startswith("+"):
        return 1
    if SIGNAL_UUID_PATTERN.fullmatch(identifier_text):
        return 2
    return 3


def _select_person_identifier(*, user, person, service=None):
    rows = list(
        PersonIdentifier.objects.filter(
            user=user,
            person=person,
            **({"service": service} if service else {}),
        ).order_by("id")
    )
    if not rows:
        return None
    if service == "signal" and len(rows) > 1:
        rows.sort(key=lambda row: (_signal_identifier_rank(row.identifier), row.id))
    chosen = rows[0]
    if len(rows) > 1:
        logging.getLogger(__name__).warning(
            "Resolved multiple identifiers for user=%s person=%s service=%s; choosing %s from %s rows",
            getattr(user, "id", None),
            getattr(person, "id", None),
            service or getattr(chosen, "service", ""),
            getattr(chosen, "identifier", ""),
            len(rows),
        )
    return chosen


def _filename_from_url(url_value):
    path = urlsplit(str(url_value or "")).path
    name = path.rsplit("/", 1)[-1]
    return name or "attachment"


def _content_type_from_filename_or_url(url_value, default="application/octet-stream"):
    _ = url_value
    return str(default or "application/octet-stream")


def _extract_xml_attachment_urls(message_stanza):
    urls = []

    def _add(candidate):
        cleaned = _clean_url(candidate)
        if not cleaned:
            return
        if not cleaned.startswith("http://") and not cleaned.startswith("https://"):
            return
        if cleaned not in urls:
            urls.append(cleaned)

    # Explicit attachments and OOB payloads.
    for node in message_stanza.xml.findall(".//{urn:xmpp:attachments}attachment"):
        _add(node.attrib.get("url"))
    for node in message_stanza.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
        _add(node.text)

    # XMPP references frequently carry attachment URIs.
    for node in message_stanza.xml.findall(".//{urn:xmpp:reference:0}reference"):
        _add(node.attrib.get("uri"))

    # Generic fallback for custom namespaces and rich message payloads.
    for node in message_stanza.xml.iter():
        for key in ("url", "uri", "href", "src"):
            _add(node.attrib.get(key))
        for match in URL_PATTERN.findall(str(node.text or "")):
            _add(match)

    return urls


def _attachment_rows_from_body_urls(body_text):
    rows = []
    seen = set()
    for raw_line in str(body_text or "").splitlines():
        candidate = _clean_url(raw_line)
        if not candidate:
            continue
        try:
            safe_url = validate_attachment_url(candidate)
            filename, content_type = validate_attachment_metadata(
                filename=_filename_from_url(safe_url),
                content_type=_content_type_from_filename_or_url(safe_url),
            )
        except Exception:
            continue
        if safe_url in seen:
            continue
        seen.add(safe_url)
        rows.append(
            {
                "url": safe_url,
                "filename": filename,
                "content_type": content_type,
            }
        )
    return rows


def _extract_xmpp_reaction(message_stanza):
    nodes = message_stanza.xml.findall(".//{urn:xmpp:reactions:0}reactions")
    if not nodes:
        return None
    node = nodes[0]
    target_id = str(node.attrib.get("id") or "").strip()
    emojis = []
    for child in node.findall("{urn:xmpp:reactions:0}reaction"):
        value = str(child.text or "").strip()
        if value:
            emojis.append(value)
    return {
        "target_id": target_id,
        "emoji": emojis[0] if emojis else "",
        "remove": len(emojis) == 0,
    }


def _extract_xmpp_reply_target_id(message_stanza):
    reply = message_stanza.xml.find(".//{urn:xmpp:reply:0}reply")
    if reply is None:
        return ""
    return str(reply.attrib.get("id") or reply.attrib.get("to") or "").strip()


def _parse_greentext_reaction(body_text):
    lines = [line.strip() for line in str(body_text or "").splitlines() if line.strip()]
    if len(lines) != 2:
        return None
    if not lines[0].startswith(">"):
        return None
    quoted = lines[0][1:].strip()
    emoji = lines[1].strip()
    if not quoted or not emoji:
        return None
    if not EMOJI_ONLY_PATTERN.match(emoji):
        return None
    return {"quoted_text": quoted, "emoji": emoji}


def _omemo_plugin_available() -> bool:
    try:
        import importlib

        return importlib.util.find_spec("slixmpp_omemo") is not None
    except Exception:
        return False


def _extract_sender_omemo_client_key(stanza) -> dict:
    """Extract OMEMO client key info from an encrypted stanza."""
    ns = "eu.siacs.conversations.axolotl"
    header = stanza.xml.find(f".//{{{ns}}}header")
    if header is None:
        return {"status": "no_omemo"}
    sid = str(header.attrib.get("sid") or "").strip()
    key_el = header.find(f"{{{ns}}}key")
    rid = str(key_el.attrib.get("rid") or "").strip() if key_el is not None else ""
    if sid or rid:
        return {"status": "detected", "client_key": f"sid:{sid},rid:{rid}"}
    return {"status": "no_omemo"}


def _format_omemo_identity_fingerprint(identity_key) -> str:
    if isinstance(identity_key, (bytes, bytearray)):
        key_bytes = bytes(identity_key)
    else:
        return ""
    try:
        from omemo.session_manager import SessionManager as _OmemoSessionManager

        return " ".join(_OmemoSessionManager.format_identity_key(key_bytes)).upper()
    except Exception:
        return ":".join(f"{b:02X}" for b in key_bytes)


# ---------------------------------------------------------------------------
# OMEMO storage + plugin implementation
# ---------------------------------------------------------------------------

try:
    from omemo.storage import Just, Maybe, Nothing
    from omemo.storage import Storage as _OmemoStorageBase
    from slixmpp.plugins.base import register_plugin as _slixmpp_register_plugin
    from slixmpp_omemo import XEP_0384 as _XEP_0384Base
    from slixmpp_omemo.base_session_manager import TrustLevel as _OmemoTrustLevel

    _OMEMO_AVAILABLE = True
except ImportError:
    _OMEMO_AVAILABLE = False
    _OmemoStorageBase = object
    _XEP_0384Base = object
    _OmemoTrustLevel = None
    _slixmpp_register_plugin = None


if _OMEMO_AVAILABLE:

    class _OmemoStorage(_OmemoStorageBase):
        """JSON-file-backed OMEMO key storage."""

        def __init__(self, path: str) -> None:
            super().__init__()
            self._path = path
            try:
                with open(path) as f:
                    self._data: dict = json.load(f)
            except (FileNotFoundError, json.JSONDecodeError):
                self._data = {}

        def _save(self) -> None:
            os.makedirs(os.path.dirname(self._path), exist_ok=True)
            with open(self._path, "w") as f:
                json.dump(self._data, f)

        async def _load(self, key: str) -> Maybe:
            if key in self._data:
                return Just(self._data[key])
            return Nothing()

        async def _store(self, key: str, value) -> None:
            self._data[key] = value
            self._save()

        async def _delete(self, key: str) -> None:
            self._data.pop(key, None)
            self._save()

    class _GiaOmemoPlugin(_XEP_0384Base):
        """Concrete XEP-0384 OMEMO plugin for the GIA XMPP gateway component.

        Uses BTBV (blind trust before verification) – appropriate for a
        server-side bridge that processes messages on behalf of users.
        """

        name = "xep_0384"
        description = "OMEMO Encryption (GIA gateway)"
        dependencies = {
            "xep_0004",
            "xep_0030",
            "xep_0060",
            "xep_0163",
            "xep_0280",
            "xep_0334",
        }
        default_config = {
            "fallback_message": "This message is OMEMO encrypted.",
            "data_dir": "",
        }

        def plugin_init(self) -> None:
            data_dir = str(self.config.get("data_dir") or "").strip()
            if not data_dir:
                data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
            os.makedirs(data_dir, exist_ok=True)
            self._storage_impl = _OmemoStorage(os.path.join(data_dir, "omemo.json"))
            super().plugin_init()

        @property
        def storage(self) -> _OmemoStorageBase:
            return self._storage_impl

        @property
        def _btbv_enabled(self) -> bool:
            return True

        async def _devices_blindly_trusted(self, blindly_trusted, identifier):
            import logging

            logging.getLogger(__name__).info(
                "OMEMO: blindly trusted %d new device(s)", len(blindly_trusted)
            )

        async def _prompt_manual_trust(self, manually_trusted, identifier):
            """Auto-trust all undecided devices (gateway mode)."""
            import logging

            log = logging.getLogger(__name__)
            log.info(
                "OMEMO: auto-trusting %d undecided device(s) (gateway mode)",
                len(manually_trusted),
            )
            session_manager = await self.get_session_manager()
            for device in manually_trusted:
                try:
                    await session_manager.set_trust(
                        device.bare_jid,
                        device.device_id,
                        device.identity_key,
                        _OmemoTrustLevel.BLINDLY_TRUSTED.value,
                    )
                except Exception as exc:
                    log.warning(
                        "OMEMO set_trust failed for %s: %s", device.bare_jid, exc
                    )


class XMPPComponent(ComponentXMPP):
    """
    A simple Slixmpp component that echoes messages.
    """

    def __init__(self, ur, jid, secret, server, port):
        self.ur = ur
        self._upload_config_warned = False
        self._reconnect_task = None
        self._reconnect_delay_seconds = 1.0
        self._reconnect_delay_max_seconds = 30.0
        self._connect_inflight = False
        self._session_live = False

        self.log = logs.get_logger("XMPP")
        logging.getLogger("slixmpp_omemo").setLevel(logging.DEBUG)

        super().__init__(jid, secret, server, port)
        # Enable message IDs so the OMEMO plugin can associate encrypted stanzas.
        self.use_message_ids = True
        # Use one reconnect strategy (our backoff loop) to avoid reconnect churn.
        self.auto_reconnect = False
        # Register chat state plugins
        register_stanza_plugin(Message, Active)
        register_stanza_plugin(Message, Composing)
        register_stanza_plugin(Message, Paused)
        register_stanza_plugin(Message, Inactive)
        register_stanza_plugin(Message, Gone)

        self.add_event_handler("session_start", self.session_start)
        self.add_event_handler("disconnected", self.on_disconnected)
        self.add_event_handler("message", self.message)
        self.register_handler(
            Callback(
                "OMEMOPubSubItemsGet",
                StanzaPath("iq/pubsub/items"),
                self._handle_pubsub_iq_get,
            )
        )
        self.register_handler(
            Callback(
                "OMEMOPubSubPublishSet",
                StanzaPath("iq/pubsub/publish"),
                self._handle_pubsub_iq_set,
            )
        )

        # Presence event handlers
        self.add_event_handler("presence_available", self.on_presence_available)
        self.add_event_handler("presence_dnd", self.on_presence_dnd)
        self.add_event_handler("presence_xa", self.on_presence_xa)
        self.add_event_handler("presence_chat", self.on_presence_chat)
        self.add_event_handler("presence_away", self.on_presence_away)
        self.add_event_handler("presence_unavailable", self.on_presence_unavailable)
        self.add_event_handler("presence_subscribe", self.on_presence_subscribe)
        self.add_event_handler("presence_subscribed", self.on_presence_subscribed)
        self.add_event_handler("presence_unsubscribe", self.on_presence_unsubscribe)
        self.add_event_handler("presence_unsubscribed", self.on_presence_unsubscribed)
        self.add_event_handler(
            "roster_subscription_request", self.on_roster_subscription_request
        )
        self.add_event_handler("privileges_advertised", self.on_privileges_advertised)

        # Chat state handlers
        self.add_event_handler("chatstate_active", self.on_chatstate_active)
        self.add_event_handler("chatstate_composing", self.on_chatstate_composing)
        self.add_event_handler("chatstate_paused", self.on_chatstate_paused)
        self.add_event_handler("chatstate_inactive", self.on_chatstate_inactive)
        self.add_event_handler("chatstate_gone", self.on_chatstate_gone)
        self._omemo_component_pubsub = self._seed_component_omemo_pubsub()

    @staticmethod
    def _clone_xml(node):
        return ET.fromstring(ET.tostring(node, encoding="unicode"))

    def _seed_component_omemo_pubsub(self):
        return {}

    def _sync_component_omemo_pubsub(self, device_id, identity_key):
        device_id = str(device_id or "").strip()
        if not device_id:
            return
        if isinstance(identity_key, (bytes, bytearray)):
            key_bytes = bytes(identity_key)
        else:
            key_bytes = str(identity_key or "").encode()
        identity_b64 = base64.b64encode(key_bytes).decode()

        devicelist = ET.Element(f"{{{OMEMO_OLD_NS}}}list")
        dev = ET.SubElement(devicelist, f"{{{OMEMO_OLD_NS}}}device")
        dev.set("id", device_id)

        bundle = ET.Element(f"{{{OMEMO_OLD_NS}}}bundle")
        ik = ET.SubElement(bundle, f"{{{OMEMO_OLD_NS}}}identityKey")
        ik.text = identity_b64

        self._omemo_component_pubsub = {
            OMEMO_OLD_DEVICELIST_NODE: {
                "item_id": "current",
                "payload": devicelist,
            },
            f"eu.siacs.conversations.axolotl.bundles:{device_id}": {
                "item_id": "current",
                "payload": bundle,
            },
        }

    async def _reply_pubsub_items(self, iq, node_name):
        reply = iq.reply()
        pubsub = ET.Element(f"{{{PUBSUB_NS}}}pubsub")
        items = ET.SubElement(pubsub, f"{{{PUBSUB_NS}}}items")
        items.set("node", node_name)
        stored = self._omemo_component_pubsub.get(node_name)
        if stored is None and node_name.startswith(
            "eu.siacs.conversations.axolotl.bundles:"
        ):
            bundle_nodes = [
                key
                for key in self._omemo_component_pubsub
                if key.startswith("eu.siacs.conversations.axolotl.bundles:")
            ]
            if len(bundle_nodes) == 1:
                stored = self._omemo_component_pubsub.get(bundle_nodes[0])
        if stored:
            item = ET.SubElement(items, f"{{{PUBSUB_NS}}}item")
            item.set("id", str(stored.get("item_id") or "current"))
            payload = stored.get("payload")
            if payload is not None:
                item.append(self._clone_xml(payload))
        reply.append(pubsub)
        await reply.send()

    async def on_iq_get(self, iq):
        if iq["type"] != "get":
            return
        try:
            iq_to = str(iq["to"] or "").split("/", 1)[0].strip().lower()
        except Exception:
            iq_to = ""
        own_jid = str(getattr(self.boundjid, "bare", "") or "").strip().lower()
        if iq_to and own_jid and iq_to != own_jid:
            return
        items = iq.xml.find(f".//{{{PUBSUB_NS}}}items")
        if items is None:
            return
        node_name = str(items.attrib.get("node") or "").strip()
        if not node_name:
            return
        self.log.debug(
            "OMEMO IQ-GET pubsub items node=%s from=%s to=%s",
            node_name,
            iq.get("from"),
            iq.get("to"),
        )
        if (
            node_name.startswith("eu.siacs.conversations.axolotl.bundles:")
            or node_name == OMEMO_OLD_DEVICELIST_NODE
            or node_name == "urn:xmpp:omemo:2:devices"
            or node_name == "urn:xmpp:omemo:2:bundles"
        ):
            await self._reply_pubsub_items(iq, node_name)

    async def on_iq_set(self, iq):
        if iq["type"] != "set":
            return
        try:
            iq_to = str(iq["to"] or "").split("/", 1)[0].strip().lower()
        except Exception:
            iq_to = ""
        own_jid = str(getattr(self.boundjid, "bare", "") or "").strip().lower()
        if iq_to and own_jid and iq_to != own_jid:
            return
        publish = iq.xml.find(f".//{{{PUBSUB_NS}}}publish")
        if publish is None:
            return
        node_name = str(publish.attrib.get("node") or "").strip()
        if not node_name:
            return
        self.log.debug(
            "OMEMO IQ-SET pubsub publish node=%s from=%s to=%s",
            node_name,
            iq.get("from"),
            iq.get("to"),
        )
        item = publish.find(f"{{{PUBSUB_NS}}}item")
        payload = None
        item_id = "current"
        if item is not None:
            item_id = str(item.attrib.get("id") or "current")
            for child in list(item):
                payload = child
                break
        if payload is not None:
            self._omemo_component_pubsub[node_name] = {
                "item_id": item_id,
                "payload": self._clone_xml(payload),
            }
        await iq.reply().send()

    def _handle_pubsub_iq_get(self, iq):
        asyncio.create_task(self.on_iq_get(iq))

    def _handle_pubsub_iq_set(self, iq):
        asyncio.create_task(self.on_iq_set(iq))

    def _user_xmpp_domain(self):
        domain = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
        if domain:
            return domain
        component_jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
        if "." in component_jid:
            return component_jid.split(".", 1)[1]
        configured_domain = str(getattr(settings, "DOMAIN", "") or "").strip()
        if configured_domain:
            return configured_domain
        return str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()

    def _user_jid(self, username):
        return f"{username}@{self._user_xmpp_domain()}"

    def _contact_component_jid(self, person_name: str, service: str) -> str:
        escaped_name = _escape_xmpp_localpart(str(person_name or "").strip().lower())
        return f"{escaped_name}|{str(service or '').strip().lower()}@{self.boundjid.bare}"

    def _build_privileged_outbound_message(
        self, *, user_jid, contact_jid, body_text, attachment_url=None
    ):
        msg = self.make_message(mto=contact_jid, mfrom=user_jid, mtype="chat")
        if not msg.get("id"):
            msg["id"] = uuid.uuid4().hex
        msg["body"] = str(body_text or "")
        if attachment_url:
            oob_element = ET.Element("{jabber:x:oob}x")
            url_element = ET.SubElement(oob_element, "{jabber:x:oob}url")
            url_element.text = str(attachment_url)
            msg.xml.append(oob_element)
        return msg

    def _can_send_privileged_messages(self, user_jid: str) -> bool:
        domain = str(user_jid.split("@", 1)[1] if "@" in user_jid else "").strip()
        if not domain:
            return False
        configured_domain = self._user_xmpp_domain()
        if configured_domain and domain == configured_domain:
            return True
        plugin = self.plugin.get("xep_0356", None)
        if plugin is None:
            return False
        perms = plugin.granted_privileges.get(domain)
        if perms is None:
            return False
        return perms.message == MessagePermission.OUTGOING

    async def send_sent_carbon_copy(
        self, *, user_jid, contact_jid, body_text, attachment_url=None
    ) -> bool:
        if not self._can_send_privileged_messages(user_jid):
            self.log.debug(
                "Skipping sent carbon copy for %s: outgoing message privilege unavailable",
                user_jid,
            )
            return False
        plugin = self.plugin.get("xep_0356", None)
        if plugin is None:
            return False
        try:
            msg = self._build_privileged_outbound_message(
                user_jid=user_jid,
                contact_jid=contact_jid,
                body_text=body_text,
                attachment_url=attachment_url,
            )
            domain = str(user_jid.split("@", 1)[1] if "@" in user_jid else "").strip()
            perms = plugin.granted_privileges.get(domain) if domain else None
            if perms is not None and perms.message == MessagePermission.OUTGOING:
                plugin.send_privileged_message(msg)
            else:
                plugin._make_privileged_message(msg).send()
            self.log.debug(
                "Sent privileged carbon copy user=%s contact=%s",
                user_jid,
                contact_jid,
            )
            return True
        except Exception as exc:
            self.log.warning(
                "Failed to send privileged carbon copy user=%s contact=%s: %s",
                user_jid,
                contact_jid,
                exc,
            )
            return False

    async def enable_carbons(self):
        """Enable XMPP Message Carbons (XEP-0280)"""
        try:
            iq = self.make_iq_set()
            iq["enable"] = ET.Element("{urn:xmpp:carbons:2}enable")
            await iq.send()
            self.log.info("Message Carbons enabled successfully")
        except Exception as e:
            self.log.error(f"Failed to enable Carbons: {e}")

    def get_identifier(self, msg):
        # Extract sender JID (full format: user@domain/resource)
        sender_jid = str(msg["from"])

        # Split into username@domain and optional resource
        sender_parts = sender_jid.split("/", 1)
        sender_bare_jid = sender_parts[0]  # Always present: user@domain
        sender_username, sender_domain = sender_bare_jid.split("@", 1)

        # Extract recipient JID (should match component JID format)
        recipient_jid = str(msg["to"])

        if "@" in recipient_jid:
            recipient_username = recipient_jid.split("@", 1)[0]
        else:
            recipient_username = recipient_jid
        # Parse recipient_name and recipient_service (e.g., "mark|signal")
        if "|" in recipient_username:
            person_name, service = recipient_username.split("|", 1)
            person_name = person_name.strip()
            service = service.strip().lower()
        else:
            person_name = recipient_username.strip()
            service = None

        try:
            # Lookup user in Django
            self.log.debug("Resolving XMPP sender user=%s", sender_username)
            user = User.objects.get(username=sender_username)

            self.log.debug("Resolving XMPP recipient person=%s", person_name)
            person = _resolve_person_from_xmpp_localpart(
                user=user, localpart_value=person_name
            )
            if person is None:
                raise Person.DoesNotExist(f"No person found for '{person_name}'")

            # Ensure a PersonIdentifier exists for this user, person, and service
            self.log.debug("Resolving XMPP identifier service=%s", service)
            if service:
                identifier = _select_person_identifier(
                    user=user,
                    person=person,
                    service=service,
                )
                if identifier is None:
                    raise PersonIdentifier.DoesNotExist(
                        f"No identifier found for person '{person_name}'"
                    )
            else:
                identifier = _select_person_identifier(user=user, person=person)
                if identifier is None:
                    raise PersonIdentifier.DoesNotExist(
                        f"No identifier found for person '{person_name}'"
                    )
            return identifier
        except Exception as e:
            self.log.error(f"Failed to resolve identifier from XMPP message: {e}")
            return None

    def _get_workspace_conversation(self, user, person):
        primary_identifier = (
            PersonIdentifier.objects.filter(user=user, person=person)
            .order_by("service")
            .first()
        )
        platform_type = primary_identifier.service if primary_identifier else "signal"
        conversation, _ = WorkspaceConversation.objects.get_or_create(
            user=user,
            platform_type=platform_type,
            title=f"{person.name} Workspace",
            defaults={"platform_thread_id": str(person.id)},
        )
        conversation.participants.add(person)
        return conversation

    def _get_or_create_plan(self, user, person):
        conversation = self._get_workspace_conversation(user, person)
        plan = conversation.mitigation_plans.order_by("-updated_at").first()
        if plan is None:
            plan = PatternMitigationPlan.objects.create(
                user=user,
                conversation=conversation,
                title=f"{person.name} Pattern Mitigation",
                objective="Mitigate repeated friction loops.",
                fundamental_items=[],
                creation_mode="guided",
                status="draft",
            )
            PatternMitigationRule.objects.create(
                user=user,
                plan=plan,
                title="Safety Before Analysis",
                content="Prioritize de-escalation before analysis.",
                enabled=True,
            )
            PatternMitigationGame.objects.create(
                user=user,
                plan=plan,
                title="Two-Turn Pause",
                instructions="Use two short turns then pause with a return time.",
                enabled=True,
            )
        return plan

    def _derived_omemo_fingerprint(self, jid: str) -> str:
        import hashlib

        return hashlib.sha256(f"xmpp-omemo-key:{jid}".encode()).hexdigest()[:32]

    def _get_omemo_plugin(self):
        """Return the active XEP-0384 plugin instance, or None if not loaded."""
        try:
            return self["xep_0384"]
        except Exception:
            return None

    async def _bootstrap_omemo_for_authentic_channel(self):
        jid = str(getattr(settings, "XMPP_JID", "") or "").strip()

        omemo_plugin = self._get_omemo_plugin()
        omemo_enabled = omemo_plugin is not None
        status = "active" if omemo_enabled else "not_available"
        reason = (
            "OMEMO plugin active" if omemo_enabled else "xep_0384 plugin not loaded"
        )
        fingerprint = self._derived_omemo_fingerprint(jid)
        if omemo_enabled:
            try:
                import asyncio as _asyncio

                # OMEMO session-manager bootstrap can take longer in component mode because
                # it performs consistency checks and initial pubsub interactions before
                # device publication is complete.
                session_manager = await _asyncio.wait_for(
                    omemo_plugin.get_session_manager(), timeout=180.0
                )
                own_devices = await session_manager.get_own_device_information()
                device_id = None

                if own_devices:
                    # own_devices is a tuple: (own_device, other_devices)
                    own_device = (
                        own_devices[0]
                        if isinstance(own_devices, (tuple, list))
                        else own_devices
                    )
                    key_bytes = own_device.identity_key
                    try:
                        from omemo.session_manager import (
                            SessionManager as _OmemoSessionManager,
                        )

                        fingerprint = " ".join(
                            _OmemoSessionManager.format_identity_key(key_bytes)
                        ).upper()
                    except Exception:
                        fingerprint = ":".join(f"{b:02X}" for b in key_bytes)
                    device_id = own_device.device_id
                    self._sync_component_omemo_pubsub(device_id, key_bytes)
                    self.log.info(
                        "OMEMO: own device created, device_id=%s fingerprint=%s",
                        device_id,
                        fingerprint,
                    )
                else:
                    # Fallback: session manager may not auto-create devices for component JIDs
                    # Manually generate a device ID and use it
                    import random

                    device_id = random.randint(1, 2**31 - 1)
                    self.log.warning(
                        "OMEMO: session manager did not create device (component JID limitation), "
                        "using fallback device_id=%s",
                        device_id,
                    )

                # CRITICAL FIX: Publish BOTH device list AND device bundle
                # Clients need both:
                # 1. Device list node: eu.siacs.conversations.axolotl/devices/{jid} (lists device IDs)
                # 2. Device bundle nodes: eu.siacs.conversations.axolotl/bundles:{device_id} (contains keys)
                # Without bundles, clients can't encrypt (they hang in "pending...")
                if device_id:
                    try:
                        namespace = "eu.siacs.conversations.axolotl"
                        pubsub_service = (
                            str(getattr(settings, "XMPP_PUBSUB_SERVICE", "pubsub"))
                            or "pubsub"
                        )

                        # Step 1: Publish device list
                        try:
                            device_list_dict = {device_id: None}
                            await session_manager._upload_device_list(
                                namespace, device_list_dict
                            )
                            self.log.info(
                                "OMEMO: device list uploaded via session manager for %s",
                                jid,
                            )
                        except (AttributeError, TypeError):
                            # Fallback: Manual publish via XEP-0060
                            try:
                                device_item = ET.Element("list")
                                device_item.set("xmlns", namespace)
                                for dev_id in device_list_dict:
                                    device_elem = ET.SubElement(device_item, "device")
                                    device_elem.set("id", str(dev_id))

                                node_name = f"{namespace}/devices/{jid}"
                                await self["xep_0060"].publish(
                                    pubsub_service, node_name, payload=device_item
                                )
                                self.log.info(
                                    "OMEMO: device list published via XEP-0060 for %s",
                                    jid,
                                )
                            except Exception as e:
                                self.log.warning(
                                    "OMEMO: device list publish failed: %s", e
                                )

                        # Step 2: Publish device bundle (CRITICAL - contains the keys!)
                        # This is what was missing - clients couldn't find the keys
                        try:
                            if own_devices:
                                # Get actual identity key from device
                                own_device = (
                                    own_devices[0]
                                    if isinstance(own_devices, (tuple, list))
                                    else own_devices
                                )
                                identity_key = own_device.identity_key
                                signed_prekey = getattr(
                                    own_device, "signed_prekey", None
                                )

                                # Build bundle item with actual keys
                                bundle_item = ET.Element("bundle")
                                bundle_item.set("xmlns", namespace)

                                # Add signed prekey
                                if signed_prekey:
                                    spk_elem = ET.SubElement(
                                        bundle_item, "signedPreKeyPublic"
                                    )
                                    spk_elem.text = signed_prekey
                                else:
                                    # Fallback: use hash of identity key
                                    import base64

                                    spk_elem = ET.SubElement(
                                        bundle_item, "signedPreKeyPublic"
                                    )
                                    spk_elem.text = base64.b64encode(
                                        identity_key
                                    ).decode()

                                # Add identity key
                                ik_elem = ET.SubElement(bundle_item, "identityKey")
                                import base64

                                ik_elem.text = base64.b64encode(identity_key).decode()

                                # Publish bundle
                                bundle_node = f"{namespace}/bundles:{device_id}"
                                await self["xep_0060"].publish(
                                    pubsub_service, bundle_node, payload=bundle_item
                                )
                                self.log.info(
                                    "OMEMO: device bundle published for %s (device_id=%s)",
                                    jid,
                                    device_id,
                                )
                        except Exception as bundle_exc:
                            self.log.warning(
                                "OMEMO: device bundle publish failed: %s", bundle_exc
                            )

                    except Exception as upload_exc:
                        self.log.warning(
                            "OMEMO: device/bundle upload error: %s", upload_exc
                        )

                # Try to refresh device list to ensure all devices are properly registered.
                try:
                    await session_manager.refresh_device_lists(jid)
                    self.log.info("OMEMO: device list refreshed for %s", jid)
                except Exception as refresh_exc:
                    self.log.debug("OMEMO: device list refresh: %s", refresh_exc)
            except _asyncio.TimeoutError:
                self.log.error(
                    "OMEMO: session manager initialization timeout after 180s. "
                    "Device list may not be published to PubSub. "
                    "Clients will not be able to discover gateway devices. "
                    "Check PubSub server connectivity and latency."
                )
                status = "timeout"
                reason = (
                    "Session manager initialization timeout - device list not published"
                )
            except Exception as exc:
                self.log.warning(
                    "OMEMO: could not initialize device information: %s", exc
                )
        self.log.info(
            "OMEMO bootstrap: jid=%s enabled=%s status=%s fingerprint=%s",
            jid,
            omemo_enabled,
            status,
            fingerprint,
        )
        transport.update_runtime_state(
            "xmpp",
            omemo_target_jid=jid,
            omemo_fingerprint=fingerprint,
            omemo_enabled=omemo_enabled,
            omemo_status=status,
            omemo_status_reason=reason,
        )

    async def _record_sender_omemo_state(
        self,
        user,
        *,
        sender_jid,
        recipient_jid,
        message_stanza,
        sender_fingerprint="",
    ):
        parsed = _extract_sender_omemo_client_key(message_stanza)
        status = str(parsed.get("status") or "no_omemo")
        client_key = str(parsed.get("client_key") or "")

        def _save_row():
            row, _ = UserXmppOmemoState.objects.get_or_create(user=user)
            details = dict(row.details or {})
            if sender_fingerprint:
                details["latest_client_fingerprint"] = str(sender_fingerprint)
            row.status = status
            row.latest_client_key = client_key
            row.last_sender_jid = str(sender_jid or "")
            row.last_target_jid = str(recipient_jid or "")
            row.details = details
            row.save(
                update_fields=[
                    "status",
                    "latest_client_key",
                    "last_sender_jid",
                    "last_target_jid",
                    "details",
                    "updated_at",
                ]
            )

        await sync_to_async(_save_row)()

    async def _route_gateway_command(
        self,
        *,
        sender_user,
        body,
        service,
        channel_identifier,
        sender_identifier,
        sender_jid,
        recipient_jid,
        local_message,
        message_meta,
        sym,
    ):
        return await dispatch_builtin_gateway_command(
            user=sender_user,
            command_text=str(body or "").strip(),
            service=str(service or "xmpp"),
            channel_identifier=str(channel_identifier or sender_jid or ""),
            sender_identifier=str(sender_identifier or sender_jid or ""),
            source_message=local_message,
            message_meta=dict(message_meta or {}),
            payload={
                "sender_jid": str(sender_jid or ""),
                "recipient_jid": str(recipient_jid or ""),
            },
            emit=sym,
        )

    async def execute_gateway_command(
        self,
        *,
        sender_user,
        body,
        service,
        channel_identifier,
        sender_identifier,
        local_message=None,
        message_meta=None,
        sender_jid="",
        recipient_jid="",
    ) -> list[str]:
        captured_replies: list[str] = []

        def _capture(value):
            text_value = str(value or "").strip()
            if text_value:
                captured_replies.append(text_value)

        await self._route_gateway_command(
            sender_user=sender_user,
            body=body,
            service=service,
            channel_identifier=channel_identifier,
            sender_identifier=sender_identifier,
            sender_jid=sender_jid,
            recipient_jid=recipient_jid,
            local_message=local_message,
            message_meta=dict(message_meta or {}),
            sym=_capture,
        )
        return captured_replies

    def _gateway_help_lines(self):
        return gateway_help_lines()

    async def _handle_mitigation_command(self, sender_user, body, sym):
        def parse_parts(raw):
            return [part.strip() for part in raw.split("|")]

        command = body.strip()
        if command == ".mitigation help":
            sym(
                "Mitigation commands: "
                ".mitigation list | "
                ".mitigation show <person> | "
                ".mitigation rule-add <person>|<title>|<content> | "
                ".mitigation rule-del <person>|<title> | "
                ".mitigation game-add <person>|<title>|<instructions> | "
                ".mitigation game-del <person>|<title> | "
                ".mitigation correction-add <person>|<title>|<clarification> | "
                ".mitigation correction-del <person>|<title> | "
                ".mitigation fundamentals-set <person>|<item1;item2;...> | "
                ".mitigation plan-set <person>|<draft|active|archived>|<auto|guided> | "
                ".mitigation auto <person>|on|off | "
                ".mitigation auto-status <person>"
            )
            return True

        if command == ".mitigation list":
            plans = await sync_to_async(list)(
                PatternMitigationPlan.objects.filter(user=sender_user)
                .select_related("conversation")
                .order_by("-updated_at")[:15]
            )
            if not plans:
                sym("No mitigation plans found.")
                return True
            rows = []
            for plan in plans:
                person_name = (
                    plan.conversation.participants.order_by("name").first().name
                    if plan.conversation.participants.exists()
                    else "Unknown"
                )
                rows.append(f"{person_name}: {plan.title}")
            sym("Plans: " + " | ".join(rows))
            return True

        if command.startswith(".mitigation show "):
            person_name = command.replace(".mitigation show ", "", 1).strip().title()
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            rule_count = await sync_to_async(plan.rules.count)()
            game_count = await sync_to_async(plan.games.count)()
            sym(f"{person.name}: {plan.title} | rules={rule_count} games={game_count}")
            return True

        if command.startswith(".mitigation rule-add "):
            payload = command.replace(".mitigation rule-add ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 3:
                sym("Usage: .mitigation rule-add <person>|<title>|<content>")
                return True
            person_name, title, content = (
                parts[0].title(),
                parts[1],
                "|".join(parts[2:]),
            )
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            await sync_to_async(PatternMitigationRule.objects.create)(
                user=sender_user,
                plan=plan,
                title=title[:255],
                content=content,
                enabled=True,
            )
            sym("Rule added.")
            return True

        if command.startswith(".mitigation rule-del "):
            payload = command.replace(".mitigation rule-del ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 2:
                sym("Usage: .mitigation rule-del <person>|<title>")
                return True
            person_name, title = parts[0].title(), "|".join(parts[1:])
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            deleted, _ = await sync_to_async(
                lambda: PatternMitigationRule.objects.filter(
                    user=sender_user,
                    plan=plan,
                    title__iexact=title,
                ).delete()
            )()
            sym("Rule deleted." if deleted else "Rule not found.")
            return True

        if command.startswith(".mitigation game-add "):
            payload = command.replace(".mitigation game-add ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 3:
                sym("Usage: .mitigation game-add <person>|<title>|<instructions>")
                return True
            person_name, title, content = (
                parts[0].title(),
                parts[1],
                "|".join(parts[2:]),
            )
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            await sync_to_async(PatternMitigationGame.objects.create)(
                user=sender_user,
                plan=plan,
                title=title[:255],
                instructions=content,
                enabled=True,
            )
            sym("Game added.")
            return True

        if command.startswith(".mitigation game-del "):
            payload = command.replace(".mitigation game-del ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 2:
                sym("Usage: .mitigation game-del <person>|<title>")
                return True
            person_name, title = parts[0].title(), "|".join(parts[1:])
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            deleted, _ = await sync_to_async(
                lambda: PatternMitigationGame.objects.filter(
                    user=sender_user,
                    plan=plan,
                    title__iexact=title,
                ).delete()
            )()
            sym("Game deleted." if deleted else "Game not found.")
            return True

        if command.startswith(".mitigation correction-add "):
            payload = command.replace(".mitigation correction-add ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 3:
                sym(
                    "Usage: .mitigation correction-add <person>|<title>|<clarification>"
                )
                return True
            person_name, title, clarification = (
                parts[0].title(),
                parts[1],
                "|".join(parts[2:]),
            )
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            await sync_to_async(PatternMitigationCorrection.objects.create)(
                user=sender_user,
                plan=plan,
                title=title[:255],
                clarification=clarification,
                source_phrase="",
                perspective="second_person",
                share_target="both",
                language_style="adapted",
                enabled=True,
            )
            sym("Correction added.")
            return True

        if command.startswith(".mitigation correction-del "):
            payload = command.replace(".mitigation correction-del ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 2:
                sym("Usage: .mitigation correction-del <person>|<title>")
                return True
            person_name, title = parts[0].title(), "|".join(parts[1:])
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            deleted, _ = await sync_to_async(
                lambda: PatternMitigationCorrection.objects.filter(
                    user=sender_user,
                    plan=plan,
                    title__iexact=title,
                ).delete()
            )()
            sym("Correction deleted." if deleted else "Correction not found.")
            return True

        if command.startswith(".mitigation fundamentals-set "):
            payload = command.replace(".mitigation fundamentals-set ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 2:
                sym("Usage: .mitigation fundamentals-set <person>|<item1;item2;...>")
                return True
            person_name, values = parts[0].title(), "|".join(parts[1:])
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            items = [item.strip() for item in values.split(";") if item.strip()]
            plan.fundamental_items = items
            await sync_to_async(plan.save)(
                update_fields=["fundamental_items", "updated_at"]
            )
            sym(f"Fundamentals updated ({len(items)}).")
            return True

        if command.startswith(".mitigation plan-set "):
            payload = command.replace(".mitigation plan-set ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 3:
                sym(
                    "Usage: .mitigation plan-set <person>|<draft|active|archived>|<auto|guided>"
                )
                return True
            person_name, status_value, mode_value = (
                parts[0].title(),
                parts[1].lower(),
                parts[2].lower(),
            )
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            plan = await sync_to_async(self._get_or_create_plan)(sender_user, person)
            valid_status = {key for key, _ in PatternMitigationPlan.STATUS_CHOICES}
            valid_modes = {
                key for key, _ in PatternMitigationPlan.CREATION_MODE_CHOICES
            }
            if status_value in valid_status:
                plan.status = status_value
            if mode_value in valid_modes:
                plan.creation_mode = mode_value
            await sync_to_async(plan.save)(
                update_fields=["status", "creation_mode", "updated_at"]
            )
            sym(f"Plan updated: status={plan.status}, mode={plan.creation_mode}")
            return True

        if command.startswith(".mitigation auto "):
            payload = command.replace(".mitigation auto ", "", 1)
            parts = parse_parts(payload)
            if len(parts) < 2:
                sym("Usage: .mitigation auto <person>|on|off")
                return True
            person_name, state = parts[0].title(), parts[1].lower()
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            conversation = await sync_to_async(self._get_workspace_conversation)(
                sender_user, person
            )
            auto_obj, _ = await sync_to_async(
                PatternMitigationAutoSettings.objects.get_or_create
            )(
                user=sender_user,
                conversation=conversation,
            )
            auto_obj.enabled = state in {"on", "true", "1", "yes"}
            await sync_to_async(auto_obj.save)(update_fields=["enabled", "updated_at"])
            sym(
                f"Automation {'enabled' if auto_obj.enabled else 'disabled'} for {person.name}."
            )
            return True

        if command.startswith(".mitigation auto-status "):
            person_name = (
                command.replace(".mitigation auto-status ", "", 1).strip().title()
            )
            person = await sync_to_async(
                lambda: Person.objects.filter(
                    user=sender_user, name__iexact=person_name
                ).first()
            )()
            if not person:
                sym("Unknown person.")
                return True
            conversation = await sync_to_async(self._get_workspace_conversation)(
                sender_user, person
            )
            auto_obj, _ = await sync_to_async(
                PatternMitigationAutoSettings.objects.get_or_create
            )(
                user=sender_user,
                conversation=conversation,
            )
            sym(
                f"{person.name}: auto={'on' if auto_obj.enabled else 'off'}, "
                f"pattern={'on' if auto_obj.auto_pattern_recognition else 'off'}, "
                f"corrections={'on' if auto_obj.auto_create_corrections else 'off'}"
            )
            return True

        return False

    def update_roster(self, jid, name=None):
        """
        Adds or updates a user in the roster.
        """
        iq = self.Iq()
        iq["type"] = "set"
        iq["roster"]["items"] = {jid: {"name": name or jid}}

        iq.send()
        self.log.debug("Updated roster: added %s (%s)", jid, name)

    def on_chatstate_active(self, msg):
        """
        Handle when a user is actively engaged in the chat.
        """
        self.log.debug("Chat state active from %s", msg["from"])

        self.get_identifier(msg)

    def on_chatstate_composing(self, msg):
        """
        Handle when a user is typing a message.
        """
        self.log.debug("Chat state composing from %s", msg["from"])

        identifier = self.get_identifier(msg)
        if identifier:
            asyncio.create_task(
                self.ur.started_typing(
                    "xmpp",
                    identifier=identifier,
                )
            )

    def on_chatstate_paused(self, msg):
        """
        Handle when a user has paused typing.
        """
        self.log.debug("Chat state paused from %s", msg["from"])

        identifier = self.get_identifier(msg)
        if identifier:
            asyncio.create_task(
                self.ur.stopped_typing(
                    "xmpp",
                    identifier=identifier,
                )
            )

    def on_chatstate_inactive(self, msg):
        """
        Handle when a user is inactive in the chat.
        """
        self.log.debug("Chat state inactive from %s", msg["from"])

        self.get_identifier(msg)

    def on_chatstate_gone(self, msg):
        """
        Handle when a user has left the chat.
        """
        self.log.debug("Chat state gone from %s", msg["from"])

        self.get_identifier(msg)

    def on_presence_available(self, pres):
        """
        Handle when a user becomes available.
        """
        self.log.debug("Presence available from %s", pres["from"])

    def on_presence_dnd(self, pres):
        """
        Handle when a user sets 'Do Not Disturb' status.
        """
        self.log.debug("Presence dnd from %s", pres["from"])

    def on_presence_xa(self, pres):
        """
        Handle when a user sets 'Extended Away' status.
        """
        self.log.debug("Presence extended-away from %s", pres["from"])

    def on_presence_chat(self, pres):
        """
        Handle when a user is actively available for chat.
        """
        self.log.debug("Presence chat-available from %s", pres["from"])

    def on_presence_away(self, pres):
        """
        Handle when a user sets 'Away' status.
        """
        self.log.debug("Presence away from %s", pres["from"])

    def on_presence_unavailable(self, pres):
        """
        Handle when a user goes offline or unavailable.
        """
        self.log.debug("Presence unavailable from %s", pres["from"])

    def on_presence_subscribe(self, pres):
        """
        Handle incoming presence subscription requests.
        Accept subscriptions to:
        1. The gateway component JID itself - for OMEMO device discovery
        2. User contacts at the gateway component JID - for user-to-user messaging
        """

        sender_jid = str(pres["from"]).split("/")[0]  # Bare JID (user@domain)
        recipient_jid = str(pres["to"]).split("/")[0]
        component_jid = str(self.boundjid.bare)

        self.log.debug(
            f"Received subscription request from {sender_jid} to {recipient_jid}"
        )

        # Check if subscription is to the gateway component itself
        if recipient_jid == component_jid:
            # Auto-accept subscriptions to the gateway component (for OMEMO)
            self.log.info(
                "Auto-accepting subscription to gateway component from %s", sender_jid
            )
            self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=component_jid)

            # Send presence availability to enable device discovery
            self.send_presence(ptype="available", pto=sender_jid, pfrom=component_jid)
            self.log.info(
                "Gateway component is available to %s (OMEMO device discovery enabled)",
                sender_jid,
            )
            return

        # Otherwise, handle user-to-user subscription (existing logic)
        try:
            # Extract sender and recipient usernames
            user_username, _ = sender_jid.split("@", 1)
            recipient_username, _ = recipient_jid.split("@", 1)

            # Parse recipient_name and recipient_service (e.g., "mark|signal")
            if "|" in recipient_username:
                person_name, service = recipient_username.split("|", 1)
                person_name = person_name.strip()
                service = service.strip().lower()
            else:
                person_name = recipient_username.strip()
                service = None

            # Lookup user in Django
            self.log.debug("Resolving subscription user=%s", user_username)
            user = User.objects.get(username=user_username)

            self.log.debug("Resolving subscription person=%s", person_name)
            person = _resolve_person_from_xmpp_localpart(
                user=user, localpart_value=person_name
            )
            if person is None:
                raise Person.DoesNotExist(f"No person found for '{person_name}'")

            # Ensure a PersonIdentifier exists for this user, person, and service
            self.log.debug("Resolving subscription identifier service=%s", service)
            if service:
                identifier = _select_person_identifier(
                    user=user,
                    person=person,
                    service=service,
                )
                if identifier is None:
                    raise PersonIdentifier.DoesNotExist(
                        f"No identifier found for person '{person_name}'"
                    )
            else:
                fallback_identifier = _select_person_identifier(
                    user=user,
                    person=person,
                )
                if fallback_identifier is None:
                    raise PersonIdentifier.DoesNotExist(
                        f"No identifier found for person '{person_name}'"
                    )
                service = str(fallback_identifier.service or "").strip().lower()

            contact_jid = self._contact_component_jid(person.name, service)

            # Accept the subscription
            self.send_presence(ptype="subscribed", pto=sender_jid, pfrom=contact_jid)
            self.log.debug(
                f"Accepted subscription from {sender_jid}, sent from {contact_jid}"
            )

            # Ask the XMPP client to grant reciprocal presence so roster-driven
            # clients do not leave bridged contacts stuck unsubscribed/offline.
            self.send_presence(ptype="subscribe", pto=sender_jid, pfrom=contact_jid)
            self.log.debug(
                "Requested reciprocal subscription from %s to %s",
                contact_jid,
                sender_jid,
            )

            # Add sender to roster
            # self.update_roster(sender_jid, name=sender_jid.split("@")[0])

            # Send presence update to sender **from the correct JID**
            self.send_presence(ptype="available", pto=sender_jid, pfrom=contact_jid)
            self.log.debug(
                "Sent presence update from %s to %s", contact_jid, sender_jid
            )

        except (User.DoesNotExist, Person.DoesNotExist, PersonIdentifier.DoesNotExist):
            # If any lookup fails, reject the subscription
            self.log.warning(
                f"Subscription request from {sender_jid} rejected (recipient does not have this contact)."
            )
            self.send_presence(ptype="unsubscribed", pto=sender_jid)

        except ValueError:
            return

    def on_presence_subscribed(self, pres):
        """
        Handle successful subscription confirmations.
        """
        self.log.debug("Subscription to %s accepted", pres["from"])

    def on_presence_unsubscribe(self, pres):
        """
        Handle when a user unsubscribes from presence updates.
        """
        self.log.debug("Presence unsubscribe from %s", pres["from"])

    def on_presence_unsubscribed(self, pres):
        """
        Handle when a user's unsubscription request is confirmed.
        """
        self.log.debug("Presence unsubscribed confirmation from %s", pres["from"])

    def on_roster_subscription_request(self, pres):
        """
        Handle roster subscription requests.
        """
        self.log.debug("Roster subscription request from %s", pres["from"])

    async def session_start(self, *args):
        self.log.info("XMPP session started")
        self._session_live = True
        self._connect_inflight = False
        self._reconnect_delay_seconds = 1.0
        if self._reconnect_task and not self._reconnect_task.done():
            self._reconnect_task.cancel()
        self._reconnect_task = None
        # This client connects as an external component, not a user client;
        # XEP-0280 (carbons) is client-scoped and not valid here.
        self.log.debug("Skipping carbons enable for component session")
        await self._bootstrap_omemo_for_authentic_channel()

    def on_privileges_advertised(self, *_args):
        plugin = self.plugin.get("xep_0356", None)
        if plugin is None:
            return
        self.log.info(
            "XMPP privileged capabilities advertised: %s",
            plugin.granted_privileges,
        )

    async def _reconnect_loop(self):
        try:
            while True:
                delay = float(self._reconnect_delay_seconds)
                await asyncio.sleep(delay)
                if self._session_live or self._connect_inflight:
                    return
                try:
                    self.log.info("XMPP reconnect attempt delay_s=%.1f", delay)
                    self._connect_inflight = True
                    connected = self.connect()
                    if connected is False:
                        raise RuntimeError("connect returned false")
                    return
                except Exception as exc:
                    self.log.warning("XMPP reconnect attempt failed: %s", exc)
                    self._connect_inflight = False
                    self._reconnect_delay_seconds = min(
                        self._reconnect_delay_max_seconds,
                        max(1.0, float(self._reconnect_delay_seconds) * 2.0),
                    )
        except asyncio.CancelledError:
            return
        finally:
            if not self._session_live:
                self._connect_inflight = False
            self._reconnect_task = None

    def _schedule_reconnect(self):
        if self._reconnect_task and not self._reconnect_task.done():
            return
        self._reconnect_task = self.loop.create_task(self._reconnect_loop())

    def on_disconnected(self, *args):
        """
        Handles XMPP disconnection and triggers a reconnect loop.
        """
        self._session_live = False
        self._connect_inflight = False
        self.log.warning(
            "XMPP disconnected, scheduling reconnect attempt in %.1fs",
            float(self._reconnect_delay_seconds),
        )
        self._schedule_reconnect()

    async def request_upload_slot(self, recipient, filename, content_type, size):
        """
        Requests an upload slot from XMPP for HTTP File Upload (XEP-0363).

        Args:
            recipient (str): The JID of the recipient.
            filename (str): The filename for the upload.
            content_type (str): The file's MIME type.
            size (int): The file size in bytes.

        Returns:
            tuple | None: (upload_url, put_url, auth_header) or None if failed.
        """
        # upload_service = await self['xep_0363'].find_upload_service()
        # if not upload_service:
        #     self.log.error("No XEP-0363 upload service found.")
        #     return None

        upload_service_jid = str(
            getattr(settings, "XMPP_UPLOAD_SERVICE", "")
            or getattr(settings, "XMPP_UPLOAD_JID", "")
        ).strip()
        candidate_services = []
        if upload_service_jid:
            candidate_services.append(upload_service_jid)

        if not upload_service_jid:
            discovered = None
            try:
                discovered = await self["xep_0363"].find_upload_service()
            except Exception as exc:
                self.log.debug("XMPP upload service discovery failed: %s", exc)
            if discovered:
                discovered_jid = ""
                try:
                    discovered_jid = str(getattr(discovered, "jid", "") or "").strip()
                except Exception:
                    discovered_jid = ""

                if not discovered_jid:
                    raw_discovered = str(discovered or "").strip()
                    if raw_discovered.startswith("<"):
                        try:
                            node = ET.fromstring(raw_discovered)
                            discovered_jid = str(node.attrib.get("from") or "").strip()
                        except Exception:
                            discovered_jid = ""
                    else:
                        discovered_jid = raw_discovered
                upload_service_jid = discovered_jid
            if upload_service_jid:
                candidate_services.append(upload_service_jid)
                self.log.info(
                    "Discovered XMPP upload service via XEP-0363: %s",
                    upload_service_jid,
                )

        default_service = str(getattr(settings, "XMPP_USER_DOMAIN", "") or "").strip()
        if default_service:
            candidate_services.append(default_service)

        deduped_services = []
        for candidate in candidate_services:
            cleaned = str(candidate or "").strip()
            if cleaned and cleaned not in deduped_services:
                deduped_services.append(cleaned)

        if not deduped_services:
            if not self._upload_config_warned:
                self.log.warning(
                    "XMPP upload service not configured/discoverable; skipping attachment upload. "
                    "Set XMPP_UPLOAD_SERVICE (or XMPP_UPLOAD_JID)."
                )
                self._upload_config_warned = True
            return None

        last_error = None
        for service_jid in deduped_services:
            try:
                slot = await self["xep_0363"].request_slot(
                    jid=service_jid,
                    filename=filename,
                    content_type=content_type,
                    size=size,
                )

                if slot is None:
                    last_error = f"empty slot response from {service_jid}"
                    continue

                root = ET.fromstring(str(slot))
                namespace = "{urn:xmpp:http:upload:0}"

                get_url = root.find(f".//{namespace}get").attrib.get("url")
                put_element = root.find(f".//{namespace}put")
                put_url = put_element.attrib.get("url")

                header_element = put_element.find(
                    f"./{namespace}header[@name='Authorization']"
                )
                auth_header = (
                    header_element.text.strip() if header_element is not None else None
                )

                if not get_url or not put_url:
                    last_error = f"missing URLs in upload slot from {service_jid}"
                    continue

                if service_jid != deduped_services[0]:
                    self.log.info(
                        "XMPP upload service fallback succeeded via %s",
                        service_jid,
                    )
                return get_url, put_url, auth_header
            except Exception as exc:
                last_error = str(exc)
                self.log.warning(
                    "XMPP upload slot request failed via %s: %s", service_jid, exc
                )

        self.log.error("Failed to obtain upload slot for %s: %s", filename, last_error)
        return None

    async def message(self, msg):
        """
        Process incoming XMPP messages.
        """

        def sym(value):
            msg.reply(f"[>] {value}").send()

        xmpp_message_id = str(msg.get("id") or "").strip()

        # Extract sender JID (full format: user@domain/resource)
        sender_jid = str(msg["from"])

        # Split into username@domain and optional resource
        sender_parts = sender_jid.split("/", 1)
        sender_bare_jid = sender_parts[0]  # Always present: user@domain
        sender_username, sender_domain = sender_bare_jid.split("@", 1)

        sender_resource = (
            sender_parts[1] if len(sender_parts) > 1 else None
        )  # Extract resource if present

        # Extract recipient JID (should match component JID format)
        recipient_jid = str(msg["to"])

        if "@" in recipient_jid:
            recipient_username, recipient_domain = recipient_jid.split("@", 1)
        else:
            recipient_username = recipient_jid
            recipient_domain = recipient_jid

        # Attempt to decrypt OMEMO-encrypted messages before body extraction.
        original_msg = msg
        omemo_plugin = self._get_omemo_plugin()
        sender_omemo_fingerprint = ""
        omemo_decrypt_error = ""
        was_omemo_encrypted = False
        if omemo_plugin:
            try:
                if omemo_plugin.is_encrypted(msg):
                    was_omemo_encrypted = True
                    decrypted, sender_device = await omemo_plugin.decrypt_message(msg)
                    msg = decrypted
                    sender_omemo_fingerprint = _format_omemo_identity_fingerprint(
                        getattr(sender_device, "identity_key", b"")
                    )
                    self.log.debug("OMEMO: decrypted message from %s", sender_jid)
            except Exception as exc:
                omemo_decrypt_error = str(exc or "").strip()
                self.log.warning(
                    "OMEMO: decryption failed from %s: %s", sender_jid, exc
                )

        # Extract message body
        body = msg["body"] if msg["body"] else ""
        parsed_reaction = _extract_xmpp_reaction(msg)
        parsed_reply_target = _extract_xmpp_reply_target_id(msg)
        greentext_reaction = _parse_greentext_reaction(body)

        attachments = []
        self.log.debug(
            "Received XMPP stanza: %s", ET.tostring(msg.xml, encoding="unicode")
        )

        # Extract attachments from standard XMPP payloads.
        for att in msg.xml.findall(".//{urn:xmpp:attachments}attachment"):
            url_value = _clean_url(att.attrib.get("url"))
            if not url_value:
                continue
            try:
                safe_url = validate_attachment_url(url_value)
                filename, content_type = validate_attachment_metadata(
                    filename=att.attrib.get("filename") or _filename_from_url(safe_url),
                    content_type=att.attrib.get("content_type")
                    or "application/octet-stream",
                )
            except Exception as exc:
                self.log.warning(
                    "xmpp dropped unsafe attachment url=%s: %s", url_value, exc
                )
                continue
            attachments.append(
                {
                    "url": safe_url,
                    "filename": filename,
                    "content_type": content_type,
                }
            )

        # Extract attachments from XEP-0066 OOB payloads.
        for oob in msg.xml.findall(".//{jabber:x:oob}x/{jabber:x:oob}url"):
            url_value = _clean_url(oob.text)
            if not url_value:
                continue
            try:
                safe_url = validate_attachment_url(url_value)
                filename, guessed_content_type = validate_attachment_metadata(
                    filename=_filename_from_url(safe_url),
                    content_type=_content_type_from_filename_or_url(safe_url),
                )
            except Exception as exc:
                self.log.warning("xmpp dropped unsafe oob url=%s: %s", url_value, exc)
                continue
            attachments.append(
                {
                    "url": safe_url,
                    "filename": filename,
                    "content_type": guessed_content_type,
                }
            )

        # Fallback extraction for alternate attachment encodings.
        extracted_urls = _extract_xml_attachment_urls(msg)
        existing_urls = {str(item.get("url") or "").strip() for item in attachments}
        for url_value in extracted_urls:
            if url_value in existing_urls:
                continue
            try:
                safe_url = validate_attachment_url(url_value)
                filename, guessed_content_type = validate_attachment_metadata(
                    filename=_filename_from_url(safe_url),
                    content_type=_content_type_from_filename_or_url(safe_url),
                )
            except Exception as exc:
                self.log.warning(
                    "xmpp dropped extracted unsafe url=%s: %s", url_value, exc
                )
                continue
            attachments.append(
                {
                    "url": safe_url,
                    "filename": filename,
                    "content_type": guessed_content_type,
                }
            )

        # Some XMPP clients send HTTP upload links only in the body text.
        if not attachments:
            body_url_attachments = _attachment_rows_from_body_urls(body)
            if body_url_attachments:
                attachments.extend(body_url_attachments)

        if (
            not body or body.strip().lower() in {"[no body]", "(no text)"}
        ) and attachments:
            attachment_urls = [
                str(item.get("url") or "").strip()
                for item in attachments
                if str(item.get("url") or "").strip()
            ]
            if attachment_urls:
                body = "\n".join(attachment_urls)

        relay_body = body
        attachment_urls_for_body = [
            str(item.get("url") or "").strip()
            for item in attachments
            if str(item.get("url") or "").strip()
        ]
        if attachment_urls_for_body:
            joined_urls = "\n".join(attachment_urls_for_body).strip()
            if str(relay_body or "").strip() == joined_urls:
                relay_body = ""
                body = ""

        self.log.debug("Extracted %s attachments from XMPP message", len(attachments))
        # Log extracted information with variable name annotations
        log_message = (
            f"Sender JID: {sender_jid}, Sender Username: {sender_username}, Sender Domain: {sender_domain}, "
            f"Sender Resource: {sender_resource if sender_resource else '[No Resource]'}, "
            f"Recipient JID: {recipient_jid}, Recipient Username: {recipient_username}, Recipient Domain: {recipient_domain}, "
            f"Body: {body or '[No Body]'}"
        )
        self.log.debug(log_message)

        # Ensure recipient domain matches our configured component
        expected_domain = settings.XMPP_JID
        if recipient_domain != expected_domain:
            self.log.warning(
                f"Invalid recipient domain: {recipient_domain}, expected {expected_domain}"
            )
            return

        # Lookup sender in Django's User model
        try:
            sender_user = User.objects.get(username=sender_username)
        except User.DoesNotExist:
            self.log.warning(f"Unknown sender: {sender_username}")
            return

        # Record the sender's OMEMO state (uses the original, pre-decryption stanza).
        try:
            await self._record_sender_omemo_state(
                sender_user,
                sender_jid=sender_jid,
                recipient_jid=recipient_jid,
                message_stanza=original_msg,
                sender_fingerprint=sender_omemo_fingerprint,
            )
        except Exception as exc:
            self.log.warning("OMEMO: failed to record sender state: %s", exc)
        omemo_observation = _extract_sender_omemo_client_key(original_msg)

        # Enforce mandatory encryption policy.
        sec_settings = None
        try:
            from core.models import UserXmppSecuritySettings

            sec_settings = await sync_to_async(
                lambda: UserXmppSecuritySettings.objects.filter(
                    user=sender_user
                ).first()
            )()
            if sec_settings and sec_settings.require_omemo:
                omemo_status = str(omemo_observation.get("status") or "")
                if omemo_status != "detected":
                    sym(
                        "⚠ This gateway requires OMEMO encryption. "
                        "Your message was not delivered. "
                        "Please enable OMEMO in your XMPP client."
                    )
                    return
        except Exception as exc:
            self.log.warning("OMEMO policy check failed: %s", exc)

        component_encrypt_with_omemo = True
        if sec_settings is not None:
            component_encrypt_with_omemo = bool(
                getattr(
                    sec_settings,
                    "encrypt_component_messages_with_omemo",
                    True,
                )
            )

        if (
            was_omemo_encrypted
            and omemo_decrypt_error
            and not body.strip()
            and not attachments
            and not parsed_reaction
        ):
            await self.send_xmpp_message(
                sender_jid,
                settings.XMPP_JID,
                "This gateway could not decrypt your OMEMO-encrypted message or attachment, so it was not delivered. Disable OMEMO for this contact or send it unencrypted.",
                use_omemo_encryption=component_encrypt_with_omemo,
            )
            return

        if recipient_jid == settings.XMPP_JID:
            self.log.debug("Handling command message sent to gateway JID")
            if body.startswith("."):
                gateway_replies = await self.execute_gateway_command(
                    sender_user=sender_user,
                    body=body,
                    service="xmpp",
                    channel_identifier=str(sender_jid or ""),
                    sender_identifier=str(sender_jid or ""),
                    sender_jid=sender_jid,
                    recipient_jid=recipient_jid,
                    local_message=None,
                    message_meta={
                        "xmpp": {
                            "sender_jid": str(sender_jid or ""),
                            "recipient_jid": str(recipient_jid or ""),
                            "omemo_status": str(omemo_observation.get("status") or ""),
                            "omemo_client_key": str(
                                omemo_observation.get("client_key") or ""
                            ),
                        }
                    },
                )
                for line in gateway_replies:
                    await self.send_xmpp_message(
                        sender_jid,
                        settings.XMPP_JID,
                        f"[>] {line}",
                        use_omemo_encryption=component_encrypt_with_omemo,
                    )
        else:
            self.log.debug("Handling routed message to contact")
            if "|" in recipient_username:
                recipient_name, recipient_service = recipient_username.split("|", 1)
                recipient_name = recipient_name.strip()
                recipient_service = recipient_service.strip().lower()

            else:
                recipient_name = recipient_username.strip()
                recipient_service = None

            person = _resolve_person_from_xmpp_localpart(
                user=sender_user, localpart_value=recipient_name
            )
            if person is None:
                sym("This person does not exist.")
                return

            if recipient_service:
                identifier = PersonIdentifier.objects.filter(
                    user=sender_user, person=person, service=recipient_service
                ).first()
                if identifier is None:
                    sym("This service identifier does not exist.")
                    return
            else:
                # Get a random identifier
                identifier = PersonIdentifier.objects.filter(
                    user=sender_user, person=person
                ).first()
                if identifier is None:
                    sym("This service identifier does not exist.")
                    return
                recipient_service = identifier.service

            # sym(str(person.__dict__))
            # sym(f"Service: {recipient_service}")

            if parsed_reaction or greentext_reaction:
                # TODO(web-ui-react): expose explicit web compose reaction actions
                # that call this same bridge path (without text heuristics).
                # TODO(edit-sync): extend bridge mapping to include edit message ids
                # and reconcile upstream edit capability differences in UI.
                # TODO(retract-sync): propagate delete/retract state through this
                # same mapping layer for protocol parity.
                reaction_payload = parsed_reaction or {
                    "target_id": parsed_reply_target,
                    "emoji": str((greentext_reaction or {}).get("emoji") or ""),
                    "remove": False,
                }
                if not str(reaction_payload.get("target_id") or "").strip():
                    text_hint = str((greentext_reaction or {}).get("quoted_text") or "")
                    hint_match = transport.resolve_bridge_from_text_hint(
                        user_id=identifier.user_id,
                        person_id=identifier.person_id,
                        service=recipient_service,
                        text_hint=text_hint,
                    )
                    reaction_payload["target_id"] = str(
                        (hint_match or {}).get("xmpp_message_id") or ""
                    )

                self.log.debug(
                    "reaction-bridge xmpp-inbound actor=%s service=%s target_xmpp_id=%s emoji=%s remove=%s via=%s",
                    sender_username,
                    recipient_service,
                    str(reaction_payload.get("target_id") or "") or "-",
                    str(reaction_payload.get("emoji") or "") or "-",
                    bool(reaction_payload.get("remove")),
                    "xmpp:reactions" if parsed_reaction else "greentext",
                )

                bridge = transport.resolve_bridge_from_xmpp(
                    user_id=identifier.user_id,
                    person_id=identifier.person_id,
                    service=recipient_service,
                    xmpp_message_id=str(reaction_payload.get("target_id") or ""),
                )
                if not bridge:
                    bridge = await history.resolve_bridge_ref(
                        user=identifier.user,
                        identifier=identifier,
                        source_service=recipient_service,
                        xmpp_message_id=str(reaction_payload.get("target_id") or ""),
                    )
                if not bridge:
                    self.log.warning(
                        "reaction-bridge xmpp-resolve-miss actor=%s service=%s target_xmpp_id=%s",
                        sender_username,
                        recipient_service,
                        str(reaction_payload.get("target_id") or "") or "-",
                    )
                    sym("Could not find upstream message for this reaction.")
                    return

                sent_ok = await transport.send_reaction(
                    recipient_service,
                    identifier.identifier,
                    emoji=str(reaction_payload.get("emoji") or ""),
                    target_message_id=str(
                        (bridge or {}).get("upstream_message_id") or ""
                    ),
                    target_timestamp=int((bridge or {}).get("upstream_ts") or 0),
                    target_author=str((bridge or {}).get("upstream_author") or ""),
                    remove=bool(reaction_payload.get("remove")),
                )
                if not sent_ok:
                    self.log.warning(
                        "reaction-bridge upstream-send-failed actor=%s service=%s recipient=%s target_upstream_id=%s target_upstream_ts=%s",
                        sender_username,
                        recipient_service,
                        identifier.identifier,
                        str((bridge or {}).get("upstream_message_id") or "") or "-",
                        int((bridge or {}).get("upstream_ts") or 0),
                    )
                    sym("Upstream protocol did not accept this reaction.")
                    return

                await history.apply_reaction(
                    user=identifier.user,
                    identifier=identifier,
                    target_message_id=str((bridge or {}).get("local_message_id") or ""),
                    target_ts=int((bridge or {}).get("upstream_ts") or 0),
                    emoji=str(reaction_payload.get("emoji") or ""),
                    source_service="xmpp",
                    actor=sender_username,
                    remove=bool(reaction_payload.get("remove")),
                    payload={
                        "target_xmpp_id": str(reaction_payload.get("target_id") or ""),
                        "xmpp_message_id": xmpp_message_id,
                    },
                )
                self.log.debug(
                    "reaction-bridge xmpp-apply-ok actor=%s service=%s local_message_id=%s",
                    sender_username,
                    recipient_service,
                    str((bridge or {}).get("local_message_id") or "") or "-",
                )
                return

            # tss = await identifier.send(body, attachments=attachments)
            session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
                identifier=identifier,
                user=identifier.user,
            )
            self.log.debug("Storing outbound XMPP message in history")
            reply_ref = reply_sync.extract_reply_ref(
                "xmpp",
                {
                    "reply_source_message_id": parsed_reply_target,
                    "reply_source_chat_id": str(sender_jid or ""),
                },
            )
            reply_target = await reply_sync.resolve_reply_target(
                identifier.user,
                session,
                reply_ref,
            )
            local_message = await history.store_message(
                session=session,
                sender="XMPP",
                text=body,
                ts=int(now().timestamp() * 1000),
                outgoing=True,
                source_service="xmpp",
                source_message_id=xmpp_message_id,
                source_chat_id=str(sender_jid 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={
                    "xmpp": {
                        "sender_jid": str(sender_jid or ""),
                        "recipient_jid": str(recipient_jid or ""),
                        "omemo_status": str(omemo_observation.get("status") or ""),
                        "omemo_client_key": str(
                            omemo_observation.get("client_key") or ""
                        ),
                        "attachments": [
                            {
                                "url": str(item.get("url") or ""),
                                "filename": str(item.get("filename") or ""),
                                "content_type": str(
                                    item.get("content_type")
                                    or "application/octet-stream"
                                ),
                            }
                            for item in attachments
                        ],
                    }
                },
            )
            self.log.debug("Stored outbound XMPP message in history")
            await self.ur.message_received(
                "xmpp",
                identifier=identifier,
                text=body,
                ts=int(now().timestamp() * 1000),
                payload={
                    "sender_jid": sender_jid,
                    "recipient_jid": recipient_jid,
                },
                local_message=local_message,
            )

            manipulations = Manipulation.objects.filter(
                group__people=identifier.person,
                user=identifier.user,
                mode="mutate",
                enabled=True,
            )
            self.log.debug("Found %s active manipulations", manipulations.count())
            if not manipulations:
                await self.ur.stopped_typing(
                    "xmpp",
                    identifier=identifier,
                    payload={"reason": "message_sent"},
                )
                await identifier.send(
                    relay_body,
                    attachments,
                    metadata={
                        "xmpp_source_id": xmpp_message_id,
                        "xmpp_source_ts": int(now().timestamp() * 1000),
                        "xmpp_body": relay_body,
                        "legacy_message_id": str(local_message.id),
                    },
                )
                self.log.info(
                    "Relayed XMPP message to %s attachment_count=%s text_len=%s",
                    recipient_service,
                    len(attachments),
                    len(str(relay_body or "")),
                )
                self.log.debug("Message sent unaltered")
                return

            manip = manipulations.first()
            chat_history = await history.get_chat_history(session)
            await utils.update_last_interaction(session)
            prompt = replies.generate_mutate_reply_prompt(
                relay_body,
                identifier.person,
                manip,
                chat_history,
            )
            self.log.debug("Running XMPP context prompt")
            result = await ai.run_prompt(
                prompt,
                manip.ai,
                operation="xmpp_mutate",
            )
            self.log.debug("Generated mutated response for XMPP message")
            await history.store_own_message(
                session=session,
                text=result,
                ts=int(now().timestamp() * 1000),
            )
            await self.ur.stopped_typing(
                "xmpp",
                identifier=identifier,
                payload={"reason": "message_sent"},
            )
            await identifier.send(
                result,
                attachments,
                metadata={
                    "xmpp_source_id": xmpp_message_id,
                    "xmpp_source_ts": int(now().timestamp() * 1000),
                    "xmpp_body": result,
                    "legacy_message_id": str(local_message.id),
                },
            )
            self.log.debug("Message sent with modifications")

    async def request_upload_slots(self, recipient_jid, attachments):
        """Requests upload slots for multiple attachments concurrently."""
        upload_tasks = [
            self.request_upload_slot(
                recipient_jid, att["filename"], att["content_type"], att["size"]
            )
            for att in attachments
        ]
        upload_slots = await asyncio.gather(*upload_tasks)

        return [
            (att, slot)
            for att, slot in zip(attachments, upload_slots)
            if slot is not None
        ]

    async def upload_and_send(self, att, upload_slot, recipient_jid, sender_jid):
        """Uploads a file and immediately sends the corresponding XMPP message."""
        upload_url, put_url, auth_header = upload_slot
        try:
            filename, content_type = validate_attachment_metadata(
                filename=att.get("filename"),
                content_type=att.get("content_type"),
                size=att.get("size"),
            )
        except Exception as exc:
            self.log.warning("xmpp blocked outbound attachment: %s", exc)
            return None
        headers = {
            "Content-Type": content_type,
            "Content-Length": str(
                int(att.get("size") or len(att.get("content") or b""))
            ),
        }
        if auth_header:
            headers["Authorization"] = auth_header

        async with aiohttp.ClientSession() as session:
            try:
                async with session.put(
                    put_url, data=att["content"], headers=headers
                ) as response:
                    if response.status not in (200, 201):
                        self.log.error(
                            f"Upload failed: {response.status} {await response.text()}"
                        )
                        return None
                self.log.debug("Successfully uploaded %s to %s", filename, upload_url)

                # Send XMPP message immediately after successful upload
                xmpp_msg_id = await self.send_xmpp_message(
                    recipient_jid, sender_jid, upload_url, attachment_url=upload_url
                )
                return {
                    "url": upload_url,
                    "xmpp_message_id": xmpp_msg_id,
                }

            except Exception as e:
                self.log.error(f"Error uploading {att['filename']} to XMPP: {e}")
                return None

    async def send_xmpp_message(
        self,
        recipient_jid,
        sender_jid,
        body_text,
        attachment_url=None,
        *,
        use_omemo_encryption=True,
    ):
        """Sends an XMPP message with either text or an attachment URL."""
        msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
        if not msg.get("id"):
            msg["id"] = uuid.uuid4().hex
        msg_id = str(msg.get("id") or "").strip()
        msg["body"] = body_text  # Body must contain only text or the URL

        if attachment_url:
            # Include <x><url> (XEP-0066) to ensure client compatibility
            oob_element = ET.Element("{jabber:x:oob}x")
            url_element = ET.SubElement(oob_element, "{jabber:x:oob}url")
            url_element.text = attachment_url
            msg.xml.append(oob_element)

        self.log.debug("Sending XMPP message: %s", msg.xml)

        # Attempt OMEMO encryption for text-only messages (not attachments)
        # when outbound policy allows it.
        if not attachment_url and use_omemo_encryption:
            omemo_plugin = self._get_omemo_plugin()
            if omemo_plugin:
                try:
                    from slixmpp.jid import JID as _JID

                    encrypted_msgs, enc_errors = await omemo_plugin.encrypt_message(
                        msg, _JID(recipient_jid)
                    )
                    if enc_errors:
                        self.log.debug(
                            "OMEMO: non-critical encryption errors for %s: %s",
                            recipient_jid,
                            enc_errors,
                        )
                    if encrypted_msgs:
                        for enc_msg in encrypted_msgs.values():
                            enc_msg.send()
                        self.log.debug(
                            "OMEMO: sent encrypted message to %s", recipient_jid
                        )
                        return msg_id
                except Exception as exc:
                    self.log.debug(
                        "OMEMO: encryption not available for %s, sending plaintext: %s",
                        recipient_jid,
                        exc,
                    )

        msg.send()
        return msg_id

    async def send_xmpp_reaction(
        self,
        recipient_jid,
        sender_jid,
        *,
        target_xmpp_id: str,
        emoji: str,
        remove: bool = False,
    ):
        msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
        if not msg.get("id"):
            msg["id"] = uuid.uuid4().hex
        msg["body"] = ""
        reactions_node = ET.Element(
            "{urn:xmpp:reactions:0}reactions",
            {"id": str(target_xmpp_id or "").strip()},
        )
        if not remove and str(emoji or "").strip():
            reaction_node = ET.SubElement(
                reactions_node,
                "{urn:xmpp:reactions:0}reaction",
            )
            reaction_node.text = str(emoji)
        msg.xml.append(reactions_node)
        msg.send()
        return str(msg.get("id") or "").strip()

    async def apply_external_reaction(
        self,
        user,
        person_identifier,
        *,
        source_service,
        emoji,
        remove,
        upstream_message_id="",
        upstream_ts=0,
        actor="",
        payload=None,
    ):
        self.log.debug(
            "reaction-bridge external-in source=%s user=%s person=%s upstream_id=%s upstream_ts=%s emoji=%s remove=%s",
            source_service,
            user.id,
            person_identifier.person_id,
            str(upstream_message_id or "") or "-",
            int(upstream_ts or 0),
            str(emoji or "") or "-",
            bool(remove),
        )
        bridge = transport.resolve_bridge_from_upstream(
            user_id=user.id,
            person_id=person_identifier.person_id,
            service=source_service,
            upstream_message_id=str(upstream_message_id or ""),
            upstream_ts=int(upstream_ts or 0),
        )
        if not bridge:
            bridge = await history.resolve_bridge_ref(
                user=user,
                identifier=person_identifier,
                source_service=source_service,
                upstream_message_id=str(upstream_message_id or ""),
                upstream_author=str(actor or ""),
                upstream_ts=int(upstream_ts or 0),
            )
        if not bridge:
            self.log.warning(
                "reaction-bridge external-resolve-miss source=%s user=%s person=%s upstream_id=%s upstream_ts=%s",
                source_service,
                user.id,
                person_identifier.person_id,
                str(upstream_message_id or "") or "-",
                int(upstream_ts or 0),
            )
            return False

        target_xmpp_id = str((bridge or {}).get("xmpp_message_id") or "").strip()
        if not target_xmpp_id:
            self.log.warning(
                "reaction-bridge external-target-missing source=%s user=%s person=%s",
                source_service,
                user.id,
                person_identifier.person_id,
            )
            return False

        sender_jid = self._contact_component_jid(
            person_identifier.person.name, person_identifier.service
        )
        recipient_jid = self._user_jid(user.username)
        await self.send_xmpp_reaction(
            recipient_jid,
            sender_jid,
            target_xmpp_id=target_xmpp_id,
            emoji=str(emoji or ""),
            remove=bool(remove),
        )
        await history.apply_reaction(
            user=user,
            identifier=person_identifier,
            target_message_id=str((bridge or {}).get("local_message_id") or ""),
            target_ts=int((bridge or {}).get("upstream_ts") or 0),
            emoji=str(emoji or ""),
            source_service=source_service,
            actor=str(actor or person_identifier.identifier),
            remove=bool(remove),
            payload=dict(payload or {}),
        )
        self.log.debug(
            "reaction-bridge external-apply-ok source=%s user=%s person=%s xmpp_id=%s local_message_id=%s",
            source_service,
            user.id,
            person_identifier.person_id,
            target_xmpp_id,
            str((bridge or {}).get("local_message_id") or "") or "-",
        )
        return True

    async def send_chat_state(self, recipient_jid, sender_jid, started):
        """Send XMPP chat-state update to the client."""
        msg = self.make_message(mto=recipient_jid, mfrom=sender_jid, mtype="chat")
        state_tag = "composing" if started else "paused"
        msg.xml.append(
            ET.Element(f"{{http://jabber.org/protocol/chatstates}}{state_tag}")
        )
        self.log.debug(
            "Sending XMPP chat-state %s: %s -> %s",
            state_tag,
            sender_jid,
            recipient_jid,
        )
        msg.send()

    async def send_typing_for_person(self, user, person_identifier, started):
        sender_jid = self._contact_component_jid(
            person_identifier.person.name, person_identifier.service
        )
        recipient_jid = self._user_jid(user.username)
        await self.send_chat_state(recipient_jid, sender_jid, started)

    async def send_from_external(
        self,
        user,
        person_identifier,
        text,
        is_outgoing_message,
        attachments=[],
        source_ref=None,
    ):
        """Handles sending XMPP messages with text and attachments."""

        sender_jid = self._contact_component_jid(
            person_identifier.person.name, person_identifier.service
        )
        recipient_jid = self._user_jid(person_identifier.user.username)
        relay_encrypt_with_omemo = True
        try:
            from core.models import UserXmppSecuritySettings

            sec_settings = await sync_to_async(
                lambda: UserXmppSecuritySettings.objects.filter(user=user).first()
            )()
            if sec_settings is not None:
                relay_encrypt_with_omemo = bool(
                    getattr(sec_settings, "encrypt_contact_messages_with_omemo", True)
                )
        except Exception as exc:
            self.log.warning("XMPP relay OMEMO settings lookup failed: %s", exc)
        if is_outgoing_message:
            xmpp_id = await self.send_xmpp_message(
                recipient_jid,
                sender_jid,
                f"YOU: {text}",
                use_omemo_encryption=relay_encrypt_with_omemo,
            )
            try:
                await self.send_sent_carbon_copy(
                    user_jid=recipient_jid,
                    contact_jid=sender_jid,
                    body_text=text,
                )
            except Exception as exc:
                self.log.warning(
                    "Sent carbon copy failed after fallback relay user=%s contact=%s: %s",
                    recipient_jid,
                    sender_jid,
                    exc,
                )
            transport.record_bridge_mapping(
                user_id=user.id,
                person_id=person_identifier.person_id,
                service=person_identifier.service,
                xmpp_message_id=xmpp_id,
                xmpp_ts=int(time.time() * 1000),
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
                text_preview=str(text or ""),
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
            )
            await history.save_bridge_ref(
                user=user,
                identifier=person_identifier,
                source_service=person_identifier.service,
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
                local_ts=int(
                    (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
                ),
                xmpp_message_id=xmpp_id,
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
            )

        # Step 1: Send text message separately
        elif text:
            xmpp_id = await self.send_xmpp_message(
                recipient_jid,
                sender_jid,
                text,
                use_omemo_encryption=relay_encrypt_with_omemo,
            )
            transport.record_bridge_mapping(
                user_id=user.id,
                person_id=person_identifier.person_id,
                service=person_identifier.service,
                xmpp_message_id=xmpp_id,
                xmpp_ts=int(time.time() * 1000),
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
                text_preview=str(text or ""),
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
            )
            await history.save_bridge_ref(
                user=user,
                identifier=person_identifier,
                source_service=person_identifier.service,
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
                local_ts=int(
                    (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
                ),
                xmpp_message_id=xmpp_id,
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
            )

        if not attachments:
            return []  # No attachments to process

        # Step 2: Request upload slots concurrently
        valid_uploads = await self.request_upload_slots(recipient_jid, attachments)
        self.log.debug("Got upload slots")
        if not valid_uploads:
            self.log.debug("No valid upload slots obtained; attachment relay skipped")
            return []

        # Step 3: Upload each file and send its message immediately after upload
        upload_tasks = [
            self.upload_and_send(att, slot, recipient_jid, sender_jid)
            for att, slot in valid_uploads
        ]
        uploaded_rows = await asyncio.gather(*upload_tasks)  # Upload files concurrently
        normalized_rows = [dict(row or {}) for row in uploaded_rows if row]
        for row in normalized_rows:
            if is_outgoing_message:
                try:
                    await self.send_sent_carbon_copy(
                        user_jid=recipient_jid,
                        contact_jid=sender_jid,
                        body_text=str(row.get("url") or ""),
                        attachment_url=str(row.get("url") or ""),
                    )
                except Exception as exc:
                    self.log.warning(
                        "Attachment sent carbon copy failed user=%s contact=%s: %s",
                        recipient_jid,
                        sender_jid,
                        exc,
                    )
            transport.record_bridge_mapping(
                user_id=user.id,
                person_id=person_identifier.person_id,
                service=person_identifier.service,
                xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
                xmpp_ts=int(time.time() * 1000),
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
                text_preview=str(row.get("url") or text or ""),
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
            )
            await history.save_bridge_ref(
                user=user,
                identifier=person_identifier,
                source_service=person_identifier.service,
                local_message_id=str((source_ref or {}).get("legacy_message_id") or ""),
                local_ts=int(
                    (source_ref or {}).get("xmpp_source_ts") or int(time.time() * 1000)
                ),
                xmpp_message_id=str(row.get("xmpp_message_id") or "").strip(),
                upstream_message_id=str(
                    (source_ref or {}).get("upstream_message_id") or ""
                ),
                upstream_author=str((source_ref or {}).get("upstream_author") or ""),
                upstream_ts=int((source_ref or {}).get("upstream_ts") or 0),
            )
        return [
            str(row.get("url") or "").strip()
            for row in normalized_rows
            if str(row.get("url") or "").strip()
        ]


class XMPPClient(ClientBase):
    def __init__(self, ur, *args, **kwargs):
        super().__init__(ur, *args, **kwargs)
        self._enabled = True
        self.client = None
        jid = str(getattr(settings, "XMPP_JID", "") or "").strip()
        secret = str(getattr(settings, "XMPP_SECRET", "") or "").strip()
        server = str(getattr(settings, "XMPP_ADDRESS", "") or "").strip()
        port = int(getattr(settings, "XMPP_PORT", 8888) or 8888)
        missing = []
        if not jid:
            missing.append("XMPP_JID")
        if not secret:
            missing.append("XMPP_SECRET")
        if not server:
            missing.append("XMPP_ADDRESS")
        if missing:
            self._enabled = False
            self.log.warning(
                "XMPP client disabled due to missing configuration: %s",
                ", ".join(missing),
            )

        if self._enabled:
            self.client = XMPPComponent(
                ur,
                jid=jid,
                secret=secret,
                server=server,
                port=port,
            )

            self.client.register_plugin("xep_0030")  # Service Discovery
            self.client.register_plugin("xep_0004")  # Data Forms
            self.client.register_plugin("xep_0060")  # PubSub
            self.client.register_plugin("xep_0199")  # XMPP Ping
            self.client.register_plugin("xep_0297")  # Forwarded
            self.client.register_plugin("xep_0356")  # Privileged Entity
            self.client.register_plugin("xep_0085")  # Chat State Notifications
            self.client.register_plugin("xep_0363")  # HTTP File Upload

            self._omemo_plugin_registered = False
            if _OMEMO_AVAILABLE:
                try:
                    data_dir = str(
                        getattr(settings, "XMPP_OMEMO_DATA_DIR", "") or ""
                    ).strip()
                    if not data_dir:
                        data_dir = str(Path(settings.BASE_DIR) / "xmpp_omemo_data")
                    # Register our concrete plugin class under the "xep_0384" name so
                    # that slixmpp's dependency resolver finds it.
                    _slixmpp_register_plugin(_GiaOmemoPlugin)
                    self.client.register_plugin(
                        "xep_0384", pconfig={"data_dir": data_dir}
                    )
                    self._omemo_plugin_registered = True
                    self.log.info(
                        "OMEMO: xep_0384 plugin registered, data_dir=%s", data_dir
                    )
                except Exception as exc:
                    self.log.warning(
                        "OMEMO: failed to register xep_0384 plugin: %s", exc
                    )
            else:
                self.log.warning("OMEMO: slixmpp_omemo not available, OMEMO disabled")

    def start(self):
        if not self._enabled or self.client is None:
            return
        self.log.info("XMPP client starting...")

        # ensure slixmpp uses the same asyncio loop as the router
        self.client.loop = self.loop

        self.client.connect()

    async def start_typing_for_person(self, user, person_identifier):
        if self.client is None:
            return
        await self.client.send_typing_for_person(user, person_identifier, True)

    async def stop_typing_for_person(self, user, person_identifier):
        if self.client is None:
            return
        await self.client.send_typing_for_person(user, person_identifier, False)
