import asyncio
import json
import re
import time
from urllib.parse import quote_plus, urlparse

import aiohttp
import websockets
from asgiref.sync import sync_to_async
from django.conf import settings
from django.urls import reverse
from signalbot import Command, Context, SignalBot

from core.clients import ClientBase, signalapi, transport
from core.messaging import (
    ai,
    history,
    media_bridge,
    natural,
    replies,
    reply_sync,
    utils,
)
from core.models import (
    Chat,
    Manipulation,
    Message,
    Person,
    PersonIdentifier,
    PlatformChatLink,
    QueuedMessage,
)
from core.util import logs

log = logs.get_logger("signalF")


_signal_http_url = getattr(settings, "SIGNAL_HTTP_URL", "").strip()
if _signal_http_url:
    parsed = urlparse(
        _signal_http_url if "://" in _signal_http_url else f"http://{_signal_http_url}"
    )
    SIGNAL_HOST = parsed.hostname or "signal"
    SIGNAL_PORT = parsed.port or 8080
else:
    SIGNAL_HOST = "signal"
    SIGNAL_PORT = 8080

SIGNAL_URL = f"{SIGNAL_HOST}:{SIGNAL_PORT}"


def _is_internal_compose_blob_url(value: str) -> bool:
    raw = str(value or "").strip()
    if not raw:
        return False
    if raw.startswith("/compose/media/blob/"):
        return True
    parsed = urlparse(raw if "://" in raw else f"https://dummy{raw}")
    return str(parsed.path or "").startswith("/compose/media/blob/")


def _is_compose_blob_only_text(text_value: str) -> bool:
    lines = [
        line.strip() for line in str(text_value or "").splitlines() if line.strip()
    ]
    if not lines:
        return False
    return all(_is_internal_compose_blob_url(line) for line in lines)


def _get_nested(payload, path):
    current = payload
    for key in path:
        if not isinstance(current, dict):
            return None
        current = current.get(key)
    return current


def _looks_like_signal_attachment(entry):
    return isinstance(entry, dict) and (
        "id" in entry or "attachmentId" in entry or "contentType" in entry
    )


def _normalize_attachment(entry):
    attachment_id = entry.get("id") or entry.get("attachmentId")
    if attachment_id is None:
        return None
    return {
        "id": attachment_id,
        "content_type": entry.get("contentType", "application/octet-stream"),
        "filename": entry.get("filename") or str(attachment_id),
        "size": entry.get("size") or 0,
        "width": entry.get("width"),
        "height": entry.get("height"),
    }


def _extract_attachments(raw_payload):
    envelope = raw_payload.get("envelope", {})
    candidate_paths = [
        ("dataMessage", "attachments"),
        ("syncMessage", "sentMessage", "attachments"),
        ("syncMessage", "editMessage", "dataMessage", "attachments"),
    ]
    results = []
    seen = set()

    for path in candidate_paths:
        found = _get_nested(envelope, path)
        if not isinstance(found, list):
            continue
        for entry in found:
            normalized = _normalize_attachment(entry)
            if not normalized:
                continue
            key = str(normalized["id"])
            if key in seen:
                continue
            seen.add(key)
            results.append(normalized)

    # Fallback: scan for attachment-shaped lists under envelope.
    if not results:
        stack = [envelope]
        while stack:
            node = stack.pop()
            if isinstance(node, dict):
                for value in node.values():
                    stack.append(value)
            elif isinstance(node, list):
                if node and all(_looks_like_signal_attachment(item) for item in node):
                    for entry in node:
                        normalized = _normalize_attachment(entry)
                        if not normalized:
                            continue
                        key = str(normalized["id"])
                        if key in seen:
                            continue
                        seen.add(key)
                        results.append(normalized)
                else:
                    for value in node:
                        stack.append(value)
    return results


def _extract_receipt_timestamps(receipt_payload):
    raw_ts = receipt_payload.get("timestamp")
    if raw_ts is None:
        raw_ts = receipt_payload.get("timestamps")
    if isinstance(raw_ts, list):
        out = []
        for item in raw_ts:
            try:
                out.append(int(item))
            except Exception:
                continue
        return out
    if raw_ts is not None:
        try:
            return [int(raw_ts)]
        except Exception:
            return []
    return []


def _extract_signal_reaction(envelope):
    paths = [
        ("dataMessage", "reaction"),
        ("syncMessage", "sentMessage", "message", "reaction"),
        ("syncMessage", "sentMessage", "reaction"),
    ]
    node = None
    for path in paths:
        candidate = _get_nested(envelope, path)
        if isinstance(candidate, dict):
            node = candidate
            break
    if not isinstance(node, dict):
        return None
    emoji = str(node.get("emoji") or "").strip()
    target_ts = node.get("targetSentTimestamp")
    if target_ts is None:
        target_ts = node.get("targetTimestamp")
    try:
        target_ts = int(target_ts)
    except Exception:
        target_ts = 0
    remove = bool(node.get("remove") or node.get("isRemove"))
    if not emoji and not remove:
        return None
    if target_ts <= 0:
        return None
    return {
        "emoji": emoji,
        "target_ts": target_ts,
        "remove": remove,
        "raw": dict(node),
    }


def _extract_signal_edit(envelope):
    paths = [
        ("dataMessage", "editMessage"),
        ("syncMessage", "sentMessage", "editMessage"),
        ("syncMessage", "editMessage"),
    ]
    node = None
    for path in paths:
        candidate = _get_nested(envelope, path)
        if isinstance(candidate, dict):
            node = candidate
            break
    if not isinstance(node, dict):
        return None

    target_ts = node.get("targetSentTimestamp")
    if target_ts is None:
        target_ts = node.get("targetTimestamp")
    if target_ts is None:
        target_ts = node.get("targetTs")
    try:
        target_ts = int(target_ts)
    except Exception:
        target_ts = 0
    if target_ts <= 0:
        return None

    data_message = node.get("dataMessage") or node.get("message") or {}
    new_text = ""
    if isinstance(data_message, dict):
        for key in ("message", "text", "body", "caption"):
            value = str(data_message.get(key) or "").strip()
            if value:
                new_text = value
                break
    if not new_text:
        new_text = str(node.get("message") or "").strip()
    if not new_text:
        return None

    return {
        "target_ts": target_ts,
        "new_text": new_text,
        "raw": dict(node),
    }


def _extract_signal_delete(envelope):
    paths = [
        ("dataMessage", "delete"),
        ("dataMessage", "remoteDelete"),
        ("syncMessage", "sentMessage", "delete"),
        ("syncMessage", "delete"),
    ]
    node = None
    for path in paths:
        candidate = _get_nested(envelope, path)
        if isinstance(candidate, dict):
            node = candidate
            break
    if not isinstance(node, dict):
        return None
    target_ts = node.get("targetSentTimestamp")
    if target_ts is None:
        target_ts = node.get("targetTimestamp")
    if target_ts is None:
        target_ts = node.get("targetTs")
    try:
        target_ts = int(target_ts)
    except Exception:
        target_ts = 0
    if target_ts <= 0:
        return None
    return {
        "target_ts": target_ts,
        "raw": dict(node),
    }


def _extract_signal_group_identifiers(payload: dict) -> list[str]:
    envelope = payload.get("envelope") or {}
    if not isinstance(envelope, dict):
        return []

    candidates = []
    paths = [
        ("group",),
        ("groupV2",),
        ("dataMessage", "groupInfo"),
        ("dataMessage", "groupV2"),
        ("syncMessage", "sentMessage", "groupInfo"),
        ("syncMessage", "sentMessage", "groupV2"),
        ("syncMessage", "sentMessage", "message", "groupInfo"),
        ("syncMessage", "sentMessage", "message", "groupV2"),
    ]
    key_names = ("id", "groupId", "groupID", "internal_id", "masterKey")
    for path in paths:
        node = _get_nested(envelope, path)
        if isinstance(node, str):
            candidates.append(node)
            continue
        if not isinstance(node, dict):
            continue
        for key_name in key_names:
            value = str(node.get(key_name) or "").strip()
            if value:
                candidates.append(value)

    unique = []
    for value in candidates:
        cleaned = str(value or "").strip()
        if cleaned and cleaned not in unique:
            unique.append(cleaned)
    return unique


def _extract_signal_text(raw_payload, default_text=""):
    text = str(default_text or "").strip()
    if text:
        return text
    payload = dict(raw_payload or {})
    envelope = dict(payload.get("envelope") or {})
    candidates = [
        envelope.get("dataMessage"),
        _get_nested(envelope, ("syncMessage", "sentMessage", "message")),
        _get_nested(envelope, ("syncMessage", "sentMessage")),
        payload.get("dataMessage"),
        payload,
    ]
    for item in candidates:
        if isinstance(item, dict):
            for key in ("message", "text", "body", "caption"):
                value = str(item.get(key) or "").strip()
                if value:
                    return value
    return ""


def _typing_started(typing_payload):
    action = str(typing_payload.get("action") or "").strip().lower()
    if action in {"started", "start", "typing", "composing"}:
        return True
    explicit = typing_payload.get("isTyping")
    if isinstance(explicit, bool):
        return explicit
    return True


def _identifier_candidates(*values):
    out = []
    seen = set()
    for value in values:
        cleaned = str(value or "").strip()
        if not cleaned:
            continue
        candidates = [cleaned]
        digits = re.sub(r"[^0-9]", "", cleaned)
        # Add basic E.164 variants for phone-shaped values.
        if digits and cleaned.count("-") < 4:
            candidates.extend([digits, f"+{digits}"])
        for candidate in candidates:
            if not candidate or candidate in seen:
                continue
            seen.add(candidate)
            out.append(candidate)
    return out


def _dedupe_person_identifiers(rows):
    deduped = []
    seen = set()
    for row in rows or []:
        key = (
            int(getattr(row, "user_id", 0) or 0),
            str(getattr(row, "person_id", "") or ""),
            str(getattr(row, "service", "") or "").strip().lower(),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(row)
    return deduped


def _digits_only(value):
    return re.sub(r"[^0-9]", "", str(value or "").strip())


class NewSignalBot(SignalBot):
    def __init__(self, ur, service, config):
        self.ur = ur
        self.service = service
        self.signal_rest = config["signal_service"]  # keep your own copy
        self.phone_number = config["phone_number"]
        super().__init__(config)
        self.log = logs.get_logger("signalI")
        self.bot_uuid = None

    async def get_own_uuid(self) -> str | None:
        async with aiohttp.ClientSession() as session:
            # config may be "signal:8080" -- ensure http://
            base = self.signal_rest
            if not base.startswith("http"):
                base = f"http://{base}"

            uri = f"{base}/v1/contacts/{self.phone_number}"
            try:
                resp = await session.get(uri)
                if resp.status != 200:
                    self.log.error(
                        f"contacts lookup failed: {resp.status} {await resp.text()}"
                    )
                    return None

                contacts_data = await resp.json()
                if isinstance(contacts_data, list):
                    for contact in contacts_data:
                        if contact.get("number") == self.phone_number:
                            return contact.get("uuid")
                return None
            except Exception as e:
                self.log.error(f"Failed to get UUID from contacts: {e}")
                return None

    async def initialize_bot(self):
        """Fetch bot's UUID and store it in self.bot_uuid."""
        try:
            self.bot_uuid = await self.get_own_uuid()
            if self.bot_uuid:
                self.log.info(f"Own UUID: {self.bot_uuid}")
            else:
                self.log.warning("Unable to fetch bot UUID.")
        except Exception as e:
            self.log.error(f"Failed to initialize bot UUID: {e}")

    async def _async_post_init(self):
        """
        Preserve SignalBot startup flow so protocol auto-detection runs.
        This flips the client to plain HTTP/WS when HTTPS/WSS is unavailable.
        """
        await self._check_signal_service()
        await self.initialize_bot()
        await self._detect_groups()
        await self._resolve_commands()
        await self._produce_consume_messages()

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

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

        for user in users:
            for group in groups:
                group_id = group.get("id") or ""
                name = group.get("name") or group_id
                if not group_id:
                    continue
                await sync_to_async(PlatformChatLink.objects.update_or_create)(
                    user=user,
                    service="signal",
                    chat_identifier=group_id,
                    defaults={
                        "person": None,
                        "person_identifier": None,
                        "is_group": True,
                        "chat_name": name,
                    },
                )

        self.log.info(
            "[Signal] upserted %d groups for %d users", len(groups), len(users)
        )

    async def _detect_groups(self):
        await super()._detect_groups()
        await self._upsert_groups()

    def start(self):
        """Start bot without blocking the caller's event loop."""
        task = self._event_loop.create_task(
            self._rerun_on_exception(self._async_post_init)
        )
        self._store_reference_to_task(task, self._running_tasks)
        self.scheduler.start()


class HandleMessage(Command):
    def __init__(self, ur, service, *args, **kwargs):
        self.ur = ur
        self.service = service
        return super().__init__(*args, **kwargs)

    async def handle(self, c: Context):
        msg = {
            "source": c.message.source,
            "source_number": c.message.source_number,
            "source_uuid": c.message.source_uuid,
            "timestamp": c.message.timestamp,
            "type": c.message.type.value,
            "text": c.message.text,
            "group": c.message.group,
            "reaction": c.message.reaction,
            "mentions": c.message.mentions,
            "raw_message": c.message.raw_message,
        }
        raw = json.loads(c.message.raw_message)
        sent_message = (
            raw.get("envelope", {}).get("syncMessage", {}).get("sentMessage", {}) or {}
        )
        dest = sent_message.get("destinationUuid")

        account = raw.get("account", "")
        source_name = raw.get("envelope", {}).get("sourceName", "")

        source_number = c.message.source_number
        source_uuid = c.message.source_uuid
        text = c.message.text
        text = _extract_signal_text(raw, text)
        ts = c.message.timestamp
        source_value = c.message.source
        envelope = raw.get("envelope", {})
        envelope_source_uuid = envelope.get("sourceUuid")
        envelope_source_number = envelope.get("sourceNumber")
        effective_source_uuid = str(envelope_source_uuid or source_uuid or "").strip()
        effective_source_number = str(
            envelope_source_number or source_number or ""
        ).strip()
        signal_source_message_id = str(
            envelope.get("serverGuid")
            or envelope.get("guid")
            or envelope.get("timestamp")
            or c.message.timestamp
            or ""
        ).strip()
        destination_number = sent_message.get("destination")

        bot_uuid = str(getattr(c.bot, "bot_uuid", "") or "").strip()
        bot_phone = str(getattr(c.bot, "phone_number", "") or "").strip()
        source_uuid_norm = effective_source_uuid
        source_number_norm = effective_source_number
        dest_norm = str(dest or "").strip()
        destination_number_norm = str(destination_number or "").strip()

        bot_phone_digits = re.sub(r"[^0-9]", "", bot_phone)
        source_phone_digits = re.sub(r"[^0-9]", "", source_number_norm)
        dest_phone_digits = re.sub(r"[^0-9]", "", destination_number_norm or dest_norm)
        is_sync_outbound = bool(dest_norm or destination_number_norm)

        # Message originating from us
        same_recipient = bool(
            source_uuid_norm and dest_norm and source_uuid_norm == dest_norm
        )

        is_from_bot = bool(
            bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid
        )
        if (not is_from_bot) and bot_phone_digits and source_phone_digits:
            is_from_bot = source_phone_digits == bot_phone_digits
        # Inbound deliveries usually do not have destination fields populated.
        # When destination is missing, treat event as inbound even if source
        # metadata drifts to our own identifiers.
        if not is_sync_outbound:
            is_from_bot = False

        # For non-sync incoming events destination is usually absent and points to us.
        is_to_bot = bool(bot_uuid and dest_norm and dest_norm == bot_uuid)
        if (not is_to_bot) and bot_phone_digits and dest_phone_digits:
            is_to_bot = dest_phone_digits == bot_phone_digits
        if (not is_to_bot) and (not dest_norm) and (not destination_number_norm):
            is_to_bot = True

        reply_to_self = same_recipient and is_from_bot  # Reply
        reply_to_others = is_to_bot and not same_recipient  # Reply
        is_outgoing_message = is_from_bot and not is_to_bot  # Do not reply

        envelope_source = envelope.get("source")

        primary_identifier = dest if is_from_bot else effective_source_uuid
        if (dest or destination_number) and is_from_bot:
            # Sync "sentMessage" events are outbound; route by destination only.
            # This prevents copying one outbound message into multiple people
            # when source fields include the bot's own identifier.
            identifier_candidates = _identifier_candidates(dest, destination_number)
        elif is_from_bot:
            identifier_candidates = _identifier_candidates(primary_identifier)
        else:
            bot_identifiers = {
                str(c.bot.bot_uuid or "").strip(),
                str(getattr(c.bot, "phone_number", "") or "").strip(),
            }
            incoming_candidates = _identifier_candidates(
                primary_identifier,
                effective_source_uuid,
                effective_source_number,
                source_value,
                envelope_source_uuid,
                envelope_source_number,
                envelope_source,
            )
            identifier_candidates = [
                value
                for value in incoming_candidates
                if value and value not in bot_identifiers
            ]
        if not identifier_candidates:
            log.warning("No Signal identifier available for message routing.")
            return

        # Resolve person identifiers once for this event.
        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(
                identifier__in=identifier_candidates,
                service=self.service,
            )
        )
        if not identifiers:
            companion_candidates = []
            for value in identifier_candidates:
                if not value:
                    continue
                companions = await sync_to_async(list)(
                    Chat.objects.filter(source_uuid=value).values_list(
                        "source_number", flat=True
                    )
                )
                companions += await sync_to_async(list)(
                    Chat.objects.filter(source_number=value).values_list(
                        "source_uuid", flat=True
                    )
                )
                companion_candidates.extend(companions)
            companion_candidates = _identifier_candidates(*companion_candidates)
            if companion_candidates:
                identifiers = await sync_to_async(list)(
                    PersonIdentifier.objects.filter(
                        identifier__in=companion_candidates,
                        service=self.service,
                    )
                )
        if not identifiers:
            # Final fallback: compare normalized phone digits to handle format drift
            # between Signal payload values and stored identifiers.
            candidate_digits = {_digits_only(value) for value in identifier_candidates}
            candidate_digits = {value for value in candidate_digits if value}
            if candidate_digits:
                signal_rows = await sync_to_async(list)(
                    PersonIdentifier.objects.filter(
                        service=self.service
                    ).select_related("user")
                )
                matched = []
                for row in signal_rows:
                    stored_digits = _digits_only(row.identifier)
                    if stored_digits and stored_digits in candidate_digits:
                        matched.append(row)
                identifiers = matched
        if not identifiers and (not is_from_bot) and (not bool(c.message.group)):
            # Single-user fallback: don't drop new private inbound contacts just
            # because they are not pre-linked yet. Create a placeholder person +
            # identifier so the chat appears and can be re-linked later.
            owner_rows = await sync_to_async(list)(
                PersonIdentifier.objects.filter(service=self.service)
                .select_related("user")
                .order_by("user_id", "id")
            )
            owner_users = []
            seen_user_ids = set()
            for row in owner_rows:
                if row.user_id in seen_user_ids:
                    continue
                seen_user_ids.add(row.user_id)
                owner_users.append(row.user)
            if len(owner_users) == 1:
                owner = owner_users[0]
                fallback_identifier = (
                    effective_source_number
                    or effective_source_uuid
                    or (identifier_candidates[0] if identifier_candidates else "")
                )
                fallback_identifier = str(fallback_identifier or "").strip()
                if fallback_identifier:
                    person, _ = await sync_to_async(Person.objects.get_or_create)(
                        user=owner,
                        name=f"Signal {fallback_identifier}",
                    )
                    pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)(
                        user=owner,
                        service=self.service,
                        identifier=fallback_identifier,
                        defaults={"person": person},
                    )
                    if pi.person_id != person.id:
                        pi.person = person
                        await sync_to_async(pi.save)(update_fields=["person"])
                    identifiers = [pi]
                    log.info(
                        "Signal inbound auto-linked new private contact identifier=%s user_id=%s",
                        fallback_identifier,
                        int(owner.id),
                    )
        if not identifiers:
            log.warning(
                "Signal inbound unmatched: candidates=%s source_uuid=%s source_number=%s effective_source_uuid=%s effective_source_number=%s dest=%s destination_number=%s envelope_source_uuid=%s envelope_source_number=%s",
                identifier_candidates,
                str(source_uuid or ""),
                str(source_number or ""),
                str(effective_source_uuid or ""),
                str(effective_source_number or ""),
                str(dest or ""),
                str(destination_number or ""),
                str(envelope_source_uuid or ""),
                str(envelope_source_number or ""),
            )

        typing_payload = envelope.get("typingMessage")
        if isinstance(typing_payload, dict):
            for identifier in identifiers:
                if _typing_started(typing_payload):
                    await self.ur.started_typing(
                        self.service,
                        identifier=identifier,
                        payload=typing_payload,
                    )
                else:
                    await self.ur.stopped_typing(
                        self.service,
                        identifier=identifier,
                        payload=typing_payload,
                    )
            return

        receipt_payload = envelope.get("receiptMessage")
        if isinstance(receipt_payload, dict):
            read_timestamps = _extract_receipt_timestamps(receipt_payload)
            read_ts = (
                envelope.get("timestamp")
                or envelope.get("serverReceivedTimestamp")
                or c.message.timestamp
            )
            for identifier in identifiers:
                await self.ur.message_read(
                    self.service,
                    identifier=identifier,
                    message_timestamps=read_timestamps,
                    read_ts=read_ts,
                    payload=receipt_payload,
                    read_by=(effective_source_uuid or effective_source_number or ""),
                )
            return

        reaction_payload = _extract_signal_reaction(envelope)
        if isinstance(reaction_payload, dict):
            log.debug(
                "reaction-bridge signal-inbound target_ts=%s emoji=%s remove=%s identifiers=%s",
                int(reaction_payload.get("target_ts") or 0),
                str(reaction_payload.get("emoji") or "") or "-",
                bool(reaction_payload.get("remove")),
                len(identifiers),
            )
            for identifier in identifiers:
                try:
                    await history.apply_reaction(
                        identifier.user,
                        identifier,
                        target_message_id="",
                        target_ts=int(reaction_payload.get("target_ts") or 0),
                        emoji=str(reaction_payload.get("emoji") or ""),
                        source_service="signal",
                        actor=(effective_source_uuid or effective_source_number or ""),
                        target_author=str(
                            (reaction_payload.get("raw") or {}).get("targetAuthorUuid")
                            or (reaction_payload.get("raw") or {}).get("targetAuthor")
                            or (reaction_payload.get("raw") or {}).get(
                                "targetAuthorNumber"
                            )
                            or ""
                        ),
                        remove=bool(reaction_payload.get("remove")),
                        payload=reaction_payload.get("raw") or {},
                    )
                except Exception as exc:
                    log.warning("Signal reaction history apply failed: %s", exc)
                try:
                    await self.ur.xmpp.client.apply_external_reaction(
                        identifier.user,
                        identifier,
                        source_service="signal",
                        emoji=str(reaction_payload.get("emoji") or ""),
                        remove=bool(reaction_payload.get("remove")),
                        upstream_message_id="",
                        upstream_ts=int(reaction_payload.get("target_ts") or 0),
                        actor=(effective_source_uuid or effective_source_number or ""),
                        payload=reaction_payload.get("raw") or {},
                    )
                except Exception as exc:
                    log.warning("Signal reaction relay to XMPP failed: %s", exc)
            return

        if reply_to_self and str(text or "").strip().startswith("."):
            responded_user_ids = set()
            for identifier in identifiers:
                if identifier.user_id in responded_user_ids:
                    continue
                responded_user_ids.add(identifier.user_id)
                gateway_replies = await self.ur.xmpp.client.execute_gateway_command(
                    sender_user=identifier.user,
                    body=text,
                    service=self.service,
                    channel_identifier=str(identifier.identifier or ""),
                    sender_identifier=str(identifier.identifier or ""),
                    local_message=None,
                    message_meta={
                        "signal": {
                            "source_uuid": str(effective_source_uuid or ""),
                            "source_number": str(effective_source_number or ""),
                            "reply_to_self": True,
                        }
                    },
                )
                for line in gateway_replies:
                    await c.send(f"[>] {line}")
            return

        # Handle attachments across multiple Signal payload variants.
        attachment_list = _extract_attachments(raw)
        xmpp_attachments = []
        compose_media_urls = []

        # Asynchronously fetch all attachments
        log.info(f"ATTACHMENT LIST {attachment_list}")
        if attachment_list:
            tasks = [
                signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list
            ]
            fetched_attachments = await asyncio.gather(*tasks)
        else:
            envelope = raw.get("envelope", {})
            log.info(f"No attachments found. Envelope keys: {list(envelope.keys())}")
            fetched_attachments = []

        for fetched, att in zip(fetched_attachments, attachment_list):
            if not fetched:
                log.warning(f"Failed to fetch attachment {att['id']} from Signal.")
                continue

            # Attach fetched file to XMPP
            xmpp_attachments.append(
                {
                    "content": fetched["content"],
                    "content_type": fetched["content_type"],
                    "filename": fetched["filename"],
                    "size": fetched["size"],
                }
            )
            blob_key = media_bridge.put_blob(
                service="signal",
                content=fetched["content"],
                filename=fetched["filename"],
                content_type=fetched["content_type"],
            )
            if blob_key:
                compose_media_urls.append(
                    f"/compose/media/blob/?key={quote_plus(str(blob_key))}"
                )

        # Keep relay payload text clean for XMPP. Blob URLs are web/history fallback
        # only and should not be injected into XMPP body text.
        relay_text = text
        if attachment_list and _is_compose_blob_only_text(relay_text):
            relay_text = ""

        # Forward incoming Signal messages to XMPP and apply mutate rules.
        identifier_text_overrides = {}
        for identifier in identifiers:
            user = identifier.user
            session_key = (identifier.user.id, identifier.person.id)

            mutate_manips = await sync_to_async(list)(
                Manipulation.objects.filter(
                    group__people=identifier.person,
                    user=identifier.user,
                    mode="mutate",
                    filter_enabled=True,
                    enabled=True,
                )
            )
            if mutate_manips:
                uploaded_urls = []
                for manip in mutate_manips:
                    prompt = replies.generate_mutate_reply_prompt(
                        relay_text,
                        None,
                        manip,
                        None,
                    )
                    log.info("Running Signal mutate prompt")
                    result = await ai.run_prompt(
                        prompt,
                        manip.ai,
                        operation="signal_mutate",
                    )
                    log.info(
                        f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
                    )
                    uploaded_urls = await self.ur.xmpp.client.send_from_external(
                        user,
                        identifier,
                        result,
                        is_outgoing_message,
                        attachments=xmpp_attachments,
                        source_ref={
                            "upstream_message_id": "",
                            "upstream_author": str(
                                effective_source_uuid or effective_source_number or ""
                            ),
                            "upstream_ts": int(ts or 0),
                        },
                    )
                resolved_text = relay_text
                if (not resolved_text) and uploaded_urls:
                    resolved_text = "\n".join(uploaded_urls)
                elif (not resolved_text) and compose_media_urls:
                    resolved_text = "\n".join(compose_media_urls)
                identifier_text_overrides[session_key] = resolved_text
            else:
                log.info(
                    f"Sending {len(xmpp_attachments)} attachments from Signal to XMPP."
                )
                uploaded_urls = await self.ur.xmpp.client.send_from_external(
                    user,
                    identifier,
                    relay_text,
                    is_outgoing_message,
                    attachments=xmpp_attachments,
                    source_ref={
                        "upstream_message_id": "",
                        "upstream_author": str(
                            effective_source_uuid or effective_source_number or ""
                        ),
                        "upstream_ts": int(ts or 0),
                    },
                )
                resolved_text = relay_text
                if (not resolved_text) and uploaded_urls:
                    resolved_text = "\n".join(uploaded_urls)
                elif (not resolved_text) and compose_media_urls:
                    resolved_text = "\n".join(compose_media_urls)
                identifier_text_overrides[session_key] = resolved_text

        # Persist message history for every resolved identifier, even when no
        # manipulations are active, so manual chat windows stay complete.
        session_cache = {}
        stored_messages = set()
        for identifier in identifiers:
            session_key = (identifier.user.id, identifier.person.id)
            if session_key in session_cache:
                chat_session = session_cache[session_key]
            else:
                chat_session = await history.get_chat_session(
                    identifier.user, identifier
                )
                session_cache[session_key] = chat_session
            reply_ref = reply_sync.extract_reply_ref(self.service, raw)
            reply_target = await reply_sync.resolve_reply_target(
                identifier.user,
                chat_session,
                reply_ref,
            )
            sender_key = (
                effective_source_uuid
                or effective_source_number
                or identifier_candidates[0]
            )
            message_key = (chat_session.id, ts, sender_key)
            message_text = identifier_text_overrides.get(session_key, relay_text)
            if message_key not in stored_messages:
                origin_tag = reply_sync.extract_origin_tag(raw)
                local_message = await history.store_message(
                    session=chat_session,
                    sender=sender_key,
                    text=message_text,
                    ts=ts,
                    outgoing=is_from_bot,
                    source_service=self.service,
                    source_message_id=signal_source_message_id,
                    source_chat_id=str(
                        destination_number_norm or dest_norm or sender_key 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=reply_sync.apply_sync_origin({}, origin_tag),
                )
                stored_messages.add(message_key)
                # Notify unified router to ensure service context is preserved
                await self.ur.message_received(
                    self.service,
                    identifier=identifier,
                    text=message_text,
                    ts=ts,
                    payload=msg,
                    local_message=local_message,
                )

        # TODO: Permission checks
        manips = await sync_to_async(list)(Manipulation.objects.filter(enabled=True))
        for manip in manips:
            person_identifier = await sync_to_async(
                lambda: PersonIdentifier.objects.filter(
                    identifier__in=identifier_candidates,
                    user=manip.user,
                    service="signal",
                    person__in=manip.group.people.all(),
                ).first()
            )()
            if person_identifier is None:
                log.warning(
                    f"{manip.name}: Message from unknown identifier(s) "
                    f"{', '.join(identifier_candidates)}."
                )
                continue

            # Find/create ChatSession once per user/person.
            session_key = (manip.user.id, person_identifier.person.id)
            if session_key in session_cache:
                chat_session = session_cache[session_key]
            else:
                chat_session = await history.get_chat_session(
                    manip.user, person_identifier
                )
                session_cache[session_key] = chat_session

            # Get the total history
            chat_history = await history.get_chat_history(chat_session)

            if replies.should_reply(
                reply_to_self,
                reply_to_others,
                is_outgoing_message,
            ):
                if manip.mode in ["silent", "mutate"]:
                    pass
                elif manip.mode in ["active", "notify", "instant"]:
                    await utils.update_last_interaction(chat_session)
                    prompt = replies.generate_reply_prompt(
                        msg, person_identifier.person, manip, chat_history
                    )

                    log.info("Running context prompt")
                    result = await ai.run_prompt(
                        prompt,
                        manip.ai,
                        operation="signal_reply",
                    )
                    if manip.mode == "active":
                        await history.store_own_message(
                            session=chat_session,
                            text=result,
                            ts=ts + 1,
                        )
                        await self.ur.xmpp.client.send_from_external(
                            manip.user,
                            person_identifier,
                            result,
                            is_outgoing_message=True,
                        )
                        await natural.natural_send_message(
                            result,
                            c.send,
                            c.start_typing,
                            c.stop_typing,
                        )
                    elif manip.mode == "notify":
                        title = f"[GIA] Suggested message to {person_identifier.person.name}"
                        manip.user.sendmsg(result, title=title)
                    elif manip.mode == "instant":
                        existing_queue = QueuedMessage.objects.filter(
                            user=chat_session.user,
                            session=chat_session,
                            manipulation=manip,
                            custom_author="BOT",
                        )

                        await history.delete_queryset(existing_queue)
                        qm = await history.store_own_message(
                            session=chat_session,
                            text=result,
                            ts=ts + 1,
                            manip=manip,
                            queue=True,
                        )
                        accept = reverse(
                            "message_accept_api", kwargs={"message_id": qm.id}
                        )
                        reject = reverse(
                            "message_reject_api", kwargs={"message_id": qm.id}
                        )
                        url = settings.URL
                        content = (
                            f"{result}\n\n"
                            f"Accept: {url}{accept}\n"
                            f"Reject: {url}{reject}"
                        )
                        title = f"[GIA] Suggested message to {person_identifier.person.name}"
                        manip.user.sendmsg(content, title=title)
                else:
                    log.error(f"Mode {manip.mode} is not implemented")

        chat_lookup = {"account": account}
        if effective_source_uuid:
            chat_lookup["source_uuid"] = effective_source_uuid
        elif effective_source_number:
            chat_lookup["source_number"] = effective_source_number
        else:
            return

        await sync_to_async(Chat.objects.update_or_create)(
            **chat_lookup,
            defaults={
                "source_uuid": effective_source_uuid,
                "source_number": effective_source_number,
                "source_name": source_name,
                "account": account,
            },
        )


class SignalClient(ClientBase):
    def __init__(self, ur, *args, **kwargs):
        super().__init__(ur, *args, **kwargs)
        self._stopping = False
        signal_number = str(getattr(settings, "SIGNAL_NUMBER", "")).strip()
        self.client = NewSignalBot(
            ur,
            self.service,
            {
                "signal_service": SIGNAL_URL,
                "phone_number": signal_number,
            },
        )

        self.client.register(HandleMessage(self.ur, self.service))
        self._command_task = None
        self._raw_receive_task = None
        self._catalog_refresh_task = None

    async def _signal_api_get_list(
        self, session: aiohttp.ClientSession, path: str
    ) -> list[dict]:
        base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
            "/"
        )
        try:
            async with session.get(f"{base}{path}") as response:
                if response.status != 200:
                    body = str(await response.text()).strip()[:300]
                    self.log.warning(
                        "signal catalog fetch failed path=%s status=%s body=%s",
                        path,
                        response.status,
                        body or "-",
                    )
                    return []
                payload = await response.json(content_type=None)
        except Exception as exc:
            self.log.warning("signal catalog fetch failed path=%s error=%s", path, exc)
            return []
        return payload if isinstance(payload, list) else []

    async def _refresh_runtime_catalog(self) -> None:
        signal_number = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
        if not signal_number:
            return

        encoded_account = quote_plus(signal_number)
        timeout = aiohttp.ClientTimeout(total=20)
        async with aiohttp.ClientSession(timeout=timeout) as session:
            identities = await self._signal_api_get_list(
                session, f"/v1/identities/{encoded_account}"
            )
            groups = await self._signal_api_get_list(
                session, f"/v1/groups/{encoded_account}"
            )

        account_digits = _digits_only(signal_number)
        contact_rows = []
        seen_contacts = set()
        for identity in identities:
            if not isinstance(identity, dict):
                continue
            number = str(identity.get("number") or "").strip()
            uuid = str(identity.get("uuid") or "").strip()
            if account_digits and number and _digits_only(number) == account_digits:
                continue
            identifiers = []
            for candidate in (number, uuid):
                cleaned = str(candidate or "").strip()
                if cleaned and cleaned not in identifiers:
                    identifiers.append(cleaned)
            if not identifiers:
                continue
            key = (
                f"uuid:{uuid.lower()}"
                if uuid
                else f"phone:{_digits_only(number) or number}"
            )
            if key in seen_contacts:
                continue
            seen_contacts.add(key)
            contact_rows.append(
                {
                    "identifier": number or uuid,
                    "identifiers": identifiers,
                    "name": str(identity.get("name") or "").strip(),
                    "number": number,
                    "uuid": uuid,
                }
            )

        group_rows = []
        seen_groups = set()
        for group in groups:
            if not isinstance(group, dict):
                continue
            group_id = str(group.get("id") or "").strip()
            internal_id = str(group.get("internal_id") or "").strip()
            identifiers = []
            for candidate in (group_id, internal_id):
                cleaned = str(candidate or "").strip()
                if cleaned and cleaned not in identifiers:
                    identifiers.append(cleaned)
            if not identifiers:
                continue
            key = group_id or internal_id
            if key in seen_groups:
                continue
            seen_groups.add(key)
            group_rows.append(
                {
                    "identifier": group_id or internal_id,
                    "identifiers": identifiers,
                    "name": str(group.get("name") or "").strip(),
                    "id": group_id,
                    "internal_id": internal_id,
                }
            )

        transport.update_runtime_state(
            self.service,
            accounts=[signal_number],
            contacts=contact_rows,
            groups=group_rows,
            catalog_refreshed_at=int(time.time()),
            catalog_error="",
        )

    async def _refresh_runtime_catalog_safe(self) -> None:
        try:
            await self._refresh_runtime_catalog()
        except asyncio.CancelledError:
            raise
        except Exception as exc:
            transport.update_runtime_state(
                self.service,
                catalog_error=str(exc).strip()[:300],
                catalog_refreshed_at=int(time.time()),
            )
            self.log.warning("signal catalog refresh failed: %s", exc)

    async def _drain_runtime_commands(self):
        """Process queued runtime commands (e.g., web UI sends via composite router)."""
        from core.clients import transport

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

    async def _execute_runtime_command(self, command):
        """Execute a single runtime command like send_message_raw."""
        from core.clients import transport

        command_id = str((command or {}).get("id") or "").strip()
        action = str((command or {}).get("action") or "").strip()
        payload = dict((command or {}).get("payload") or {})
        if not command_id:
            return

        if action == "send_message_raw":
            recipient = str(payload.get("recipient") or "").strip()
            text = payload.get("text")
            attachments = payload.get("attachments") or []
            metadata = dict(payload.get("metadata") or {})
            try:
                result = await signalapi.send_message_raw(
                    recipient_uuid=recipient,
                    text=text,
                    attachments=attachments,
                    metadata=metadata,
                    detailed=True,
                )
                if isinstance(result, dict) and (not bool(result.get("ok"))):
                    status_value = int(result.get("status") or 0)
                    error_text = str(result.get("error") or "").strip()
                    recipient_value = str(result.get("recipient") or recipient).strip()
                    raise RuntimeError(
                        "signal_send_failed"
                        f" status={status_value or 'unknown'}"
                        f" recipient={recipient_value or 'unknown'}"
                        f" error={error_text or 'unknown'}"
                    )
                if result is False or result is None:
                    raise RuntimeError("signal_send_failed")
                legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
                if legacy_message_id and isinstance(result, int):
                    message_row = await sync_to_async(
                        lambda: Message.objects.filter(id=legacy_message_id).first()
                    )()
                    if message_row is not None:
                        update_fields = []
                        if int(message_row.delivered_ts or 0) <= 0:
                            message_row.delivered_ts = int(result)
                            update_fields.append("delivered_ts")
                        if str(message_row.source_message_id or "").strip() != str(
                            result
                        ):
                            message_row.source_message_id = str(result)
                            update_fields.append("source_message_id")
                        if update_fields:
                            await sync_to_async(message_row.save)(
                                update_fields=update_fields
                            )
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": True,
                        "timestamp": (
                            int(result)
                            if isinstance(result, int)
                            else int(time.time() * 1000)
                        ),
                    },
                )
            except Exception as exc:
                self.log.error(f"send_message_raw failed: {exc}", exc_info=True)
                transport.set_runtime_command_result(
                    self.service,
                    command_id,
                    {
                        "ok": False,
                        "error": str(exc),
                    },
                )
            return

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

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

    async def _command_loop(self):
        """Background task to periodically drain queued commands."""
        while not self._stopping:
            try:
                await self._drain_runtime_commands()
            except Exception as exc:
                self.log.warning(f"Command loop error: {exc}")
            await asyncio.sleep(1)

    async def _resolve_signal_group_identifiers(self, group_candidates: list[str]):
        unique_candidates = []
        for value in group_candidates or []:
            cleaned = str(value or "").strip()
            if cleaned and cleaned not in unique_candidates:
                unique_candidates.append(cleaned)
        if not unique_candidates:
            return []

        runtime_groups = transport.get_runtime_state(self.service).get("groups") or []
        expanded = list(unique_candidates)
        for item in runtime_groups:
            if not isinstance(item, dict):
                continue
            identifiers = []
            for candidate in item.get("identifiers") or []:
                cleaned = str(candidate or "").strip()
                if cleaned:
                    identifiers.append(cleaned)
            if not identifiers:
                continue
            if not any(candidate in identifiers for candidate in unique_candidates):
                continue
            for candidate in identifiers:
                if candidate not in expanded:
                    expanded.append(candidate)

        exact_identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(
                service=self.service,
                identifier__in=expanded,
            ).select_related("user", "person")
        )
        if exact_identifiers:
            return exact_identifiers

        bare_candidates = []
        for candidate in expanded:
            bare = str(candidate or "").strip().split("@", 1)[0].strip()
            if bare and bare not in bare_candidates:
                bare_candidates.append(bare)

        links = await sync_to_async(list)(
            PlatformChatLink.objects.filter(
                service=self.service,
                is_group=True,
                chat_identifier__in=bare_candidates,
            )
            .select_related("user", "person_identifier", "person")
            .order_by("id")
        )
        if not links:
            return []

        results = []
        seen_ids = set()
        for link in links:
            if link.person_identifier_id:
                if link.person_identifier_id not in seen_ids:
                    seen_ids.add(link.person_identifier_id)
                    results.append(link.person_identifier)
                continue
            if not link.person_id:
                continue
            group_pi = await sync_to_async(
                lambda: PersonIdentifier.objects.filter(
                    user=link.user,
                    person=link.person,
                    service=self.service,
                    identifier__in=expanded,
                )
                .select_related("user", "person")
                .first()
            )()
            if group_pi is None:
                group_pi = await sync_to_async(
                    lambda: PersonIdentifier.objects.filter(
                        user=link.user,
                        person=link.person,
                        service=self.service,
                    )
                    .select_related("user", "person")
                    .first()
                )()
            if group_pi is not None and group_pi.id not in seen_ids:
                seen_ids.add(group_pi.id)
                results.append(group_pi)
        return results

    async def _resolve_signal_identifiers(
        self,
        source_uuid: str,
        source_number: str,
        group_candidates: list[str] | None = None,
    ):
        group_rows = await self._resolve_signal_group_identifiers(group_candidates or [])
        if group_rows:
            return _dedupe_person_identifiers(group_rows)
        candidates = _identifier_candidates(source_uuid, source_number)
        if not candidates:
            return []
        identifiers = await sync_to_async(list)(
            PersonIdentifier.objects.filter(
                identifier__in=candidates,
                service=self.service,
            )
        )
        if identifiers:
            return _dedupe_person_identifiers(identifiers)
        candidate_digits = {_digits_only(value) for value in candidates}
        candidate_digits = {value for value in candidate_digits if value}
        if not candidate_digits:
            return []
        rows = await sync_to_async(list)(
            PersonIdentifier.objects.filter(service=self.service).select_related("user")
        )
        return _dedupe_person_identifiers(
            [
                row
                for row in rows
                if _digits_only(getattr(row, "identifier", "")) in candidate_digits
            ]
        )

    async def _auto_link_single_user_signal_identifier(
        self, source_uuid: str, source_number: str
    ):
        owner_rows = await sync_to_async(list)(
            PersonIdentifier.objects.filter(service=self.service)
            .select_related("user")
            .order_by("user_id", "id")
        )
        users = []
        seen = set()
        for row in owner_rows:
            if row.user_id in seen:
                continue
            seen.add(row.user_id)
            users.append(row.user)
        if len(users) != 1:
            return []
        owner = users[0]
        fallback_identifier = str(source_number or source_uuid or "").strip()
        if not fallback_identifier:
            return []
        person, _ = await sync_to_async(Person.objects.get_or_create)(
            user=owner,
            name=f"Signal {fallback_identifier}",
        )
        pi, _ = await sync_to_async(PersonIdentifier.objects.get_or_create)(
            user=owner,
            service=self.service,
            identifier=fallback_identifier,
            defaults={"person": person},
        )
        if pi.person_id != person.id:
            pi.person = person
            await sync_to_async(pi.save)(update_fields=["person"])
        self.log.info(
            "signal raw-receive auto-linked identifier=%s user_id=%s",
            fallback_identifier,
            int(owner.id),
        )
        return _dedupe_person_identifiers([pi])

    async def _build_xmpp_relay_attachments(self, payload: dict):
        attachment_list = _extract_attachments(payload)
        xmpp_attachments = []
        compose_media_urls = []
        if not attachment_list:
            return xmpp_attachments, compose_media_urls

        fetched_attachments = await asyncio.gather(
            *[signalapi.fetch_signal_attachment(att["id"]) for att in attachment_list]
        )
        for fetched, att in zip(fetched_attachments, attachment_list):
            if not fetched:
                self.log.warning(
                    "signal raw attachment fetch failed attachment_id=%s", att["id"]
                )
                continue
            xmpp_attachments.append(
                {
                    "content": fetched["content"],
                    "content_type": fetched["content_type"],
                    "filename": fetched["filename"],
                    "size": fetched["size"],
                }
            )
            blob_key = media_bridge.put_blob(
                service="signal",
                content=fetched["content"],
                filename=fetched["filename"],
                content_type=fetched["content_type"],
            )
            if blob_key:
                compose_media_urls.append(
                    f"/compose/media/blob/?key={quote_plus(str(blob_key))}"
                )
        return xmpp_attachments, compose_media_urls

    async def _relay_signal_inbound_to_xmpp(
        self,
        *,
        identifiers,
        relay_text,
        xmpp_attachments,
        compose_media_urls,
        source_uuid,
        source_number,
        ts,
    ):
        resolved_text_by_session = {}
        for identifier in identifiers:
            user = identifier.user
            session_key = (identifier.user.id, identifier.person.id)
            mutate_manips = await sync_to_async(list)(
                Manipulation.objects.filter(
                    group__people=identifier.person,
                    user=identifier.user,
                    mode="mutate",
                    filter_enabled=True,
                    enabled=True,
                )
            )
            if mutate_manips:
                uploaded_urls = []
                for manip in mutate_manips:
                    prompt = replies.generate_mutate_reply_prompt(
                        relay_text,
                        None,
                        manip,
                        None,
                    )
                    result = await ai.run_prompt(
                        prompt,
                        manip.ai,
                        operation="signal_mutate",
                    )
                    uploaded_urls = await self.ur.xmpp.client.send_from_external(
                        user,
                        identifier,
                        result,
                        False,
                        attachments=xmpp_attachments,
                        source_ref={
                            "upstream_message_id": "",
                            "upstream_author": str(
                                source_uuid or source_number or ""
                            ),
                            "upstream_ts": int(ts or 0),
                        },
                    )
                resolved_text = relay_text
                if (not resolved_text) and uploaded_urls:
                    resolved_text = "\n".join(uploaded_urls)
                elif (not resolved_text) and compose_media_urls:
                    resolved_text = "\n".join(compose_media_urls)
                resolved_text_by_session[session_key] = resolved_text
                continue

            uploaded_urls = await self.ur.xmpp.client.send_from_external(
                user,
                identifier,
                relay_text,
                False,
                attachments=xmpp_attachments,
                source_ref={
                    "upstream_message_id": "",
                    "upstream_author": str(source_uuid or source_number or ""),
                    "upstream_ts": int(ts or 0),
                },
            )
            resolved_text = relay_text
            if (not resolved_text) and uploaded_urls:
                resolved_text = "\n".join(uploaded_urls)
            elif (not resolved_text) and compose_media_urls:
                resolved_text = "\n".join(compose_media_urls)
            resolved_text_by_session[session_key] = resolved_text
        return resolved_text_by_session

    async def _process_raw_inbound_event(self, raw_message: str):
        try:
            payload = json.loads(raw_message or "{}")
        except Exception:
            return
        exception_payload = (
            payload.get("exception") if isinstance(payload, dict) else None
        )
        if isinstance(exception_payload, dict):
            err_type = str(exception_payload.get("type") or "").strip()
            err_msg = str(exception_payload.get("message") or "").strip()
            envelope = payload.get("envelope") or {}
            envelope_source_uuid = ""
            envelope_source_number = ""
            envelope_ts = 0
            envelope_keys = []
            if isinstance(envelope, dict):
                envelope_source_uuid = str(envelope.get("sourceUuid") or "").strip()
                envelope_source_number = str(envelope.get("sourceNumber") or "").strip()
                try:
                    envelope_ts = int(
                        envelope.get("timestamp")
                        or envelope.get("serverReceivedTimestamp")
                        or 0
                    )
                except Exception:
                    envelope_ts = 0
                envelope_keys = sorted(list(envelope.keys()))[:20]
            payload_excerpt = json.dumps(payload, ensure_ascii=True)[:1200]
            transport.update_runtime_state(
                self.service,
                last_inbound_exception_type=err_type,
                last_inbound_exception_message=err_msg,
                last_inbound_exception_ts=int(
                    (envelope.get("timestamp") if isinstance(envelope, dict) else 0)
                    or int(time.time() * 1000)
                ),
                last_inbound_exception_account=str(
                    payload.get("account") or ""
                ).strip(),
                last_inbound_exception_source_uuid=envelope_source_uuid,
                last_inbound_exception_source_number=envelope_source_number,
                last_inbound_exception_envelope_ts=envelope_ts,
                last_inbound_exception_envelope_keys=envelope_keys,
                last_inbound_exception_payload_excerpt=payload_excerpt,
            )
            self.log.warning(
                "signal raw-receive exception type=%s message=%s source_uuid=%s source_number=%s envelope_ts=%s",
                err_type or "-",
                err_msg or "-",
                envelope_source_uuid or "-",
                envelope_source_number or "-",
                envelope_ts or 0,
            )
            return
        envelope = payload.get("envelope") or {}
        if not isinstance(envelope, dict):
            return
        group_candidates = _extract_signal_group_identifiers(payload)
        preferred_group_id = ""
        for candidate in group_candidates:
            cleaned = str(candidate or "").strip()
            if cleaned.startswith("group."):
                preferred_group_id = cleaned
                break
        if not preferred_group_id and group_candidates:
            preferred_group_id = str(group_candidates[0] or "").strip()
        sync_sent_message = _get_nested(envelope, ("syncMessage", "sentMessage")) or {}
        if isinstance(sync_sent_message, dict) and sync_sent_message:
            raw_text = sync_sent_message.get("message")
            if isinstance(raw_text, dict):
                text = _extract_signal_text(
                    {
                        "envelope": {
                            "syncMessage": {"sentMessage": {"message": raw_text}}
                        }
                    },
                    str(
                        raw_text.get("message")
                        or raw_text.get("text")
                        or raw_text.get("body")
                        or ""
                    ).strip(),
                )
            else:
                text = _extract_signal_text(payload, str(raw_text or "").strip())

            destination_uuid = str(
                sync_sent_message.get("destinationUuid")
                or sync_sent_message.get("destination")
                or ""
            ).strip()
            destination_number = str(
                sync_sent_message.get("destinationNumber")
                or sync_sent_message.get("destinationE164")
                or sync_sent_message.get("destination")
                or ""
            ).strip()
            identifiers = await self._resolve_signal_identifiers(
                destination_uuid,
                destination_number,
                group_candidates,
            )
            if not identifiers:
                identifiers = await self._auto_link_single_user_signal_identifier(
                    destination_uuid,
                    destination_number,
                )
            reaction_payload = _extract_signal_reaction(envelope)
            edit_payload = _extract_signal_edit(envelope)
            delete_payload = _extract_signal_delete(envelope)
            if identifiers and isinstance(reaction_payload, dict):
                source_uuid = str(
                    envelope.get("sourceUuid") or envelope.get("source") or ""
                ).strip()
                source_number = str(envelope.get("sourceNumber") or "").strip()
                for identifier in identifiers:
                    try:
                        await history.apply_reaction(
                            identifier.user,
                            identifier,
                            target_message_id="",
                            target_ts=int(reaction_payload.get("target_ts") or 0),
                            emoji=str(reaction_payload.get("emoji") or ""),
                            source_service="signal",
                            actor=(source_uuid or source_number or ""),
                            target_author=str(
                                (reaction_payload.get("raw") or {}).get(
                                    "targetAuthorUuid"
                                )
                                or (reaction_payload.get("raw") or {}).get(
                                    "targetAuthor"
                                )
                                or (reaction_payload.get("raw") or {}).get(
                                    "targetAuthorNumber"
                                )
                                or ""
                            ),
                            remove=bool(reaction_payload.get("remove")),
                            payload=reaction_payload.get("raw") or {},
                        )
                    except Exception as exc:
                        self.log.warning(
                            "signal raw sync reaction history apply failed: %s", exc
                        )
                    try:
                        await self.ur.xmpp.client.apply_external_reaction(
                            identifier.user,
                            identifier,
                            source_service="signal",
                            emoji=str(reaction_payload.get("emoji") or ""),
                            remove=bool(reaction_payload.get("remove")),
                            upstream_message_id="",
                            upstream_ts=int(reaction_payload.get("target_ts") or 0),
                            actor=(source_uuid or source_number or ""),
                            payload=reaction_payload.get("raw") or {},
                        )
                    except Exception as exc:
                        self.log.warning(
                            "signal raw sync reaction relay to XMPP failed: %s", exc
                        )
            if identifiers and isinstance(edit_payload, dict):
                source_uuid = str(
                    envelope.get("sourceUuid") or envelope.get("source") or ""
                ).strip()
                source_number = str(envelope.get("sourceNumber") or "").strip()
                for identifier in identifiers:
                    try:
                        await history.apply_message_edit(
                            identifier.user,
                            identifier,
                            target_message_id="",
                            target_ts=int(edit_payload.get("target_ts") or 0),
                            new_text=str(edit_payload.get("new_text") or ""),
                            source_service="signal",
                            actor=(source_uuid or source_number or ""),
                            payload=edit_payload.get("raw") or {},
                        )
                    except Exception as exc:
                        self.log.warning(
                            "signal raw sync edit history apply failed: %s", exc
                        )
                transport.update_runtime_state(
                    self.service,
                    last_inbound_ok_ts=int(time.time() * 1000),
                    last_inbound_exception_type="",
                    last_inbound_exception_message="",
                )
                return
            if identifiers and isinstance(delete_payload, dict):
                source_uuid = str(
                    envelope.get("sourceUuid") or envelope.get("source") or ""
                ).strip()
                source_number = str(envelope.get("sourceNumber") or "").strip()
                for identifier in identifiers:
                    try:
                        await history.apply_message_delete(
                            identifier.user,
                            identifier,
                            target_message_id="",
                            target_ts=int(delete_payload.get("target_ts") or 0),
                            source_service="signal",
                            actor=(source_uuid or source_number or ""),
                            payload=delete_payload.get("raw") or {},
                        )
                    except Exception as exc:
                        self.log.warning(
                            "signal raw sync delete history apply failed: %s", exc
                        )
                transport.update_runtime_state(
                    self.service,
                    last_inbound_ok_ts=int(time.time() * 1000),
                    last_inbound_exception_type="",
                    last_inbound_exception_message="",
                )
                return
            if identifiers and text:
                ts_raw = (
                    sync_sent_message.get("timestamp")
                    or envelope.get("timestamp")
                    or envelope.get("serverReceivedTimestamp")
                    or int(time.time() * 1000)
                )
                try:
                    ts = int(ts_raw)
                except Exception:
                    ts = int(time.time() * 1000)
                source_message_id = str(
                    envelope.get("serverGuid")
                    or envelope.get("guid")
                    or envelope.get("timestamp")
                    or ts
                ).strip()
                sender_key = (
                    str(getattr(self.client, "bot_uuid", "") or "").strip()
                    or str(getattr(self.client, "phone_number", "") or "").strip()
                    or str(payload.get("account") or "").strip()
                    or "self"
                )
                source_chat_id = (
                    preferred_group_id
                    or destination_number
                    or destination_uuid
                    or sender_key
                )
                reply_ref = reply_sync.extract_reply_ref(self.service, payload)
                for identifier in identifiers:
                    session = await history.get_chat_session(
                        identifier.user, identifier
                    )
                    reply_target = await reply_sync.resolve_reply_target(
                        identifier.user,
                        session,
                        reply_ref,
                    )
                    exists = await sync_to_async(
                        lambda: Message.objects.filter(
                            user=identifier.user,
                            session=session,
                            source_service=self.service,
                            source_message_id=source_message_id,
                        ).exists()
                    )()
                    if exists:
                        continue
                    await history.store_message(
                        session=session,
                        sender=sender_key,
                        text=text,
                        ts=ts,
                        outgoing=True,
                        source_service=self.service,
                        source_message_id=source_message_id,
                        source_chat_id=source_chat_id,
                        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={},
                    )
                transport.update_runtime_state(
                    self.service,
                    last_inbound_ok_ts=int(time.time() * 1000),
                    last_inbound_exception_type="",
                    last_inbound_exception_message="",
                )
            return
        if envelope.get("typingMessage") or envelope.get("receiptMessage"):
            return
        data_message = envelope.get("dataMessage") or {}
        if not isinstance(data_message, dict):
            return

        source_uuid = str(
            envelope.get("sourceUuid") or envelope.get("source") or ""
        ).strip()
        source_number = str(envelope.get("sourceNumber") or "").strip()
        bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip()
        bot_phone = str(getattr(self.client, "phone_number", "") or "").strip()
        if source_uuid and bot_uuid and source_uuid == bot_uuid:
            return
        if (
            source_number
            and bot_phone
            and _digits_only(source_number) == _digits_only(bot_phone)
        ):
            return

        identifiers = await self._resolve_signal_identifiers(
            source_uuid,
            source_number,
            group_candidates,
        )
        reaction_payload = _extract_signal_reaction(envelope)
        edit_payload = _extract_signal_edit(envelope)
        delete_payload = _extract_signal_delete(envelope)
        if (not identifiers) and (
            isinstance(reaction_payload, dict)
            or isinstance(edit_payload, dict)
            or isinstance(delete_payload, dict)
        ):
            # Sync events from our own linked device can arrive with source=our
            # account and destination=<contact>. Resolve by destination as fallback.
            destination_uuid = str(
                envelope.get("destinationServiceId")
                or envelope.get("destinationUuid")
                or ""
            ).strip()
            destination_number = str(envelope.get("destinationNumber") or "").strip()
            if destination_uuid or destination_number:
                identifiers = await self._resolve_signal_identifiers(
                    destination_uuid,
                    destination_number,
                    group_candidates,
                )
        if not identifiers:
            identifiers = await self._auto_link_single_user_signal_identifier(
                source_uuid, source_number
            )
        if not identifiers:
            self.log.warning(
                "signal raw-receive unmatched source_uuid=%s source_number=%s text=%s",
                source_uuid,
                source_number,
                str(data_message.get("message") or "")[:160],
            )
            return

        if isinstance(reaction_payload, dict):
            for identifier in identifiers:
                try:
                    await history.apply_reaction(
                        identifier.user,
                        identifier,
                        target_message_id="",
                        target_ts=int(reaction_payload.get("target_ts") or 0),
                        emoji=str(reaction_payload.get("emoji") or ""),
                        source_service="signal",
                        actor=(source_uuid or source_number or ""),
                        target_author=str(
                            (reaction_payload.get("raw") or {}).get("targetAuthorUuid")
                            or (reaction_payload.get("raw") or {}).get("targetAuthor")
                            or (reaction_payload.get("raw") or {}).get(
                                "targetAuthorNumber"
                            )
                            or ""
                        ),
                        remove=bool(reaction_payload.get("remove")),
                        payload=reaction_payload.get("raw") or {},
                    )
                except Exception as exc:
                    self.log.warning(
                        "signal raw reaction history apply failed: %s", exc
                    )
                try:
                    await self.ur.xmpp.client.apply_external_reaction(
                        identifier.user,
                        identifier,
                        source_service="signal",
                        emoji=str(reaction_payload.get("emoji") or ""),
                        remove=bool(reaction_payload.get("remove")),
                        upstream_message_id="",
                        upstream_ts=int(reaction_payload.get("target_ts") or 0),
                        actor=(source_uuid or source_number or ""),
                        payload=reaction_payload.get("raw") or {},
                    )
                except Exception as exc:
                    self.log.warning(
                        "signal raw reaction relay to XMPP failed: %s", exc
                    )
            transport.update_runtime_state(
                self.service,
                last_inbound_ok_ts=int(time.time() * 1000),
                last_inbound_exception_type="",
                last_inbound_exception_message="",
            )
            return
        if isinstance(edit_payload, dict):
            for identifier in identifiers:
                try:
                    await history.apply_message_edit(
                        identifier.user,
                        identifier,
                        target_message_id="",
                        target_ts=int(edit_payload.get("target_ts") or 0),
                        new_text=str(edit_payload.get("new_text") or ""),
                        source_service="signal",
                        actor=(source_uuid or source_number or ""),
                        payload=edit_payload.get("raw") or {},
                    )
                except Exception as exc:
                    self.log.warning("signal raw edit history apply failed: %s", exc)
            transport.update_runtime_state(
                self.service,
                last_inbound_ok_ts=int(time.time() * 1000),
                last_inbound_exception_type="",
                last_inbound_exception_message="",
            )
            return
        if isinstance(delete_payload, dict):
            for identifier in identifiers:
                try:
                    await history.apply_message_delete(
                        identifier.user,
                        identifier,
                        target_message_id="",
                        target_ts=int(delete_payload.get("target_ts") or 0),
                        source_service="signal",
                        actor=(source_uuid or source_number or ""),
                        payload=delete_payload.get("raw") or {},
                    )
                except Exception as exc:
                    self.log.warning("signal raw delete history apply failed: %s", exc)
            transport.update_runtime_state(
                self.service,
                last_inbound_ok_ts=int(time.time() * 1000),
                last_inbound_exception_type="",
                last_inbound_exception_message="",
            )
            return

        text = _extract_signal_text(
            payload, str(data_message.get("message") or "").strip()
        )
        relay_text = text
        xmpp_attachments, compose_media_urls = await self._build_xmpp_relay_attachments(
            payload
        )
        if xmpp_attachments and _is_compose_blob_only_text(relay_text):
            relay_text = ""
        if not relay_text and not xmpp_attachments:
            return

        ts_raw = (
            envelope.get("timestamp")
            or envelope.get("serverReceivedTimestamp")
            or int(time.time() * 1000)
        )
        try:
            ts = int(ts_raw)
        except Exception:
            ts = int(time.time() * 1000)
        source_message_id = str(
            envelope.get("serverGuid")
            or envelope.get("guid")
            or envelope.get("timestamp")
            or ts
        ).strip()
        sender_key = (
            source_uuid
            or source_number
            or (identifiers[0].identifier if identifiers else "")
        )
        source_chat_id = preferred_group_id or source_number or source_uuid or sender_key
        reply_ref = reply_sync.extract_reply_ref(self.service, payload)
        resolved_text_by_session = await self._relay_signal_inbound_to_xmpp(
            identifiers=identifiers,
            relay_text=relay_text,
            xmpp_attachments=xmpp_attachments,
            compose_media_urls=compose_media_urls,
            source_uuid=source_uuid,
            source_number=source_number,
            ts=ts,
        )

        for identifier in identifiers:
            session = await history.get_chat_session(identifier.user, identifier)
            reply_target = await reply_sync.resolve_reply_target(
                identifier.user,
                session,
                reply_ref,
            )
            exists = await sync_to_async(
                lambda: Message.objects.filter(
                    user=identifier.user,
                    session=session,
                    source_service=self.service,
                    source_message_id=source_message_id,
                ).exists()
            )()
            if exists:
                continue
            message_text = resolved_text_by_session.get(
                (identifier.user.id, identifier.person.id),
                relay_text if relay_text else "\n".join(compose_media_urls),
            )
            local_message = await history.store_message(
                session=session,
                sender=sender_key,
                text=message_text,
                ts=ts,
                outgoing=False,
                source_service=self.service,
                source_message_id=source_message_id,
                source_chat_id=source_chat_id,
                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={},
            )
            await self.ur.message_received(
                self.service,
                identifier=identifier,
                text=message_text,
                ts=ts,
                payload=payload,
                local_message=local_message,
            )
        transport.update_runtime_state(
            self.service,
            last_inbound_ok_ts=int(time.time() * 1000),
            last_inbound_exception_type="",
            last_inbound_exception_message="",
        )

    async def _raw_receive_loop(self):
        signal_number = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
        if not signal_number:
            return
        uri = f"ws://{SIGNAL_URL}/v1/receive/{signal_number}"
        poll_uri = f"http://{SIGNAL_URL}/v1/receive/{signal_number}"
        use_http_polling = False
        while not self._stopping:
            try:
                transport.update_runtime_state(self.service, accounts=[signal_number])
                if use_http_polling:
                    timeout = aiohttp.ClientTimeout(total=15)
                    async with aiohttp.ClientSession(timeout=timeout) as session:
                        async with session.get(poll_uri) as response:
                            response.raise_for_status()
                            payload = await response.json(content_type=None)
                    if isinstance(payload, dict):
                        payload = [payload]
                    if not isinstance(payload, list):
                        payload = []
                    for item in payload:
                        if not isinstance(item, dict):
                            continue
                        await self._process_raw_inbound_event(json.dumps(item))
                    continue
                async with websockets.connect(uri, ping_interval=None) as websocket:
                    async for raw_message in websocket:
                        await self._process_raw_inbound_event(raw_message)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                if (
                    not use_http_polling
                    and "server rejected WebSocket connection: HTTP 200" in str(exc)
                ):
                    self.log.info(
                        "signal raw-receive switching to HTTP polling for %s",
                        signal_number,
                    )
                    use_http_polling = True
                    continue
                self.log.warning("signal raw-receive loop error: %s", exc)
                await asyncio.sleep(2)
            transport.update_runtime_state(self.service, accounts=[])

    def start(self):
        self.log.info("Signal client starting...")
        self.client._event_loop = self.loop
        # Start background command processing loop
        if not self._command_task or self._command_task.done():
            self._command_task = self.loop.create_task(self._command_loop())
        if not self._raw_receive_task or self._raw_receive_task.done():
            self._raw_receive_task = self.loop.create_task(self._raw_receive_loop())
        if not self._catalog_refresh_task or self._catalog_refresh_task.done():
            self._catalog_refresh_task = self.loop.create_task(
                self._refresh_runtime_catalog_safe()
            )
        # Keep signalbot's internal receive consumer disabled to avoid competing
        # consumers. The raw loop adapts between websocket and HTTP polling
        # depending on the deployed signal-cli-rest-api behavior.
