from __future__ import annotations

import hashlib
import json
import re
import time
from datetime import datetime
from datetime import timezone as dt_timezone
from difflib import SequenceMatcher
from urllib.parse import urlencode, urlparse

from asgiref.sync import async_to_sync
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core import signing
from django.core.cache import cache
from django.db.models import Q
from django.http import (
    HttpResponse,
    HttpResponseBadRequest,
    HttpResponseNotFound,
    JsonResponse,
)
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone as dj_timezone
from django.views import View

from core.assist.engine import process_inbound_assist
from core.clients import transport
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.commands.policies import ensure_variant_policies_for_profile
from core.events.ledger import append_event_sync
from core.messaging import ai as ai_runner
from core.messaging import history, media_bridge
from core.messaging.utils import messages_to_string
from core.models import (
    AI,
    Chat,
    ChatSession,
    ChatTaskSource,
    CommandAction,
    CommandChannelBinding,
    CommandProfile,
    Message,
    MessageEvent,
    PatternMitigationPlan,
    Person,
    PersonIdentifier,
    PlatformChatLink,
    WorkspaceConversation,
)
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people, spans_for_range
from core.realtime.typing_state import get_person_typing_state
from core.translation.engine import process_inbound_translation
from core.transports.capabilities import supports, unsupported_reason
from core.views.workspace import (
    INSIGHT_METRICS,
    _build_engage_payload,
    _parse_draft_options,
)

COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE = 12
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
COMPOSE_COMMAND_SLUGS = ("bp",)
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)
IMAGE_EXTENSIONS = (
    ".png",
    ".jpg",
    ".jpeg",
    ".gif",
    ".webp",
    ".bmp",
    ".avif",
    ".svg",
)
EMPTY_TEXT_VALUES = {
    "",
    "[No Body]",
    "[no body]",
    "(no text)",
}


def _uniq_ordered(values):
    seen = set()
    output = []
    for value in values:
        cleaned = _clean_url(value)
        if not cleaned or cleaned in seen:
            continue
        seen.add(cleaned)
        output.append(cleaned)
    return output


def _default_service(service: str | None) -> str:
    value = str(service or "").strip().lower()
    if value in {"signal", "whatsapp", "instagram", "xmpp"}:
        return value
    return "signal"


def _identifier_variants(service: str, identifier: str) -> list[str]:
    value = str(identifier or "").strip()
    if not value:
        return []
    service_key = _default_service(service)
    variants = [value]

    bare = value.split("@", 1)[0].strip()
    if bare and bare not in variants:
        variants.append(bare)

    if service_key == "signal":
        digits = re.sub(r"[^0-9]", "", value)
        if digits and digits not in variants:
            variants.append(digits)
        if digits:
            plus = f"+{digits}"
            if plus not in variants:
                variants.append(plus)
    elif service_key == "whatsapp":
        if bare:
            direct = f"{bare}@s.whatsapp.net"
            group = f"{bare}@g.us"
            if direct not in variants:
                variants.append(direct)
            if group not in variants:
                variants.append(group)
    return variants


def _contact_lookup_key(service: str, identifier: str) -> str:
    """
    Normalize contact identifiers for compose UI dedupe and name lookups.
    """
    service_key = _default_service(service)
    value = str(identifier or "").strip()
    if not value:
        return ""
    if service_key == "whatsapp":
        lowered = value.lower()
        if lowered.endswith("@g.us"):
            return lowered
        bare = value.split("@", 1)[0].strip().split(":", 1)[0].strip().lower()
        return bare
    return value


def _contact_lookup_keys(service: str, identifier: str) -> list[str]:
    service_key = _default_service(service)
    value = str(identifier or "").strip()
    if not value:
        return []
    keys = []
    for candidate in [value, *(_identifier_variants(service_key, value) or [])]:
        normalized = _contact_lookup_key(service_key, candidate)
        if normalized and normalized not in keys:
            keys.append(normalized)
    return keys


def _group_channel_identifier(service: str, group_link: PlatformChatLink, bare_id: str) -> str:
    service_key = _default_service(service)
    if service_key == "whatsapp":
        return str(group_link.chat_jid or f"{bare_id}@g.us").strip()
    return bare_id


def _safe_limit(raw) -> int:
    try:
        value = int(raw or 40)
    except (TypeError, ValueError):
        value = 40
    return max(10, min(value, 200))


def _safe_after_ts(raw) -> int:
    try:
        value = int(raw or 0)
    except (TypeError, ValueError):
        value = 0
    return max(0, value)


def _safe_page(raw) -> int:
    try:
        value = int(raw or 1)
    except (TypeError, ValueError):
        value = 1
    return max(1, min(value, 100))


def _request_scope(request, source: str = "GET"):
    data = request.GET if str(source).upper() == "GET" else request.POST
    service = _default_service(data.get("service"))
    identifier = str(data.get("identifier") or "").strip()
    person = None
    person_id = data.get("person")
    if person_id:
        person = get_object_or_404(Person, id=person_id, user=request.user)
    return service, identifier, person


def _normalize_contact_search_query(raw) -> str:
    return " ".join(str(raw or "").strip().split())


def _filter_manual_contact_rows(rows, query: str):
    search = _normalize_contact_search_query(query).lower()
    if not search:
        return list(rows or [])
    tokens = [token for token in search.split(" ") if token]
    if not tokens:
        return list(rows or [])
    filtered = []
    for row in list(rows or []):
        haystack = " ".join(
            [
                str(row.get("person_name") or ""),
                str(row.get("linked_person_name") or ""),
                str(row.get("detected_name") or ""),
                str(row.get("service") or ""),
                str(row.get("identifier") or ""),
            ]
        ).lower()
        if all(token in haystack for token in tokens):
            filtered.append(row)
    return filtered


def _format_ts_label(ts_value: int) -> str:
    try:
        as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
        return dj_timezone.localtime(as_dt).strftime("%H:%M")
    except Exception:
        return str(ts_value or "")


def _format_ts_datetime_label(ts_value: int) -> str:
    try:
        as_dt = datetime.fromtimestamp(int(ts_value) / 1000, tz=dt_timezone.utc)
        return dj_timezone.localtime(as_dt).strftime("%Y-%m-%d %H:%M")
    except Exception:
        return str(ts_value or "")


def _history_preview_text(value: str, limit: int = 180) -> str:
    text = re.sub(r"\s+", " ", str(value or "")).strip()
    if not text or text in EMPTY_TEXT_VALUES:
        return "(no text)"
    if len(text) <= limit:
        return text
    return text[: limit - 3].rstrip() + "..."


def _safe_history_direction(raw: str | None) -> str:
    value = str(raw or "").strip().lower()
    if value in {"incoming", "outgoing"}:
        return value
    return ""


def _safe_history_days(raw: str | None) -> str:
    value = str(raw or "").strip().lower()
    if value in {"7", "30", "90", "365", "all"}:
        return value
    return "30"


def _serialize_availability_spans(spans):
    def _value(row, key, default=None):
        if isinstance(row, dict):
            return row.get(key, default)
        return getattr(row, key, default)

    rows = []
    for row in list(spans or []):
        rows.append(
            {
                "id": int(_value(row, "id", 0) or 0),
                "service": str(_value(row, "service", "") or ""),
                "state": str(_value(row, "state", "unknown") or "unknown"),
                "start_ts": int(_value(row, "start_ts", 0) or 0),
                "end_ts": int(_value(row, "end_ts", 0) or 0),
                "confidence_start": float(_value(row, "confidence_start", 0.0) or 0.0),
                "confidence_end": float(_value(row, "confidence_end", 0.0) or 0.0),
                "payload": dict(_value(row, "payload", {}) or {}),
            }
        )
    return rows


def _availability_summary_for_person(*, user, person: Person, service: str) -> dict:
    person_key = str(person.id)
    selected_service = str(service or "").strip().lower()
    state_map = latest_state_for_people(
        user=user,
        person_ids=[person_key],
        service=selected_service,
    )
    row = state_map.get(person_key)
    is_cross_service = False
    if row is None and selected_service:
        state_map = latest_state_for_people(
            user=user,
            person_ids=[person_key],
            service="",
        )
        row = state_map.get(person_key)
        is_cross_service = row is not None
    if row is None:
        return {}

    ts_value = int(row.get("ts") or 0)
    state_value = str(row.get("state") or "unknown").strip().lower() or "unknown"
    return {
        "state": state_value,
        "state_label": state_value.title(),
        "service": str(row.get("service") or selected_service or "").strip().lower(),
        "confidence": float(row.get("confidence") or 0.0),
        "source_kind": str(row.get("source_kind") or "").strip(),
        "ts": ts_value,
        "ts_label": _format_ts_label(ts_value) if ts_value > 0 else "",
        "is_cross_service": bool(is_cross_service),
    }


def _compose_availability_payload(
    *,
    user,
    person: Person | None,
    service: str,
    range_start: int,
    range_end: int,
) -> tuple[bool, list[dict], dict]:
    settings_row = get_availability_settings(user)
    if person is None or not settings_row.enabled or not settings_row.show_in_chat:
        return False, [], {}

    service_key = str(service or "").strip().lower()
    rows = _serialize_availability_spans(
        spans_for_range(
            user=user,
            person=person,
            start_ts=int(range_start or 0),
            end_ts=int(range_end or 0),
            service=service_key,
            limit=200,
        )
    )
    used_cross_service = False
    if not rows and service_key:
        rows = _serialize_availability_spans(
            spans_for_range(
                user=user,
                person=person,
                start_ts=int(range_start or 0),
                end_ts=int(range_end or 0),
                service="",
                limit=200,
            )
        )
        used_cross_service = bool(rows)

    summary = _availability_summary_for_person(
        user=user,
        person=person,
        service=service_key,
    )
    if used_cross_service and summary:
        summary["is_cross_service"] = True
    return True, rows, summary


def _is_outgoing(msg: Message) -> bool:
    is_outgoing = str(msg.custom_author or "").upper() in {"USER", "BOT"}
    if not is_outgoing:
        return False

    # Signal self-chat special case:
    # platform-originated Signal sync events can carry our own sender id and
    # are currently stored as USER; render them as counterpart-side so the
    # thread reads naturally when messaging ourselves.
    try:
        session = getattr(msg, "session", None)
        identifier_obj = getattr(session, "identifier", None)
        service = str(getattr(identifier_obj, "service", "") or "").strip().lower()
        if service != "signal":
            return True

        sender_uuid = str(getattr(msg, "sender_uuid", "") or "").strip()
        identifier_value = str(getattr(identifier_obj, "identifier", "") or "").strip()
        if not sender_uuid or not identifier_value:
            return True

        if sender_uuid.lower() == identifier_value.lower():
            return False
        sender_digits = re.sub(r"[^0-9]", "", sender_uuid)
        identifier_digits = re.sub(r"[^0-9]", "", identifier_value)
        if sender_digits and identifier_digits and sender_digits == identifier_digits:
            return False
    except Exception:
        return is_outgoing

    return True


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


def _extract_urls(text_value: str) -> list[str]:
    found = []
    for match in URL_PATTERN.findall(str(text_value or "")):
        cleaned = _clean_url(match)
        if cleaned and cleaned not in found:
            found.append(cleaned)
    return found


def _is_url_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(bool(URL_PATTERN.fullmatch(line)) for line in lines)


def _looks_like_image_url(url_value: str) -> bool:
    if not url_value:
        return False
    parsed = urlparse(url_value)
    path = str(parsed.path or "").lower()
    if path.endswith("/compose/media/blob/"):
        return True
    return path.endswith(IMAGE_EXTENSIONS)


def _is_xmpp_share_url(url_value: str) -> bool:
    if not url_value:
        return False
    parsed = urlparse(url_value)
    host = str(parsed.netloc or "").strip().lower()
    configured = (
        str(
            getattr(settings, "XMPP_UPLOAD_SERVICE", "")
            or getattr(settings, "XMPP_UPLOAD_JID", "")
        )
        .strip()
        .lower()
    )
    if not configured:
        return False
    configured_host = configured
    if "://" in configured:
        configured_host = (urlparse(configured).netloc or configured_host).lower()
    if "@" in configured_host:
        configured_host = configured_host.split("@", 1)[-1]
    configured_host = configured_host.split("/", 1)[0]
    return host == configured_host


def _image_url_from_text(text_value: str) -> str:
    urls = _image_urls_from_text(text_value)
    return urls[0] if urls else ""


def _image_urls_from_text(text_value: str) -> list[str]:
    urls = _uniq_ordered(_extract_urls(text_value))
    if not urls:
        return []

    confident = [url for url in urls if _looks_like_image_url(url)]
    if confident:
        return confident

    # Fallback: some XMPP upload URLs have no file extension.
    if _is_url_only_text(text_value):
        return urls

    return []


def _looks_like_image_name(name_value: str) -> bool:
    value = str(name_value or "").strip().lower()
    return bool(value) and value.endswith(IMAGE_EXTENSIONS)


def _extract_attachment_image_urls(blob) -> list[str]:
    urls = []
    if isinstance(blob, str):
        normalized = _clean_url(blob)
        if normalized and _looks_like_image_url(normalized):
            urls.append(normalized)
        return urls

    if isinstance(blob, dict):
        content_type = (
            str(
                blob.get("content_type")
                or blob.get("contentType")
                or blob.get("mime_type")
                or blob.get("mimetype")
                or ""
            )
            .strip()
            .lower()
        )
        filename = str(blob.get("filename") or blob.get("fileName") or "").strip()
        image_hint = content_type.startswith("image/") or _looks_like_image_name(
            filename
        )

        direct_urls = []
        for key in ("url", "source_url", "download_url", "proxy_url", "href", "uri"):
            normalized = _clean_url(blob.get(key))
            if not normalized:
                continue
            if (
                image_hint
                or _looks_like_image_url(normalized)
                or _is_xmpp_share_url(normalized)
            ):
                direct_urls.append(normalized)
        urls.extend(direct_urls)
        blob_key = str(blob.get("blob_key") or "").strip()
        # Prefer source-hosted URLs and use blob fallback only
        # when no usable direct URL exists.
        if blob_key and image_hint and not direct_urls:
            urls.append(f"/compose/media/blob/?key={quote_plus(blob_key)}")

        nested = blob.get("attachments")
        if isinstance(nested, list):
            for row in nested:
                urls.extend(_extract_attachment_image_urls(row))
        return urls

    if isinstance(blob, list):
        for row in blob:
            urls.extend(_extract_attachment_image_urls(row))
    return urls


def _attachment_image_urls_by_message(messages):
    rows = list(messages or [])
    if not rows:
        return {}

    by_message = {}
    unresolved = []

    for msg in rows:
        text_value = str(msg.text or "").strip()
        if text_value and text_value not in EMPTY_TEXT_VALUES:
            continue
        unresolved.append(msg)

    if not unresolved:
        return by_message

    legacy_ids = [str(msg.id) for msg in unresolved]
    linked_events = MessageEvent.objects.filter(
        user=rows[0].user,
        raw_payload_ref__legacy_message_id__in=legacy_ids,
    ).order_by("ts")

    for event in linked_events:
        legacy_id = str(
            (event.raw_payload_ref or {}).get("legacy_message_id") or ""
        ).strip()
        if not legacy_id:
            continue
        urls = _uniq_ordered(
            _extract_attachment_image_urls(event.attachments)
            + _extract_attachment_image_urls(event.raw_payload_ref or {})
        )
        if urls:
            by_message.setdefault(legacy_id, urls)

    missing = [msg for msg in unresolved if str(msg.id) not in by_message]
    if not missing:
        return by_message

    min_ts = min(int(msg.ts or 0) for msg in missing) - 3000
    max_ts = max(int(msg.ts or 0) for msg in missing) + 3000
    fallback_events = (
        MessageEvent.objects.filter(
            user=rows[0].user,
            source_system="xmpp",
            ts__gte=min_ts,
            ts__lte=max_ts,
        )
        .exclude(attachments=[])
        .order_by("ts")
    )
    fallback_list = list(fallback_events)
    for msg in missing:
        if str(msg.id) in by_message:
            continue
        msg_ts = int(msg.ts or 0)
        candidates = [
            event for event in fallback_list if abs(int(event.ts or 0) - msg_ts) <= 3000
        ]
        if not candidates:
            continue
        event = candidates[0]
        urls = _uniq_ordered(
            _extract_attachment_image_urls(event.attachments)
            + _extract_attachment_image_urls(event.raw_payload_ref or {})
        )
        if urls:
            by_message[str(msg.id)] = urls

    return by_message


def _serialize_message(msg: Message) -> dict:
    text_value = str(msg.text or "")
    image_urls = _image_urls_from_text(text_value)
    image_url = image_urls[0] if image_urls else ""
    hide_text = bool(
        image_urls
        and _is_url_only_text(text_value)
        and all(_looks_like_image_url(url) for url in image_urls)
    )
    display_text = (
        text_value if text_value.strip() else ("(no text)" if not image_url else "")
    )
    author = str(msg.custom_author or "").strip()
    is_outgoing = _is_outgoing(msg)

    # Determine source service for display: prefer explicit session identifier service
    source_service = "web"
    try:
        if getattr(msg, "session", None) and getattr(msg.session, "identifier", None):
            svc = str(msg.session.identifier.service or "").strip().lower()
            if svc:
                source_service = svc
    except Exception:
        pass
    sender_uuid_value = str(getattr(msg, "sender_uuid", "") or "").strip()
    if sender_uuid_value.lower() == "xmpp":
        source_service = "xmpp"

    # Outgoing messages created by the web compose UI should be labeled Web Chat.
    # Outgoing messages originating from platform runtimes (Signal sync, etc.)
    # should keep their service label.
    service_labels = {
        "xmpp": "XMPP",
        "whatsapp": "WhatsApp",
        "signal": "Signal",
        "instagram": "Instagram",
        "web": "Web Chat",
    }
    if is_outgoing:
        source_label = (
            "Web Chat"
            if not sender_uuid_value
            else service_labels.get(
                source_service, source_service.title() if source_service else "Unknown"
            )
        )
    else:
        source_label = service_labels.get(
            source_service, source_service.title() if source_service else "Unknown"
        )

    # Ensure source_label is never empty for UI rendering
    if not source_label:
        source_label = "Unknown"
    delivered_ts = int(msg.delivered_ts or 0)
    read_ts = int(msg.read_ts or 0)
    delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else ""
    read_display = _format_ts_label(int(read_ts)) if read_ts else ""
    ts_val = int(msg.ts or 0)
    delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None
    read_delta = int(read_ts - ts_val) if read_ts and ts_val else None
    # Human friendly delta strings
    delivered_delta_display = (
        _format_gap_duration(delivered_delta)
        if delivered_delta is not None and int(delivered_delta) > 0
        else ""
    )
    read_delta_display = (
        _format_gap_duration(read_delta)
        if read_delta is not None and int(read_delta) > 0
        else ""
    )
    # Receipt payload and metadata
    receipt_payload = msg.receipt_payload or {}
    deleted_payload = dict((receipt_payload or {}).get("deleted") or {})
    is_deleted = bool(
        (receipt_payload or {}).get("is_deleted")
        or deleted_payload
        or (receipt_payload or {}).get("delete_events")
    )
    deleted_ts = 0
    for candidate in (
        deleted_payload.get("deleted_ts"),
        deleted_payload.get("updated_at"),
        deleted_payload.get("ts"),
    ):
        try:
            deleted_ts = int(candidate or 0)
        except Exception:
            deleted_ts = 0
        if deleted_ts > 0:
            break
    deleted_display = _format_ts_label(deleted_ts) if deleted_ts > 0 else ""
    deleted_actor = str(deleted_payload.get("actor") or "").strip()
    deleted_source_service = str(deleted_payload.get("source_service") or "").strip()

    edit_history_rows = []
    for row in list((receipt_payload or {}).get("edit_history") or []):
        item = dict(row or {})
        edited_ts = 0
        for candidate in (
            item.get("edited_ts"),
            item.get("updated_at"),
            item.get("ts"),
        ):
            try:
                edited_ts = int(candidate or 0)
            except Exception:
                edited_ts = 0
            if edited_ts > 0:
                break
        previous_text = str(item.get("previous_text") or "")
        new_text = str(item.get("new_text") or "")
        edit_history_rows.append(
            {
                "edited_ts": edited_ts,
                "edited_display": _format_ts_label(edited_ts) if edited_ts > 0 else "",
                "source_service": str(item.get("source_service") or "").strip().lower(),
                "actor": str(item.get("actor") or "").strip(),
                "previous_text": previous_text,
                "new_text": new_text,
            }
        )
    edit_history_rows.sort(key=lambda row: int(row.get("edited_ts") or 0))
    edit_count = len(edit_history_rows)
    last_edit_ts = int(edit_history_rows[-1].get("edited_ts") or 0) if edit_count else 0
    last_edit_display = _format_ts_label(last_edit_ts) if last_edit_ts > 0 else ""

    if is_deleted:
        display_text = "(message deleted)"
        image_urls = []
        image_url = ""
        hide_text = False

    read_source_service = str(msg.read_source_service or "").strip()
    read_by_identifier = str(msg.read_by_identifier or "").strip()
    reaction_rows = []
    seen_reactions = set()
    for row in list(receipt_payload.get("reactions") or []):
        item = dict(row or {})
        if bool(item.get("removed")):
            continue
        emoji = str(item.get("emoji") or "").strip()
        if not emoji:
            continue
        # Keep actor/source normalization stable to avoid duplicate/hiding issues.
        actor = str(item.get("actor") or "").strip().lower()
        source = str(item.get("source_service") or "").strip().lower()
        key = (emoji, actor, source)
        if key in seen_reactions:
            continue
        seen_reactions.add(key)
        reaction_rows.append(
            {
                "emoji": emoji,
                "actor": actor,
                "source_service": source,
            }
        )

    reply_preview = ""
    try:
        reply_obj = getattr(msg, "reply_to", None)
        if reply_obj is not None:
            reply_preview = str(getattr(reply_obj, "text", "") or "").strip()
    except Exception:
        reply_preview = ""
    if reply_preview:
        reply_preview = re.sub(r"\s+", " ", reply_preview).strip()
        if len(reply_preview) > 140:
            reply_preview = reply_preview[:137].rstrip() + "..."

    return {
        "id": str(msg.id),
        "ts": int(msg.ts or 0),
        "display_ts": _format_ts_label(int(msg.ts or 0)),
        "text": text_value,
        "display_text": display_text,
        "image_url": image_url,
        "image_urls": image_urls,
        "hide_text": hide_text,
        "author": author,
        "sender_uuid": sender_uuid_value,
        "outgoing": _is_outgoing(msg),
        "source_service": source_service,
        "source_label": source_label,
        "delivered_ts": delivered_ts,
        "read_ts": read_ts,
        "delivered_display": delivered_display,
        "read_display": read_display,
        "delivered_delta": delivered_delta,
        "read_delta": read_delta,
        "delivered_delta_display": delivered_delta_display,
        "read_delta_display": read_delta_display,
        "receipt_payload": receipt_payload,
        "read_source_service": read_source_service,
        "read_by_identifier": read_by_identifier,
        "is_deleted": is_deleted,
        "deleted_ts": deleted_ts,
        "deleted_display": deleted_display,
        "deleted_actor": deleted_actor,
        "deleted_source_service": deleted_source_service,
        "edit_history": edit_history_rows,
        "edit_count": edit_count,
        "is_edited": bool(edit_count),
        "last_edit_ts": last_edit_ts,
        "last_edit_display": last_edit_display,
        "reactions": reaction_rows,
        "source_message_id": str(getattr(msg, "source_message_id", "") or ""),
        "reply_to_id": str(getattr(msg, "reply_to_id", "") or ""),
        "reply_source_message_id": str(
            getattr(msg, "reply_source_message_id", "") or ""
        ),
        "reply_preview": reply_preview,
        "message_meta": {
            "origin_tag": str(
                (getattr(msg, "message_meta", {}) or {}).get("origin_tag") or ""
            )
        },
    }

THREAD_METRIC_COPY_OVERRIDES = {
    "inbound_messages": {
        "calculation": (
            "Count of counterpart-to-user messages in the sampled analysis window."
        ),
        "psychology": (
            "Lower counts can indicate reduced reach-back or temporary withdrawal."
        ),
    },
    "outbound_messages": {
        "calculation": (
            "Count of user-to-counterpart messages in the sampled analysis window."
        ),
        "psychology": (
            "Large imbalances can reflect chasing or over-functioning dynamics."
        ),
    },
}
def _format_gap_duration(ms_value):
    value = max(0, int(ms_value or 0))
    seconds = value // 1000
    if seconds < 60:
        return f"{seconds}s"
    minutes = seconds // 60
    if minutes < 60:
        return f"{minutes}m"
    hours = minutes // 60
    rem_minutes = minutes % 60
    if rem_minutes == 0:
        return f"{hours}h"
    return f"{hours}h {rem_minutes}m"
def _metric_copy(slug, fallback_title):
    spec = INSIGHT_METRICS.get(slug) or {}
    override = THREAD_METRIC_COPY_OVERRIDES.get(slug) or {}
    return {
        "title": spec.get("title") or fallback_title,
        "calculation": override.get("calculation") or spec.get("calculation") or "",
        "psychology": override.get("psychology") or spec.get("psychology") or "",
    }
def _serialize_messages_with_artifacts(messages):
    rows = list(messages or [])
    serialized = [_serialize_message(msg) for msg in rows]
    attachment_images = _attachment_image_urls_by_message(rows)
    for idx, msg in enumerate(rows):
        item = serialized[idx]
        if item.get("image_urls"):
            continue
        recovered = _uniq_ordered(attachment_images.get(str(msg.id)) or [])
        if not recovered:
            continue
        item["image_urls"] = recovered
        item["image_url"] = recovered[0]
        text_value = str(msg.text or "").strip()
        if text_value in EMPTY_TEXT_VALUES:
            item["hide_text"] = True
            item["display_text"] = ""

    return serialized


def _render_compose_message_rows(
    message_rows, *, show_empty_state=False, empty_message=""
):
    return render_to_string(
        "partials/compose-message-rows.html",
        {
            "message_rows": list(message_rows or []),
            "show_empty_state": bool(show_empty_state),
            "empty_message": str(empty_message or ""),
        },
    )


def _owner_name(user) -> str:
    return user.first_name or user.get_full_name().strip() or user.username or "Me"


def _compose_ws_token(user_id, service, identifier, person_id):
    payload = {
        "u": int(user_id),
        "s": str(service or ""),
        "i": str(identifier or ""),
        "p": str(person_id) if person_id else "",
        "exp": int(time.time()) + (60 * 60 * 12),
    }
    return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)


def _compose_ai_cache_key(
    kind, user_id, service, identifier, person_id, last_ts, limit
):
    raw = "|".join(
        [
            str(kind or ""),
            str(user_id),
            str(service or ""),
            str(identifier or ""),
            str(person_id or ""),
            str(last_ts or 0),
            str(limit or 0),
        ]
    )
    digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
    return f"compose:{kind}:{digest}"


def _plain_text(value):
    cleaned = re.sub(r"\s+", " ", str(value or "").strip())
    cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
    cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
    cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
    return cleaned.strip()


def _engage_body_only(value):
    lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
    if lines and lines[0].startswith("**"):
        lines = lines[1:]
    if lines and lines[0].lower() == "guidance:":
        lines = lines[1:]
    return _plain_text(" ".join(lines))


def _messages_for_ai(user, person_identifier, limit):
    if person_identifier is None:
        return []
    session, _ = ChatSession.objects.get_or_create(
        user=user, identifier=person_identifier
    )
    rows = list(
        Message.objects.filter(user=user, session=session)
        .select_related("session", "session__identifier", "session__identifier__person")
        .order_by("-ts")[:limit]
    )
    rows.reverse()
    return rows


def _fallback_drafts():
    return [
        {
            "label": "Soft",
            "text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
        },
        {
            "label": "Neutral",
            "text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
        },
        {
            "label": "Firm",
            "text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
        },
    ]


def _build_draft_prompt(owner_name, person_name, transcript):
    return [
        {
            "role": "system",
            "content": (
                "Generate exactly three short reply drafts for a chat. "
                "Return labels Soft, Neutral, Firm. "
                "Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
                "Each draft must be one to two sentences, plain text, no markdown."
            ),
        },
        {
            "role": "user",
            "content": (
                f"Me: {owner_name}\n"
                f"Other: {person_name}\n"
                f"Conversation:\n{transcript}"
            ),
        },
    ]


def _build_summary_prompt(owner_name, person_name, transcript):
    return [
        {
            "role": "system",
            "content": (
                "Create a concise conversation summary with three sections. "
                "Use this exact structure:\n"
                "Headlines:\n- ...\n"
                "Patterns:\n- ...\n"
                "Suggested Next Message:\n- ...\n"
                "Keep each bullet practical and specific."
            ),
        },
        {
            "role": "user",
            "content": (
                f"Me: {owner_name}\n"
                f"Other: {person_name}\n"
                f"Conversation:\n{transcript}"
            ),
        },
    ]


def _to_float(value):
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _format_number(value, precision=2):
    number = _to_float(value)
    if number is None:
        return "-"
    rounded = round(number, precision)
    if float(rounded).is_integer():
        return str(int(rounded))
    return f"{rounded:.{precision}f}"


def _percent_change(current, previous):
    now_val = _to_float(current)
    prev_val = _to_float(previous)
    if now_val is None or prev_val is None:
        return None
    if abs(prev_val) < 1e-9:
        return None
    return ((now_val - prev_val) / abs(prev_val)) * 100.0


def _trend_meta(current, previous, higher_is_better=True):
    now_val = _to_float(current)
    prev_val = _to_float(previous)
    if now_val is None or prev_val is None:
        return {
            "direction": "unknown",
            "icon": "fa-solid fa-minus",
            "class_name": "has-text-grey",
            "meaning": "No comparison yet",
        }
    delta = now_val - prev_val
    if abs(delta) < 1e-9:
        return {
            "direction": "flat",
            "icon": "fa-solid fa-minus",
            "class_name": "has-text-grey",
            "meaning": "No meaningful change",
        }
    is_up = delta > 0
    improves = is_up if higher_is_better else not is_up
    return {
        "direction": "up" if is_up else "down",
        "icon": (
            "fa-solid fa-arrow-trend-up" if is_up else "fa-solid fa-arrow-trend-down"
        ),
        "class_name": "has-text-success" if improves else "has-text-danger",
        "meaning": "Improving signal" if improves else "Risk signal",
    }


def _emotion_meta(metric_kind, value, metric_key=None):
    score = _to_float(value)
    if score is None:
        return {
            "icon": "fa-regular fa-face-meh-blank",
            "class_name": "has-text-grey",
            "label": "Unknown",
        }
    if metric_kind == "confidence":
        score = score * 100.0
    if metric_kind == "count":
        key = str(metric_key or "").strip().lower()
        if key == "sample_days":
            if score >= 14:
                return {
                    "icon": "fa-solid fa-calendar-check",
                    "class_name": "has-text-success",
                    "label": "Broad Coverage",
                }
            if score >= 5:
                return {
                    "icon": "fa-solid fa-calendar-days",
                    "class_name": "has-text-warning",
                    "label": "Adequate Coverage",
                }
            return {
                "icon": "fa-solid fa-calendar-xmark",
                "class_name": "has-text-danger",
                "label": "Narrow Coverage",
            }
        if score >= 80:
            return {
                "icon": "fa-solid fa-chart-column",
                "class_name": "has-text-success",
                "label": "Rich Data",
            }
        if score >= 30:
            return {
                "icon": "fa-solid fa-chart-simple",
                "class_name": "has-text-warning",
                "label": "Moderate Data",
            }
        return {
            "icon": "fa-solid fa-chart-line",
            "class_name": "has-text-danger",
            "label": "Sparse Data",
        }
    if score >= 75:
        return {
            "icon": "fa-regular fa-face-smile",
            "class_name": "has-text-success",
            "label": "Positive",
        }
    if score >= 50:
        return {
            "icon": "fa-regular fa-face-meh",
            "class_name": "has-text-warning",
            "label": "Mixed",
        }
    return {
        "icon": "fa-regular fa-face-frown",
        "class_name": "has-text-danger",
        "label": "Strained",
    }


def _quick_insights_rows(conversation):
    latest = conversation.metric_snapshots.first()
    previous = (
        conversation.metric_snapshots.order_by("-computed_at")[1:2].first()
        if conversation.metric_snapshots.count() > 1
        else None
    )
    metric_specs = [
        {
            "key": "stability_score",
            "label": "Stability Score",
            "doc_slug": "stability_score",
            "field": "stability_score",
            "source": "conversation",
            "kind": "score",
            "icon": "fa-solid fa-heart-pulse",
            "higher_better": True,
        },
        {
            "key": "stability_confidence",
            "label": "Stability Confidence",
            "doc_slug": "stability_confidence",
            "field": "stability_confidence",
            "source": "conversation",
            "kind": "confidence",
            "icon": "fa-solid fa-shield-check",
            "higher_better": True,
        },
        {
            "key": "sample_messages",
            "label": "Sample Messages",
            "doc_slug": "sample_messages",
            "field": "stability_sample_messages",
            "source": "conversation",
            "kind": "count",
            "icon": "fa-solid fa-message",
            "higher_better": True,
        },
        {
            "key": "sample_days",
            "label": "Sample Days",
            "doc_slug": "sample_days",
            "field": "stability_sample_days",
            "source": "conversation",
            "kind": "count",
            "icon": "fa-solid fa-calendar-days",
            "higher_better": True,
        },
        {
            "key": "commitment_inbound",
            "label": "Commit In",
            "doc_slug": "commitment_inbound",
            "field": "commitment_inbound_score",
            "source": "conversation",
            "kind": "score",
            "icon": "fa-solid fa-inbox",
            "higher_better": True,
        },
        {
            "key": "commitment_outbound",
            "label": "Commit Out",
            "doc_slug": "commitment_outbound",
            "field": "commitment_outbound_score",
            "source": "conversation",
            "kind": "score",
            "icon": "fa-solid fa-paper-plane",
            "higher_better": True,
        },
        {
            "key": "commitment_confidence",
            "label": "Commit Confidence",
            "doc_slug": "commitment_confidence",
            "field": "commitment_confidence",
            "source": "conversation",
            "kind": "confidence",
            "icon": "fa-solid fa-badge-check",
            "higher_better": True,
        },
        {
            "key": "reciprocity",
            "label": "Reciprocity",
            "doc_slug": "reciprocity_score",
            "field": "reciprocity_score",
            "source": "snapshot",
            "kind": "score",
            "icon": "fa-solid fa-right-left",
            "higher_better": True,
        },
        {
            "key": "continuity",
            "label": "Continuity",
            "doc_slug": "continuity_score",
            "field": "continuity_score",
            "source": "snapshot",
            "kind": "score",
            "icon": "fa-solid fa-link",
            "higher_better": True,
        },
        {
            "key": "response",
            "label": "Response",
            "doc_slug": "response_score",
            "field": "response_score",
            "source": "snapshot",
            "kind": "score",
            "icon": "fa-solid fa-gauge-high",
            "higher_better": True,
        },
        {
            "key": "volatility",
            "label": "Volatility",
            "doc_slug": "volatility_score",
            "field": "volatility_score",
            "source": "snapshot",
            "kind": "score",
            "icon": "fa-solid fa-wave-square",
            "higher_better": True,
        },
        {
            "key": "inbound_messages",
            "label": "Inbound Messages",
            "doc_slug": "inbound_messages",
            "field": "inbound_messages",
            "source": "snapshot",
            "kind": "count",
            "icon": "fa-solid fa-arrow-down",
            "higher_better": True,
        },
        {
            "key": "outbound_messages",
            "label": "Outbound Messages",
            "doc_slug": "outbound_messages",
            "field": "outbound_messages",
            "source": "snapshot",
            "kind": "count",
            "icon": "fa-solid fa-arrow-up",
            "higher_better": True,
        },
    ]
    rows = []
    for spec in metric_specs:
        field_name = spec["field"]
        metric_copy = _metric_copy(spec.get("doc_slug") or spec["key"], spec["label"])
        if spec["source"] == "conversation":
            current = getattr(conversation, field_name, None)
            previous_value = getattr(previous, field_name, None) if previous else None
        else:
            current = getattr(latest, field_name, None) if latest else None
            previous_value = getattr(previous, field_name, None) if previous else None
        trend = _trend_meta(
            current,
            previous_value,
            higher_is_better=spec.get("higher_better", True),
        )
        delta_pct = _percent_change(current, previous_value)
        point_count = conversation.metric_snapshots.exclude(
            **{f"{field_name}__isnull": True}
        ).count()
        emotion = _emotion_meta(spec["kind"], current, spec["key"])
        rows.append(
            {
                "key": spec["key"],
                "label": spec["label"],
                "icon": spec["icon"],
                "value": current,
                "display_value": _format_number(
                    current,
                    3 if spec["kind"] == "confidence" else 2,
                ),
                "delta_pct": delta_pct,
                "delta_label": f"{delta_pct:+.2f}%" if delta_pct is not None else "n/a",
                "point_count": point_count,
                "trend": trend,
                "emotion": emotion,
                "calculation": metric_copy.get("calculation") or "",
                "psychology": metric_copy.get("psychology") or "",
            }
        )
    return {
        "rows": rows,
        "snapshot_count": conversation.metric_snapshots.count(),
        "latest_computed_at": latest.computed_at if latest else None,
    }


def _participant_feedback_state_label(conversation, person):
    payload = conversation.participant_feedback or {}
    if not isinstance(payload, dict) or person is None:
        return ""
    raw = payload.get(str(person.id)) or {}
    if not isinstance(raw, dict):
        return ""
    state_key = str(raw.get("state") or "").strip().lower()
    return {
        "withdrawing": "Withdrawing",
        "overextending": "Overextending",
        "balanced": "Balanced",
    }.get(state_key, "")


def _build_engage_prompt(owner_name, person_name, transcript):
    return [
        {
            "role": "system",
            "content": (
                "Write one short de-escalating outreach in shared framing. "
                "Use 'we/us/our' only. No names. One or two sentences."
            ),
        },
        {
            "role": "user",
            "content": (
                f"Me: {owner_name}\n"
                f"Other: {person_name}\n"
                f"Conversation:\n{transcript}"
            ),
        },
    ]


def _latest_plan_for_person(user, person):
    if person is None:
        return None
    conversation = (
        PatternMitigationPlan.objects.filter(
            user=user,
            conversation__participants=person,
        )
        .select_related("conversation")
        .order_by("-updated_at")
        .first()
    )
    return conversation


def _best_engage_source(plan):
    if plan is None:
        return (None, "")
    correction = plan.corrections.order_by("-created_at").first()
    if correction:
        return (correction, "correction")
    rule = plan.rules.order_by("-created_at").first()
    if rule:
        return (rule, "rule")
    game = plan.games.order_by("-created_at").first()
    if game:
        return (game, "game")
    return (None, "")


def _engage_source_options(plan):
    if plan is None:
        return []
    options = []
    for rule in plan.rules.order_by("created_at"):
        options.append(
            {
                "value": f"rule:{rule.id}",
                "label": f"Rule: {rule.title}",
            }
        )
    for game in plan.games.order_by("created_at"):
        options.append(
            {
                "value": f"game:{game.id}",
                "label": f"Game: {game.title}",
            }
        )
    for correction in plan.corrections.order_by("created_at"):
        options.append(
            {
                "value": f"correction:{correction.id}",
                "label": f"Correction: {correction.title}",
            }
        )
    return options


def _engage_source_from_ref(plan, source_ref):
    if plan is None:
        return (None, "", "")
    ref = str(source_ref or "").strip()
    if ":" not in ref:
        return (None, "", "")
    kind, raw_id = ref.split(":", 1)
    kind = kind.strip().lower()
    raw_id = raw_id.strip()
    model_by_kind = {
        "rule": plan.rules,
        "game": plan.games,
        "correction": plan.corrections,
    }
    queryset = model_by_kind.get(kind)
    if queryset is None:
        return (None, "", "")
    obj = queryset.filter(id=raw_id).first()
    if obj is None:
        return (None, "", "")
    return (obj, kind, f"{kind}:{obj.id}")


def _context_base(user, service, identifier, person):
    service = _default_service(service)
    identifier_variants = _identifier_variants(service, identifier)
    person_identifier = None
    if person is None and identifier:
        for candidate in identifier_variants or [identifier]:
            bare_id = str(candidate or "").strip().split("@", 1)[0].strip()
            if not bare_id:
                continue
            group_link = PlatformChatLink.objects.filter(
                user=user,
                service=service,
                chat_identifier=bare_id,
                is_group=True,
            ).first()
            if group_link:
                return {
                    "person_identifier": None,
                    "service": service,
                    "identifier": _group_channel_identifier(
                        service, group_link, bare_id
                    ),
                    "person": None,
                    "is_group": True,
                    "group_name": group_link.chat_name or bare_id,
                }
    if person is not None:
        if identifier_variants:
            person_identifier = PersonIdentifier.objects.filter(
                user=user,
                person=person,
                service=service,
                identifier__in=identifier_variants,
            ).first()
        if person_identifier is None:
            person_identifier = (
                PersonIdentifier.objects.filter(
                    user=user,
                    person=person,
                    service=service,
                ).first()
                or PersonIdentifier.objects.filter(user=user, person=person).first()
            )
    if person_identifier is None and identifier and person is None:
        person_identifier = PersonIdentifier.objects.filter(
            user=user,
            service=service,
            identifier__in=identifier_variants or [identifier],
        ).first()
    if person_identifier is None and identifier and person is None:
        person_identifier = PersonIdentifier.objects.filter(
            user=user,
            identifier__in=identifier_variants or [identifier],
        ).first()

    if person_identifier:
        service = person_identifier.service
        identifier = person_identifier.identifier
        person = person_identifier.person
        if service == "whatsapp" and identifier and "@" not in str(identifier):
            bare_id = str(identifier).split("@", 1)[0].strip()
            group_link = PlatformChatLink.objects.filter(
                user=user,
                service="whatsapp",
                chat_identifier=bare_id,
                is_group=True,
            ).first()
            if group_link is not None:
                identifier = str(group_link.chat_jid or f"{bare_id}@g.us")

    return {
        "person_identifier": person_identifier,
        "service": service,
        "identifier": identifier,
        "person": person,
        "is_group": False,
        "group_name": "",
    }


def _latest_whatsapp_bridge_ref(message: Message | None) -> dict:
    if message is None:
        return {}
    payload = dict(getattr(message, "receipt_payload", {}) or {})
    refs = dict(payload.get("bridge_refs") or {})
    rows = list(refs.get("whatsapp") or [])
    best = {}
    best_updated = -1
    for row in rows:
        if not isinstance(row, dict):
            continue
        upstream_id = str(row.get("upstream_message_id") or "").strip()
        if not upstream_id:
            continue
        updated_at = int(row.get("updated_at") or 0)
        if updated_at >= best_updated:
            best = dict(row)
            best_updated = updated_at
    return best


def _latest_signal_bridge_ref(message: Message | None) -> dict:
    if message is None:
        return {}
    payload = dict(getattr(message, "receipt_payload", {}) or {})
    refs = dict(payload.get("bridge_refs") or {})
    rows = list(refs.get("signal") or [])
    best = {}
    best_updated = -1
    for row in rows:
        if not isinstance(row, dict):
            continue
        has_upstream = str(row.get("upstream_message_id") or "").strip() or int(
            row.get("upstream_ts") or 0
        )
        if not has_upstream:
            continue
        updated_at = int(row.get("updated_at") or 0)
        if updated_at >= best_updated:
            best = dict(row)
            best_updated = updated_at
    return best


def _build_whatsapp_reply_metadata(
    reply_to: Message | None, channel_identifier: str
) -> dict:
    if reply_to is None:
        return {}
    target_message_id = ""
    participant = ""

    source_service = str(getattr(reply_to, "source_service", "") or "").strip().lower()
    if source_service == "whatsapp":
        target_message_id = str(
            getattr(reply_to, "source_message_id", "") or ""
        ).strip()
        participant = str(getattr(reply_to, "sender_uuid", "") or "").strip()

    if not target_message_id:
        bridge_ref = _latest_whatsapp_bridge_ref(reply_to)
        target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
        participant = (
            participant or str(bridge_ref.get("upstream_author") or "").strip()
        )

    if not target_message_id:
        return {}

    remote_jid = str(channel_identifier or "").strip()
    if "@" not in remote_jid and remote_jid:
        remote_jid = f"{remote_jid}@g.us"

    return {
        "reply_to_upstream_message_id": target_message_id,
        "reply_to_participant": participant,
        "reply_to_remote_jid": remote_jid,
    }


def _build_signal_reply_metadata(
    reply_to: Message | None, channel_identifier: str
) -> dict:
    if reply_to is None:
        return {}

    quote_timestamp = 0
    source_message_id = str(getattr(reply_to, "source_message_id", "") or "").strip()
    if source_message_id.isdigit():
        quote_timestamp = int(source_message_id)
    if not quote_timestamp:
        bridge_ref = _latest_signal_bridge_ref(reply_to)
        upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
        if upstream_id.isdigit():
            quote_timestamp = int(upstream_id)
        if not quote_timestamp:
            quote_timestamp = int(bridge_ref.get("upstream_ts") or 0)
    if not quote_timestamp:
        quote_timestamp = int(getattr(reply_to, "ts", 0) or 0)
    if quote_timestamp <= 0:
        return {}

    quote_author = ""
    sender_uuid = str(getattr(reply_to, "sender_uuid", "") or "").strip()
    if sender_uuid:
        quote_author = sender_uuid
    # Signal quote payloads work best with phone-style identifiers.
    # Inbound rows may store sender UUID; prefer known chat/number in that case.
    source_chat_id = str(getattr(reply_to, "source_chat_id", "") or "").strip()
    if quote_author and SIGNAL_UUID_PATTERN.match(quote_author):
        if source_chat_id:
            quote_author = source_chat_id
    if (
        str(getattr(reply_to, "custom_author", "") or "").strip().upper()
        in {"USER", "BOT"}
        or not quote_author
    ):
        quote_author = (
            str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or quote_author
        )
    if not quote_author:
        quote_author = source_chat_id
    if not quote_author:
        quote_author = str(channel_identifier or "").strip()
    if not quote_author:
        return {}

    quote_text = str(getattr(reply_to, "text", "") or "").strip()
    payload = {
        "quote_timestamp": int(quote_timestamp),
        "quote_author": quote_author,
    }
    if quote_text:
        payload["quote_text"] = quote_text[:512]
    return payload


def _parse_bool(value, default: bool = False) -> bool:
    if value is None:
        return bool(default)
    raw = str(value).strip().lower()
    if raw in {"1", "true", "yes", "on"}:
        return True
    if raw in {"0", "false", "no", "off"}:
        return False
    return bool(default)


def _reaction_actor_key(user_id, service: str) -> str:
    return f"web:{int(user_id)}:{str(service or '').strip().lower()}"


def _resolve_reaction_target(
    message: Message, service: str, channel_identifier: str
) -> dict:
    service_key = _default_service(service)
    message_source_service = (
        str(getattr(message, "source_service", "") or "").strip().lower()
    )
    source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
    sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
    source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
    delivered_ts = int(getattr(message, "delivered_ts", 0) or 0)
    local_ts = int(getattr(message, "ts", 0) or 0)

    if service_key == "signal":
        target_ts = 0
        if message_source_service == "signal" and source_message_id.isdigit():
            target_ts = int(source_message_id)
        bridge_ref = _latest_signal_bridge_ref(message)
        if not target_ts:
            upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
            if upstream_id.isdigit():
                target_ts = int(upstream_id)
            if not target_ts:
                target_ts = int(bridge_ref.get("upstream_ts") or 0)
        # Local web messages are only reactable once bridge refs exist.
        if not target_ts and message_source_service == "signal":
            target_ts = delivered_ts or local_ts
        if target_ts <= 0:
            return {"error": "signal_target_unresolvable"}

        target_author = sender_uuid
        if not target_author:
            target_author = str(bridge_ref.get("upstream_author") or "").strip()
        if str(getattr(message, "custom_author", "") or "").strip().upper() in {
            "USER",
            "BOT",
        }:
            target_author = (
                str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
                or target_author
            )
        if not target_author:
            target_author = source_chat_id or str(channel_identifier or "").strip()
        if not target_author:
            return {"error": "signal_target_author_unresolvable"}

        return {
            "target_message_id": "",
            "target_ts": int(target_ts),
            "target_author": target_author,
        }

    if service_key == "whatsapp":
        target_message_id = (
            source_message_id if message_source_service == "whatsapp" else ""
        )
        target_ts = delivered_ts or local_ts
        bridge_ref = _latest_whatsapp_bridge_ref(message)
        if not target_message_id:
            target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
            if not target_ts:
                target_ts = int(bridge_ref.get("upstream_ts") or 0)
        if not target_message_id:
            return {"error": "whatsapp_target_unresolvable"}
        return {
            "target_message_id": target_message_id,
            "target_ts": int(target_ts or 0),
            "target_author": "",
        }

    return {"error": "service_not_supported"}


def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
    value = str(identifier or "").strip()
    if not value:
        return ""
    if service == "whatsapp":
        return value.split("@", 1)[0].strip()
    return value


def _command_channel_identifier_variants(service: str, identifier: str) -> set[str]:
    canonical = _canonical_command_channel_identifier(service, identifier)
    if not canonical:
        return set()
    variants = {canonical}
    if service == "whatsapp":
        variants.add(f"{canonical}@g.us")
    return variants


def _ensure_bp_profile_and_actions(user) -> CommandProfile:
    profile, _ = CommandProfile.objects.get_or_create(
        user=user,
        slug="bp",
        defaults={
            "name": "Business Plan",
            "enabled": True,
            "trigger_token": ".bp",
            "reply_required": True,
            "exact_match_only": True,
            "window_scope": "conversation",
            "visibility_mode": "status_in_source",
        },
    )
    if not profile.enabled:
        profile.enabled = True
        profile.save(update_fields=["enabled", "updated_at"])
    if str(profile.trigger_token or "").strip() != ".bp":
        profile.trigger_token = ".bp"
        profile.save(update_fields=["trigger_token", "updated_at"])
    for action_type, position in (
        ("extract_bp", 0),
        ("save_document", 1),
        ("post_result", 2),
    ):
        row, created = CommandAction.objects.get_or_create(
            profile=profile,
            action_type=action_type,
            defaults={"enabled": True, "position": position},
        )
        if (not created) and (not row.enabled):
            row.enabled = True
            row.save(update_fields=["enabled", "updated_at"])
    ensure_variant_policies_for_profile(profile)
    return profile


def _toggle_command_for_channel(
    *,
    user,
    service: str,
    identifier: str,
    slug: str,
    enabled: bool,
) -> tuple[bool, str]:
    service_key = _default_service(service)
    canonical_identifier = _canonical_command_channel_identifier(
        service_key, identifier
    )
    if not canonical_identifier:
        return (False, "missing_identifier")

    if slug != "bp":
        return (False, f"unknown_command:{slug}")

    profile = _ensure_bp_profile_and_actions(user)
    if not profile.enabled and enabled:
        profile.enabled = True
        profile.save(update_fields=["enabled", "updated_at"])

    variants = _command_channel_identifier_variants(service_key, canonical_identifier)
    if not variants:
        return (False, "missing_identifier")

    if enabled:
        for direction in ("ingress", "egress"):
            row, _ = CommandChannelBinding.objects.get_or_create(
                profile=profile,
                direction=direction,
                service=service_key,
                channel_identifier=canonical_identifier,
                defaults={"enabled": True},
            )
            if not row.enabled:
                row.enabled = True
                row.save(update_fields=["enabled", "updated_at"])
            CommandChannelBinding.objects.filter(
                profile=profile,
                direction=direction,
                service=service_key,
                channel_identifier__in=list(variants - {canonical_identifier}),
            ).update(enabled=False)
    else:
        CommandChannelBinding.objects.filter(
            profile=profile,
            direction__in=("ingress", "egress"),
            service=service_key,
            channel_identifier__in=list(variants),
        ).update(enabled=False)
    return (True, "")


def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
    service_key = _default_service(service)
    variants = _command_channel_identifier_variants(service_key, identifier)
    profiles = list(
        CommandProfile.objects.filter(user=user, slug__in=COMPOSE_COMMAND_SLUGS).order_by(
            "slug", "id"
        )
    )
    by_slug = {str(row.slug or "").strip(): row for row in profiles}
    if "bp" not in by_slug:
        by_slug["bp"] = CommandProfile(
            user=user,
            slug="bp",
            name="Business Plan",
            trigger_token=".bp",
            enabled=True,
        )
    slugs = sorted(by_slug.keys())
    options = []
    for slug in slugs:
        profile = by_slug[slug]
        enabled_here = False
        if variants:
            enabled_here = CommandChannelBinding.objects.filter(
                profile_id=profile.id if profile.id else None,
                direction="ingress",
                service=service_key,
                channel_identifier__in=list(variants),
                enabled=True,
            ).exists()
        if slug == "bp":
            policies = (
                ensure_variant_policies_for_profile(profile) if profile.id else {}
            )
            label_by_key = {
                "bp": "bp",
                "bp_set": "bp set",
                "bp_set_range": "bp set range",
            }
            options.extend(
                [
                    {
                        "slug": "bp",
                        "toggle_slug": "bp",
                        "name": "bp",
                        "trigger_token": ".bp",
                        "enabled_here": bool(enabled_here),
                        "profile_enabled": bool(profile.enabled),
                        "mode_label": str(
                            (
                                policies.get("bp").generation_mode
                                if policies.get("bp")
                                else "ai"
                            )
                        ).upper(),
                    },
                    {
                        "slug": "bp_set",
                        "toggle_slug": "bp",
                        "name": "bp set",
                        "trigger_token": ".bp set",
                        "enabled_here": bool(enabled_here),
                        "profile_enabled": bool(profile.enabled),
                        "mode_label": str(
                            (
                                policies.get("bp_set").generation_mode
                                if policies.get("bp_set")
                                else "verbatim"
                            )
                        ).upper(),
                    },
                    {
                        "slug": "bp_set_range",
                        "toggle_slug": "bp",
                        "name": "bp set range",
                        "trigger_token": ".bp set range",
                        "enabled_here": bool(enabled_here),
                        "profile_enabled": bool(profile.enabled),
                        "mode_label": str(
                            (
                                policies.get("bp_set_range").generation_mode
                                if policies.get("bp_set_range")
                                else "verbatim"
                            )
                        ).upper(),
                    },
                ]
            )
            for row in options:
                if row.get("slug") in label_by_key:
                    row["enabled_label"] = (
                        "Enabled" if row.get("enabled_here") else "Disabled"
                    )
        else:
            options.append(
                {
                    "slug": slug,
                    "toggle_slug": slug,
                    "name": str(profile.name or slug).strip() or slug,
                    "trigger_token": str(profile.trigger_token or "").strip(),
                    "enabled_here": bool(enabled_here),
                    "profile_enabled": bool(profile.enabled),
                    "mode_label": "",
                    "enabled_label": "Enabled" if enabled_here else "Disabled",
                }
            )
    return options


def _bp_binding_summary_for_channel(user, service: str, identifier: str) -> dict:
    service_key = _default_service(service)
    variants = _command_channel_identifier_variants(service_key, identifier)
    if not variants:
        return {"ingress_count": 0, "egress_count": 0}
    profile = CommandProfile.objects.filter(user=user, slug="bp").order_by("id").first()
    if profile is None:
        return {"ingress_count": 0, "egress_count": 0}
    ingress_count = CommandChannelBinding.objects.filter(
        profile=profile,
        direction="ingress",
        service=service_key,
        channel_identifier__in=list(variants),
        enabled=True,
    ).count()
    egress_count = CommandChannelBinding.objects.filter(
        profile=profile,
        direction="egress",
        service=service_key,
        channel_identifier__in=list(variants),
        enabled=True,
    ).count()
    return {"ingress_count": int(ingress_count), "egress_count": int(egress_count)}


def _toggle_task_announce_for_channel(
    *,
    user,
    service: str,
    identifier: str,
    enabled: bool,
) -> tuple[bool, str]:
    service_key = _default_service(service)
    canonical_identifier = _canonical_command_channel_identifier(
        service_key, identifier
    )
    if not canonical_identifier:
        return (False, "missing_identifier")
    variants = _command_channel_identifier_variants(service_key, canonical_identifier)
    rows = list(
        ChatTaskSource.objects.filter(
            user=user,
            service=service_key,
            channel_identifier__in=list(variants),
        ).order_by("-updated_at")
    )
    if not rows:
        return (False, "task_source_mapping_missing")
    for row in rows:
        settings_row = dict(row.settings or {})
        settings_row["announce_task_id"] = bool(enabled)
        row.settings = settings_row
        row.save(update_fields=["settings", "updated_at"])
    return (True, "")


def _compose_urls(service, identifier, person_id):
    service_key = _default_service(service)
    identifier_value = str(identifier or "").strip()
    if service_key == "whatsapp" and "@" in identifier_value:
        identifier_value = identifier_value.split("@", 1)[0].strip()
    query = {"service": service_key, "identifier": identifier_value}
    if person_id:
        query["person"] = str(person_id)
    payload = urlencode(query)
    return {
        "page_url": f"{reverse('compose_page')}?{payload}",
        "widget_url": f"{reverse('compose_widget')}?{payload}",
        "workspace_url": f"{reverse('compose_workspace')}?{payload}",
    }


def _service_icon_class(service: str) -> str:
    key = str(service or "").strip().lower()
    if key == "signal":
        return "fa-solid fa-signal"
    if key == "whatsapp":
        return "fa-brands fa-whatsapp"
    if key == "instagram":
        return "fa-brands fa-instagram"
    if key == "xmpp":
        return "fa-solid fa-comments"
    return "fa-solid fa-address-card"


def _service_label(service: str) -> str:
    key = str(service or "").strip().lower()
    labels = {
        "signal": "Signal",
        "whatsapp": "WhatsApp",
        "instagram": "Instagram",
        "xmpp": "XMPP",
    }
    return labels.get(key, key.title() if key else "Unknown")


def _service_order(service: str) -> int:
    key = str(service or "").strip().lower()
    order = {
        "signal": 0,
        "whatsapp": 1,
        "instagram": 2,
        "xmpp": 3,
    }
    return order.get(key, 99)


def _signal_identifier_shape(value: str) -> str:
    raw = str(value or "").strip()
    if not raw:
        return "unknown"
    if SIGNAL_UUID_PATTERN.fullmatch(raw):
        return "uuid"
    digits = re.sub(r"[^0-9]", "", raw)
    if digits and raw.replace("+", "").replace(" ", "").replace("-", "").isdigit():
        return "phone"
    if digits and raw.isdigit():
        return "phone"
    return "other"


def _preferred_signal_identifier(identifiers: list[str], *, is_group: bool) -> str:
    cleaned = []
    for value in identifiers:
        candidate = str(value or "").strip()
        if candidate and candidate not in cleaned:
            cleaned.append(candidate)
    if not cleaned:
        return ""
    if is_group:
        for candidate in cleaned:
            if candidate.startswith("group."):
                return candidate
        return cleaned[0]
    for candidate in cleaned:
        if _signal_identifier_shape(candidate) == "phone":
            return candidate
    for candidate in cleaned:
        if _signal_identifier_shape(candidate) == "uuid":
            return candidate
    return cleaned[0]


def _signal_runtime_alias_map() -> dict[str, set[str]]:
    state = transport.get_runtime_state("signal") or {}
    alias_map: dict[str, set[str]] = {}
    for bucket_name in ("contacts", "groups"):
        rows = state.get(bucket_name) or []
        if not isinstance(rows, list):
            continue
        for item in rows:
            if not isinstance(item, dict):
                continue
            identifiers = []
            candidates = item.get("identifiers")
            if isinstance(candidates, list):
                identifiers.extend(candidates)
            identifiers.extend(
                [
                    item.get("identifier"),
                    item.get("number"),
                    item.get("uuid"),
                    item.get("id"),
                    item.get("internal_id"),
                ]
            )
            unique = []
            for value in identifiers:
                candidate = str(value or "").strip()
                if candidate and candidate not in unique:
                    unique.append(candidate)
            if len(unique) < 2:
                continue
            for identifier in unique:
                alias_map[identifier] = {value for value in unique if value != identifier}
    return alias_map


def _manual_contact_rows(user):
    rows = []
    row_index_by_key = {}
    identifier_rows = list(
        PersonIdentifier.objects.filter(user=user)
        .select_related("person")
        .order_by("person__name", "service", "identifier")
    )

    def _clean_detected_name(value: str) -> str:
        text = str(value or "").strip()
        if not text:
            return ""
        if text in {"~", "-", "_"}:
            return ""
        return text

    def add_row(
        *,
        service,
        identifier,
        person=None,
        source="linked",
        account="",
        detected_name="",
    ):
        service_key = _default_service(service)
        identifier_value = str(identifier or "").strip()
        if not identifier_value:
            return
        key = (service_key, _contact_lookup_key(service_key, identifier_value))
        if not key[1]:
            key = (service_key, identifier_value)
        linked_person_name = person.name if person else ""
        detected = _clean_detected_name(detected_name or account or "")
        row_index = row_index_by_key.get(key)
        if row_index is not None:
            existing = rows[row_index]
            if person is not None and not existing.get("linked_person"):
                existing["linked_person"] = True
                existing["person_id"] = str(person.id)
                existing["linked_person_name"] = linked_person_name
            if detected and not existing.get("detected_name"):
                existing["detected_name"] = detected
            if (
                service_key == "whatsapp"
                and "@" in identifier_value
                and "@g.us" not in identifier_value.lower()
                and "@" not in str(existing.get("identifier") or "")
            ):
                urls = _compose_urls(
                    service_key,
                    identifier_value,
                    person.id if person else (existing.get("person_id") or None),
                )
                existing["identifier"] = identifier_value
                existing["compose_url"] = urls["page_url"]
                existing["compose_widget_url"] = urls["widget_url"]
                existing["match_url"] = (
                    f"{reverse('compose_contact_match')}?"
                    f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
                )
            existing["person_name"] = (
                str(existing.get("linked_person_name") or "").strip()
                or str(existing.get("detected_name") or "").strip()
                or str(existing.get("identifier") or "").strip()
            )
            return
        urls = _compose_urls(
            service_key, identifier_value, person.id if person else None
        )
        person_name = linked_person_name or detected or identifier_value
        rows.append(
            {
                "person_name": person_name,
                "linked_person_name": linked_person_name,
                "person_id": str(person.id) if person else "",
                "detected_name": detected,
                "service": service_key,
                "service_icon_class": _service_icon_class(service_key),
                "identifier": identifier_value,
                "compose_url": urls["page_url"],
                "compose_widget_url": urls["widget_url"],
                "linked_person": bool(person),
                "source": source,
                "match_url": (
                    f"{reverse('compose_contact_match')}?"
                    f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
                ),
            }
        )
        row_index_by_key[key] = len(rows) - 1

    for row in identifier_rows:
        if _default_service(row.service) == "signal":
            continue
        add_row(
            service=row.service,
            identifier=row.identifier,
            person=row.person,
            source="linked",
        )

    group_links = (
        PlatformChatLink.objects.filter(user=user, is_group=True)
        .order_by("service", "chat_name", "chat_identifier")
    )
    for link in group_links:
        if _default_service(link.service) == "signal":
            continue
        group_identifier = _group_channel_identifier(
            str(link.service or "").strip(),
            link,
            str(link.chat_identifier or "").strip(),
        )
        if not group_identifier:
            continue
        add_row(
            service=link.service,
            identifier=group_identifier,
            source=f"{_default_service(link.service)}_group",
            detected_name=str(link.chat_name or "").strip(),
        )

    signal_links = {
        str(row.identifier): row
        for row in identifier_rows
        if _default_service(row.service) == "signal"
    }
    signal_state = transport.get_runtime_state("signal") or {}
    signal_accounts = [
        str(value or "").strip()
        for value in (signal_state.get("accounts") or [])
        if str(value or "").strip()
    ]
    signal_account_set = set(signal_accounts)
    signal_entities = {}
    signal_alias_index = {}

    def _signal_entity_key(identifiers_list: list[str], *, is_group: bool) -> str:
        preferred = _preferred_signal_identifier(identifiers_list, is_group=is_group)
        if is_group:
            return f"group:{preferred}"
        for candidate in identifiers_list:
            if _signal_identifier_shape(candidate) == "uuid":
                return f"contact:uuid:{candidate.lower()}"
        for candidate in identifiers_list:
            if _signal_identifier_shape(candidate) == "phone":
                return f"contact:phone:{candidate}"
        return f"contact:other:{preferred.lower()}"

    def _resolve_signal_entity_key(candidate: str) -> str:
        cleaned = str(candidate or "").strip()
        if not cleaned:
            return ""
        for variant in _identifier_variants("signal", cleaned):
            entity_key = signal_alias_index.get(variant)
            if entity_key:
                return entity_key
        return ""

    def _register_signal_entity(
        *,
        identifiers_list,
        is_group: bool,
        detected_name="",
        person=None,
        source="signal_runtime",
    ):
        unique_identifiers = []
        for value in identifiers_list or []:
            cleaned = str(value or "").strip()
            if (
                not cleaned
                or cleaned in unique_identifiers
                or cleaned in signal_account_set
            ):
                continue
            unique_identifiers.append(cleaned)
        if not unique_identifiers:
            return None
        entity_key = ""
        for identifier in unique_identifiers:
            entity_key = _resolve_signal_entity_key(identifier)
            if entity_key:
                break
        if not entity_key:
            entity_key = _signal_entity_key(unique_identifiers, is_group=is_group)
        entity = signal_entities.get(entity_key)
        if entity is None:
            entity = {
                "is_group": bool(is_group),
                "identifiers": [],
                "detected_name": _clean_detected_name(detected_name or ""),
                "person": person,
                "sources": set(),
            }
            signal_entities[entity_key] = entity
        for identifier in unique_identifiers:
            if identifier not in entity["identifiers"]:
                entity["identifiers"].append(identifier)
            for variant in _identifier_variants("signal", identifier):
                signal_alias_index[variant] = entity_key
        cleaned_name = _clean_detected_name(detected_name or "")
        if cleaned_name and not entity["detected_name"]:
            entity["detected_name"] = cleaned_name
        if person is not None and entity.get("person") is None:
            entity["person"] = person
        entity["sources"].add(str(source or "").strip() or "signal_runtime")
        return entity

    for row in signal_links.values():
        _register_signal_entity(
            identifiers_list=[row.identifier],
            is_group=False,
            detected_name=(str(row.person.name or "").strip() if row.person else ""),
            person=row.person,
            source="linked",
        )

    signal_group_links = (
        PlatformChatLink.objects.filter(user=user, service="signal", is_group=True)
        .order_by("chat_name", "chat_identifier")
    )
    for link in signal_group_links:
        group_identifier = _group_channel_identifier(
            "signal",
            link,
            str(link.chat_identifier or "").strip(),
        )
        if not group_identifier:
            continue
        _register_signal_entity(
            identifiers_list=[group_identifier],
            is_group=True,
            detected_name=str(link.chat_name or "").strip(),
            source="signal_group",
        )

    signal_contacts = signal_state.get("contacts") or []
    if isinstance(signal_contacts, list):
        for item in signal_contacts:
            if not isinstance(item, dict):
                continue
            candidate_identifiers = item.get("identifiers")
            if not isinstance(candidate_identifiers, list):
                candidate_identifiers = [
                    item.get("identifier"),
                    item.get("number"),
                    item.get("uuid"),
                ]
            linked = None
            for candidate in candidate_identifiers:
                cleaned = str(candidate or "").strip()
                if not cleaned:
                    continue
                linked = signal_links.get(cleaned)
                if linked is not None:
                    break
            _register_signal_entity(
                identifiers_list=candidate_identifiers,
                is_group=False,
                detected_name=str(item.get("name") or "").strip(),
                person=(linked.person if linked else None),
                source="signal_runtime",
            )

    signal_groups = signal_state.get("groups") or []
    if isinstance(signal_groups, list):
        for item in signal_groups:
            if not isinstance(item, dict):
                continue
            candidate_identifiers = item.get("identifiers")
            if not isinstance(candidate_identifiers, list):
                candidate_identifiers = [
                    item.get("identifier"),
                    item.get("id"),
                    item.get("internal_id"),
                ]
            _register_signal_entity(
                identifiers_list=candidate_identifiers,
                is_group=True,
                detected_name=str(item.get("name") or "").strip(),
                source="signal_group_raw",
            )

    if not signal_contacts and not signal_groups:
        signal_chats = (
            Chat.objects.only("source_uuid", "source_number", "source_name", "account")
            .order_by("-id")[:250]
        )
        for chat in signal_chats:
            uuid_candidate = str(chat.source_uuid or "").strip()
            number_candidate = str(chat.source_number or "").strip()
            fallback_linked = None
            if uuid_candidate:
                fallback_linked = signal_links.get(uuid_candidate)
            if fallback_linked is None and number_candidate:
                fallback_linked = signal_links.get(number_candidate)
            linked = fallback_linked
            if linked is None:
                for candidate in (uuid_candidate, number_candidate):
                    linked = signal_links.get(candidate)
                    if linked is not None:
                        break
            _register_signal_entity(
                identifiers_list=[uuid_candidate, number_candidate],
                is_group=False,
                detected_name=_clean_detected_name(
                    chat.source_name or chat.account or ""
                ),
                person=(linked.person if linked else None),
                source="signal_chat",
            )

    for entity in signal_entities.values():
        entity_identifiers = list(entity.get("identifiers") or [])
        identifier_value = _preferred_signal_identifier(
            entity_identifiers,
            is_group=bool(entity.get("is_group")),
        )
        if not identifier_value:
            continue
        add_row(
            service="signal",
            identifier=identifier_value,
            person=entity.get("person"),
            source=",".join(sorted(entity.get("sources") or {"signal_runtime"})),
            account=entity.get("detected_name") or "",
            detected_name=entity.get("detected_name") or "",
        )
        if rows:
            rows[-1]["identifier_aliases"] = [
                candidate
                for candidate in entity_identifiers
                if str(candidate or "").strip() and candidate != identifier_value
            ]
            rows[-1]["identifier_search"] = " ".join(
                [rows[-1]["identifier"]] + rows[-1]["identifier_aliases"]
            ).strip()

    whatsapp_links = {}
    for row in identifier_rows:
        if _default_service(row.service) != "whatsapp":
            continue
        for candidate in _contact_lookup_keys("whatsapp", row.identifier):
            whatsapp_links.setdefault(candidate, row)

    whatsapp_names = {}

    def _remember_whatsapp_name(identifier_value: str, name_value: str) -> None:
        cleaned_name = _clean_detected_name(name_value)
        if not cleaned_name:
            return
        for candidate in _contact_lookup_keys("whatsapp", identifier_value):
            whatsapp_names.setdefault(candidate, cleaned_name)

    whatsapp_state = transport.get_runtime_state("whatsapp") or {}
    wa_contacts = whatsapp_state.get("contacts") or []
    wa_accounts = whatsapp_state.get("accounts") or []
    wa_account_keys = {
        _contact_lookup_key("whatsapp", value)
        for value in wa_accounts
        if str(value or "").strip()
    }
    if isinstance(wa_contacts, list):
        for item in wa_contacts:
            if not isinstance(item, dict):
                continue
            candidate = str(item.get("jid") or item.get("identifier") or "").strip()
            if not candidate:
                continue
            candidate_keys = _contact_lookup_keys("whatsapp", candidate)
            if candidate_keys and candidate_keys[0] in wa_account_keys:
                continue
            detected_name = _clean_detected_name(
                item.get("name")
                or item.get("push_name")
                or item.get("full_name")
                or item.get("first_name")
                or item.get("chat")
                or ""
            )
            if detected_name.lower() == "linked account":
                continue
            _remember_whatsapp_name(candidate, detected_name)
            linked = None
            for lookup_key in candidate_keys:
                linked = whatsapp_links.get(lookup_key)
                if linked is not None:
                    break
            add_row(
                service="whatsapp",
                identifier=candidate,
                person=(linked.person if linked else None),
                source="whatsapp_runtime",
                account=detected_name,
                detected_name=detected_name,
            )

    for row in rows:
        if _default_service(row.get("service")) != "whatsapp":
            continue
        if row.get("detected_name"):
            continue
        for lookup_key in _contact_lookup_keys("whatsapp", row.get("identifier")):
            detected_name = whatsapp_names.get(lookup_key)
            if not detected_name:
                continue
            row["detected_name"] = detected_name
            row["person_name"] = (
                str(row.get("linked_person_name") or "").strip()
                or detected_name
                or str(row.get("identifier") or "").strip()
            )
            break

    rows.sort(
        key=lambda row: (
            0 if row.get("linked_person") else 1,
            row["person_name"].lower(),
            _service_order(row.get("service")),
            row["identifier"],
        )
    )
    return rows


def _recent_manual_contacts(
    user,
    *,
    current_service: str,
    current_identifier: str,
    current_person: Person | None,
    limit: int = 12,
):
    all_rows = _manual_contact_rows(user)
    if not all_rows:
        return []

    def _normalize_recent_identifier(service_value: str, identifier_value: str) -> str:
        svc = _default_service(service_value)
        raw = str(identifier_value or "").strip()
        if svc == "whatsapp" and "@" in raw:
            return raw.split("@", 1)[0].strip()
        return raw

    current_service_key = _default_service(current_service)
    current_identifier_value = _normalize_recent_identifier(
        current_service_key, str(current_identifier or "").strip()
    )
    current_person_id = str(current_person.id) if current_person else ""

    row_by_key = {
        (
            str(row.get("service") or "").strip().lower(),
            _normalize_recent_identifier(
                str(row.get("service") or "").strip().lower(),
                str(row.get("identifier") or "").strip(),
            ),
        ): row
        for row in all_rows
    }
    by_person_service = {}
    person_links = (
        PersonIdentifier.objects.filter(user=user)
        .select_related("person")
        .order_by("person__name", "service", "identifier")
    )
    for link in person_links:
        person_id = str(link.person_id or "")
        if not person_id:
            continue
        service_key = _default_service(link.service)
        identifier_value = _normalize_recent_identifier(
            service_key, str(link.identifier or "").strip()
        )
        if not identifier_value:
            continue
        by_person_service.setdefault(person_id, {})
        if service_key not in by_person_service[person_id]:
            by_person_service[person_id][service_key] = {
                "identifier": identifier_value,
                "person_name": str(link.person.name or "").strip() or identifier_value,
            }
    if current_person_id and current_service_key and current_identifier_value:
        by_person_service.setdefault(current_person_id, {})
        by_person_service[current_person_id][current_service_key] = {
            "identifier": current_identifier_value,
            "person_name": str(current_person.name or "").strip()
            if current_person is not None
            else current_identifier_value,
        }
    ordered_keys = []
    seen_keys = set()
    recent_values = (
        Message.objects.filter(
            user=user,
            session__identifier__isnull=False,
        )
        .values_list(
            "session__identifier__service",
            "session__identifier__identifier",
        )
        .order_by("-ts", "-id")[:1000]
    )
    for service_value, identifier_value in recent_values:
        service_key = _default_service(service_value)
        normalized_identifier = _normalize_recent_identifier(
            service_key, str(identifier_value or "").strip()
        )
        key = (
            service_key,
            normalized_identifier,
        )
        if not key[1] or key in seen_keys:
            continue
        seen_keys.add(key)
        ordered_keys.append(key)
        if len(ordered_keys) >= limit:
            break

    current_key = (current_service_key, current_identifier_value)
    if current_key[1]:
        if current_key in ordered_keys:
            ordered_keys.remove(current_key)
        ordered_keys.insert(0, current_key)

    rows = []
    seen_people = set()
    seen_unknown = set()
    for service_key, identifier_value in ordered_keys:
        row = dict(row_by_key.get((service_key, identifier_value)) or {})
        if not row:
            fallback_person = None
            if (
                current_person is not None
                and service_key == current_service_key
                and identifier_value == current_identifier_value
            ):
                fallback_person = current_person
            row = {
                "person_name": (
                    str(fallback_person.name or "").strip()
                    if fallback_person is not None
                    else identifier_value
                )
                or identifier_value,
                "linked_person_name": (
                    str(fallback_person.name or "").strip()
                    if fallback_person is not None
                    else ""
                ),
                "detected_name": "",
                "service": service_key,
                "service_icon_class": _service_icon_class(service_key),
                "identifier": identifier_value,
                "person_id": str(fallback_person.id) if fallback_person else "",
                "linked_person": bool(fallback_person),
                "source": "recent",
            }
        row["person_id"] = str(row.get("person_id") or "")
        person_id = row["person_id"]
        if person_id:
            if person_id in seen_people:
                continue
            seen_people.add(person_id)
            service_map = dict(by_person_service.get(person_id) or {})
            if service_key not in service_map and identifier_value:
                service_map[service_key] = {
                    "identifier": identifier_value,
                    "person_name": str(row.get("person_name") or "").strip()
                    or identifier_value,
                }

            selected_service = service_key
            selected_identifier = identifier_value
            if person_id == current_person_id and current_service_key in service_map:
                selected_service = current_service_key
                selected_identifier = str(
                    (service_map.get(current_service_key) or {}).get("identifier") or ""
                ).strip()
            elif selected_service not in service_map:
                for fallback_service in ("whatsapp", "signal", "instagram", "xmpp"):
                    if fallback_service in service_map:
                        selected_service = fallback_service
                        selected_identifier = str(
                            (service_map.get(fallback_service) or {}).get("identifier")
                            or ""
                        ).strip()
                        break
            selected_identifier = selected_identifier or identifier_value
            selected_identifier = _normalize_recent_identifier(
                selected_service, selected_identifier
            )

            row["service"] = selected_service
            row["service_label"] = _service_label(selected_service)
            row["identifier"] = selected_identifier
            row["person_name"] = (
                str(row.get("linked_person_name") or "").strip()
                or str(row.get("person_name") or "").strip()
                or selected_identifier
            )
            service_identifiers = {}
            for svc in ("signal", "whatsapp", "instagram", "xmpp"):
                svc_identifier = str(
                    (service_map.get(svc) or {}).get("identifier") or ""
                ).strip()
                svc_identifier = _normalize_recent_identifier(svc, svc_identifier)
                if svc_identifier:
                    service_identifiers[svc] = svc_identifier
        else:
            unknown_key = (service_key, identifier_value)
            if unknown_key in seen_unknown:
                continue
            seen_unknown.add(unknown_key)
            row["service_label"] = _service_label(service_key)
            service_identifiers = {
                service_key: _normalize_recent_identifier(service_key, identifier_value)
            }

        row["is_active"] = (
            row.get("service") == current_service_key
            and _normalize_recent_identifier(
                str(row.get("service") or "").strip().lower(),
                str(row.get("identifier") or "").strip(),
            )
            == current_identifier_value
        )
        if not person_id:
            service_identifiers = {
                service_key: _normalize_recent_identifier(service_key, identifier_value)
            }
        if not service_identifiers:
            service_identifiers = {
                str(row.get("service") or service_key).strip().lower(): _normalize_recent_identifier(
                    str(row.get("service") or service_key).strip().lower(),
                    str(row.get("identifier") or identifier_value).strip(),
                )
            }
        row["service_identifiers"] = service_identifiers
        row["service_identifiers_json"] = json.dumps(
            service_identifiers,
            separators=(",", ":"),
            sort_keys=True,
        )
        rows.append(row)
        if len(rows) >= limit:
            break
    return rows


def _name_for_match(value: str) -> str:
    lowered = re.sub(r"[^a-z0-9]+", " ", str(value or "").strip().lower())
    return re.sub(r"\s+", " ", lowered).strip()


def _suggest_people_for_candidate(candidate: dict, people: list[Person]) -> list[dict]:
    if not people:
        return []
    base_name = str(candidate.get("detected_name") or "").strip()
    if not base_name:
        return []
    base_norm = _name_for_match(base_name)
    if not base_norm:
        return []

    scored = []
    base_tokens = {token for token in base_norm.split(" ") if token}
    for person in people:
        person_norm = _name_for_match(person.name)
        if not person_norm:
            continue
        ratio = SequenceMatcher(None, base_norm, person_norm).ratio()
        person_tokens = {token for token in person_norm.split(" ") if token}
        overlap = 0.0
        if base_tokens and person_tokens:
            overlap = len(base_tokens & person_tokens) / max(
                len(base_tokens), len(person_tokens)
            )
        score = max(ratio, overlap)
        if score < 0.62:
            continue
        scored.append(
            {
                "person": person,
                "score": score,
            }
        )

    scored.sort(key=lambda item: item["score"], reverse=True)
    return scored[:3]


def _load_messages(user, person_identifier, limit):
    if person_identifier is None:
        return {"session": None, "messages": []}

    session, _ = ChatSession.objects.get_or_create(
        user=user,
        identifier=person_identifier,
    )
    messages = list(
        Message.objects.filter(user=user, session=session)
        .select_related("session", "session__identifier", "session__identifier__person")
        .order_by("-ts")[:limit]
    )
    messages.reverse()
    return {"session": session, "messages": messages}


def _panel_context(
    request,
    service: str,
    identifier: str,
    person: Person | None,
    render_mode: str,
    notice: str = "",
    level: str = "success",
):
    base = _context_base(request.user, service, identifier, person)
    limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
    session_bundle = _load_messages(request.user, base["person_identifier"], limit)
    serialized_messages = _serialize_messages_with_artifacts(session_bundle["messages"])
    last_ts = 0
    if session_bundle["messages"]:
        last_ts = int(session_bundle["messages"][-1].ts or 0)
    ws_token = _compose_ws_token(
        user_id=request.user.id,
        service=base["service"],
        identifier=base["identifier"],
        person_id=base["person"].id if base["person"] else None,
    )
    ws_url = ""
    if bool(getattr(settings, "COMPOSE_WS_ENABLED", False)):
        ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"

    platform_options = []
    if base["person"] is not None:
        linked_identifiers = list(
            PersonIdentifier.objects.filter(
                user=request.user,
                person=base["person"],
            ).order_by("service", "id")
        )
        by_service = {}
        for row in linked_identifiers:
            service_key = _default_service(row.service)
            identifier_value = str(row.identifier or "").strip()
            if not identifier_value:
                continue
            if service_key not in by_service:
                by_service[service_key] = identifier_value
        if base["service"] and base["identifier"]:
            by_service[base["service"]] = base["identifier"]

        for service_key in sorted(by_service.keys(), key=_service_order):
            identifier_value = by_service[service_key]
            platform_options.append(
                {
                    "service": service_key,
                    "service_label": _service_label(service_key),
                    "identifier": identifier_value,
                    "person_id": str(base["person"].id),
                    "is_active": (
                        service_key == base["service"]
                        and identifier_value == base["identifier"]
                    ),
                }
            )
    elif base["identifier"]:
        platform_options.append(
            {
                "service": base["service"],
                "service_label": _service_label(base["service"]),
                "identifier": base["identifier"],
                "person_id": "",
                "is_active": True,
            }
        )

    unique_raw = (
        f"{base['service']}|{base['identifier']}|{request.user.id}|{time.time_ns()}"
    )
    unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
    typing_state = get_person_typing_state(
        user_id=request.user.id,
        person_id=base["person"].id if base["person"] else None,
    )
    recent_contacts = _recent_manual_contacts(
        request.user,
        current_service=base["service"],
        current_identifier=base["identifier"],
        current_person=base["person"],
        limit=12,
    )
    signal_ingest_warning = ""
    if base["service"] == "signal":
        signal_state = transport.get_runtime_state("signal") or {}
        error_type = str(signal_state.get("last_inbound_exception_type") or "").strip()
        error_message = str(
            signal_state.get("last_inbound_exception_message") or ""
        ).strip()
        try:
            error_ts = int(signal_state.get("last_inbound_exception_ts") or 0)
        except Exception:
            error_ts = 0
        try:
            ok_ts = int(signal_state.get("last_inbound_ok_ts") or 0)
        except Exception:
            ok_ts = 0
        if (error_type or error_message) and error_ts >= ok_ts:
            signal_ingest_warning = (
                "Signal inbound decrypt/metadata error detected"
                + (f" ({error_type})" if error_type else "")
                + (f": {error_message[:220]}" if error_message else "")
            )

    return {
        "service": base["service"],
        "identifier": base["identifier"],
        "person": base["person"],
        "serialized_messages": serialized_messages,
        "last_ts": last_ts,
        "limit": limit,
        "notice_message": notice,
        "notice_level": level,
        "render_mode": render_mode,
        "compose_ws_url": ws_url,
        "manual_icon_class": "fa-solid fa-paper-plane",
        "panel_id": f"compose-panel-{unique}",
        "typing_state_json": json.dumps(typing_state),
        "capability_send": supports(base["service"], "send"),
        "capability_send_reason": unsupported_reason(base["service"], "send"),
        "platform_options": platform_options,
        "recent_contacts": recent_contacts,
        "signal_ingest_warning": signal_ingest_warning,
        "is_group": base.get("is_group", False),
        "group_name": base.get("group_name", ""),
    }


class ComposeContactsDropdown(LoginRequiredMixin, View):
    def get(self, request):
        all_value = str(request.GET.get("all") or "").strip().lower()
        fetch_all = all_value in {"1", "true", "yes", "y", "all"}
        preview_limit = 10
        contact_rows = _manual_contact_rows(request.user)
        rows = contact_rows if fetch_all else contact_rows[:preview_limit]
        return render(
            request,
            "partials/nav-contacts-dropdown.html",
            {
                "items": rows,
                "manual_icon_class": "fa-solid fa-paper-plane",
                "is_preview": not fetch_all,
                "fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
                "match_url": reverse("compose_contact_match"),
            },
        )


class ComposeWorkspace(LoginRequiredMixin, View):
    template_name = "pages/compose-workspace.html"

    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        limit = _safe_limit(request.GET.get("limit") or 40)

        initial_widget_url = ""
        history_widget_query = {"limit": limit}
        if identifier or person is not None:
            base = _context_base(request.user, service, identifier, person)
            if base["identifier"]:
                urls = _compose_urls(
                    base["service"],
                    base["identifier"],
                    base["person"].id if base["person"] else None,
                )
                initial_widget_url = (
                    f"{urls['widget_url']}&{urlencode({'limit': limit})}"
                )
            if base["service"]:
                history_widget_query["service"] = base["service"]
            if base["person"] is not None:
                history_widget_query["person"] = str(base["person"].id)
            elif base["identifier"]:
                history_widget_query["q"] = str(base["identifier"])

        contacts_widget_url = reverse("compose_workspace_contacts_widget")
        history_widget_url = (
            f"{reverse('compose_workspace_history_widget')}"
            f"?{urlencode(history_widget_query)}"
        )
        context = {
            "contacts_widget_url": contacts_widget_url,
            "history_widget_url": history_widget_url,
            "initial_widget_url": initial_widget_url,
        }
        return render(request, self.template_name, context)


class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
    def _context(self, request):
        page = _safe_page(request.GET.get("page") or 1)
        search_query = _normalize_contact_search_query(request.GET.get("q"))
        total_contacts = _manual_contact_rows(request.user)
        is_search_results = bool(search_query)
        visible_limit = page * COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
        fetch_limit = visible_limit + COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE
        if is_search_results:
            matched_rows = _filter_manual_contact_rows(total_contacts, search_query)
            source_rows = matched_rows
            visible_count = min(len(source_rows), visible_limit)
            visible_rows = source_rows[:visible_count]
            next_count = min(
                COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
                max(0, len(source_rows) - visible_count),
            )
            total_matches = len(matched_rows)
            result_mode = "search"
        else:
            source_rows = _recent_manual_contacts(
                request.user,
                current_service="",
                current_identifier="",
                current_person=None,
                limit=fetch_limit,
            )
            if not source_rows:
                source_rows = total_contacts[:fetch_limit]
                result_mode = "all_contacts"
            else:
                result_mode = "active_chats"
            visible_count = min(len(source_rows), visible_limit)
            visible_rows = source_rows[:visible_count]
            next_count = min(
                COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE,
                max(0, len(source_rows) - visible_count),
            )
            total_matches = len(source_rows)
        unique = "compose-workspace-contacts"
        return {
            "title": "Contacts",
            "unique": unique,
            "window_content": "partials/compose-workspace-contacts-widget.html",
            "widget_options": 'gs-w="3" gs-h="12" gs-x="0" gs-y="0" gs-min-w="3"',
            "contact_rows": visible_rows,
            "launcher_form_id": f"{unique}-form",
            "search_input_id": f"{unique}-search",
            "results_id": f"{unique}-results",
            "results_url": f"{reverse('compose_workspace_contacts_widget')}?fragment=results",
            "search_query": search_query,
            "total_contacts": len(total_contacts),
            "total_matches": total_matches,
            "visible_count": visible_count,
            "has_more": visible_count < len(source_rows),
            "next_page": page + 1,
            "next_count": next_count,
            "result_mode": result_mode,
            "is_search_results": is_search_results,
        }

    def get(self, request):
        context = self._context(request)
        if str(request.GET.get("fragment") or "").strip().lower() == "results":
            return render(
                request,
                "partials/compose-workspace-contact-results.html",
                context,
            )
        return render(request, "mixins/wm/widget.html", context)


class ComposeWorkspaceHistoryWidget(LoginRequiredMixin, View):
    def _history_rows(self, request):
        page = _safe_page(request.GET.get("page") or 1)
        limit = _safe_limit(request.GET.get("limit") or 40)
        search_query = _normalize_contact_search_query(request.GET.get("q"))
        service = str(request.GET.get("service") or "").strip().lower()
        if service not in {"signal", "whatsapp", "instagram", "xmpp"}:
            service = ""
        direction = _safe_history_direction(request.GET.get("direction"))
        days = _safe_history_days(request.GET.get("days"))
        person_scope = None
        person_id = str(request.GET.get("person") or "").strip()
        if person_id:
            person_scope = (
                Person.objects.filter(user=request.user, id=person_id)
                .only("id", "name")
                .first()
            )
            if person_scope is None:
                person_id = ""

        queryset = (
            Message.objects.filter(user=request.user)
            .select_related("session__identifier__person")
            .order_by("-ts", "-id")
        )
        if service:
            queryset = queryset.filter(session__identifier__service=service)
        if person_id:
            queryset = queryset.filter(session__identifier__person_id=person_id)
        if direction == "outgoing":
            queryset = queryset.filter(custom_author__in=["USER", "BOT"])
        elif direction == "incoming":
            queryset = queryset.exclude(custom_author__in=["USER", "BOT"])
        if days != "all":
            cutoff_ts = int(time.time() * 1000) - (int(days) * 24 * 60 * 60 * 1000)
            queryset = queryset.filter(ts__gte=cutoff_ts)

        if search_query:
            for token in [token for token in search_query.split(" ") if token]:
                queryset = queryset.filter(
                    Q(text__icontains=token)
                    | Q(session__identifier__person__name__icontains=token)
                    | Q(session__identifier__identifier__icontains=token)
                    | Q(source_message_id__icontains=token)
                )

        offset = max(0, (page - 1) * COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE)
        rows = list(queryset[offset : offset + COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE + 1])
        has_more = len(rows) > COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE
        visible_rows = rows[:COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE]

        serialized_rows = []
        for message in visible_rows:
            identifier_obj = getattr(getattr(message, "session", None), "identifier", None)
            person = getattr(identifier_obj, "person", None)
            service_key = _default_service(getattr(identifier_obj, "service", "") or "")
            identifier_value = str(getattr(identifier_obj, "identifier", "") or "").strip()
            urls = _compose_urls(
                service_key,
                identifier_value,
                getattr(person, "id", None),
            )
            serialized_rows.append(
                {
                    "id": str(message.id),
                    "person_name": str(getattr(person, "name", "") or identifier_value),
                    "person_id": str(getattr(person, "id", "") or ""),
                    "identifier": identifier_value,
                    "service": service_key,
                    "service_label": _service_label(service_key),
                    "outgoing": _is_outgoing(message),
                    "direction_label": "Outgoing" if _is_outgoing(message) else "Incoming",
                    "display_ts": _format_ts_datetime_label(int(message.ts or 0)),
                    "text_preview": _history_preview_text(message.text or ""),
                    "compose_widget_url": urls["widget_url"],
                    "compose_page_url": urls["page_url"],
                }
            )

        return {
            "page": page,
            "limit": limit,
            "search_query": search_query,
            "service": service,
            "direction": direction,
            "days": days,
            "person_scope": person_scope,
            "person_scope_id": person_id,
            "history_rows": serialized_rows,
            "has_more": has_more,
            "next_page": page + 1,
            "result_start": offset + 1 if serialized_rows else 0,
            "result_end": offset + len(serialized_rows),
        }

    def _context(self, request):
        unique = "compose-workspace-history"
        rows_context = self._history_rows(request)
        return {
            "title": "Message History",
            "unique": unique,
            "window_content": "partials/compose-workspace-history-widget.html",
            "widget_options": 'gs-w="8" gs-h="14" gs-x="4" gs-y="0" gs-min-w="4"',
            "widget_icon": "fa-solid fa-clock-rotate-left",
            "browser_form_id": f"{unique}-form",
            "search_input_id": f"{unique}-search",
            "service_input_id": f"{unique}-service",
            "direction_input_id": f"{unique}-direction",
            "days_input_id": f"{unique}-days",
            "thread_limit_input_id": f"{unique}-limit",
            "results_id": f"{unique}-results",
            "results_url": f"{reverse('compose_workspace_history_widget')}?fragment=results",
            "service_options": [
                ("", "All services"),
                ("signal", "Signal"),
                ("whatsapp", "WhatsApp"),
                ("instagram", "Instagram"),
                ("xmpp", "XMPP"),
            ],
            "direction_options": [
                ("", "All directions"),
                ("incoming", "Incoming"),
                ("outgoing", "Outgoing"),
            ],
            "days_options": [
                ("7", "Last 7 days"),
                ("30", "Last 30 days"),
                ("90", "Last 90 days"),
                ("365", "Last year"),
                ("all", "All time"),
            ],
            "thread_limit_options": COMPOSE_THREAD_WINDOW_OPTIONS,
            **rows_context,
        }

    def get(self, request):
        context = self._context(request)
        if str(request.GET.get("fragment") or "").strip().lower() == "results":
            return render(
                request,
                "partials/compose-workspace-history-results.html",
                context,
            )
        return render(request, "mixins/wm/widget.html", context)


class ComposeContactMatch(LoginRequiredMixin, View):
    template_name = "pages/compose-contact-match.html"

    def _service_choices(self):
        return [
            ("signal", "Signal"),
            ("whatsapp", "WhatsApp"),
            ("instagram", "Instagram"),
            ("xmpp", "XMPP"),
        ]

    def _context(self, request, notice="", level="info"):
        people_qs = Person.objects.filter(user=request.user).order_by("name")
        people = list(people_qs)
        candidates = _manual_contact_rows(request.user)
        for row in candidates:
            row["suggestions"] = []
            if row.get("linked_person"):
                continue
            row["suggestions"] = _suggest_people_for_candidate(row, people)
        return {
            "people": people,
            "candidates": candidates,
            "service_choices": self._service_choices(),
            "notice_message": notice,
            "notice_level": level,
            "prefill_service": _default_service(request.GET.get("service")),
            "prefill_identifier": str(request.GET.get("identifier") or "").strip(),
        }

    def get(self, request):
        return render(request, self.template_name, self._context(request))

    def _signal_companion_identifiers(self, identifier: str) -> set[str]:
        value = str(identifier or "").strip()
        if not value:
            return set()
        companions = set()
        runtime_aliases = _signal_runtime_alias_map()
        for variant in _identifier_variants("signal", value):
            companions.update(runtime_aliases.get(variant) or set())
        source_shape = _signal_identifier_shape(value)
        signal_rows = Chat.objects.filter(source_uuid=value) | Chat.objects.filter(
            source_number=value
        )
        for chat in signal_rows.order_by("-id")[:1000]:
            for candidate in (chat.source_uuid, chat.source_number):
                cleaned = str(candidate or "").strip()
                if not cleaned or cleaned == value:
                    continue
                # Keep auto-linking conservative: only same-shape companions.
                if source_shape != "other":
                    candidate_shape = _signal_identifier_shape(cleaned)
                    if candidate_shape != source_shape:
                        continue
                companions.add(cleaned)
        return companions

    def post(self, request):
        person_id = str(request.POST.get("person_id") or "").strip()
        person_name = str(request.POST.get("person_name") or "").strip()
        service = _default_service(request.POST.get("service"))
        identifier = str(request.POST.get("identifier") or "").strip()
        if not identifier:
            return render(
                request,
                self.template_name,
                self._context(request, "Identifier is required.", "warning"),
            )
        person = None
        if person_id:
            person = Person.objects.filter(id=person_id, user=request.user).first()
        if person is None and person_name:
            person = Person.objects.create(user=request.user, name=person_name)
        if person is None:
            return render(
                request,
                self.template_name,
                self._context(request, "Select a person or create one.", "warning"),
            )

        row = PersonIdentifier.objects.filter(
            user=request.user,
            service=service,
            identifier=identifier,
        ).first()
        if row is None:
            PersonIdentifier.objects.create(
                user=request.user,
                person=person,
                service=service,
                identifier=identifier,
            )
            message = f"Linked {identifier} ({service}) to {person.name}."
        else:
            if row.person_id != person.id:
                row.person = person
                row.save(update_fields=["person"])
                message = f"Re-linked {identifier} ({service}) to {person.name}."
            else:
                message = (
                    f"{identifier} ({service}) is already linked to {person.name}."
                )

        linked_companions = 0
        skipped_companions = 0
        if service == "signal":
            companions = self._signal_companion_identifiers(identifier)
            for candidate in companions:
                existing = PersonIdentifier.objects.filter(
                    user=request.user,
                    service="signal",
                    identifier=candidate,
                ).first()
                if existing is None:
                    PersonIdentifier.objects.create(
                        user=request.user,
                        person=person,
                        service="signal",
                        identifier=candidate,
                    )
                    linked_companions += 1
                    continue
                if existing.person_id != person.id:
                    skipped_companions += 1
            if linked_companions:
                message = (
                    f"{message} Added {linked_companions} companion Signal identifier"
                    f"{'' if linked_companions == 1 else 's'}."
                )
            if skipped_companions:
                message = (
                    f"{message} Skipped {skipped_companions} companion identifier"
                    f"{'' if skipped_companions == 1 else 's'} already linked to another person."
                )
        return render(
            request,
            self.template_name,
            self._context(request, message, "success"),
        )


class ComposeContactCreate(LoginRequiredMixin, View):
    template_name = "pages/compose-contact-match.html"

    def post(self, request):
        service = _default_service(request.POST.get("service"))
        identifier = str(request.POST.get("identifier") or "").strip()
        person_name = str(request.POST.get("person_name") or "").strip()

        if not identifier:
            return render(
                request,
                self.template_name,
                self._context(request, "Identifier is required.", "warning"),
            )

        if not person_name:
            return render(
                request,
                self.template_name,
                self._context(request, "Person name is required.", "warning"),
            )

        existing = PersonIdentifier.objects.filter(
            user=request.user,
            service=service,
            identifier=identifier,
        ).first()

        if existing and existing.person:
            return render(
                request,
                self.template_name,
                self._context(
                    request,
                    f"{identifier} ({service}) is already linked to {existing.person.name}.",
                    "warning",
                ),
            )

        person = Person.objects.create(user=request.user, name=person_name)

        PersonIdentifier.objects.create(
            user=request.user,
            person=person,
            service=service,
            identifier=identifier,
        )

        message = f"Created person '{person_name}' and linked {identifier} ({service})."
        return render(
            request,
            self.template_name,
            self._context(request, message, "success"),
        )


class ComposeContactCreateAll(LoginRequiredMixin, View):
    template_name = "pages/compose-contact-match.html"

    def post(self, request):
        candidates = _manual_contact_rows(request.user)

        created_count = 0
        skipped_count = 0
        errors = []

        for candidate in candidates:
            if candidate.get("linked_person"):
                skipped_count += 1
                continue

            detected_name = candidate.get("detected_name", "")
            if not detected_name:
                skipped_count += 1
                continue

            service = candidate.get("service", "")
            identifier = candidate.get("identifier", "")

            if not service or not identifier:
                skipped_count += 1
                continue

            existing = PersonIdentifier.objects.filter(
                user=request.user,
                service=service,
                identifier=identifier,
            ).first()

            if existing and existing.person:
                skipped_count += 1
                continue

            try:
                person = Person.objects.create(user=request.user, name=detected_name)

                PersonIdentifier.objects.create(
                    user=request.user,
                    person=person,
                    service=service,
                    identifier=identifier,
                )

                created_count += 1
            except Exception as e:
                errors.append(f"{identifier} ({service}): {str(e)}")
                skipped_count += 1

        if errors:
            message = (
                f"Created {created_count} contacts. Errors: {'; '.join(errors[:5])}"
            )
            level = "warning"
        elif created_count > 0:
            message = f"Created {created_count} new contact{'s' if created_count != 1 else ''}. Skipped {skipped_count}."
            level = "success"
        else:
            message = f"No new contacts to create. {skipped_count} already linked or without names."
            level = "info"

        return render(
            request,
            self.template_name,
            self._context(request, message, level),
        )


class ComposePage(LoginRequiredMixin, View):
    template_name = "pages/compose.html"

    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return HttpResponseBadRequest("Missing contact identifier.")

        context = _panel_context(
            request=request,
            service=service,
            identifier=identifier,
            person=person,
            render_mode="page",
        )
        return render(request, self.template_name, context)


class ComposeWidget(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return HttpResponseBadRequest("Missing contact identifier.")

        panel_context = _panel_context(
            request=request,
            service=service,
            identifier=identifier,
            person=person,
            render_mode="widget",
        )
        title_name = (
            panel_context["person"].name
            if panel_context["person"] is not None
            else panel_context["identifier"]
        )
        widget_key = hashlib.sha1(
            (
                f"{request.user.pk}:"
                f"{panel_context['service']}:"
                f"{panel_context['identifier']}:"
                f"{getattr(panel_context['person'], 'pk', '')}"
            ).encode("utf-8")
        ).hexdigest()[:12]
        context = {
            "title": f"Manual Chat: {title_name}",
            "unique": f"compose-widget-{widget_key}",
            "window_content": "partials/compose-panel.html",
            "widget_control_class": "gia-widget-control-no-scroll",
            "widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
            "widget_style_hrefs": [static("css/compose-panel.css")],
            "widget_script_srcs": [
                static("js/compose-panel-core.js"),
                static("js/compose-panel-thread.js"),
                static("js/compose-panel-send.js"),
                static("js/compose-panel.js"),
            ],
            **panel_context,
        }
        return render(request, "mixins/wm/widget.html", context)


class ComposeThread(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return HttpResponseBadRequest("Missing contact identifier.")

        limit = _safe_limit(request.GET.get("limit") or 60)
        after_ts = _safe_after_ts(request.GET.get("after_ts"))
        base = _context_base(request.user, service, identifier, person)
        latest_ts = after_ts
        messages = []
        seed_previous = None
        session_ids = ComposeHistorySync._session_ids_for_scope(
            user=request.user,
            person=base["person"],
            service=base["service"],
            person_identifier=base["person_identifier"],
            explicit_identifier=base["identifier"],
        )
        if base["person_identifier"] is not None:
            session, _ = ChatSession.objects.get_or_create(
                user=request.user,
                identifier=base["person_identifier"],
            )
            # Don't convert UUIDs to int; keep them as UUIDs for the filter query
            session_ids = list({*session_ids, session.id})
        if session_ids:
            base_queryset = Message.objects.filter(
                user=request.user,
                session_id__in=session_ids,
            )
            queryset = base_queryset
            if after_ts > 0:
                seed_previous = (
                    base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
                )
                # Use a small rolling window to capture late/out-of-order timestamps.
                # Client-side dedupe by message id prevents duplicate rendering.
                window_start = max(0, int(after_ts) - 5 * 60 * 1000)
                queryset = queryset.filter(ts__gte=window_start)
            rows_desc = list(
                queryset.select_related(
                    "session",
                    "session__identifier",
                    "session__identifier__person",
                    "reply_to",
                ).order_by("-ts")[:limit]
            )
            rows_desc.reverse()
            messages = rows_desc
            newest = (
                Message.objects.filter(
                    user=request.user,
                    session_id__in=session_ids,
                )
                .order_by("-ts")
                .values_list("ts", flat=True)
                .first()
            )
            if newest:
                latest_ts = max(latest_ts, int(newest))
        serialized_messages = _serialize_messages_with_artifacts(messages)
        payload = {
            "messages": serialized_messages,
            "messages_html": _render_compose_message_rows(serialized_messages),
            "last_ts": latest_ts,
            "typing": get_person_typing_state(
                user_id=request.user.id,
                person_id=base["person"].id if base["person"] else None,
            ),
        }
        return JsonResponse(payload)


class ComposeHistorySync(LoginRequiredMixin, View):
    @staticmethod
    def _session_ids_for_identifier(user, person_identifier):
        if person_identifier is None:
            return []
        return list(
            ChatSession.objects.filter(
                user=user,
                identifier=person_identifier,
            ).values_list("id", flat=True)
        )

    @staticmethod
    def _identifier_variants(service: str, identifier: str):
        raw = str(identifier or "").strip()
        if not raw:
            return []
        values = {raw}
        if service == "whatsapp":
            digits = re.sub(r"[^0-9]", "", raw)
            if digits:
                values.add(digits)
                values.add(f"+{digits}")
                values.add(f"{digits}@s.whatsapp.net")
            if "@" in raw:
                local = raw.split("@", 1)[0].strip()
                if local:
                    values.add(local)
        elif service == "signal":
            # Signal identifiers can be UUID or phone number
            digits = re.sub(r"[^0-9]", "", raw)
            if digits and not raw.count("-") >= 4:
                # Likely a phone number; add variants
                values.add(digits)
                values.add(f"+{digits}")
            # If it looks like a UUID (has hyphens), keep only the original format
            # Signal UUIDs are strict and don't have variants
        return [value for value in values if value]

    @classmethod
    def _session_ids_for_scope(
        cls,
        user,
        person,
        service: str,
        person_identifier,
        explicit_identifier: str,
    ):
        identifiers = []
        if person_identifier is not None:
            identifiers.append(person_identifier)
        if person is not None:
            identifiers.extend(
                list(
                    PersonIdentifier.objects.filter(
                        user=user,
                        person=person,
                        service=service,
                    )
                )
            )
        variants = cls._identifier_variants(service, explicit_identifier)
        if variants:
            variant_qs = PersonIdentifier.objects.filter(
                user=user,
                service=service,
                identifier__in=variants,
            )
            if person is not None:
                variant_qs = variant_qs.filter(person=person)
            identifiers.extend(list(variant_qs))
        unique_ids = []
        seen = set()
        for row in identifiers:
            row_id = int(row.id)
            if row_id in seen:
                continue
            seen.add(row_id)
            unique_ids.append(row_id)
        if not unique_ids:
            return []
        result = list(
            ChatSession.objects.filter(
                user=user,
                identifier_id__in=unique_ids,
            ).values_list("id", flat=True)
        )
        return result

    @staticmethod
    def _reconcile_duplicate_messages(user, session_ids):
        if not session_ids:
            return 0
        rows = list(
            Message.objects.filter(
                user=user,
                session_id__in=session_ids,
            )
            .order_by("id")
            .values("id", "session_id", "ts", "sender_uuid", "text", "custom_author")
        )
        seen = {}
        duplicate_ids = []
        for row in rows:
            dedupe_key = (
                int(row.get("session_id") or 0),
                int(row.get("ts") or 0),
                str(row.get("sender_uuid") or ""),
                str(row.get("text") or ""),
                str(row.get("custom_author") or ""),
            )
            if dedupe_key in seen:
                duplicate_ids.append(row["id"])
                continue
            seen[dedupe_key] = row["id"]
        if not duplicate_ids:
            return 0
        Message.objects.filter(user=user, id__in=duplicate_ids).delete()
        return len(duplicate_ids)

    def post(self, request):
        service, identifier, person = _request_scope(request, "POST")
        if not identifier and person is None:
            return JsonResponse(
                {
                    "ok": False,
                    "message": "Missing contact identifier.",
                    "level": "danger",
                }
            )

        base = _context_base(request.user, service, identifier, person)
        if base["person_identifier"] is None:
            return JsonResponse(
                {
                    "ok": False,
                    "message": "No linked identifier for this contact yet.",
                    "level": "warning",
                }
            )

        session_ids = self._session_ids_for_scope(
            user=request.user,
            person=base["person"],
            service=base["service"],
            person_identifier=base["person_identifier"],
            explicit_identifier=base["identifier"],
        )
        before_count = 0
        if session_ids:
            before_count = Message.objects.filter(
                user=request.user,
                session_id__in=session_ids,
            ).count()

        runtime_result = {}
        if base["service"] == "whatsapp":
            command_id = transport.enqueue_runtime_command(
                "whatsapp",
                "force_history_sync",
                {
                    "identifier": base["identifier"],
                    "person_id": str(base["person"].id) if base["person"] else "",
                },
            )
            runtime_result = async_to_sync(transport.wait_runtime_command_result)(
                "whatsapp",
                command_id,
                timeout=25,
            )
            if runtime_result is None:
                return JsonResponse(
                    {
                        "ok": False,
                        "message": (
                            "History sync timed out. Runtime may still be processing; "
                            "watch Runtime Debug and retry."
                        ),
                        "level": "warning",
                    }
                )
            if not runtime_result.get("ok"):
                error_text = str(runtime_result.get("error") or "history_sync_failed")
                return JsonResponse(
                    {
                        "ok": False,
                        "message": f"History sync failed: {error_text}",
                        "level": "danger",
                    }
                )
        else:
            return JsonResponse(
                {
                    "ok": False,
                    "message": (
                        f"Force history sync is only available for WhatsApp right now "
                        f"(current: {base['service']})."
                    ),
                    "level": "warning",
                }
            )

        session_ids = self._session_ids_for_scope(
            user=request.user,
            person=base["person"],
            service=base["service"],
            person_identifier=base["person_identifier"],
            explicit_identifier=base["identifier"],
        )
        raw_after_count = 0
        if session_ids:
            raw_after_count = Message.objects.filter(
                user=request.user,
                session_id__in=session_ids,
            ).count()
        dedup_removed = self._reconcile_duplicate_messages(request.user, session_ids)
        after_count = raw_after_count
        if dedup_removed > 0:
            after_count = Message.objects.filter(
                user=request.user,
                session_id__in=session_ids,
            ).count()

        imported_count = max(0, int(raw_after_count) - int(before_count))
        net_new_count = max(0, int(after_count) - int(before_count))
        delta = max(0, int(after_count) - int(before_count))
        if delta > 0:
            detail = []
            if imported_count:
                detail.append(f"imported {imported_count}")
            if dedup_removed:
                detail.append(f"reconciled {dedup_removed} duplicate(s)")
            suffix = f" ({', '.join(detail)})" if detail else ""
            return JsonResponse(
                {
                    "ok": True,
                    "message": f"History sync complete. Net +{net_new_count} message(s){suffix}.",
                    "level": "success",
                    "new_messages": net_new_count,
                    "imported_messages": imported_count,
                    "reconciled_duplicates": dedup_removed,
                    "before": before_count,
                    "after": after_count,
                    "runtime_result": runtime_result,
                }
            )
        if dedup_removed > 0:
            return JsonResponse(
                {
                    "ok": True,
                    "message": (
                        f"History sync complete. Reconciled {dedup_removed} duplicate message(s)."
                    ),
                    "level": "success",
                    "new_messages": 0,
                    "imported_messages": imported_count,
                    "reconciled_duplicates": dedup_removed,
                    "before": before_count,
                    "after": after_count,
                    "runtime_result": runtime_result,
                }
            )
        return JsonResponse(
            {
                "ok": True,
                "message": (
                    (
                        "History sync completed, but this WhatsApp runtime session does not expose "
                        "message text history yet "
                        f"({str(runtime_result.get('sqlite_error') or 'no_message_history_source')}). "
                        "Live incoming/outgoing messages will continue to sync."
                    )
                    if str(runtime_result.get("sqlite_error") or "").strip()
                    else "History sync completed. No new messages were found yet; retry in a few seconds."
                ),
                "level": "info",
                "new_messages": 0,
                "imported_messages": imported_count,
                "reconciled_duplicates": dedup_removed,
                "before": before_count,
                "after": after_count,
                "runtime_result": runtime_result,
            }
        )


class ComposeCancelSend(LoginRequiredMixin, View):
    def post(self, request):
        service, identifier, _ = _request_scope(request, "POST")
        command_id = str(request.POST.get("command_id") or "").strip()
        if not identifier:
            return JsonResponse({"ok": False, "error": "missing_identifier"})
        # If a specific command_id is supplied, cancel that command only.
        if command_id:
            ok = transport.cancel_runtime_command(service, command_id)
            return JsonResponse({"ok": True, "cancelled": [command_id] if ok else []})
        cancelled = transport.cancel_runtime_commands_for_recipient(service, identifier)
        return JsonResponse({"ok": True, "cancelled": cancelled})


class ComposeCommandResult(LoginRequiredMixin, View):
    """Return the runtime command result for a queued send (if available).

    GET parameters: `service`, `command_id`.
    Returns JSON: if pending -> {"pending": True}, else returns the result dict.
    """

    def get(self, request):
        timeout_s = 30.0
        force_json = str(request.GET.get("format") or "").strip().lower() == "json"
        is_hx_request = (
            str(request.headers.get("HX-Request") or "").strip().lower() == "true"
        ) and not force_json
        service = _default_service(request.GET.get("service"))
        command_id = str(request.GET.get("command_id") or "").strip()
        if not command_id:
            if is_hx_request:
                return render(
                    request,
                    "partials/compose-send-status.html",
                    {
                        "notice_message": "Missing command id.",
                        "notice_level": "warning",
                    },
                )
            return JsonResponse(
                {"ok": False, "error": "missing_command_id"}, status=400
            )
        # Non-blocking check for runtime command result
        result = async_to_sync(transport.wait_runtime_command_result)(
            service, command_id, timeout=0.1
        )
        if result is None:
            age_s = transport.runtime_command_age_seconds(service, command_id)
            if age_s is not None and age_s >= timeout_s:
                timeout_result = {
                    "ok": False,
                    "error": f"runtime_command_timeout:{int(timeout_s)}s",
                }
                if is_hx_request:
                    return render(
                        request,
                        "partials/compose-send-status.html",
                        {
                            "notice_message": str(
                                timeout_result.get("error") or "Send failed."
                            ),
                            "notice_level": "danger",
                        },
                    )
                return JsonResponse({"pending": False, "result": timeout_result})
            return HttpResponse(status=204)
        if is_hx_request:
            ok = bool(result.get("ok")) if isinstance(result, dict) else False
            message = "" if ok else str((result or {}).get("error") or "Send failed.")
            level = "success" if ok else "danger"
            return render(
                request,
                "partials/compose-send-status.html",
                {
                    "notice_message": message,
                    "notice_level": level,
                },
            )
        return JsonResponse({"pending": False, "result": result})


class ComposeToggleCommand(LoginRequiredMixin, View):
    def post(self, request):
        service, identifier, _ = _request_scope(request, "POST")
        channel_identifier = _canonical_command_channel_identifier(
            service, str(identifier or "")
        )
        if not channel_identifier:
            return JsonResponse(
                {"ok": False, "error": "missing_identifier"}, status=400
            )
        if service not in {"web", "xmpp", "signal", "whatsapp"}:
            return JsonResponse(
                {"ok": False, "error": f"unsupported_service:{service}"},
                status=400,
            )
        slug = str(request.POST.get("slug") or "bp").strip().lower() or "bp"
        if slug in {"bp_set", "bp_set_range"}:
            slug = "bp"
        enabled = str(request.POST.get("enabled") or "1").strip().lower() in {
            "1",
            "true",
            "yes",
            "on",
        }
        if slug == "task_announce":
            ok, error = _toggle_task_announce_for_channel(
                user=request.user,
                service=service,
                identifier=channel_identifier,
                enabled=enabled,
            )
        else:
            ok, error = _toggle_command_for_channel(
                user=request.user,
                service=service,
                identifier=channel_identifier,
                slug=slug,
                enabled=enabled,
            )
        if not ok:
            return JsonResponse(
                {
                    "ok": False,
                    "error": error or "command_toggle_failed",
                },
                status=400,
            )
        command_options = _command_options_for_channel(
            request.user, service, channel_identifier
        )
        for row in command_options:
            if row.get("slug") == slug:
                row["enabled_here"] = bool(enabled)
        message = (
            f"{slug} enabled for this chat."
            if enabled
            else f"{slug} disabled for this chat."
        )
        scoped_settings_url = f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
        return JsonResponse(
            {
                "ok": True,
                "message": message,
                "slug": slug,
                "enabled": bool(enabled),
                "command_options": command_options,
                "settings_url": (
                    f"{reverse('tasks_settings')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
                    if slug == "task_announce"
                    else scoped_settings_url
                ),
            }
        )


class ComposeBindBP(ComposeToggleCommand):
    def post(self, request):
        service, identifier, _ = _request_scope(request, "POST")
        ok, error = _toggle_command_for_channel(
            user=request.user,
            service=service,
            identifier=str(identifier or ""),
            slug="bp",
            enabled=True,
        )
        if not ok:
            return JsonResponse(
                {"ok": False, "error": error or "command_toggle_failed"},
                status=400,
            )
        return JsonResponse(
            {
                "ok": True,
                "message": "bp enabled for this chat.",
                "slug": "bp",
                "enabled": True,
                "settings_url": f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': str(identifier or '')})}",
            }
        )


class ComposeMediaBlob(LoginRequiredMixin, View):
    """
    Serve cached media blobs for authenticated compose image previews.
    """

    def get(self, request):
        blob_key = str(request.GET.get("key") or "").strip()
        if not blob_key:
            return HttpResponseBadRequest("Missing blob key.")

        row = media_bridge.get_blob(blob_key)
        if not row:
            return HttpResponseNotFound("Blob not found.")

        content = row.get("content") or b""
        content_type = str(row.get("content_type") or "application/octet-stream")
        filename = str(row.get("filename") or "attachment.bin")
        response = HttpResponse(content, content_type=content_type)
        response["Content-Length"] = str(len(content))
        response["Content-Disposition"] = f'inline; filename="{filename}"'
        return response


class ComposeDrafts(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "Missing contact identifier."})

        base = _context_base(request.user, service, identifier, person)
        limit = _safe_limit(request.GET.get("limit") or 60)
        messages = _messages_for_ai(request.user, base["person_identifier"], limit)
        if not messages:
            return JsonResponse(
                {
                    "ok": True,
                    "cached": False,
                    "source": "fallback",
                    "drafts": _fallback_drafts(),
                }
            )
        nocache = str(request.GET.get("nocache") or "").strip().lower() in {
            "1",
            "true",
            "yes",
            "on",
        }

        last_ts = int(messages[-1].ts or 0)
        cache_key = _compose_ai_cache_key(
            "drafts",
            request.user.id,
            base["service"],
            base["identifier"],
            base["person"].id if base["person"] else "",
            last_ts,
            limit,
        )
        cached = cache.get(cache_key)
        if cached and not nocache:
            if isinstance(cached, dict):
                return JsonResponse(
                    {
                        "ok": True,
                        "cached": True,
                        "source": str(cached.get("source") or "unknown"),
                        "drafts": list(cached.get("drafts") or []),
                    }
                )
            return JsonResponse(
                {
                    "ok": True,
                    "cached": True,
                    "source": "unknown",
                    "drafts": list(cached or []),
                }
            )

        ai_obj = AI.objects.filter(user=request.user).first()
        transcript = messages_to_string(
            messages,
            author_rewrites={
                "USER": _owner_name(request.user),
                "BOT": "Assistant",
            },
        )
        drafts = _fallback_drafts()
        source = "fallback"
        if ai_obj is not None:
            try:
                result = async_to_sync(ai_runner.run_prompt)(
                    _build_draft_prompt(
                        owner_name=_owner_name(request.user),
                        person_name=base["person"].name if base["person"] else "Other",
                        transcript=transcript,
                    ),
                    ai_obj,
                    operation="compose_drafts",
                )
                parsed = _parse_draft_options(result)
                if parsed:
                    drafts = parsed
                    source = "ai"
            except Exception:
                pass

        cache.set(
            cache_key,
            {"source": source, "drafts": drafts},
            timeout=COMPOSE_AI_CACHE_TTL,
        )
        return JsonResponse(
            {"ok": True, "cached": False, "source": source, "drafts": drafts}
        )


class ComposeSummary(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "Missing contact identifier."})

        base = _context_base(request.user, service, identifier, person)
        limit = _safe_limit(request.GET.get("limit") or 60)
        messages = _messages_for_ai(request.user, base["person_identifier"], limit)
        if not messages:
            return JsonResponse({"ok": True, "cached": False, "summary": ""})

        last_ts = int(messages[-1].ts or 0)
        cache_key = _compose_ai_cache_key(
            "summary",
            request.user.id,
            base["service"],
            base["identifier"],
            base["person"].id if base["person"] else "",
            last_ts,
            limit,
        )
        cached = cache.get(cache_key)
        if cached:
            return JsonResponse({"ok": True, "cached": True, "summary": cached})

        ai_obj = AI.objects.filter(user=request.user).first()
        transcript = messages_to_string(
            messages,
            author_rewrites={
                "USER": _owner_name(request.user),
                "BOT": "Assistant",
            },
        )
        if ai_obj is None:
            fallback = (
                "Headlines:\n"
                "- Conversation loaded.\n"
                "Patterns:\n"
                "- Not enough AI context configured yet.\n"
                "Suggested Next Message:\n"
                "- I want us to keep this clear and constructive."
            )
            cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
            return JsonResponse({"ok": True, "cached": False, "summary": fallback})

        try:
            summary = async_to_sync(ai_runner.run_prompt)(
                _build_summary_prompt(
                    owner_name=_owner_name(request.user),
                    person_name=base["person"].name if base["person"] else "Other",
                    transcript=transcript,
                ),
                ai_obj,
                operation="compose_summary",
            )
        except Exception as exc:
            return JsonResponse({"ok": False, "error": str(exc)})

        summary = str(summary or "").strip()
        cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
        return JsonResponse({"ok": True, "cached": False, "summary": summary})


class ComposeQuickInsights(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "Missing contact identifier."})

        base = _context_base(request.user, service, identifier, person)
        person = base["person"]
        if person is None:
            return JsonResponse(
                {
                    "ok": False,
                    "error": "Quick Insights needs a linked person.",
                }
            )

        conversation = (
            WorkspaceConversation.objects.filter(
                user=request.user,
                participants=person,
            )
            .order_by("-last_event_ts", "-created_at")
            .first()
        )
        if conversation is None:
            return JsonResponse(
                {
                    "ok": True,
                    "empty": True,
                    "summary": {
                        "person_name": person.name,
                        "platform": "",
                        "state": "Calibrating",
                        "thread": "",
                        "last_event": "",
                        "last_ai_run": "",
                        "workspace_created": "",
                        "snapshot_count": 0,
                        "platform_docs": _metric_copy("platform", "Platform"),
                        "state_docs": _metric_copy(
                            "stability_state", "Participant State"
                        ),
                        "thread_docs": _metric_copy("thread", "Thread"),
                        "snapshot_docs": {
                            "calculation": (
                                "Count of stored workspace metric snapshots for this person."
                            ),
                            "psychology": (
                                "More points improve trend reliability; sparse points are "
                                "best treated as directional signals."
                            ),
                        },
                    },
                    "rows": [],
                    "docs": [
                        "Quick Insights needs at least one workspace conversation snapshot.",
                        "Run AI operations in AI Workspace to generate the first data points.",
                    ],
                }
            )

        payload = _quick_insights_rows(conversation)
        participant_state = _participant_feedback_state_label(conversation, person)
        selected_platform_label = _service_label(base["service"])
        return JsonResponse(
            {
                "ok": True,
                "empty": False,
                "summary": {
                    "person_name": person.name,
                    "platform": selected_platform_label,
                    "platform_scope": "All linked platforms",
                    "state": participant_state
                    or conversation.get_stability_state_display(),
                    "stability_state": conversation.get_stability_state_display(),
                    "thread": conversation.platform_thread_id or "",
                    "last_event": (
                        _format_ts_label(conversation.last_event_ts or 0)
                        if conversation.last_event_ts
                        else ""
                    ),
                    "last_ai_run": (
                        dj_timezone.localtime(conversation.last_ai_run_at).strftime(
                            "%Y-%m-%d %H:%M"
                        )
                        if conversation.last_ai_run_at
                        else ""
                    ),
                    "workspace_created": dj_timezone.localtime(
                        conversation.created_at
                    ).strftime("%Y-%m-%d %H:%M"),
                    "snapshot_count": payload["snapshot_count"],
                    "platform_docs": _metric_copy("platform", "Platform"),
                    "state_docs": _metric_copy("stability_state", "Participant State"),
                    "thread_docs": _metric_copy("thread", "Thread"),
                    "snapshot_docs": {
                        "calculation": (
                            "Count of stored workspace metric snapshots for this person."
                        ),
                        "psychology": (
                            "More points improve trend reliability; sparse points are "
                            "best treated as directional signals."
                        ),
                    },
                },
                "rows": payload["rows"],
                "docs": [
                    "Each row shows current value, percent change vs previous point, and data-point count.",
                    "Arrow color indicates improving or risk direction for that metric.",
                    "State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
                    "Values are computed from all linked platform messages for this person.",
                    "Data labels are metric-specific (for example, day coverage is rated separately from message volume).",
                    "Face indicator maps value range to positive, mixed, or strained climate.",
                    "Use this card for fast triage; open AI Workspace for full graphs and details.",
                ],
            }
        )


class ComposeEngagePreview(LoginRequiredMixin, View):
    def get(self, request):
        service, identifier, person = _request_scope(request, "GET")
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "Missing contact identifier."})

        base = _context_base(request.user, service, identifier, person)
        limit = _safe_limit(request.GET.get("limit") or 60)
        messages = _messages_for_ai(request.user, base["person_identifier"], limit)
        transcript = messages_to_string(
            messages,
            author_rewrites={
                "USER": _owner_name(request.user),
                "BOT": "Assistant",
            },
        )

        owner_name = _owner_name(request.user)
        recipient_name = base["person"].name if base["person"] else "Other"
        plan = _latest_plan_for_person(request.user, base["person"])
        source_options = _engage_source_options(plan)
        source_options_with_custom = (
            [{"value": "auto", "label": "Auto"}]
            + source_options
            + [{"value": "custom", "label": "Custom"}]
        )
        source_ref = str(request.GET.get("source_ref") or "auto").strip().lower()
        custom_text = str(request.GET.get("custom_text") or "").strip()

        source_obj = None
        source_kind = ""
        selected_source = source_ref if source_ref else "auto"
        if selected_source == "custom":
            selected_source = "custom"
        else:
            if selected_source == "auto":
                fallback_obj, fallback_kind = _best_engage_source(plan)
                if fallback_obj is not None:
                    source_obj = fallback_obj
                    source_kind = fallback_kind
            else:
                source_obj, source_kind, explicit_ref = _engage_source_from_ref(
                    plan,
                    selected_source,
                )
                if source_obj is None:
                    selected_source = "auto"
                    fallback_obj, fallback_kind = _best_engage_source(plan)
                    if fallback_obj is not None:
                        source_obj = fallback_obj
                        source_kind = fallback_kind
                else:
                    selected_source = explicit_ref

        preview = ""
        outbound = ""
        artifact_label = "AI-generated"
        if selected_source == "custom":
            outbound = _plain_text(custom_text)
            if outbound:
                preview = f"**Custom Engage** (Correction)\n\nGuidance:\n{outbound}"
                artifact_label = "Custom"
            else:
                preview = (
                    "**Custom Engage** (Correction)\n\nGuidance:\n"
                    "Enter your custom engagement text to preview."
                )
        elif source_obj is not None:
            payload = _build_engage_payload(
                source_obj=source_obj,
                source_kind=source_kind,
                share_target="other",
                framing="shared",
                context_note="",
                owner_name=owner_name,
                recipient_name=recipient_name,
            )
            preview = str(payload.get("preview") or "").strip()
            outbound = _engage_body_only(payload.get("outbound") or "")
            artifact_label = (
                f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
            )
        else:
            ai_obj = AI.objects.filter(user=request.user).first()
            if ai_obj is not None:
                try:
                    generated = async_to_sync(ai_runner.run_prompt)(
                        _build_engage_prompt(owner_name, recipient_name, transcript),
                        ai_obj,
                        operation="compose_engage",
                    )
                    outbound = _plain_text(generated)
                except Exception:
                    outbound = ""
            if not outbound:
                outbound = (
                    "We should slow down, clarify what we mean, and respond with care."
                )
            preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"

        token = ""
        if outbound:
            token = signing.dumps(
                {
                    "u": request.user.id,
                    "s": base["service"],
                    "i": base["identifier"],
                    "p": str(base["person"].id) if base["person"] else "",
                    "outbound": outbound,
                    "exp": int(time.time()) + (60 * 10),
                },
                salt=COMPOSE_ENGAGE_TOKEN_SALT,
            )
        return JsonResponse(
            {
                "ok": True,
                "preview": preview,
                "outbound": outbound,
                "token": token,
                "artifact": artifact_label,
                "options": source_options_with_custom,
                "selected_source": selected_source,
                "custom_text": custom_text,
            }
        )


class ComposeEngageSend(LoginRequiredMixin, View):
    def post(self, request):
        service, identifier, person = _request_scope(request, "POST")
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "Missing contact identifier."})

        failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
        failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
        if failsafe_arm != "1" or failsafe_confirm != "1":
            return JsonResponse(
                {"ok": False, "error": "Enable send confirmation before sending."}
            )

        token = str(request.POST.get("engage_token") or "").strip()
        if not token:
            return JsonResponse({"ok": False, "error": "Missing engage token."})
        try:
            payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
        except Exception:
            return JsonResponse({"ok": False, "error": "Invalid engage token."})

        if int(payload.get("u") or 0) != int(request.user.id):
            return JsonResponse({"ok": False, "error": "Token does not match user."})
        if int(payload.get("exp") or 0) < int(time.time()):
            return JsonResponse({"ok": False, "error": "Engage token expired."})
        outbound = str(payload.get("outbound") or "").strip()
        if not outbound:
            return JsonResponse({"ok": False, "error": "Empty engage payload."})

        base = _context_base(request.user, service, identifier, person)
        ts = async_to_sync(transport.send_message_raw)(
            base["service"],
            base["identifier"],
            text=outbound,
            attachments=[],
        )
        if not ts:
            return JsonResponse({"ok": False, "error": "Send failed."})

        if base["person_identifier"] is not None:
            session, _ = ChatSession.objects.get_or_create(
                user=request.user,
                identifier=base["person_identifier"],
            )
            ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
            created = Message.objects.create(
                user=request.user,
                session=session,
                sender_uuid="",
                text=outbound,
                ts=ts_value,
                delivered_ts=ts_value if str(ts).isdigit() else None,
                custom_author="USER",
            )
            try:
                append_event_sync(
                    user=request.user,
                    session=session,
                    ts=ts_value,
                    event_type="message_created",
                    direction="out",
                    actor_identifier="USER",
                    origin_transport="web",
                    origin_message_id=str(created.id),
                    origin_chat_id=str(base["identifier"] or ""),
                    payload={"message_id": str(created.id), "text": outbound},
                    raw_payload={},
                    trace_id="",
                )
            except Exception:
                pass

        return JsonResponse({"ok": True, "message": "Shared engage sent."})


class ComposeSend(LoginRequiredMixin, View):
    @staticmethod
    def _response(request, *, ok, message="", level="info", panel_id=""):
        response = render(
            request,
            "partials/compose-send-status.html",
            {
                "notice_message": message,
                "notice_level": level,
            },
        )
        trigger_payload = {
            "composeSendResult": {
                "ok": bool(ok),
                "message": str(message or ""),
                "level": str(level or "info"),
                "panel_id": str(panel_id or ""),
            }
        }
        # Optional: include command id to allow client-side cancellation UI
        if hasattr(request, "_compose_command_id") and request._compose_command_id:
            trigger_payload["composeSendCommandId"] = {
                "command_id": str(request._compose_command_id)
            }
        if ok:
            trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
        response["HX-Trigger"] = json.dumps(trigger_payload)
        return response

    def post(self, request):
        service, identifier, person = _request_scope(request, "POST")
        render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
        if render_mode not in {"page", "widget"}:
            render_mode = "page"
        panel_id = str(request.POST.get("panel_id") or "").strip()

        if not identifier and person is None:
            return HttpResponseBadRequest("Missing contact identifier.")

        failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
        failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
        if failsafe_arm != "1" or failsafe_confirm != "1":
            return self._response(
                request,
                ok=False,
                message="Enable send confirmation before sending.",
                level="warning",
                panel_id=panel_id,
            )

        text = str(request.POST.get("text") or "").strip()
        reply_to_message_id = str(request.POST.get("reply_to_message_id") or "").strip()
        if not text:
            return self._response(
                request,
                ok=False,
                message="Message is empty.",
                level="danger",
                panel_id=panel_id,
            )

        base = _context_base(request.user, service, identifier, person)
        from core.util import logs as util_logs

        logger = util_logs.get_logger("compose")
        log_prefix = (
            f"[ComposeSend] service={base['service']} identifier={base['identifier']}"
        )
        if bool(
            getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)
        ) and not supports(
            str(base["service"] or "").strip().lower(),
            "send",
        ):
            reason = unsupported_reason(
                str(base["service"] or "").strip().lower(), "send"
            )
            return self._response(
                request,
                ok=False,
                message=f"Send not supported: {reason}",
                level="warning",
                panel_id=panel_id,
            )
        logger.debug(f"{log_prefix} text_len={len(text)} attempting send")

        # If runtime is out-of-process, enqueue command and return immediately (non-blocking).
        # Expose command id for cancellation so the client can cancel or poll later.
        runtime_client = transport.get_runtime_client(base["service"]) or None
        logger.debug(
            f"{log_prefix} runtime_client={type(runtime_client).__name__ if runtime_client else 'None (queued)'}"
        )
        ts = None
        command_id = None
        created_message = None
        session = None
        reply_to = None
        if base["person_identifier"] is not None:
            session, _ = ChatSession.objects.get_or_create(
                user=request.user,
                identifier=base["person_identifier"],
            )
            if reply_to_message_id:
                reply_to = Message.objects.filter(
                    user=request.user,
                    session=session,
                    id=reply_to_message_id,
                ).first()
        if runtime_client is None:
            outbound_reply_metadata = {}
            if base["service"] == "whatsapp":
                outbound_reply_metadata = _build_whatsapp_reply_metadata(
                    reply_to, str(base["identifier"] or "")
                )
            elif base["service"] == "signal":
                outbound_reply_metadata = _build_signal_reply_metadata(
                    reply_to, str(base["identifier"] or "")
                )
            if base["service"] == "whatsapp":
                runtime_state = transport.get_runtime_state("whatsapp")
                last_seen = int(runtime_state.get("runtime_seen_at") or 0)
                is_connected = bool(runtime_state.get("connected"))
                pair_status = (
                    str(runtime_state.get("pair_status") or "").strip().lower()
                )
                now_s = int(time.time())
                # Runtime may process sends even when `connected` lags false briefly;
                # heartbeat freshness is the reliable signal for queue availability.
                heartbeat_age = now_s - last_seen if last_seen > 0 else 10**9
                runtime_healthy = bool(is_connected) or pair_status == "connected"
                if (not runtime_healthy) and (last_seen <= 0 or heartbeat_age > 20):
                    logger.warning(
                        f"{log_prefix} runtime heartbeat stale (connected={is_connected}, pair_status={pair_status}, last_seen={last_seen}, age={heartbeat_age}); refusing queued send"
                    )
                    return self._response(
                        request,
                        ok=False,
                        message=(
                            "WhatsApp runtime is not connected right now. "
                            "Please wait for reconnect, then retry send."
                        ),
                        level="warning",
                        panel_id=panel_id,
                    )
            # Persist local message first so runtime can attach upstream bridge refs.
            ts = int(time.time() * 1000)
            if session is not None:
                created_message = Message.objects.create(
                    user=request.user,
                    session=session,
                    sender_uuid="",
                    text=text,
                    ts=int(ts),
                    delivered_ts=None,
                    custom_author="USER",
                    source_service="web",
                    source_message_id=str(ts),
                    source_chat_id=str(base["identifier"] or ""),
                    reply_to=reply_to,
                    reply_source_service="web" if reply_to is not None else None,
                    reply_source_message_id=(
                        str(reply_to.id) if reply_to is not None else None
                    ),
                    message_meta={},
                )
                try:
                    append_event_sync(
                        user=request.user,
                        session=session,
                        ts=int(ts),
                        event_type="message_created",
                        direction="out",
                        actor_identifier="USER",
                        origin_transport="web",
                        origin_message_id=str(created_message.id),
                        origin_chat_id=str(base["identifier"] or ""),
                        payload={"message_id": str(created_message.id), "text": text},
                        raw_payload={},
                        trace_id="",
                    )
                except Exception:
                    pass
            command_id = transport.enqueue_runtime_command(
                base["service"],
                "send_message_raw",
                {
                    "recipient": base["identifier"],
                    "text": text,
                    "attachments": [],
                    "metadata": (
                        {
                            "legacy_message_id": str(created_message.id),
                            "local_ts": int(ts),
                            **outbound_reply_metadata,
                        }
                        if created_message is not None
                        else outbound_reply_metadata
                    ),
                },
            )
            logger.debug(f"{log_prefix} command_id={command_id} enqueued")
            # attach command id to request so _response can include it in HX-Trigger
            request._compose_command_id = command_id
            # Do NOT wait here — return immediately so the UI doesn't block.
        else:
            # In-process runtime can perform the send synchronously and return a timestamp.
            outbound_reply_metadata = {}
            if base["service"] == "whatsapp":
                outbound_reply_metadata = _build_whatsapp_reply_metadata(
                    reply_to, str(base["identifier"] or "")
                )
            elif base["service"] == "signal":
                outbound_reply_metadata = _build_signal_reply_metadata(
                    reply_to, str(base["identifier"] or "")
                )
            ts = async_to_sync(transport.send_message_raw)(
                base["service"],
                base["identifier"],
                text=text,
                attachments=[],
                metadata=outbound_reply_metadata,
            )
        # For queued sends we set `ts` to a local timestamp; for in-process sends ts may be False.
        if not ts:
            return self._response(
                request,
                ok=False,
                message="Send failed. Check service account state.",
                level="danger",
                panel_id=panel_id,
            )

        if base["person_identifier"] is not None and created_message is None:
            # For in-process sends (Signal, etc), ts is a timestamp or True.
            # For queued sends (WhatsApp/UR), ts is a local timestamp.
            # Set delivered_ts only if we got a real timestamp OR if it's an in-process sync send.
            msg_ts = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
            delivered_ts = msg_ts if runtime_client is not None else None
            created_message = Message.objects.create(
                user=request.user,
                session=session,
                sender_uuid="",
                text=text,
                ts=msg_ts,
                delivered_ts=delivered_ts,
                custom_author="USER",
                source_service="web",
                source_message_id=str(msg_ts),
                source_chat_id=str(base["identifier"] or ""),
                reply_to=reply_to,
                reply_source_service="web" if reply_to is not None else None,
                reply_source_message_id=(
                    str(reply_to.id) if reply_to is not None else None
                ),
                message_meta={},
            )
            try:
                append_event_sync(
                    user=request.user,
                    session=session,
                    ts=msg_ts,
                    event_type="message_created",
                    direction="out",
                    actor_identifier="USER",
                    origin_transport="web",
                    origin_message_id=str(created_message.id),
                    origin_chat_id=str(base["identifier"] or ""),
                    payload={"message_id": str(created_message.id), "text": text},
                    raw_payload={},
                    trace_id="",
                )
            except Exception:
                pass
        if created_message is not None:
            async_to_sync(process_inbound_message)(
                CommandContext(
                    service="web",
                    channel_identifier=str(base["identifier"] or ""),
                    message_id=str(created_message.id),
                    user_id=int(request.user.id),
                    message_text=text,
                    payload={},
                )
            )
            async_to_sync(process_inbound_assist)(created_message)
            async_to_sync(process_inbound_translation)(created_message)
            # Notify XMPP clients from runtime so cross-platform sends appear there too.
            if base["service"] in {"signal", "whatsapp"}:
                try:
                    transport.enqueue_runtime_command(
                        base["service"],
                        "notify_xmpp_sent",
                        {
                            "person_identifier_id": str(base["person_identifier"].id),
                            "text": text,
                        },
                    )
                except Exception as exc:
                    logger.warning(f"{log_prefix} failed to enqueue xmpp notify: {exc}")

        # If we enqueued, inform the client the message is queued and include command id.
        if runtime_client is None:
            return self._response(
                request,
                ok=True,
                message="Message queued for sending.",
                level="info",
                panel_id=panel_id,
            )

        return self._response(
            request,
            ok=True,
            message="",
            level="success",
            panel_id=panel_id,
        )


class ComposeReact(LoginRequiredMixin, View):
    def post(self, request):
        service, identifier, person = _request_scope(request, "POST")
        service_key = _default_service(service)
        if service_key not in {"signal", "whatsapp"}:
            return JsonResponse({"ok": False, "error": "service_not_supported"})
        if bool(
            getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)
        ) and not supports(service_key, "reactions"):
            return JsonResponse(
                {
                    "ok": False,
                    "error": "unsupported_action",
                    "reason": unsupported_reason(service_key, "reactions"),
                }
            )
        if not identifier and person is None:
            return JsonResponse({"ok": False, "error": "missing_scope"})

        message_id = str(request.POST.get("message_id") or "").strip()
        emoji = str(request.POST.get("emoji") or "").strip()
        remove_raw = request.POST.get("remove")
        remove_forced = remove_raw is not None and str(remove_raw).strip() != ""
        if not message_id:
            return JsonResponse({"ok": False, "error": "message_id_required"})
        if not emoji:
            return JsonResponse({"ok": False, "error": "emoji_required"})

        base = _context_base(request.user, service_key, identifier, person)
        person_identifier = base.get("person_identifier")
        if person_identifier is None:
            return JsonResponse({"ok": False, "error": "message_scope_unresolvable"})

        session = ChatSession.objects.filter(
            user=request.user,
            identifier=person_identifier,
        ).first()
        if session is None:
            return JsonResponse({"ok": False, "error": "session_not_found"})

        message = Message.objects.filter(
            user=request.user,
            session=session,
            id=message_id,
        ).first()
        if message is None:
            return JsonResponse({"ok": False, "error": "message_not_found"})

        target = _resolve_reaction_target(
            message=message,
            service=service_key,
            channel_identifier=str(base.get("identifier") or "").strip(),
        )
        if target.get("error"):
            return JsonResponse({"ok": False, "error": str(target.get("error"))})

        actor_key = _reaction_actor_key(request.user.id, service_key)
        remove = _parse_bool(remove_raw, default=False)
        if not remove_forced:
            existing_rows = list((message.receipt_payload or {}).get("reactions") or [])
            has_same_reaction = False
            for row in existing_rows:
                item = dict(row or {})
                if bool(item.get("removed")):
                    continue
                if str(item.get("emoji") or "").strip() != emoji:
                    continue
                if str(item.get("source_service") or "").strip().lower() != service_key:
                    continue
                if str(item.get("actor") or "").strip() != actor_key:
                    continue
                has_same_reaction = True
                break
            remove = bool(has_same_reaction)

        target_ts = int(target.get("target_ts") or 0)
        target_message_id = str(target.get("target_message_id") or "").strip()
        sent = async_to_sync(transport.send_reaction)(
            service_key,
            str(base.get("identifier") or "").strip(),
            emoji=emoji,
            target_message_id=target_message_id,
            target_timestamp=target_ts if target_ts > 0 else None,
            target_author=str(target.get("target_author") or "").strip(),
            remove=bool(remove),
        )
        if not sent:
            return JsonResponse({"ok": False, "error": "reaction_send_failed"})

        updated = async_to_sync(history.apply_reaction)(
            request.user,
            person_identifier,
            target_message_id=target_message_id,
            target_ts=target_ts,
            emoji=emoji,
            source_service=service_key,
            actor=actor_key,
            remove=bool(remove),
            payload={
                "source": "compose_react",
                "message_id": str(message.id),
            },
        )
        if updated is None:
            updated = Message.objects.filter(
                user=request.user,
                session=session,
                id=message_id,
            ).first()
        serialized = _serialize_message(updated or message)
        return JsonResponse(
            {
                "ok": True,
                "message_id": str(message.id),
                "emoji": emoji,
                "remove": bool(remove),
                "target_upstream_ts": target_ts,
                "target_upstream_id": target_message_id,
                "reactions": list(serialized.get("reactions") or []),
            }
        )
