import asyncio
import base64
import io
import os
import secrets
import shutil
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus

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

from core.clients import signalapi
from core.messaging import media_bridge
from core.security.attachments import (
    validate_attachment_metadata,
    validate_attachment_url,
)
from core.transports.capabilities import supports, unsupported_reason
from core.util import logs

log = logs.get_logger("transport")

_RUNTIME_STATE_TTL = 60 * 60 * 24
_RUNTIME_COMMANDS_TTL = 60 * 15
_RUNTIME_COMMAND_RESULT_TTL = 60
_BRIDGE_MAP_TTL = 60 * 60 * 24 * 14
_RUNTIME_CLIENTS: dict[str, Any] = {}


def _service_key(service: str) -> str:
    return str(service or "").strip().lower()


def _capability_checks_enabled() -> bool:
    return bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True))


def _runtime_key(service: str) -> str:
    return f"gia:service:runtime:{_service_key(service)}"


def _runtime_commands_key(service: str) -> str:
    return f"gia:service:commands:{_service_key(service)}"


def _runtime_command_result_key(service: str, command_id: str) -> str:
    return f"gia:service:command-result:{_service_key(service)}:{command_id}"


def _runtime_command_cancel_key(service: str, command_id: str) -> str:
    return f"gia:service:command-cancel:{_service_key(service)}:{command_id}"


def _runtime_command_meta_key(service: str, command_id: str) -> str:
    return f"gia:service:command-meta:{_service_key(service)}:{command_id}"


def _bridge_map_key(user_id: int, person_id: int, service: str) -> str:
    return f"gia:bridge:map:{int(user_id)}:{int(person_id)}:{_service_key(service)}"


def _gateway_base(service: str) -> str:
    key = f"{service.upper()}_HTTP_URL"
    default = f"http://{service}:8080"
    return str(getattr(settings, key, default)).rstrip("/")


def _as_qr_png(data: str) -> bytes:
    image = qrcode.make(data)
    stream = io.BytesIO()
    image.save(stream, format="PNG")
    return stream.getvalue()


def _parse_timestamp(data: Any):
    if isinstance(data, dict):
        ts = data.get("timestamp")
        if ts:
            return ts
    return None


def _attachment_has_inline_content(attachment: dict | None) -> bool:
    value = (attachment or {}).get("content")
    return isinstance(value, (bytes, bytearray, memoryview))


def _normalize_inline_content(attachment: dict) -> dict:
    row = dict(attachment or {})
    content = row.get("content")
    if isinstance(content, memoryview):
        row["content"] = content.tobytes()
    elif isinstance(content, bytearray):
        row["content"] = bytes(content)
    if isinstance(row.get("content"), bytes) and not row.get("size"):
        row["size"] = len(row["content"])
    return row


async def prepare_outbound_attachments(service: str, attachments: list | None) -> list:
    """
    Resolve outbound attachment refs into payloads once, in parallel.

    This is the shared media-prep layer for XMPP -> {Signal, WhatsApp} sends,
    so attachment performance improvements live in one place.

    TODO: Stream per-attachment send as each payload resolves (as_completed)
    to reduce first-byte latency for large media batches.
    """
    rows = [dict(att or {}) for att in (attachments or [])]
    if not rows:
        return []

    async def _resolve(row: dict):
        if _attachment_has_inline_content(row):
            return _normalize_inline_content(row)
        fetched = await fetch_attachment(service, row)
        if not fetched:
            return row
        merged = dict(row)
        merged.update(dict(fetched or {}))
        return _normalize_inline_content(merged)

    return await asyncio.gather(*[_resolve(row) for row in rows])


def register_runtime_client(service: str, client: Any):
    """
    Register an in-process runtime client (UR process).
    """
    _RUNTIME_CLIENTS[_service_key(service)] = client


def get_runtime_client(service: str):
    return _RUNTIME_CLIENTS.get(_service_key(service))


def get_runtime_state(service: str) -> dict[str, Any]:
    return dict(cache.get(_runtime_key(service)) or {})


def update_runtime_state(service: str, **updates):
    """
    Persist runtime state to shared cache so web/UI process can read it.
    """
    key = _runtime_key(service)
    state = dict(cache.get(key) or {})
    state.update(updates)
    state["updated_at"] = int(time.time())
    cache.set(key, state, timeout=_RUNTIME_STATE_TTL)
    return state


def record_bridge_mapping(
    *,
    user_id: int,
    person_id: int,
    service: str,
    xmpp_message_id: str = "",
    xmpp_ts: int | None = None,
    upstream_message_id: str = "",
    upstream_author: str = "",
    upstream_ts: int | None = None,
    text_preview: str = "",
    local_message_id: str = "",
):
    key = _bridge_map_key(user_id, person_id, service)
    rows = list(cache.get(key) or [])
    now_ts = int(time.time() * 1000)
    entry = {
        "xmpp_message_id": str(xmpp_message_id or "").strip(),
        "xmpp_ts": int(xmpp_ts or 0),
        "upstream_message_id": str(upstream_message_id or "").strip(),
        "upstream_author": str(upstream_author or "").strip(),
        "upstream_ts": int(upstream_ts or 0),
        "text_preview": str(text_preview or "").strip()[:1000],
        "local_message_id": str(local_message_id or "").strip(),
        "updated_at": now_ts,
    }
    if not entry["xmpp_message_id"] and not entry["upstream_message_id"]:
        if entry["upstream_ts"] <= 0 and entry["xmpp_ts"] <= 0:
            return None

    deduped = []
    for row in rows:
        same_xmpp = bool(entry["xmpp_message_id"]) and (
            str((row or {}).get("xmpp_message_id") or "").strip()
            == entry["xmpp_message_id"]
        )
        same_upstream = bool(entry["upstream_message_id"]) and (
            str((row or {}).get("upstream_message_id") or "").strip()
            == entry["upstream_message_id"]
        )
        if same_xmpp or same_upstream:
            continue
        deduped.append(dict(row or {}))

    deduped.append(entry)
    if len(deduped) > 2000:
        deduped = deduped[-2000:]
    cache.set(key, deduped, timeout=_BRIDGE_MAP_TTL)
    log.debug(
        "reaction-bridge map-write service=%s user=%s person=%s xmpp_id=%s upstream_id=%s upstream_ts=%s local_id=%s rows=%s",
        service,
        user_id,
        person_id,
        entry.get("xmpp_message_id") or "-",
        entry.get("upstream_message_id") or "-",
        entry.get("upstream_ts") or 0,
        entry.get("local_message_id") or "-",
        len(deduped),
    )
    return entry


def resolve_bridge_from_xmpp(
    *, user_id: int, person_id: int, service: str, xmpp_message_id: str
):
    target_id = str(xmpp_message_id or "").strip()
    if not target_id:
        return None
    key = _bridge_map_key(user_id, person_id, service)
    rows = list(cache.get(key) or [])
    for row in reversed(rows):
        if str((row or {}).get("xmpp_message_id") or "").strip() == target_id:
            log.debug(
                "reaction-bridge resolve-xmpp-hit service=%s user=%s person=%s xmpp_id=%s upstream_id=%s",
                service,
                user_id,
                person_id,
                target_id,
                str((row or {}).get("upstream_message_id") or "-").strip(),
            )
            return dict(row or {})
    log.debug(
        "reaction-bridge resolve-xmpp-miss service=%s user=%s person=%s xmpp_id=%s rows=%s",
        service,
        user_id,
        person_id,
        target_id,
        len(rows),
    )
    return None


def resolve_bridge_from_upstream(
    *,
    user_id: int,
    person_id: int,
    service: str,
    upstream_message_id: str = "",
    upstream_ts: int | None = None,
):
    key = _bridge_map_key(user_id, person_id, service)
    rows = list(cache.get(key) or [])
    target_id = str(upstream_message_id or "").strip()
    if target_id:
        for row in reversed(rows):
            if str((row or {}).get("upstream_message_id") or "").strip() == target_id:
                log.debug(
                    "reaction-bridge resolve-upstream-id-hit service=%s user=%s person=%s upstream_id=%s xmpp_id=%s",
                    service,
                    user_id,
                    person_id,
                    target_id,
                    str((row or {}).get("xmpp_message_id") or "-").strip(),
                )
                return dict(row or {})
    target_ts = int(upstream_ts or 0)
    if target_ts > 0:
        best = None
        best_gap = None
        for row in rows:
            row_ts = int((row or {}).get("upstream_ts") or 0)
            if row_ts <= 0:
                continue
            gap = abs(row_ts - target_ts)
            row_updated = int((row or {}).get("updated_at") or 0)
            best_updated = int((best or {}).get("updated_at") or 0) if best else 0
            if (
                best is None
                or gap < best_gap
                or (gap == best_gap and row_updated > best_updated)
            ):
                best = dict(row or {})
                best_gap = gap
        if best is not None and best_gap is not None and best_gap <= 15_000:
            log.debug(
                "reaction-bridge resolve-upstream-ts-hit service=%s user=%s person=%s target_ts=%s gap_ms=%s picked_xmpp_id=%s picked_upstream_ts=%s",
                service,
                user_id,
                person_id,
                target_ts,
                best_gap,
                str((best or {}).get("xmpp_message_id") or "-").strip(),
                int((best or {}).get("upstream_ts") or 0),
            )
            return best
    log.debug(
        "reaction-bridge resolve-upstream-miss service=%s user=%s person=%s upstream_id=%s upstream_ts=%s rows=%s",
        service,
        user_id,
        person_id,
        target_id or "-",
        target_ts,
        len(rows),
    )
    return None


def resolve_bridge_from_text_hint(
    *, user_id: int, person_id: int, service: str, text_hint: str
):
    hint = str(text_hint or "").strip().lower()
    if not hint:
        return None
    key = _bridge_map_key(user_id, person_id, service)
    rows = list(cache.get(key) or [])
    for row in reversed(rows):
        preview = str((row or {}).get("text_preview") or "").strip().lower()
        if preview and (preview == hint or hint in preview):
            return dict(row or {})
    return None


def enqueue_runtime_command(
    service: str, action: str, payload: dict | None = None
) -> str:
    service_key = _service_key(service)
    command_id = secrets.token_hex(12)
    command = {
        "id": command_id,
        "action": str(action or "").strip(),
        "payload": dict(payload or {}),
        "created_at": int(time.time()),
    }
    key = _runtime_commands_key(service_key)
    queued = list(cache.get(key) or [])
    queued.append(command)
    # Keep queue bounded to avoid unbounded growth.
    if len(queued) > 200:
        queued = queued[-200:]
    cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
    cache.set(
        _runtime_command_meta_key(service_key, command_id),
        {
            "created_at": int(command.get("created_at") or int(time.time())),
            "action": str(command.get("action") or ""),
        },
        timeout=_RUNTIME_COMMANDS_TTL,
    )
    return command_id


def pop_runtime_command(service: str) -> dict[str, Any] | None:
    service_key = _service_key(service)
    key = _runtime_commands_key(service_key)
    queued = list(cache.get(key) or [])
    if not queued:
        return None
    command = dict(queued.pop(0) or {})
    cache.set(key, queued, timeout=_RUNTIME_COMMANDS_TTL)
    return command


def set_runtime_command_result(
    service: str, command_id: str, result: dict | None = None
):
    service_key = _service_key(service)
    result_key = _runtime_command_result_key(service_key, command_id)
    payload = dict(result or {})
    payload.setdefault("completed_at", int(time.time()))
    cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
    cache.delete(_runtime_command_meta_key(service_key, command_id))


def cancel_runtime_command(service: str, command_id: str):
    """Mark a runtime command as cancelled and set a result so waiters are released."""
    service_key = _service_key(service)
    result_key = _runtime_command_result_key(service_key, command_id)
    cancel_key = _runtime_command_cancel_key(service_key, command_id)
    payload = {"ok": False, "error": "cancelled", "completed_at": int(time.time())}
    cache.set(result_key, payload, timeout=_RUNTIME_COMMAND_RESULT_TTL)
    cache.set(cancel_key, True, timeout=60)
    cache.delete(_runtime_command_meta_key(service_key, command_id))
    return True


def runtime_command_age_seconds(service: str, command_id: str) -> float | None:
    service_key = _service_key(service)
    meta = cache.get(_runtime_command_meta_key(service_key, command_id))
    if not isinstance(meta, dict):
        return None
    try:
        created_at = int(meta.get("created_at") or 0)
    except Exception:
        created_at = 0
    if created_at <= 0:
        return None
    return max(0.0, time.time() - created_at)


def cancel_runtime_commands_for_recipient(service: str, recipient: str) -> list[str]:
    """Cancel any queued runtime commands for the given recipient and return their ids."""
    service_key = _service_key(service)
    key = _runtime_commands_key(service_key)
    queued = list(cache.get(key) or [])
    cancelled = []
    for cmd in list(queued):
        payload = dict(cmd.get("payload") or {})
        if str(payload.get("recipient") or "").strip() == str(recipient or "").strip():
            cmd_id = str(cmd.get("id") or "").strip()
            if cmd_id:
                cancel_runtime_command(service_key, cmd_id)
                cancelled.append(cmd_id)
    return cancelled


async def wait_runtime_command_result(
    service: str, command_id: str, timeout: float = 20.0
):
    service_key = _service_key(service)
    result_key = _runtime_command_result_key(service_key, command_id)
    deadline = time.monotonic() + max(0.1, float(timeout or 0.0))
    while time.monotonic() < deadline:
        payload = cache.get(result_key)
        if payload is not None:
            cache.delete(result_key)
            if isinstance(payload, dict):
                return dict(payload)
            return {}
        await asyncio.sleep(0.2)
    return None


def list_accounts(service: str):
    """
    Return account identifiers for service UI list.
    """
    service_key = _service_key(service)
    state = get_runtime_state(service_key)
    accounts = state.get("accounts") or []
    if service_key == "whatsapp" and not accounts:
        contacts = state.get("contacts") or []
        recovered = []
        seen = set()
        for row in contacts:
            if not isinstance(row, dict):
                continue
            candidate = str(row.get("identifier") or row.get("jid") or "").strip()
            if not candidate or candidate in seen:
                continue
            seen.add(candidate)
            recovered.append(candidate)
        if recovered:
            accounts = recovered
            update_runtime_state(service_key, accounts=recovered)
    if isinstance(accounts, list):
        return accounts
    return []


def _account_key(value: str) -> str:
    raw = str(value or "").strip().lower()
    if "@" in raw:
        raw = raw.split("@", 1)[0]
    return raw


def _wipe_signal_cli_local_state() -> bool:
    """
    Best-effort local signal-cli state reset for json-rpc deployments where
    REST account delete endpoints are unavailable.
    """
    config_roots = []
    base_dir = getattr(settings, "BASE_DIR", None)
    if base_dir:
        config_roots.append(str(Path(base_dir) / "signal-cli-config"))
    config_roots.extend(
        [
            "/code/signal-cli-config",
            "/signal-cli-config",
            "/home/.local/share/signal-cli",
        ]
    )
    removed_any = False
    seen_roots = set()
    for root in config_roots:
        root = str(root or "").strip()
        if not root or root in seen_roots:
            continue
        seen_roots.add(root)
        if not os.path.isdir(root):
            continue
        try:
            entries = os.listdir(root)
        except Exception:
            continue
        for entry in entries:
            if not entry:
                continue
            # Keep runtime configuration scaffold; wipe account/pairing state.
            if entry in {"jsonrpc2.yml", "jsonrpc.yml"}:
                continue
            path = os.path.join(root, entry)
            if os.path.isdir(path):
                try:
                    shutil.rmtree(path)
                    removed_any = True
                except Exception:
                    continue
            else:
                try:
                    os.remove(path)
                    removed_any = True
                except Exception:
                    continue
    return removed_any


def unlink_account(service: str, account: str) -> bool:
    service_key = _service_key(service)
    account_value = str(account or "").strip()
    if not account_value:
        return False

    if service_key == "signal":
        import requests

        base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
            "/"
        )
        target = quote_plus(account_value)
        unlinked = False
        for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
            try:
                response = requests.delete(f"{base}{path}", timeout=20)
                if response.ok:
                    unlinked = True
                    break
            except Exception:
                continue
        if unlinked:
            return True
        wiped = _wipe_signal_cli_local_state()
        if not wiped:
            return False
        # Best-effort verification: if the REST API still reports the same account,
        # the runtime likely still holds active linked state and the UI should not
        # claim relink is ready yet.
        remaining_accounts = list_accounts("signal")
        for row in remaining_accounts:
            if isinstance(row, dict):
                candidate = (
                    row.get("number")
                    or row.get("id")
                    or row.get("jid")
                    or row.get("account")
                )
            else:
                candidate = row
            if _account_key(str(candidate or "")) == _account_key(account_value):
                return False
        return True

    if service_key in {"whatsapp", "instagram"}:
        state = get_runtime_state(service_key)
        key = _account_key(account_value)

        raw_accounts = state.get("accounts") or []
        accounts = []
        for row in raw_accounts:
            value = str(row or "").strip()
            if not value:
                continue
            if _account_key(value) == key:
                continue
            accounts.append(value)

        raw_contacts = state.get("contacts") or []
        contacts = []
        for row in raw_contacts:
            if not isinstance(row, dict):
                continue
            identifier = str(row.get("identifier") or "").strip()
            jid = str(row.get("jid") or "").strip()
            if _account_key(identifier) == key or _account_key(jid) == key:
                continue
            contacts.append(row)

        update_runtime_state(
            service_key,
            accounts=accounts,
            contacts=contacts,
            connected=bool(accounts),
            pair_status=("connected" if accounts else ""),
            pair_qr="",
            warning=(
                "" if accounts else "Account unlinked. Add account to link again."
            ),
            last_event="account_unlinked",
            last_error="",
        )
        return True

    return False


def get_service_warning(service: str) -> str:
    service_key = _service_key(service)
    if service_key == "signal":
        return ""

    state = get_runtime_state(service_key)
    warning = str(state.get("warning") or "").strip()
    if warning:
        return warning

    if not state.get("connected"):
        return (
            f"{service_key.title()} runtime is not connected yet. "
            "Start UR with the service enabled, open Services -> "
            f"{service_key.title()} -> Add Account, then scan the QR from "
            "WhatsApp Linked Devices."
        )
    return ""


def request_pairing(service: str, device_name: str = ""):
    """
    Mark a runtime pairing request so UR clients can refresh QR/pair state.
    """
    service_key = _service_key(service)
    if service_key not in {"whatsapp", "instagram"}:
        return
    state = get_runtime_state(service_key)
    existing_accounts = state.get("accounts") or []
    is_connected = bool(state.get("connected"))
    pair_status = str(state.get("pair_status") or "").strip().lower()
    if existing_accounts and (is_connected or pair_status == "connected"):
        update_runtime_state(
            service_key,
            warning="Account already linked.",
            pair_status="connected",
            pair_qr="",
        )
        return
    device = str(device_name or "GIA Device").strip() or "GIA Device"
    update_runtime_state(
        service_key,
        pair_device=device,
        pair_requested_at=int(time.time()),
        pair_status="pending",
        pair_qr="",
        pair_request_source="web",
    )


async def _gateway_json(method: str, url: str, payload=None):
    timeout = aiohttp.ClientTimeout(total=20)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        request = getattr(session, method.lower())
        async with request(url, json=payload) as response:
            body = await response.read()
            if not body:
                return response.status, None
            try:
                return response.status, orjson.loads(body)
            except Exception:
                return response.status, None


async def _normalize_gateway_attachment(service: str, row: dict, session):
    normalized = dict(row or {})
    content = normalized.get("content")
    if isinstance(content, memoryview):
        content = content.tobytes()
    if isinstance(content, bytes):
        filename, content_type = validate_attachment_metadata(
            filename=normalized.get("filename") or "attachment.bin",
            content_type=normalized.get("content_type") or "application/octet-stream",
            size=normalized.get("size") or len(content),
        )
        blob_key = media_bridge.put_blob(
            service=service,
            content=content,
            filename=filename,
            content_type=content_type,
        )
        return {
            "blob_key": blob_key,
            "filename": filename,
            "content_type": content_type,
            "size": normalized.get("size") or len(content),
        }

    if normalized.get("blob_key"):
        return normalized

    source_url = normalized.get("url")
    if source_url:
        try:
            safe_url = validate_attachment_url(source_url)
            async with session.get(safe_url) as response:
                if response.status == 200:
                    payload = await response.read()
                    filename, content_type = validate_attachment_metadata(
                        filename=normalized.get("filename")
                        or safe_url.rstrip("/").split("/")[-1]
                        or "attachment.bin",
                        content_type=normalized.get("content_type")
                        or response.headers.get(
                            "Content-Type", "application/octet-stream"
                        ),
                        size=normalized.get("size") or len(payload),
                    )
                    blob_key = media_bridge.put_blob(
                        service=service,
                        content=payload,
                        filename=filename,
                        content_type=content_type,
                    )
                    return {
                        "blob_key": blob_key,
                        "filename": filename,
                        "content_type": content_type,
                        "size": normalized.get("size") or len(payload),
                    }
        except Exception as exc:
            log.warning(
                "%s attachment fetch failed for %s: %s",
                service,
                source_url,
                exc,
            )
    return normalized


async def _gateway_send(service: str, recipient: str, text=None, attachments=None):
    base = _gateway_base(service)
    url = f"{base}/v1/send"
    timeout = aiohttp.ClientTimeout(total=20)
    async with aiohttp.ClientSession(timeout=timeout) as media_session:
        normalized_attachments = await asyncio.gather(
            *[
                _normalize_gateway_attachment(service, dict(att or {}), media_session)
                for att in (attachments or [])
            ]
        )

    data = {
        "recipient": recipient,
        "text": text or "",
        "attachments": normalized_attachments,
    }
    status, payload = await _gateway_json("post", url, data)
    if 200 <= status < 300:
        ts = _parse_timestamp(payload)
        return ts if ts else True
    log.warning("%s gateway send failed (%s): %s", service, status, payload)
    return False


async def _gateway_typing(service: str, recipient: str, started: bool):
    base = _gateway_base(service)
    action = "start" if started else "stop"
    url = f"{base}/v1/typing/{action}"
    payload = {"recipient": recipient}
    status, _ = await _gateway_json("post", url, payload)
    if 200 <= status < 300:
        return True
    return False


async def send_message_raw(
    service: str,
    recipient: str,
    text=None,
    attachments=None,
    metadata: dict | None = None,
):
    """
    Unified outbound send path used by models/views/UR.
    """
    service_key = _service_key(service)
    if _capability_checks_enabled() and not supports(service_key, "send"):
        reason = unsupported_reason(service_key, "send")
        log.warning(
            "capability-check failed service=%s feature=send: %s",
            service_key,
            reason,
        )
        return False
    if service_key == "signal":
        prepared_attachments = await prepare_outbound_attachments(
            service_key, attachments or []
        )
        meta = dict(metadata or {})
        result = await signalapi.send_message_raw(
            recipient,
            text,
            prepared_attachments,
            metadata=meta,
        )
        xmpp_source_id = str(meta.get("xmpp_source_id") or "").strip()
        if xmpp_source_id and result:
            from core.models import PersonIdentifier

            identifier_row = await sync_to_async(
                lambda: PersonIdentifier.objects.filter(
                    service="signal",
                    identifier=recipient,
                )
                .select_related("user", "person")
                .first()
            )()
            if identifier_row is not None:
                record_bridge_mapping(
                    user_id=identifier_row.user_id,
                    person_id=identifier_row.person_id,
                    service="signal",
                    xmpp_message_id=xmpp_source_id,
                    xmpp_ts=int(meta.get("xmpp_source_ts") or 0),
                    upstream_message_id="",
                    upstream_author=str(meta.get("upstream_author") or ""),
                    upstream_ts=int(result) if isinstance(result, int) else 0,
                    text_preview=str(meta.get("xmpp_body") or text or ""),
                    local_message_id=str(meta.get("legacy_message_id") or ""),
                )
                from core.messaging import history

                await history.save_bridge_ref(
                    user=identifier_row.user,
                    identifier=identifier_row,
                    source_service="signal",
                    local_message_id=str(meta.get("legacy_message_id") or ""),
                    local_ts=int(meta.get("xmpp_source_ts") or 0),
                    xmpp_message_id=xmpp_source_id,
                    upstream_message_id="",
                    upstream_author=str(meta.get("upstream_author") or ""),
                    upstream_ts=int(result) if isinstance(result, int) else 0,
                )
        return result

    if service_key == "whatsapp":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "send_message_raw"):
            try:
                runtime_result = await runtime_client.send_message_raw(
                    recipient,
                    text=text,
                    attachments=attachments or [],
                    metadata=dict(metadata or {}),
                )
                if runtime_result is not False and runtime_result is not None:
                    return runtime_result
            except Exception as exc:
                log.warning("%s runtime send failed: %s", service_key, exc)
        # Web/UI process cannot access UR in-process runtime client directly.
        # Hand off send to UR via shared cache command queue.
        command_attachments = []
        for att in (attachments or []):
            row = dict(att or {})
            # Keep payload cache-friendly and avoid embedding raw bytes.
            for key in ("content",):
                row.pop(key, None)
            command_attachments.append(row)
        command_id = enqueue_runtime_command(
            service_key,
            "send_message_raw",
            {
                "recipient": recipient,
                "text": text or "",
                "attachments": command_attachments,
                "metadata": dict(metadata or {}),
            },
        )
        command_result = await wait_runtime_command_result(
            service_key,
            command_id,
            timeout=20.0,
        )
        if isinstance(command_result, dict):
            if command_result.get("ok"):
                ts = _parse_timestamp(command_result)
                return ts if ts else True
            err = str(command_result.get("error") or "").strip()
            log.warning("whatsapp queued send failed: %s", err or "unknown")
            return False
        log.warning("whatsapp queued send timed out waiting for runtime result")
        return False

    if service_key == "instagram":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "send_message_raw"):
            try:
                runtime_result = await runtime_client.send_message_raw(
                    recipient,
                    text=text,
                    attachments=attachments or [],
                    metadata=dict(metadata or {}),
                )
                if runtime_result is not False and runtime_result is not None:
                    return runtime_result
            except Exception as exc:
                log.warning("%s runtime send failed: %s", service_key, exc)
        return await _gateway_send(
            service_key,
            recipient,
            text=text,
            attachments=attachments or [],
        )

    if service_key == "xmpp":
        raise NotImplementedError("Direct XMPP send is handled by the XMPP client.")
    raise NotImplementedError(f"Unsupported service: {service}")


async def send_reaction(
    service: str,
    recipient: str,
    *,
    emoji: str,
    target_message_id: str = "",
    target_timestamp: int | None = None,
    target_author: str = "",
    remove: bool = False,
):
    service_key = _service_key(service)
    if _capability_checks_enabled() and not supports(service_key, "reactions"):
        reason = unsupported_reason(service_key, "reactions")
        log.warning(
            "capability-check failed service=%s feature=reactions: %s",
            service_key,
            reason,
        )
        return False
    if not str(emoji or "").strip() and not remove:
        return False

    if service_key == "signal":
        log.debug(
            "reaction-bridge send service=signal recipient=%s target_ts=%s target_author=%s remove=%s",
            recipient,
            int(target_timestamp or 0),
            str(target_author or recipient),
            bool(remove),
        )
        return await signalapi.send_reaction(
            recipient_uuid=recipient,
            emoji=str(emoji or ""),
            target_timestamp=target_timestamp,
            target_author=str(target_author or recipient),
            remove=remove,
        )

    if service_key == "whatsapp":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "send_reaction"):
            try:
                log.debug(
                    "reaction-bridge send service=whatsapp runtime recipient=%s target_id=%s target_ts=%s remove=%s",
                    recipient,
                    str(target_message_id or "") or "-",
                    int(target_timestamp or 0),
                    bool(remove),
                )
                result = await runtime_client.send_reaction(
                    recipient,
                    emoji=str(emoji or ""),
                    target_message_id=str(target_message_id or ""),
                    target_timestamp=(int(target_timestamp) if target_timestamp else 0),
                    remove=bool(remove),
                )
                if result:
                    return True
            except Exception as exc:
                log.warning("%s runtime reaction failed: %s", service_key, exc)

        command_id = enqueue_runtime_command(
            service_key,
            "send_reaction",
            {
                "recipient": recipient,
                "emoji": str(emoji or ""),
                "target_message_id": str(target_message_id or ""),
                "target_timestamp": int(target_timestamp or 0),
                "remove": bool(remove),
            },
        )
        command_result = await wait_runtime_command_result(
            service_key,
            command_id,
            timeout=20.0,
        )
        log.debug(
            "reaction-bridge send service=whatsapp queued-result ok=%s command_id=%s",
            bool(isinstance(command_result, dict) and command_result.get("ok")),
            command_id,
        )
        return bool(isinstance(command_result, dict) and command_result.get("ok"))

    return False


async def start_typing(service: str, recipient: str):
    service_key = _service_key(service)
    if _capability_checks_enabled() and not supports(service_key, "typing"):
        log.warning(
            "capability-check failed service=%s feature=typing: %s",
            service_key,
            unsupported_reason(service_key, "typing"),
        )
        return False
    if service_key == "signal":
        await signalapi.start_typing(recipient)
        return True

    if service_key == "whatsapp":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "start_typing"):
            try:
                result = await runtime_client.start_typing(recipient)
                if result:
                    return True
            except Exception as exc:
                log.warning("%s runtime start_typing failed: %s", service_key, exc)
        return False

    if service_key == "instagram":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "start_typing"):
            try:
                result = await runtime_client.start_typing(recipient)
                if result:
                    return True
            except Exception as exc:
                log.warning("%s runtime start_typing failed: %s", service_key, exc)
        return await _gateway_typing(service_key, recipient, started=True)
    return False


async def stop_typing(service: str, recipient: str):
    service_key = _service_key(service)
    if _capability_checks_enabled() and not supports(service_key, "typing"):
        log.warning(
            "capability-check failed service=%s feature=typing: %s",
            service_key,
            unsupported_reason(service_key, "typing"),
        )
        return False
    if service_key == "signal":
        await signalapi.stop_typing(recipient)
        return True

    if service_key == "whatsapp":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "stop_typing"):
            try:
                result = await runtime_client.stop_typing(recipient)
                if result:
                    return True
            except Exception as exc:
                log.warning("%s runtime stop_typing failed: %s", service_key, exc)
        return False

    if service_key == "instagram":
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "stop_typing"):
            try:
                result = await runtime_client.stop_typing(recipient)
                if result:
                    return True
            except Exception as exc:
                log.warning("%s runtime stop_typing failed: %s", service_key, exc)
        return await _gateway_typing(service_key, recipient, started=False)
    return False


async def fetch_attachment(service: str, attachment_ref: dict):
    """
    Fetch attachment bytes from a source service or URL.
    """
    service_key = _service_key(service)
    if service_key == "signal":
        attachment_id = attachment_ref.get("id") or attachment_ref.get("attachment_id")
        if attachment_id:
            return await signalapi.fetch_signal_attachment(attachment_id)

    runtime_client = get_runtime_client(service_key)
    if runtime_client and hasattr(runtime_client, "fetch_attachment"):
        try:
            from_runtime = await runtime_client.fetch_attachment(attachment_ref)
            if from_runtime:
                return from_runtime
        except Exception as exc:
            log.warning("%s runtime attachment fetch failed: %s", service_key, exc)

    direct_url = attachment_ref.get("url")
    blob_key = attachment_ref.get("blob_key")
    if blob_key:
        return media_bridge.get_blob(blob_key)
    if direct_url:
        safe_url = validate_attachment_url(direct_url)
        timeout = aiohttp.ClientTimeout(total=20)
        async with aiohttp.ClientSession(timeout=timeout) as session:
            async with session.get(safe_url) as response:
                if response.status != 200:
                    return None
                content = await response.read()
                filename, content_type = validate_attachment_metadata(
                    filename=attachment_ref.get("filename")
                    or safe_url.rstrip("/").split("/")[-1]
                    or "attachment.bin",
                    content_type=response.headers.get(
                        "Content-Type",
                        attachment_ref.get("content_type", "application/octet-stream"),
                    ),
                    size=len(content),
                )
                return {
                    "content": content,
                    "content_type": content_type,
                    "filename": filename,
                    "size": len(content),
                }
    return None


def _qr_from_runtime_state(service: str) -> bytes | None:
    state = get_runtime_state(service)
    qr_payload = str(state.get("pair_qr") or "").strip()
    if not qr_payload:
        return None
    if qr_payload.startswith("data:image/") and "," in qr_payload:
        _, b64_data = qr_payload.split(",", 1)
        try:
            return base64.b64decode(b64_data)
        except Exception:
            return None
    return _as_qr_png(qr_payload)


def get_link_qr(service: str, device_name: str):
    """
    Returns PNG bytes for account-linking QR.

    - Signal: uses signal-cli REST endpoint.
    - WhatsApp/Instagram: runtime QR from shared state when available.
      Falls back to local pairing token QR in development.
    """
    service_key = _service_key(service)
    device = (device_name or "GIA Device").strip()

    if service_key == "signal":
        import requests

        base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
            "/"
        )
        response = requests.get(
            f"{base}/v1/qrcodelink",
            params={"device_name": device},
            timeout=5,
        )
        response.raise_for_status()
        return response.content

    if service_key in {"whatsapp", "instagram"}:
        runtime_client = get_runtime_client(service_key)
        if runtime_client and hasattr(runtime_client, "get_link_qr_png"):
            try:
                image_bytes = runtime_client.get_link_qr_png(device)
                if image_bytes:
                    return image_bytes
            except Exception:
                pass

        cached = _qr_from_runtime_state(service_key)
        if cached:
            return cached

        if service_key == "whatsapp":
            state = get_runtime_state(service_key)
            existing_accounts = state.get("accounts") or []
            pair_status = str(state.get("pair_status") or "").strip().lower()
            if existing_accounts and (
                bool(state.get("connected")) or pair_status == "connected"
            ):
                raise RuntimeError(
                    "WhatsApp account already linked in this runtime. "
                    "Only one active linked device is supported. "
                    "Unlink the current account first, then add a new one."
                )
            raise RuntimeError(
                "Neonize has not provided a pairing QR yet. "
                "Ensure UR is running with WHATSAPP_ENABLED=true and retry."
            )

        token = secrets.token_urlsafe(24)
        uri = f"gia://{service_key}/link?device={device}&token={token}"
        update_runtime_state(
            service_key,
            pair_device=device,
            pair_requested_at=int(time.time()),
            warning=(
                "Waiting for runtime pairing QR. "
                "If this persists, check UR logs and Neonize session state."
            ),
        )
        return _as_qr_png(uri)

    raise NotImplementedError(f"Unsupported service for QR linking: {service}")


def image_bytes_to_base64(image_bytes: bytes) -> str:
    return base64.b64encode(image_bytes).decode("utf-8")
