"""Attachment persistence and media conversion helpers."""

from __future__ import annotations

import asyncio
import contextlib
import hashlib
import json
import mimetypes
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
from uuid import uuid4

import nio

from .attachment_ids import normalize_attachment_id
from .constants import ATTACHMENT_IDS_KEY
from .logging_config import get_logger
from .matrix.media import (
    AudioMessageEvent,
    FileOrVideoMessageEvent,
    ImageMessageEvent,
    download_media_bytes,
    is_audio_message_event,
    is_file_or_video_message_event,
    is_image_message_event,
    is_matrix_media_dispatch_event,
    is_video_message_event,
    media_mime_type,
    media_payload_exceeds_limit,
    parse_matrix_media_dispatch_event_source,
    resolve_image_mime_type,
)
from .timing import emit_elapsed_timing

if TYPE_CHECKING:
    from collections.abc import Sequence

    from .matrix.client import ResolvedVisibleMessage

logger = get_logger(__name__)

_AttachmentKind = Literal["audio", "file", "image", "video"]
_ATTACHMENT_RETENTION_DAYS = 30
_CLEANUP_INTERVAL = timedelta(hours=1)
_last_cleanup_time_by_storage_path: dict[Path, datetime] = {}


@dataclass(frozen=True)
class AttachmentRecord:
    """Persistent metadata for an attachment stored on local disk."""

    attachment_id: str
    local_path: Path
    kind: _AttachmentKind
    filename: str | None = None
    mime_type: str | None = None
    room_id: str | None = None
    thread_id: str | None = None
    source_event_id: str | None = None
    sender: str | None = None
    event_timestamp: int | None = None
    size_bytes: int | None = None
    content_sha256: str | None = None
    created_at: str | None = None

    def to_payload(self) -> dict[str, Any]:
        """Serialize record into a JSON-safe dictionary."""
        return {
            "attachment_id": self.attachment_id,
            "local_path": str(self.local_path),
            "kind": self.kind,
            "filename": self.filename,
            "mime_type": self.mime_type,
            "room_id": self.room_id,
            "thread_id": self.thread_id,
            "source_event_id": self.source_event_id,
            "sender": self.sender,
            "event_timestamp": self.event_timestamp,
            "size_bytes": self.size_bytes,
            "content_sha256": self.content_sha256,
            "created_at": self.created_at,
        }


def parse_attachment_ids_from_event_source(event_source: dict[str, Any] | None) -> list[str]:
    """Extract attachment IDs from Matrix event content metadata."""
    if not isinstance(event_source, dict):
        return []
    content = event_source.get("content")
    if not isinstance(content, dict):
        return []
    raw_attachment_ids = content.get(ATTACHMENT_IDS_KEY)
    if not isinstance(raw_attachment_ids, list):
        return []
    normalized: list[str] = []
    seen_attachment_ids: set[str] = set()
    for raw_attachment_id in raw_attachment_ids:
        if not isinstance(raw_attachment_id, str):
            continue
        attachment_id = normalize_attachment_id(raw_attachment_id)
        if attachment_id and attachment_id not in seen_attachment_ids:
            seen_attachment_ids.add(attachment_id)
            normalized.append(attachment_id)
    return normalized


def parse_attachment_ids_from_thread_history(thread_history: Sequence[ResolvedVisibleMessage]) -> list[str]:
    """Extract attachment IDs referenced by message metadata in thread history."""
    attachment_ids: list[str] = []
    seen_attachment_ids: set[str] = set()
    for message in thread_history:
        message_attachment_ids = parse_attachment_ids_from_event_source({"content": message.content})
        for attachment_id in message_attachment_ids:
            if attachment_id not in seen_attachment_ids:
                seen_attachment_ids.add(attachment_id)
                attachment_ids.append(attachment_id)
    return attachment_ids


def _thread_history_message_in_scope(message: ResolvedVisibleMessage, thread_id: str | None) -> bool:
    if thread_id is None:
        return message.thread_id is None
    return thread_id in (message.thread_id, message.event_id)


_MEDIA_MSGTYPES = frozenset({"m.audio", "m.file", "m.image", "m.video"})


def _attachment_ids_for_visible_message(message: ResolvedVisibleMessage) -> list[str]:
    """Return attachment IDs carried by one visible message.

    MindRoom-sent messages reference attachments via content metadata; raw
    media events map to the deterministic per-event attachment ID used at
    registration time.
    """
    attachment_ids = parse_attachment_ids_from_event_source({"content": message.content})
    if attachment_ids:
        return attachment_ids
    if message.content.get("msgtype") in _MEDIA_MSGTYPES and message.event_id:
        return [_attachment_id_for_event(message.event_id)]
    return []


def _attachment_record_in_message_scope(
    record: AttachmentRecord,
    message: ResolvedVisibleMessage,
    *,
    room_id: str | None,
) -> bool:
    if room_id is not None and record.room_id != room_id:
        return False
    # A record belongs to the thread of the message that references it:
    # thread members match by thread ID, thread roots by their own event ID,
    # and room-level messages only see room-level (thread-less) records.
    return record.thread_id in (message.thread_id, message.event_id)


def attachment_records_for_visible_message(
    storage_path: Path,
    message: ResolvedVisibleMessage,
    *,
    room_id: str | None,
) -> list[AttachmentRecord]:
    """Resolve attachment records carried by one visible message, scoped to its room and thread."""
    attachment_ids = _attachment_ids_for_visible_message(message)
    if not attachment_ids:
        return []
    records = resolve_attachments(storage_path, attachment_ids)
    return [record for record in records if _attachment_record_in_message_scope(record, message, room_id=room_id)]


_MAX_RENDERED_FILENAME_LENGTH = 80


def _sanitize_rendered_filename(filename: str) -> str:
    """Neutralize newline/quote injection from attacker-controlled filenames."""
    sanitized = "".join(char for char in filename if char.isprintable()).replace('"', "'").strip()
    if len(sanitized) > _MAX_RENDERED_FILENAME_LENGTH:
        sanitized = f"{sanitized[: _MAX_RENDERED_FILENAME_LENGTH - 1]}…"
    return sanitized


def _attachment_provenance_line(record: AttachmentRecord) -> str:
    details: list[str] = [record.kind]
    if record.filename:
        details.append(f'"{_sanitize_rendered_filename(record.filename)}"')
    if record.sender:
        details.append(f"from {record.sender}")
    if record.event_timestamp is not None:
        timestamp = datetime.fromtimestamp(record.event_timestamp / 1000, UTC)
        details.append(f"sent {timestamp.strftime('%Y-%m-%d %H:%M UTC')}")
    if record.source_event_id:
        details.append(f"event {record.source_event_id}")
    return f"- {record.attachment_id} ({', '.join(details)})"


def format_attachments_prompt(current_records: list[AttachmentRecord]) -> str | None:
    """Render provenance for attachments sent with the current message.

    Earlier attachments are not listed here; they are annotated in place on
    the thread-history messages that carried them.
    """
    if not current_records:
        return None
    lines = ["Attachments sent with the current message (use tool calls to inspect or process them by ID):"]
    lines.extend(_attachment_provenance_line(record) for record in current_records)
    return "\n".join(lines)


def format_voice_transcript_attachment_guidance(current_records: list[AttachmentRecord]) -> str | None:
    """Render model-only guidance for raw audio that already has a transcript."""
    audio_attachment_ids = [record.attachment_id for record in current_records if record.kind == "audio"]
    if not audio_attachment_ids:
        return None
    rendered_ids = ", ".join(audio_attachment_ids)
    message_label = "message" if len(audio_attachment_ids) == 1 else "messages"
    id_label = "ID" if len(audio_attachment_ids) == 1 else "IDs"
    verb = "is" if len(audio_attachment_ids) == 1 else "are"
    return (
        f"MindRoom already transcribed the current voice {message_label}. "
        f"The raw audio attachment {id_label} {verb} available for verification or deeper audio work: "
        f"{rendered_ids}. "
        "Only inspect or re-transcribe the raw audio if the user asks, the transcript seems wrong, "
        "or the task specifically requires audio-level analysis."
    )


def format_attachment_annotation(attachment_records: list[AttachmentRecord]) -> str | None:
    """Render a compact inline annotation for attachments carried by one message."""
    if not attachment_records:
        return None
    parts = [
        f'{record.attachment_id} ({record.kind}, "{_sanitize_rendered_filename(record.filename)}")'
        if record.filename
        else f"{record.attachment_id} ({record.kind})"
        for record in attachment_records
    ]
    return f"[attachments: {', '.join(parts)}]"


def _attachments_dir(storage_path: Path) -> Path:
    return storage_path / "attachments"


def _incoming_media_dir(storage_path: Path) -> Path:
    return storage_path / "incoming_media"


def _attachment_record_path(storage_path: Path, attachment_id: str) -> Path:
    return _attachments_dir(storage_path) / f"{attachment_id}.json"


def _extension_from_mime_type(mime_type: str | None) -> str:
    """Map MIME type to a stable file extension."""
    if not mime_type:
        return ".bin"
    normalized_mime_type = mime_type.split(";", 1)[0].strip().lower()
    extension = mimetypes.guess_extension(normalized_mime_type)
    if extension:
        return extension
    return ".bin"


def _store_media_bytes_locally(
    storage_path: Path,
    event_id: str,
    media_bytes: bytes | None,
    mime_type: str | None,
) -> Path | None:
    """Persist media bytes to storage so agents can access them as files."""
    if media_bytes is None:
        return None
    incoming_media_dir = _incoming_media_dir(storage_path)
    safe_name = _attachment_id_for_event(event_id)
    extension = _extension_from_mime_type(mime_type)
    media_path = incoming_media_dir / f"{safe_name}{extension}"
    try:
        incoming_media_dir.mkdir(parents=True, exist_ok=True)
        media_path.write_bytes(media_bytes)
    except OSError:
        logger.exception("Failed to persist media payload")
        return None
    return media_path


async def _store_media_bytes_locally_async(
    storage_path: Path,
    event_id: str,
    media_bytes: bytes | None,
    mime_type: str | None,
) -> Path | None:
    """Persist media bytes without blocking the event loop."""
    return await asyncio.to_thread(
        _store_media_bytes_locally,
        storage_path,
        event_id,
        media_bytes,
        mime_type,
    )


def _attachment_id_for_event(event_id: str) -> str:
    """Create a stable low-collision attachment ID from a Matrix event ID."""
    digest = hashlib.sha256(event_id.encode("utf-8")).hexdigest()
    return f"att_{digest[:24]}"


def _record_mtime(path: Path) -> datetime | None:
    try:
        return datetime.fromtimestamp(path.stat().st_mtime, UTC)
    except OSError:
        return None


def _record_created_at(record: AttachmentRecord, record_path: Path) -> datetime | None:
    if isinstance(record.created_at, str) and record.created_at:
        with contextlib.suppress(ValueError):
            parsed = datetime.fromisoformat(record.created_at)
            if parsed.tzinfo is None:
                return parsed.replace(tzinfo=UTC)
            return parsed.astimezone(UTC)
    return _record_mtime(record_path)


def _is_managed_media_path(storage_path: Path, local_path: Path) -> bool:
    """Return whether *local_path* lives under this storage's incoming media dir."""
    incoming_media_dir = _incoming_media_dir(storage_path).resolve()
    with contextlib.suppress(OSError):
        return local_path.resolve().is_relative_to(incoming_media_dir)
    return False


def _collect_attachment_cleanup_state(
    storage_path: Path,
    *,
    cutoff: datetime,
) -> tuple[
    set[Path],
    dict[Path, int],
    list[tuple[AttachmentRecord, Path]],
    list[Path],
]:
    """Collect active/expired attachment metadata needed for cleanup."""
    active_media_paths: set[Path] = set()
    active_media_ref_counts: dict[Path, int] = {}
    expired_records: list[tuple[AttachmentRecord, Path]] = []
    stale_record_paths: list[Path] = []

    for record_path in _attachments_dir(storage_path).glob("*.json"):
        record = load_attachment(storage_path, record_path.stem)
        if record is None:
            record_mtime = _record_mtime(record_path)
            if record_mtime is not None and record_mtime < cutoff:
                stale_record_paths.append(record_path)
            continue

        created_at = _record_created_at(record, record_path)
        if created_at is not None and created_at < cutoff:
            expired_records.append((record, record_path))
            continue

        resolved_media_path = record.local_path.resolve()
        active_media_paths.add(resolved_media_path)
        active_media_ref_counts[resolved_media_path] = active_media_ref_counts.get(resolved_media_path, 0) + 1

    return active_media_paths, active_media_ref_counts, expired_records, stale_record_paths


def _remove_paths(paths: list[Path]) -> int:
    """Delete filesystem paths, ignoring filesystem errors."""
    removed = 0
    for path in paths:
        try:
            path.unlink(missing_ok=True)
        except OSError:
            continue
        removed += 1
    return removed


def _prune_expired_records_and_collect_removable_media_paths(
    storage_path: Path,
    *,
    expired_records: list[tuple[AttachmentRecord, Path]],
    active_media_ref_counts: dict[Path, int],
) -> set[Path]:
    """Delete expired metadata records and collect removable managed media files."""
    removable_media_paths: set[Path] = set()
    for record, record_path in expired_records:
        with contextlib.suppress(OSError):
            record_path.unlink(missing_ok=True)

        resolved_media_path = record.local_path.resolve()
        if not _is_managed_media_path(storage_path, resolved_media_path):
            continue
        if active_media_ref_counts.get(resolved_media_path, 0) == 0:
            removable_media_paths.add(resolved_media_path)
    return removable_media_paths


def _prune_orphan_incoming_media(
    storage_path: Path,
    *,
    cutoff: datetime,
    active_media_paths: set[Path],
) -> int:
    """Delete old incoming-media files that are no longer referenced."""
    incoming_media_dir = _incoming_media_dir(storage_path)
    if not incoming_media_dir.is_dir():
        return 0

    removed = 0
    for media_path in incoming_media_dir.iterdir():
        if not media_path.is_file():
            continue
        resolved_media_path = media_path.resolve()
        if resolved_media_path in active_media_paths:
            continue
        media_mtime = _record_mtime(media_path)
        if media_mtime is None or media_mtime >= cutoff:
            continue
        try:
            media_path.unlink(missing_ok=True)
        except OSError:
            continue
        removed += 1
    return removed


def _cleanup_attachment_storage(storage_path: Path) -> None:
    """Prune expired attachment metadata and managed media files."""
    attachments_dir = _attachments_dir(storage_path)
    if not attachments_dir.is_dir():
        return

    cutoff = datetime.now(UTC) - timedelta(days=_ATTACHMENT_RETENTION_DAYS)
    active_media_paths, active_media_ref_counts, expired_records, stale_record_paths = (
        _collect_attachment_cleanup_state(
            storage_path,
            cutoff=cutoff,
        )
    )
    stale_records_removed = _remove_paths(stale_record_paths)

    removable_media_paths = _prune_expired_records_and_collect_removable_media_paths(
        storage_path,
        expired_records=expired_records,
        active_media_ref_counts=active_media_ref_counts,
    )
    expired_media_removed = _remove_paths(list(removable_media_paths))
    orphan_media_removed = _prune_orphan_incoming_media(
        storage_path,
        cutoff=cutoff,
        active_media_paths=active_media_paths,
    )
    logger.debug(
        "Attachment cleanup completed",
        stale_records_removed=stale_records_removed,
        expired_records_removed=len(expired_records),
        expired_media_removed=expired_media_removed,
        orphan_media_removed=orphan_media_removed,
        active_media_paths=len(active_media_paths),
    )


def _maybe_cleanup_attachment_storage(storage_path: Path) -> None:
    """Run cleanup at most once per ``_CLEANUP_INTERVAL``."""
    resolved_storage_path = storage_path.resolve()
    now = datetime.now(UTC)
    last_cleanup_time = _last_cleanup_time_by_storage_path.get(resolved_storage_path)
    if last_cleanup_time is not None and now - last_cleanup_time < _CLEANUP_INTERVAL:
        return
    try:
        _cleanup_attachment_storage(resolved_storage_path)
        _last_cleanup_time_by_storage_path[resolved_storage_path] = now
    except Exception:
        logger.exception("Failed to prune expired attachment storage")


def register_local_attachment(
    storage_path: Path,
    local_path: Path,
    *,
    kind: _AttachmentKind,
    attachment_id: str | None = None,
    filename: str | None = None,
    mime_type: str | None = None,
    room_id: str | None = None,
    thread_id: str | None = None,
    source_event_id: str | None = None,
    sender: str | None = None,
    event_timestamp: int | None = None,
) -> AttachmentRecord | None:
    """Register a local file as an attachment and persist metadata."""
    if not local_path.is_file():
        logger.warning("Attachment path does not exist", path=str(local_path), kind=kind)
        return None

    hasher = hashlib.sha256()
    size_bytes = 0
    try:
        with local_path.open("rb") as fh:
            for chunk in iter(lambda: fh.read(65536), b""):
                hasher.update(chunk)
                size_bytes += len(chunk)
    except OSError:
        logger.exception("Failed to hash attachment file", path=str(local_path))
        return None
    content_sha256 = hasher.hexdigest()

    resolved_attachment_id = attachment_id or f"att_{uuid4().hex[:16]}"
    normalized_attachment_id = normalize_attachment_id(resolved_attachment_id)
    if normalized_attachment_id is None:
        logger.warning("Invalid attachment ID", attachment_id=resolved_attachment_id)
        return None

    record = AttachmentRecord(
        attachment_id=normalized_attachment_id,
        local_path=local_path.resolve(),
        kind=kind,
        filename=filename,
        mime_type=mime_type,
        room_id=room_id,
        thread_id=thread_id,
        source_event_id=source_event_id,
        sender=sender,
        event_timestamp=event_timestamp,
        size_bytes=size_bytes,
        content_sha256=content_sha256,
        created_at=datetime.now(UTC).isoformat(),
    )

    record_path = _attachment_record_path(storage_path, normalized_attachment_id)
    tmp_path = record_path.with_suffix(f".{uuid4().hex[:8]}.tmp")
    try:
        record_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path.write_text(json.dumps(record.to_payload(), sort_keys=True), encoding="utf-8")
        tmp_path.replace(record_path)
    except OSError:
        logger.exception("Failed to persist attachment metadata", attachment_id=normalized_attachment_id)
        with contextlib.suppress(OSError):
            tmp_path.unlink(missing_ok=True)
        return None

    _maybe_cleanup_attachment_storage(storage_path)

    return record


def _filename_for_media_event(event: FileOrVideoMessageEvent | ImageMessageEvent | AudioMessageEvent) -> str | None:
    """Extract best-effort filename from Matrix media event content."""
    content = event.source.get("content", {})
    filename = content.get("filename")
    if isinstance(filename, str) and filename:
        return filename
    return event.body if isinstance(event.body, str) and event.body else None


async def _register_media_attachment(
    *,
    storage_path: Path,
    event_id: str,
    media_bytes: bytes | None,
    mime_type: str | None,
    room_id: str,
    thread_id: str | None,
    sender: str,
    event_timestamp: int | None,
    filename: str | None,
    kind: _AttachmentKind,
) -> AttachmentRecord | None:
    """Persist media bytes and register a scoped attachment record."""
    if media_payload_exceeds_limit(media_bytes):
        logger.warning(
            "Matrix media attachment exceeds byte limit",
            event_id=event_id,
            kind=kind,
            size_bytes=len(media_bytes) if media_bytes is not None else None,
        )
        return None
    local_media_path = await _store_media_bytes_locally_async(
        storage_path,
        event_id,
        media_bytes,
        mime_type,
    )
    if local_media_path is None:
        return None
    return await asyncio.to_thread(
        register_local_attachment,
        storage_path,
        local_media_path,
        kind=kind,
        attachment_id=_attachment_id_for_event(event_id),
        filename=filename,
        mime_type=mime_type,
        room_id=room_id,
        thread_id=thread_id,
        source_event_id=event_id,
        sender=sender,
        event_timestamp=event_timestamp,
    )


async def _register_file_or_video_attachment(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    event: FileOrVideoMessageEvent,
) -> AttachmentRecord | None:
    """Persist a file/video event and register it as an attachment record."""
    media_bytes = await download_media_bytes(client, event)
    kind: _AttachmentKind = "video" if is_video_message_event(event) else "file"
    return await _register_media_attachment(
        storage_path=storage_path,
        event_id=event.event_id,
        media_bytes=media_bytes,
        mime_type=media_mime_type(event),
        room_id=room_id,
        thread_id=thread_id,
        sender=event.sender,
        event_timestamp=event.server_timestamp,
        filename=_filename_for_media_event(event),
        kind=kind,
    )


async def _register_image_attachment(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    event: ImageMessageEvent,
    image_bytes: bytes | None = None,
) -> AttachmentRecord | None:
    """Persist an image event and register it as an attachment record."""
    media_bytes = image_bytes if image_bytes is not None else await download_media_bytes(client, event)
    mime_resolution = resolve_image_mime_type(media_bytes, media_mime_type(event))
    if mime_resolution.is_mismatch:
        logger.warning(
            "Image attachment MIME mismatch between Matrix metadata and payload bytes",
            event_id=event.event_id,
            declared_mime_type=mime_resolution.declared_mime_type,
            detected_mime_type=mime_resolution.detected_mime_type,
        )
    return await _register_media_attachment(
        storage_path=storage_path,
        event_id=event.event_id,
        media_bytes=media_bytes,
        mime_type=mime_resolution.effective_mime_type,
        room_id=room_id,
        thread_id=thread_id,
        sender=event.sender,
        event_timestamp=event.server_timestamp,
        filename=_filename_for_media_event(event),
        kind="image",
    )


async def register_audio_attachment(
    storage_path: Path,
    *,
    event_id: str,
    audio_bytes: bytes | None,
    mime_type: str | None,
    room_id: str,
    thread_id: str | None,
    sender: str,
    event_timestamp: int | None = None,
    filename: str | None = None,
) -> AttachmentRecord | None:
    """Persist raw audio bytes and register them as an attachment record."""
    return await _register_media_attachment(
        storage_path=storage_path,
        event_id=event_id,
        media_bytes=audio_bytes,
        mime_type=mime_type,
        room_id=room_id,
        thread_id=thread_id,
        sender=sender,
        event_timestamp=event_timestamp,
        filename=filename,
        kind="audio",
    )


def load_attachment(storage_path: Path, attachment_id: str) -> AttachmentRecord | None:
    """Load attachment metadata by ID."""
    normalized_attachment_id = normalize_attachment_id(attachment_id)
    if normalized_attachment_id is None:
        return None
    record_path = _attachment_record_path(storage_path, normalized_attachment_id)
    if not record_path.is_file():
        return None

    try:
        raw_payload = json.loads(record_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        logger.exception("Failed to parse attachment metadata", attachment_id=normalized_attachment_id)
        return None

    if not isinstance(raw_payload, dict):
        return None

    kind = raw_payload.get("kind")
    local_path = raw_payload.get("local_path")
    if kind not in {"audio", "file", "image", "video"} or not isinstance(local_path, str) or not local_path:
        return None

    filename = raw_payload.get("filename")
    mime_type = raw_payload.get("mime_type")
    room_id = raw_payload.get("room_id")
    thread_id = raw_payload.get("thread_id")
    source_event_id = raw_payload.get("source_event_id")
    sender = raw_payload.get("sender")
    event_timestamp = raw_payload.get("event_timestamp")
    size_bytes = raw_payload.get("size_bytes")
    content_sha256 = raw_payload.get("content_sha256")
    created_at = raw_payload.get("created_at")

    return AttachmentRecord(
        attachment_id=normalized_attachment_id,
        local_path=Path(local_path),
        kind=kind,
        filename=filename if isinstance(filename, str) else None,
        mime_type=mime_type if isinstance(mime_type, str) else None,
        room_id=room_id if isinstance(room_id, str) else None,
        thread_id=thread_id if isinstance(thread_id, str) else None,
        source_event_id=source_event_id if isinstance(source_event_id, str) else None,
        sender=sender if isinstance(sender, str) else None,
        event_timestamp=event_timestamp if isinstance(event_timestamp, int) else None,
        size_bytes=size_bytes if isinstance(size_bytes, int) else None,
        content_sha256=content_sha256 if isinstance(content_sha256, str) else None,
        created_at=created_at if isinstance(created_at, str) else None,
    )


def resolve_attachments(storage_path: Path, attachment_ids: list[str]) -> list[AttachmentRecord]:
    """Resolve a list of attachment IDs into records, preserving order."""
    resolved: list[AttachmentRecord] = []
    seen_ids: set[str] = set()
    for attachment_id in attachment_ids:
        normalized_attachment_id = normalize_attachment_id(attachment_id)
        if normalized_attachment_id is None or normalized_attachment_id in seen_ids:
            continue
        seen_ids.add(normalized_attachment_id)
        record = load_attachment(storage_path, normalized_attachment_id)
        if record is not None:
            resolved.append(record)
    return resolved


def filter_attachments_for_context(
    attachment_records: list[AttachmentRecord],
    *,
    room_id: str,
    thread_id: str | None = None,
) -> tuple[list[AttachmentRecord], list[str]]:
    """Keep only attachments registered for the current room/thread context."""
    allowed_records: list[AttachmentRecord] = []
    rejected_attachment_ids: list[str] = []
    for record in attachment_records:
        if record.room_id != room_id:
            rejected_attachment_ids.append(record.attachment_id)
            continue

        if thread_id is None:
            if record.thread_id is None:
                allowed_records.append(record)
            else:
                rejected_attachment_ids.append(record.attachment_id)
            continue

        if record.thread_id == thread_id:
            allowed_records.append(record)
        else:
            rejected_attachment_ids.append(record.attachment_id)
    return allowed_records, rejected_attachment_ids


def _load_existing_context_attachment(
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    event_id: str,
) -> AttachmentRecord | None:
    existing_record = load_attachment(storage_path, _attachment_id_for_event(event_id))
    if (
        existing_record is not None
        and existing_record.room_id == room_id
        and existing_record.thread_id == thread_id
        and existing_record.local_path.is_file()
    ):
        return existing_record
    return None


def _media_event_from_thread_history_message(
    room_id: str,
    message: ResolvedVisibleMessage,
) -> FileOrVideoMessageEvent | ImageMessageEvent | None:
    content = {key: value for key, value in message.content.items() if isinstance(key, str)}
    return parse_matrix_media_dispatch_event_source(
        {
            "content": content,
            "event_id": message.event_id,
            "origin_server_ts": message.timestamp,
            "room_id": room_id,
            "sender": message.sender,
            "type": "m.room.message",
        },
    )


async def register_matrix_media_attachment(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    event: FileOrVideoMessageEvent | ImageMessageEvent,
    image_bytes: bytes | None = None,
) -> AttachmentRecord | None:
    """Persist an image/file/video Matrix event and register an attachment record."""
    if is_image_message_event(event):
        return await _register_image_attachment(
            client,
            storage_path,
            room_id=room_id,
            thread_id=thread_id,
            event=event,
            image_bytes=image_bytes,
        )
    if is_file_or_video_message_event(event):
        return await _register_file_or_video_attachment(
            client,
            storage_path,
            room_id=room_id,
            thread_id=thread_id,
            event=event,
        )
    msg = f"Expected image, file, or video event, got {type(event).__name__}"
    raise TypeError(msg)


async def _register_thread_history_media_attachment(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    event: FileOrVideoMessageEvent | ImageMessageEvent,
) -> AttachmentRecord | None:
    existing_record = _load_existing_context_attachment(
        storage_path,
        room_id=room_id,
        thread_id=thread_id,
        event_id=event.event_id,
    )
    if existing_record is not None:
        return existing_record

    return await register_matrix_media_attachment(
        client,
        storage_path,
        room_id=room_id,
        thread_id=thread_id,
        event=event,
    )


async def register_thread_history_media_attachments(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str | None,
    thread_history: Sequence[ResolvedVisibleMessage],
) -> list[str]:
    """Register unannotated image/file/video events visible in thread history."""
    attachment_ids: list[str] = []
    seen_attachment_ids: set[str] = set()
    for message in thread_history:
        if not _thread_history_message_in_scope(message, thread_id):
            continue
        event = _media_event_from_thread_history_message(room_id, message)
        if event is None:
            continue
        attachment_record = await _register_thread_history_media_attachment(
            client,
            storage_path,
            room_id=room_id,
            thread_id=thread_id,
            event=event,
        )
        if attachment_record is None or attachment_record.attachment_id in seen_attachment_ids:
            continue
        seen_attachment_ids.add(attachment_record.attachment_id)
        attachment_ids.append(attachment_record.attachment_id)
    return attachment_ids


async def resolve_thread_attachment_ids(
    client: nio.AsyncClient,
    storage_path: Path,
    *,
    room_id: str,
    thread_id: str,
    thread_root_event: nio.Event | None = None,
) -> list[str]:
    """Resolve attachment IDs from thread root event metadata or media payload.

    When *thread_root_event* is provided, the ``room_get_event`` round-trip
    is skipped, avoiding duplicate homeserver calls when the caller already
    fetched the root event for image/audio resolution.
    """
    started = time.monotonic()
    event_kind = "provided" if thread_root_event is not None else "fetched"

    def finish(attachment_ids: list[str], outcome: str) -> list[str]:
        emit_elapsed_timing(
            "response_payload.resolve_thread_attachment_ids",
            started,
            room_id=room_id,
            thread_id=thread_id,
            outcome=outcome,
            event_kind=event_kind,
            attachment_count=len(attachment_ids),
        )
        return attachment_ids

    event = thread_root_event
    if event is None:
        response = await client.room_get_event(room_id, thread_id)
        if not isinstance(response, nio.RoomGetEventResponse):
            return finish([], "event_fetch_miss")
        event = response.event

    event_attachment_ids = parse_attachment_ids_from_event_source(event.source)
    if event_attachment_ids:
        return finish(event_attachment_ids, "event_metadata")

    # Check for an existing attachment record for any media root (file, video,
    # image, or audio). Audio roots are registered by the voice handler and
    # can be looked up but not re-downloaded here.
    if not is_matrix_media_dispatch_event(event) and not is_audio_message_event(event):
        return finish([], "not_media_root")

    existing_record = _load_existing_context_attachment(
        storage_path,
        room_id=room_id,
        thread_id=thread_id,
        event_id=event.event_id,
    )
    if existing_record is not None:
        return finish([existing_record.attachment_id], "existing_record")

    # Audio roots cannot be re-registered here (the voice handler owns that
    # lifecycle), so only existing audio records are returned above.
    record = (
        await register_matrix_media_attachment(
            client,
            storage_path,
            room_id=room_id,
            thread_id=thread_id,
            event=event,
        )
        if is_matrix_media_dispatch_event(event)
        else None
    )
    if record is None:
        return finish([], "no_record")
    return finish([record.attachment_id], "registered_record")


def attachments_for_tool_payload(attachment_records: list[AttachmentRecord]) -> list[dict[str, Any]]:
    """Render attachment records for tool JSON responses."""
    payloads: list[dict[str, Any]] = []
    for record in attachment_records:
        payload = record.to_payload()
        payload["available"] = record.local_path.is_file()
        payloads.append(payload)
    return payloads
