import time
import uuid

from asgiref.sync import sync_to_async
from django.conf import settings

from core.events.ledger import append_event
from core.messaging.utils import messages_to_string
from core.models import ChatSession, Message, QueuedMessage
from core.observability.tracing import ensure_trace_id
from core.util import logs

log = logs.get_logger("history")

# Prompt-window controls:
# - Full message history is always persisted in the database.
# - Only the prompt input window is reduced.
# - Max values are hard safety rails; runtime chooses a smaller adaptive subset.
# - Min value prevents overly aggressive clipping on very long average messages.
DEFAULT_PROMPT_HISTORY_MAX_MESSAGES = getattr(
    settings, "PROMPT_HISTORY_MAX_MESSAGES", 120
)
DEFAULT_PROMPT_HISTORY_MAX_CHARS = getattr(
    settings,
    "PROMPT_HISTORY_MAX_CHARS",
    24000,
)
DEFAULT_PROMPT_HISTORY_MIN_MESSAGES = getattr(
    settings, "PROMPT_HISTORY_MIN_MESSAGES", 24
)


def _build_recent_history(messages, max_chars):
    """
    Build the final prompt transcript under a strict character budget.

    Method:
    1. Iterate messages from newest to oldest so recency is prioritized.
    2. For each message, estimate the rendered line length exactly as it will
       appear in the prompt transcript.
    3. Stop once adding another line would exceed `max_chars`, while still
       guaranteeing at least one message can be included.
    4. Reverse back to chronological order for readability in prompts.
    """
    if not messages:
        return ""

    selected = []
    total_chars = 0
    # Recency-first packing, then reorder to chronological output later.
    for msg in reversed(messages):
        author = msg.custom_author or msg.session.identifier.person.name
        line = f"[{msg.ts}] <{author}> {msg.text}"
        line_len = len(line) + 1
        # Keep at least one line even if it alone exceeds max_chars.
        if selected and (total_chars + line_len) > max_chars:
            break
        selected.append(msg)
        total_chars += line_len

    selected.reverse()
    return messages_to_string(selected)


def _compute_adaptive_message_limit(messages, max_messages, max_chars):
    """
    Derive how many messages to include before final char-budget packing.

    This function intentionally avoids hand-picked threshold buckets.
    Instead, it computes a budget-derived estimate:
    - Build a recent sample (up to 80 messages) representing current chat style.
    - Measure *rendered* line lengths (timestamp + author + text), not raw text.
    - Estimate average line length from that sample.
    - Convert char budget into message budget: floor(max_chars / avg_line_len).
    - Clamp to configured min/max rails.

    Why two stages:
    - Stage A (this function): estimate count from current message density.
    - Stage B (`_build_recent_history`): enforce exact char ceiling.
    This keeps behavior stable while guaranteeing hard prompt budget compliance.
    """
    if not messages:
        return DEFAULT_PROMPT_HISTORY_MIN_MESSAGES

    sample = messages[-min(len(messages), 80) :]
    rendered_lengths = []
    for msg in sample:
        author = (
            msg.custom_author
            if msg.custom_author
            else msg.session.identifier.person.name
        )
        text = msg.text or ""
        # Match the line shape used in _build_recent_history/messages_to_string.
        rendered_lengths.append(len(f"[{msg.ts}] <{author}> {text}") + 1)

    # Defensive denominator: never divide by zero.
    avg_line_len = (
        (sum(rendered_lengths) / len(rendered_lengths)) if rendered_lengths else 1.0
    )
    avg_line_len = max(avg_line_len, 1.0)

    budget_based = int(max_chars / avg_line_len)
    adaptive = max(DEFAULT_PROMPT_HISTORY_MIN_MESSAGES, budget_based)
    adaptive = min(max_messages, adaptive)
    return max(1, adaptive)


async def get_chat_history(
    session,
    max_messages=DEFAULT_PROMPT_HISTORY_MAX_MESSAGES,
    max_chars=DEFAULT_PROMPT_HISTORY_MAX_CHARS,
):
    """
    Return prompt-ready chat history with adaptive windowing and hard budget limits.

    Pipeline:
    1. Fetch a bounded recent slice from DB (performance guard).
    2. Estimate adaptive message count from observed rendered message density.
    3. Keep only the newest `adaptive_limit` messages.
    4. Pack those lines under `max_chars` exactly.
    """
    # Storage remains complete; only prompt context is reduced.
    fetch_limit = max(max_messages * 3, 200)
    fetch_limit = min(fetch_limit, 1000)
    stored_messages = await sync_to_async(list)(
        Message.objects.filter(session=session, user=session.user).order_by("-ts")[
            :fetch_limit
        ]
    )
    stored_messages.reverse()
    adaptive_limit = _compute_adaptive_message_limit(
        stored_messages,
        max_messages=max_messages,
        max_chars=max_chars,
    )
    selected_messages = stored_messages[-adaptive_limit:]
    recent_chat_history = _build_recent_history(selected_messages, max_chars=max_chars)
    chat_history = f"Recent Messages:\n{recent_chat_history}"

    return chat_history


async def get_chat_session(user, identifier):
    chat_session, _ = await sync_to_async(ChatSession.objects.get_or_create)(
        identifier=identifier,
        user=user,
    )
    return chat_session


async def store_message(
    session,
    sender,
    text,
    ts,
    outgoing=False,
    source_service="",
    source_message_id="",
    source_chat_id="",
    reply_to=None,
    reply_source_service="",
    reply_source_message_id="",
    message_meta=None,
    trace_id="",
    raw_payload=None,
):
    log.debug("Storing message for session=%s outgoing=%s", session.id, outgoing)
    msg = await sync_to_async(Message.objects.create)(
        user=session.user,
        session=session,
        sender_uuid=sender,
        text=text,
        ts=ts,
        delivered_ts=ts,
        custom_author="USER" if outgoing else None,
        source_service=(source_service or None),
        source_message_id=str(source_message_id or "").strip() or None,
        source_chat_id=str(source_chat_id or "").strip() or None,
        reply_to=reply_to,
        reply_source_service=str(reply_source_service or "").strip() or None,
        reply_source_message_id=str(reply_source_message_id or "").strip() or None,
        message_meta=dict(message_meta or {}),
    )
    try:
        await append_event(
            user=session.user,
            session=session,
            ts=int(ts),
            event_type="message_created",
            direction="out" if bool(outgoing) else "in",
            actor_identifier=str(sender or ""),
            origin_transport=str(source_service or ""),
            origin_message_id=str(source_message_id or ""),
            origin_chat_id=str(source_chat_id or ""),
            payload={
                "message_id": str(msg.id),
                "text": str(text or ""),
                "reply_source_service": str(reply_source_service or ""),
                "reply_source_message_id": str(reply_source_message_id or ""),
                "outgoing": bool(outgoing),
            },
            raw_payload=dict(raw_payload or {}),
            trace_id=ensure_trace_id(trace_id, message_meta or {}),
        )
    except Exception as exc:
        log.warning("Event ledger append failed for message=%s: %s", msg.id, exc)

    return msg


async def store_own_message(
    session,
    text,
    ts,
    manip=None,
    queue=False,
    source_service="",
    source_message_id="",
    source_chat_id="",
    reply_to=None,
    reply_source_service="",
    reply_source_message_id="",
    message_meta=None,
    trace_id="",
    raw_payload=None,
):
    log.debug("Storing own message for session=%s queue=%s", session.id, queue)
    cast = {
        "user": session.user,
        "session": session,
        "custom_author": "BOT",
        "text": text,
        "ts": ts,
        "delivered_ts": ts,
        "source_service": (source_service or None),
        "source_message_id": str(source_message_id or "").strip() or None,
        "source_chat_id": str(source_chat_id or "").strip() or None,
        "reply_to": reply_to,
        "reply_source_service": str(reply_source_service or "").strip() or None,
        "reply_source_message_id": str(reply_source_message_id or "").strip() or None,
        "message_meta": dict(message_meta or {}),
    }
    if queue:
        msg_object = QueuedMessage
        cast["manipulation"] = manip
    else:
        msg_object = Message

    msg = await sync_to_async(msg_object.objects.create)(
        **cast,
    )
    if msg_object is Message:
        try:
            await append_event(
                user=session.user,
                session=session,
                ts=int(ts),
                event_type="message_created",
                direction="out",
                actor_identifier="BOT",
                origin_transport=str(source_service or ""),
                origin_message_id=str(source_message_id or ""),
                origin_chat_id=str(source_chat_id or ""),
                payload={
                    "message_id": str(msg.id),
                    "text": str(text or ""),
                    "queued": bool(queue),
                    "reply_source_service": str(reply_source_service or ""),
                    "reply_source_message_id": str(reply_source_message_id or ""),
                },
                raw_payload=dict(raw_payload or {}),
                trace_id=ensure_trace_id(trace_id, message_meta or {}),
            )
        except Exception as exc:
            log.warning(
                "Event ledger append failed for own message=%s: %s", msg.id, exc
            )

    return msg


async def delete_queryset(queryset):
    await sync_to_async(queryset.delete, thread_sensitive=True)()


async def apply_read_receipts(
    user,
    identifier,
    message_timestamps,
    read_ts=None,
    source_service="signal",
    read_by_identifier="",
    payload=None,
    trace_id="",
    receipt_event_type="read_receipt",
):
    """
    Persist delivery/read metadata for one identifier's messages.
    """
    ts_values = []
    for item in message_timestamps or []:
        try:
            ts_values.append(int(item))
        except Exception:
            continue
    if not ts_values:
        return 0

    try:
        read_at = int(read_ts) if read_ts else None
    except Exception:
        read_at = None
    normalized_event_type = str(receipt_event_type or "read_receipt").strip().lower()
    if normalized_event_type not in {"read_receipt", "delivery_receipt"}:
        normalized_event_type = "read_receipt"

    rows = await sync_to_async(list)(
        Message.objects.filter(
            user=user,
            session__identifier=identifier,
            ts__in=ts_values,
        ).select_related("session")
    )
    updated = 0
    for message in rows:
        dirty = []
        if message.delivered_ts is None:
            message.delivered_ts = read_at or message.ts
            dirty.append("delivered_ts")
        if (
            normalized_event_type == "read_receipt"
            and read_at
            and (message.read_ts is None or read_at > message.read_ts)
        ):
            message.read_ts = read_at
            dirty.append("read_ts")
        if (
            normalized_event_type == "read_receipt"
            and source_service
            and message.read_source_service != source_service
        ):
            message.read_source_service = source_service
            dirty.append("read_source_service")
        if (
            normalized_event_type == "read_receipt"
            and read_by_identifier
            and message.read_by_identifier != read_by_identifier
        ):
            message.read_by_identifier = read_by_identifier
            dirty.append("read_by_identifier")
        if payload:
            receipt_data = dict(message.receipt_payload or {})
            receipt_data[str(source_service)] = payload
            message.receipt_payload = receipt_data
            dirty.append("receipt_payload")
        if dirty:
            await sync_to_async(message.save)(update_fields=dirty)
            updated += 1
            try:
                await append_event(
                    user=user,
                    session=message.session,
                    ts=int(read_at or message.ts or 0),
                    event_type=normalized_event_type,
                    direction="system",
                    actor_identifier=str(read_by_identifier or ""),
                    origin_transport=str(source_service or ""),
                    origin_message_id=str(message.source_message_id or message.id),
                    origin_chat_id=str(message.source_chat_id or ""),
                    payload={
                        "message_id": str(message.id),
                        "message_ts": int(message.ts or 0),
                        "read_ts": int(read_at or 0),
                        "receipt_event_type": normalized_event_type,
                        "read_by_identifier": str(read_by_identifier or ""),
                        "timestamps": [int(v) for v in ts_values],
                    },
                    raw_payload=dict(payload or {}),
                    trace_id=ensure_trace_id(trace_id, payload or {}),
                )
            except Exception as exc:
                log.warning(
                    "Event ledger append failed for receipt message=%s: %s",
                    message.id,
                    exc,
                )
    return updated


async def apply_reaction(
    user,
    identifier,
    *,
    target_message_id="",
    target_ts=0,
    emoji="",
    source_service="",
    actor="",
    remove=False,
    payload=None,
    trace_id="",
    target_author="",
):
    log.debug(
        "reaction-bridge history-apply start user=%s person_identifier=%s target_message_id=%s target_ts=%s source=%s actor=%s remove=%s emoji=%s",
        getattr(user, "id", "-"),
        getattr(identifier, "id", "-"),
        str(target_message_id or "") or "-",
        int(target_ts or 0),
        str(source_service or "") or "-",
        str(actor or "") or "-",
        bool(remove),
        str(emoji or "") or "-",
    )
    queryset = Message.objects.filter(
        user=user,
        session__identifier=identifier,
    ).select_related("session")

    target = None
    match_strategy = "none"
    target_author_value = str(target_author or "").strip()
    target_uuid = str(target_message_id or "").strip()
    if target_uuid:
        is_uuid = True
        try:
            uuid.UUID(str(target_uuid))
        except Exception:
            is_uuid = False
        if is_uuid:
            target = await sync_to_async(
                lambda: queryset.filter(id=target_uuid).order_by("-ts").first()
            )()
            if target is not None:
                match_strategy = "local_message_id"
        if target is None:
            target = await sync_to_async(
                lambda: queryset.filter(source_message_id=target_uuid)
                .order_by("-ts")
                .first()
            )()
            if target is not None:
                match_strategy = "source_message_id"

    if target is None:
        try:
            ts_value = int(target_ts or 0)
        except Exception:
            ts_value = 0
        if ts_value > 0:
            # Signal reactions target source timestamp; prefer deterministic exact matches.
            exact_candidates = await sync_to_async(list)(
                queryset.filter(source_message_id=str(ts_value)).order_by("-ts")[:20]
            )
            if target_author_value and exact_candidates:
                filtered = [
                    row
                    for row in exact_candidates
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if filtered:
                    exact_candidates = filtered
            if exact_candidates:
                target = exact_candidates[0]
                match_strategy = "exact_source_message_id_ts"
                log.debug(
                    "reaction-bridge history-apply exact-source-ts target_ts=%s picked_message_id=%s candidates=%s",
                    ts_value,
                    str(target.id),
                    len(exact_candidates),
                )

        if target is None and ts_value > 0:
            strict_ts_rows = await sync_to_async(list)(
                queryset.filter(ts=ts_value).order_by("-id")[:20]
            )
            if target_author_value and strict_ts_rows:
                filtered = [
                    row
                    for row in strict_ts_rows
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if filtered:
                    strict_ts_rows = filtered
            if strict_ts_rows:
                target = strict_ts_rows[0]
                match_strategy = "strict_ts_match"
                log.debug(
                    "reaction-bridge history-apply strict-ts target_ts=%s picked_message_id=%s candidates=%s",
                    ts_value,
                    str(target.id),
                    len(strict_ts_rows),
                )

        if target is None and ts_value > 0:
            lower = ts_value - 10_000
            upper = ts_value + 10_000
            window_rows = await sync_to_async(list)(
                queryset.filter(ts__gte=lower, ts__lte=upper).order_by("ts")[:200]
            )
            if target_author_value and window_rows:
                author_rows = [
                    row
                    for row in window_rows
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if author_rows:
                    window_rows = author_rows
            if window_rows:
                target = min(
                    window_rows,
                    key=lambda row: (
                        abs(int(row.ts or 0) - ts_value),
                        -int(row.ts or 0),
                    ),
                )
                log.debug(
                    "reaction-bridge history-apply ts-match target_ts=%s picked_message_id=%s picked_ts=%s candidates=%s",
                    ts_value,
                    str(target.id),
                    int(target.ts or 0),
                    len(window_rows),
                )
                match_strategy = "nearest_ts_window"

    if target is None:
        log.warning(
            "reaction-bridge history-apply miss user=%s person_identifier=%s target_message_id=%s target_ts=%s",
            getattr(user, "id", "-"),
            getattr(identifier, "id", "-"),
            str(target_message_id or "") or "-",
            int(target_ts or 0),
        )
        return None

    reactions = list((target.receipt_payload or {}).get("reactions") or [])
    normalized_source = str(source_service or "").strip().lower()
    normalized_actor = str(actor or "").strip()
    normalized_emoji = str(emoji or "").strip()
    reaction_key = (
        normalized_source,
        normalized_actor,
        normalized_emoji,
    )

    merged = []
    replaced = False
    for item in reactions:
        row = dict(item or {})
        row_key = (
            str(row.get("source_service") or "").strip().lower(),
            str(row.get("actor") or "").strip(),
            str(row.get("emoji") or "").strip(),
        )
        if not row_key[2] and bool(row.get("removed")):
            # Keep malformed remove rows out of active reaction set.
            continue
        if row_key == reaction_key:
            row["removed"] = bool(remove)
            row["updated_at"] = int(target_ts or target.ts or 0)
            row["payload"] = dict(payload or {})
            row["match_strategy"] = match_strategy
            merged.append(row)
            replaced = True
            continue
        merged.append(row)

    if not replaced and (normalized_emoji or not bool(remove)):
        merged.append(
            {
                "emoji": normalized_emoji,
                "source_service": normalized_source,
                "actor": normalized_actor,
                "removed": bool(remove),
                "updated_at": int(target_ts or target.ts or 0),
                "payload": dict(payload or {}),
                "match_strategy": match_strategy,
            }
        )
    elif not replaced and bool(remove):
        receipt_payload = dict(target.receipt_payload or {})
        reaction_events = list(receipt_payload.get("reaction_events") or [])
        reaction_events.append(
            {
                "emoji": normalized_emoji,
                "source_service": normalized_source,
                "actor": normalized_actor,
                "removed": True,
                "updated_at": int(target_ts or target.ts or 0),
                "payload": dict(payload or {}),
                "match_strategy": match_strategy,
                "skip_reason": "remove_without_emoji_or_match",
            }
        )
        if len(reaction_events) > 200:
            reaction_events = reaction_events[-200:]
        receipt_payload["reaction_events"] = reaction_events
        target.receipt_payload = receipt_payload
        await sync_to_async(target.save)(update_fields=["receipt_payload"])
        log.debug(
            "reaction-bridge history-apply remove-without-match message_id=%s strategy=%s",
            str(target.id),
            match_strategy,
        )
        return target

    receipt_payload = dict(target.receipt_payload or {})
    receipt_payload["reactions"] = merged
    if match_strategy:
        receipt_payload["reaction_last_match_strategy"] = str(match_strategy)
    target.receipt_payload = receipt_payload
    await sync_to_async(target.save)(update_fields=["receipt_payload"])
    try:
        await append_event(
            user=user,
            session=target.session,
            ts=int(target_ts or target.ts or 0),
            event_type="reaction_removed" if bool(remove) else "reaction_added",
            direction="system",
            actor_identifier=str(actor or ""),
            origin_transport=str(source_service or ""),
            origin_message_id=str(target.source_message_id or target.id),
            origin_chat_id=str(target.source_chat_id or ""),
            payload={
                "message_id": str(target.id),
                "target_message_id": str(target_message_id or target.id),
                "target_ts": int(target_ts or target.ts or 0),
                "emoji": str(emoji or ""),
                "remove": bool(remove),
                "source_service": normalized_source,
                "actor": normalized_actor,
                "match_strategy": match_strategy,
            },
            raw_payload=dict(payload or {}),
            trace_id=ensure_trace_id(trace_id, payload or {}),
        )
    except Exception as exc:
        log.warning(
            "Event ledger append failed for reaction on message=%s: %s",
            target.id,
            exc,
        )
    log.debug(
        "reaction-bridge history-apply ok message_id=%s reactions=%s",
        str(target.id),
        len(merged),
    )
    return target


async def _resolve_message_target(
    user,
    identifier,
    *,
    target_message_id="",
    target_ts=0,
    target_author="",
):
    queryset = Message.objects.filter(
        user=user,
        session__identifier=identifier,
    ).select_related("session")

    target = None
    match_strategy = "none"
    target_author_value = str(target_author or "").strip()
    target_uuid = str(target_message_id or "").strip()
    if target_uuid:
        is_uuid = True
        try:
            uuid.UUID(str(target_uuid))
        except Exception:
            is_uuid = False
        if is_uuid:
            target = await sync_to_async(
                lambda: queryset.filter(id=target_uuid).order_by("-ts").first()
            )()
            if target is not None:
                match_strategy = "local_message_id"
        if target is None:
            target = await sync_to_async(
                lambda: queryset.filter(source_message_id=target_uuid)
                .order_by("-ts")
                .first()
            )()
            if target is not None:
                match_strategy = "source_message_id"

    if target is None:
        try:
            ts_value = int(target_ts or 0)
        except Exception:
            ts_value = 0
        if ts_value > 0:
            exact_candidates = await sync_to_async(list)(
                queryset.filter(source_message_id=str(ts_value)).order_by("-ts")[:20]
            )
            if target_author_value and exact_candidates:
                filtered = [
                    row
                    for row in exact_candidates
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if filtered:
                    exact_candidates = filtered
            if exact_candidates:
                target = exact_candidates[0]
                match_strategy = "exact_source_message_id_ts"

        if target is None and ts_value > 0:
            strict_ts_rows = await sync_to_async(list)(
                queryset.filter(ts=ts_value).order_by("-id")[:20]
            )
            if target_author_value and strict_ts_rows:
                filtered = [
                    row
                    for row in strict_ts_rows
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if filtered:
                    strict_ts_rows = filtered
            if strict_ts_rows:
                target = strict_ts_rows[0]
                match_strategy = "strict_ts_match"

        if target is None and ts_value > 0:
            lower = ts_value - 10_000
            upper = ts_value + 10_000
            window_rows = await sync_to_async(list)(
                queryset.filter(ts__gte=lower, ts__lte=upper).order_by("ts")[:200]
            )
            if target_author_value and window_rows:
                author_rows = [
                    row
                    for row in window_rows
                    if str(row.sender_uuid or "").strip() == target_author_value
                ]
                if author_rows:
                    window_rows = author_rows
            if window_rows:
                target = min(
                    window_rows,
                    key=lambda row: (
                        abs(int(row.ts or 0) - ts_value),
                        -int(row.ts or 0),
                    ),
                )
                match_strategy = "nearest_ts_window"

    return target, match_strategy


async def apply_message_edit(
    user,
    identifier,
    *,
    target_message_id="",
    target_ts=0,
    new_text="",
    source_service="",
    actor="",
    payload=None,
    trace_id="",
    target_author="",
):
    target, match_strategy = await _resolve_message_target(
        user,
        identifier,
        target_message_id=target_message_id,
        target_ts=target_ts,
        target_author=target_author,
    )
    if target is None:
        log.warning(
            "edit-sync history-apply miss user=%s person_identifier=%s target_message_id=%s target_ts=%s",
            getattr(user, "id", "-"),
            getattr(identifier, "id", "-"),
            str(target_message_id or "") or "-",
            int(target_ts or 0),
        )
        return None

    old_text = str(target.text or "")
    updated_text = str(new_text or "")
    event_ts = int(target_ts or target.ts or int(time.time() * 1000))
    receipt_payload = dict(target.receipt_payload or {})
    edit_history = list(receipt_payload.get("edit_history") or [])
    edit_history.append(
        {
            "edited_ts": int(event_ts),
            "source_service": str(source_service or "").strip().lower(),
            "actor": str(actor or "").strip(),
            "previous_text": old_text,
            "new_text": updated_text,
            "match_strategy": str(match_strategy or ""),
            "payload": dict(payload or {}),
        }
    )
    if len(edit_history) > 200:
        edit_history = edit_history[-200:]
    receipt_payload["edit_history"] = edit_history
    receipt_payload["last_edited_ts"] = int(event_ts)
    receipt_payload["edit_count"] = len(edit_history)
    target.receipt_payload = receipt_payload

    update_fields = ["receipt_payload"]
    if old_text != updated_text:
        target.text = updated_text
        update_fields.append("text")
    await sync_to_async(target.save)(update_fields=update_fields)
    try:
        await append_event(
            user=user,
            session=target.session,
            ts=int(event_ts),
            event_type="message_edited",
            direction="system",
            actor_identifier=str(actor or ""),
            origin_transport=str(source_service or ""),
            origin_message_id=str(target.source_message_id or target.id),
            origin_chat_id=str(target.source_chat_id or ""),
            payload={
                "message_id": str(target.id),
                "target_message_id": str(target_message_id or target.id),
                "target_ts": int(target_ts or target.ts or 0),
                "old_text": old_text,
                "new_text": updated_text,
                "source_service": str(source_service or "").strip().lower(),
                "actor": str(actor or ""),
                "match_strategy": str(match_strategy or ""),
            },
            raw_payload=dict(payload or {}),
            trace_id=ensure_trace_id(trace_id, payload or {}),
        )
    except Exception as exc:
        log.warning(
            "Event ledger append failed for message edit message=%s: %s",
            target.id,
            exc,
        )
    return target


async def apply_message_delete(
    user,
    identifier,
    *,
    target_message_id="",
    target_ts=0,
    source_service="",
    actor="",
    payload=None,
    trace_id="",
    target_author="",
):
    target, match_strategy = await _resolve_message_target(
        user,
        identifier,
        target_message_id=target_message_id,
        target_ts=target_ts,
        target_author=target_author,
    )
    if target is None:
        log.warning(
            "delete-sync history-apply miss user=%s person_identifier=%s target_message_id=%s target_ts=%s",
            getattr(user, "id", "-"),
            getattr(identifier, "id", "-"),
            str(target_message_id or "") or "-",
            int(target_ts or 0),
        )
        return None

    event_ts = int(target_ts or target.ts or int(time.time() * 1000))
    deleted_row = {
        "deleted_ts": int(event_ts),
        "source_service": str(source_service or "").strip().lower(),
        "actor": str(actor or "").strip(),
        "match_strategy": str(match_strategy or ""),
        "payload": dict(payload or {}),
    }
    receipt_payload = dict(target.receipt_payload or {})
    delete_events = list(receipt_payload.get("delete_events") or [])
    delete_events.append(dict(deleted_row))
    if len(delete_events) > 200:
        delete_events = delete_events[-200:]
    receipt_payload["delete_events"] = delete_events
    receipt_payload["deleted"] = deleted_row
    receipt_payload["is_deleted"] = True
    target.receipt_payload = receipt_payload
    await sync_to_async(target.save)(update_fields=["receipt_payload"])
    try:
        await append_event(
            user=user,
            session=target.session,
            ts=int(event_ts),
            event_type="message_deleted",
            direction="system",
            actor_identifier=str(actor or ""),
            origin_transport=str(source_service or ""),
            origin_message_id=str(target.source_message_id or target.id),
            origin_chat_id=str(target.source_chat_id or ""),
            payload={
                "message_id": str(target.id),
                "target_message_id": str(target_message_id or target.id),
                "target_ts": int(target_ts or target.ts or 0),
                "source_service": str(source_service or "").strip().lower(),
                "actor": str(actor or ""),
                "match_strategy": str(match_strategy or ""),
            },
            raw_payload=dict(payload or {}),
            trace_id=ensure_trace_id(trace_id, payload or {}),
        )
    except Exception as exc:
        log.warning(
            "Event ledger append failed for message delete message=%s: %s",
            target.id,
            exc,
        )
    return target


def _iter_bridge_refs(receipt_payload, source_service):
    payload = dict(receipt_payload or {})
    refs = payload.get("bridge_refs") or {}
    rows = refs.get(str(source_service or "").strip().lower()) or []
    return [dict(row or {}) for row in rows if isinstance(row, dict)]


def _set_bridge_refs(receipt_payload, source_service, rows):
    payload = dict(receipt_payload or {})
    refs = dict(payload.get("bridge_refs") or {})
    refs[str(source_service or "").strip().lower()] = list(rows or [])
    payload["bridge_refs"] = refs
    return payload


async def save_bridge_ref(
    user,
    identifier,
    *,
    source_service,
    local_message_id="",
    local_ts=0,
    xmpp_message_id="",
    upstream_message_id="",
    upstream_author="",
    upstream_ts=0,
):
    # TODO(edit-sync): persist upstream edit identifiers/version vectors here so
    # edit operations can target exact upstream message revisions.
    # TODO(delete-sync): persist upstream deletion tombstone metadata here and
    # keep bridge refs resolvable even after local message redaction.
    source_key = str(source_service or "").strip().lower()
    if not source_key:
        return None

    queryset = Message.objects.filter(
        user=user,
        session__identifier=identifier,
    ).select_related("session")

    target = None
    message_id = str(local_message_id or "").strip()
    if message_id:
        target = await sync_to_async(
            lambda: queryset.filter(id=message_id).order_by("-ts").first()
        )()

    if target is None:
        try:
            ts_value = int(local_ts or 0)
        except Exception:
            ts_value = 0
        if ts_value > 0:
            lower = ts_value - 10_000
            upper = ts_value + 10_000
            rows = await sync_to_async(list)(
                queryset.filter(ts__gte=lower, ts__lte=upper).order_by("-ts")[:200]
            )
            if rows:
                target = min(
                    rows,
                    key=lambda row: (
                        abs(int(row.ts or 0) - ts_value),
                        -int(row.ts or 0),
                    ),
                )

    if target is None:
        return None

    row = {
        "xmpp_message_id": str(xmpp_message_id or "").strip(),
        "upstream_message_id": str(upstream_message_id or "").strip(),
        "upstream_author": str(upstream_author or "").strip(),
        "upstream_ts": int(upstream_ts or 0),
        "updated_at": int(local_ts or target.ts or 0),
    }

    existing = _iter_bridge_refs(target.receipt_payload or {}, source_key)
    merged = []
    for item in existing:
        same_xmpp = row["xmpp_message_id"] and (
            str(item.get("xmpp_message_id") or "").strip() == row["xmpp_message_id"]
        )
        same_upstream = row["upstream_message_id"] and (
            str(item.get("upstream_message_id") or "").strip()
            == row["upstream_message_id"]
        )
        if same_xmpp or same_upstream:
            continue
        merged.append(item)
    merged.append(row)
    if len(merged) > 100:
        merged = merged[-100:]

    target.receipt_payload = _set_bridge_refs(
        target.receipt_payload or {},
        source_key,
        merged,
    )
    await sync_to_async(target.save)(update_fields=["receipt_payload"])
    return {
        "local_message_id": str(target.id),
        "local_ts": int(target.ts or 0),
        **row,
    }


async def resolve_bridge_ref(
    user,
    identifier,
    *,
    source_service,
    xmpp_message_id="",
    upstream_message_id="",
    upstream_author="",
    upstream_ts=0,
):
    source_key = str(source_service or "").strip().lower()
    if not source_key:
        return None

    rows = await sync_to_async(list)(
        Message.objects.filter(
            user=user,
            session__identifier=identifier,
        )
        .order_by("-ts")
        .only("id", "ts", "receipt_payload")[:500]
    )

    xmpp_id = str(xmpp_message_id or "").strip()
    upstream_id = str(upstream_message_id or "").strip()
    author = str(upstream_author or "").strip()
    try:
        target_ts = int(upstream_ts or 0)
    except Exception:
        target_ts = 0

    # 1) exact IDs first
    for message in rows:
        refs = _iter_bridge_refs(message.receipt_payload or {}, source_key)
        for ref in refs:
            if xmpp_id and str(ref.get("xmpp_message_id") or "").strip() == xmpp_id:
                return {
                    "local_message_id": str(message.id),
                    "local_ts": int(message.ts or 0),
                    **dict(ref or {}),
                }
            if upstream_id and (
                str(ref.get("upstream_message_id") or "").strip() == upstream_id
            ):
                return {
                    "local_message_id": str(message.id),
                    "local_ts": int(message.ts or 0),
                    **dict(ref or {}),
                }

    # 2) timestamp proximity with optional author tie-break
    best = None
    best_key = None
    if target_ts > 0:
        for message in rows:
            refs = _iter_bridge_refs(message.receipt_payload or {}, source_key)
            for ref in refs:
                row_ts = int(ref.get("upstream_ts") or 0)
                if row_ts <= 0:
                    continue
                gap = abs(row_ts - target_ts)
                if gap > 15_000:
                    continue
                row_author = str(ref.get("upstream_author") or "").strip()
                author_penalty = 0 if (not author or author == row_author) else 1
                freshness = int(ref.get("updated_at") or 0)
                key = (gap, author_penalty, -freshness)
                if best is None or key < best_key:
                    best = {
                        "local_message_id": str(message.id),
                        "local_ts": int(message.ts or 0),
                        **dict(ref or {}),
                    }
                    best_key = key
    return best
