"""BlueSky API client implementation using XRPC endpoints."""

import asyncio
import contextlib
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import httpx
from pydantic import HttpUrl

from marqetive.core.base import ProgressCallback, SocialMediaPlatform
from marqetive.core.exceptions import (
    MediaUploadError,
    PlatformAuthError,
    PlatformError,
    PostNotFoundError,
    RateLimitError,
    ThreadCancelledException,
    ValidationError,
)
from marqetive.core.models import (
    AuthCredentials,
    Comment,
    CommentStatus,
    Conversation,
    ConversationListResponse,
    DirectMessage,
    DMCreateRequest,
    GroupDMCreateRequest,
    InteractionUserListResponse,
    MediaAttachment,
    MediaType,
    MessageListResponse,
    Post,
    PostCreateRequest,
    PostStatus,
    PostUpdateRequest,
    ProgressStatus,
)
from marqetive.platforms.bluesky.exceptions import map_bluesky_error
from marqetive.platforms.bluesky.media import (
    BLUESKY_ALLOWED_IMAGE_EXTENSIONS,
    BLUESKY_ALLOWED_VIDEO_EXTENSIONS,
    BlueSkyMediaManager,
    resolve_bluesky_image_mime_type,
)
from marqetive.platforms.bluesky.models import (
    BatchMessageItem,
    BlueSkyInteractionUser,
)
from marqetive.utils.retry import retry_async_func

BLUESKY_API_BASE = "https://bsky.social/xrpc"
BLUESKY_POST_COLLECTION = "app.bsky.feed.post"
BLUESKY_REPLY_REFERENCE_MAX_ATTEMPTS = 3
BLUESKY_REPLY_REFERENCE_BASE_DELAY_SECONDS = 0.25


class BlueSkyClient(SocialMediaPlatform):
    """BlueSky API client using direct XRPC calls."""

    def __init__(
        self,
        credentials: AuthCredentials,
        timeout: float = 30.0,
        progress_callback: ProgressCallback | None = None,
    ) -> None:
        super().__init__(
            platform_name="bluesky",
            credentials=credentials,
            base_url=BLUESKY_API_BASE,
            timeout=timeout,
            progress_callback=progress_callback,
        )
        self.repo_identifier = self._get_repo_identifier()
        self._media_manager: BlueSkyMediaManager | None = None
        self._pds_base_url: str | None = None

    async def __aenter__(self) -> "BlueSkyClient":
        await super().__aenter__()
        self._media_manager = BlueSkyMediaManager(
            access_token=self.credentials.access_token,
            token_type=self.credentials.token_type,
            timeout=self.timeout,
            pds_base_url=self.base_url,
        )
        await self._media_manager.__aenter__()
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        if self._media_manager:
            await self._media_manager.__aexit__(exc_type, exc_val, exc_tb)
            self._media_manager = None
        await super().__aexit__(exc_type, exc_val, exc_tb)

    def _get_repo_identifier(self) -> str:
        """Resolve BlueSky repo identifier (DID or handle)."""
        did = self.credentials.additional_data.get("did")
        if isinstance(did, str) and did:
            return did
        if self.credentials.user_id:
            return self.credentials.user_id
        if self.credentials.username:
            return self.credentials.username

        raise PlatformAuthError(
            "BlueSky requires user_id (DID/handle), username, or 'did' in additional_data",
            platform=self.platform_name,
        )

    async def authenticate(self) -> AuthCredentials:
        """Validate current BlueSky credentials."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")
        if await self.is_authenticated():
            return self.credentials

        raise PlatformAuthError(
            "Invalid or expired credentials. Please re-authenticate via BlueSky.",
            platform=self.platform_name,
        )

    async def refresh_token(self) -> AuthCredentials:
        """Refresh BlueSky access token using refresh JWT."""
        if not self.credentials.refresh_token:
            raise PlatformAuthError(
                "No refresh token available",
                platform=self.platform_name,
            )

        try:

            async def _refresh_session() -> dict[str, Any]:
                async with httpx.AsyncClient(timeout=self.timeout) as client:
                    response = await client.post(
                        f"{BLUESKY_API_BASE}/com.atproto.server.refreshSession",
                        headers={
                            "Authorization": f"Bearer {self.credentials.refresh_token}"
                        },
                    )
                    response.raise_for_status()
                    return response.json()

            data = await retry_async_func(_refresh_session)

        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            mapped = map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            )
            if isinstance(mapped, Exception):
                raise mapped from e
            raise PlatformAuthError(
                f"Failed to refresh BlueSky token: {e.response.text}",
                platform=self.platform_name,
                status_code=e.response.status_code,
            ) from e

        except httpx.HTTPError as e:
            raise PlatformAuthError(
                f"Network error refreshing token: {e}",
                platform=self.platform_name,
            ) from e

        self.credentials.access_token = data["accessJwt"]
        if "refreshJwt" in data:
            self.credentials.refresh_token = data["refreshJwt"]
        if "did" in data:
            self.credentials.user_id = data["did"]
            self.repo_identifier = data["did"]
            self.credentials.additional_data["did"] = data["did"]
        if "handle" in data:
            self.credentials.username = data["handle"]
        expires_at_raw = data.get("expiresAt")
        expires_in = data.get("expiresIn")

        if isinstance(expires_at_raw, str):
            with contextlib.suppress(ValueError):
                self.credentials.expires_at = datetime.fromisoformat(
                    expires_at_raw.replace("Z", "+00:00")
                )
        elif isinstance(expires_at_raw, int | float):
            ts = float(expires_at_raw)
            if ts > 1_000_000_000_000:
                ts /= 1000.0
            self.credentials.expires_at = datetime.fromtimestamp(ts, tz=UTC)
        elif isinstance(expires_in, int | float):
            self.credentials.expires_at = datetime.now(UTC) + timedelta(
                seconds=float(expires_in)
            )
        else:
            self.credentials.expires_at = datetime.now(UTC) + timedelta(
                hours=1, minutes=50
            )

        if self._media_manager:
            self._media_manager.update_credentials(
                access_token=self.credentials.access_token,
                token_type=self.credentials.token_type,
            )

        return self.credentials

    async def is_authenticated(self) -> bool:
        """Check whether the current access token is valid."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/com.atproto.server.getSession",
            )
            return bool(response.data.get("did"))
        except (httpx.HTTPError, TypeError):
            return False

    def _validate_create_post_request(self, request: PostCreateRequest) -> None:
        """Validate BlueSky post creation request."""
        pass

    def _get_image_mime_type(self, media_url: str) -> str:
        """Get MIME type for an image URL after extension validation."""
        return resolve_bluesky_image_mime_type(
            media_url,
            platform=self.platform_name,
        )

    def _classify_media_urls(
        self, media_urls: list[str]
    ) -> tuple[list[str], list[str]]:
        """Split media URLs into image and video groups."""
        image_urls: list[str] = []
        video_urls: list[str] = []

        for media_url in media_urls:
            parsed = urlparse(media_url)
            extension = Path(parsed.path).suffix.lower()
            if extension in BLUESKY_ALLOWED_IMAGE_EXTENSIONS:
                image_urls.append(media_url)
            elif extension in BLUESKY_ALLOWED_VIDEO_EXTENSIONS:
                video_urls.append(media_url)
            else:
                allowed_images = ", ".join(sorted(BLUESKY_ALLOWED_IMAGE_EXTENSIONS))
                allowed_videos = ", ".join(sorted(BLUESKY_ALLOWED_VIDEO_EXTENSIONS))
                raise MediaUploadError(
                    "Unsupported BlueSky media format "
                    f"'{extension}'. Allowed images: {allowed_images}. "
                    f"Allowed videos: {allowed_videos}",
                    platform=self.platform_name,
                )

        if image_urls and video_urls:
            raise MediaUploadError(
                "BlueSky does not support mixing images and videos in a single post",
                platform=self.platform_name,
            )

        if len(video_urls) > 1:
            raise MediaUploadError(
                "BlueSky supports only one video per post",
                platform=self.platform_name,
            )

        return image_urls, video_urls

    def _get_video_alt_text(self, request: PostCreateRequest) -> str:
        """Resolve alt text for a BlueSky video embed."""
        alt_texts = request.additional_data.get("alt_texts")
        if isinstance(alt_texts, list) and alt_texts and isinstance(alt_texts[0], str):
            return alt_texts[0]

        alt_text = request.additional_data.get("alt_text")
        return alt_text if isinstance(alt_text, str) else ""

    def _get_video_aspect_ratio(
        self, request: PostCreateRequest
    ) -> dict[str, int] | None:
        """Resolve aspect ratio for a BlueSky video embed, if provided."""
        aspect_ratio = request.additional_data.get("aspect_ratio")
        if not isinstance(aspect_ratio, dict):
            return None

        width = aspect_ratio.get("width")
        height = aspect_ratio.get("height")
        if (
            isinstance(width, int)
            and isinstance(height, int)
            and width > 0
            and height > 0
        ):
            return {"width": width, "height": height}

        return None

    def _resolve_video_upload_did(self) -> str:
        """Resolve DID required for BlueSky video uploads."""
        did_value = self.repo_identifier
        if not did_value.startswith("did:"):
            did_candidate = self.credentials.additional_data.get("did")
            if isinstance(did_candidate, str) and did_candidate.startswith("did:"):
                did_value = did_candidate
            elif isinstance(
                self.credentials.user_id, str
            ) and self.credentials.user_id.startswith("did:"):
                did_value = self.credentials.user_id
            else:
                raise MediaUploadError(
                    "BlueSky video uploads require a DID. "
                    "Provide credentials.additional_data['did'] or user_id as a DID.",
                    platform=self.platform_name,
                )

        return did_value

    async def _resolve_pds_base_url(self) -> str:
        """Resolve the user's PDS base URL for service auth."""
        if self._pds_base_url and self._pds_base_url != self.base_url:
            return self._pds_base_url
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        for key in ("pds", "pds_url", "pds_endpoint"):
            candidate = self.credentials.additional_data.get(key)
            if isinstance(candidate, str) and candidate:
                pds_url = candidate.rstrip("/")
                if not pds_url.endswith("/xrpc"):
                    pds_url = f"{pds_url}/xrpc"
                self._pds_base_url = pds_url
                if self._media_manager:
                    self._media_manager.update_pds_base_url(pds_url)
                return pds_url

        pds_url: str | None = None
        response = await retry_async_func(
            self.api_client.get,
            "/com.atproto.server.getSession",
        )
        did_doc = response.data.get("didDoc", {})
        services = did_doc.get("service", []) if isinstance(did_doc, dict) else []
        for service in services:
            if not isinstance(service, dict):
                continue
            service_id = str(service.get("id", ""))
            service_type = str(service.get("type", ""))
            if (
                service_id.endswith("#atproto_pds")
                or service_type == "AtprotoPersonalDataServer"
            ):
                endpoint = service.get("serviceEndpoint")
                if isinstance(endpoint, str) and endpoint:
                    pds_url = endpoint.rstrip("/")
                    break

        if not pds_url:
            did_value = self._resolve_video_upload_did()
            if did_value.startswith("did:web:"):
                host = did_value.removeprefix("did:web:").strip()
                if host:
                    pds_url = f"https://{host}"

            elif did_value.startswith("did:plc:"):
                try:
                    async with httpx.AsyncClient(timeout=self.timeout) as client:
                        plc_response = await client.get(
                            f"https://plc.directory/{did_value}"
                        )
                        plc_response.raise_for_status()
                        plc_doc = plc_response.json()

                except httpx.HTTPError:
                    plc_doc = {}
                    raise

                plc_services = plc_doc.get("service", [])
                if isinstance(plc_services, dict):
                    plc_services = [
                        {"id": key, **value}
                        for key, value in plc_services.items()
                        if isinstance(value, dict)
                    ]
                if isinstance(plc_services, list):
                    for service in plc_services:
                        if not isinstance(service, dict):
                            continue
                        service_id = str(service.get("id", ""))
                        service_type = str(service.get("type", ""))
                        if (
                            service_id.endswith("#atproto_pds")
                            or service_type == "AtprotoPersonalDataServer"
                        ):
                            endpoint = service.get("serviceEndpoint") or service.get(
                                "endpoint"
                            )
                            if isinstance(endpoint, str) and endpoint:
                                pds_url = endpoint.rstrip("/")
                                break

        if not pds_url:
            pds_url = self.base_url.rstrip("/")

        if not pds_url.endswith("/xrpc"):
            pds_url = f"{pds_url}/xrpc"

        self._pds_base_url = pds_url
        self.credentials.additional_data["pds"] = pds_url
        if self._media_manager:
            self._media_manager.update_pds_base_url(pds_url)
        return pds_url

    async def _upload_video_for_embed(self, media_url: str) -> dict[str, Any]:
        """Upload a video via the video service and return the processed blob."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")
        if not self._media_manager:
            raise RuntimeError("Client must be used as async context manager")
        upload_name = ""
        if media_url.startswith(("http://", "https://")):
            parsed_url = urlparse(media_url)
            upload_name = Path(parsed_url.path).name
        else:
            upload_name = Path(media_url).name

        await self._resolve_pds_base_url()
        result = await self._media_manager.upload_video(
            media_url,
            did=self._resolve_video_upload_did(),
            upload_name=upload_name or None,
        )
        return result.blob

    def _normalize_post_uri(self, post_id: str) -> str:
        """Normalize post identifier to an AT URI."""
        if post_id.startswith("at://"):
            return post_id

        if post_id.startswith("https://bsky.app/profile/"):
            parts = post_id.replace("https://bsky.app/profile/", "").split("/")
            if len(parts) >= 3 and parts[1] == "post":
                profile = parts[0]
                rkey = parts[2]
                return f"at://{profile}/{BLUESKY_POST_COLLECTION}/{rkey}"

        return f"at://{self.repo_identifier}/{BLUESKY_POST_COLLECTION}/{post_id}"

    def _extract_rkey_from_uri(self, uri: str) -> str:
        """Extract record key from an AT URI."""
        parts = uri.split("/")
        if len(parts) < 5:
            raise ValidationError(
                f"Invalid BlueSky post URI: {uri}",
                platform=self.platform_name,
                field="post_id",
            )
        return parts[-1]

    def _build_post_url(self, uri: str) -> HttpUrl | None:
        """Convert AT URI to public bsky.app URL."""
        parts = uri.split("/")
        if len(parts) < 5:
            return None
        profile = parts[2]
        rkey = parts[4]
        return HttpUrl(f"https://bsky.app/profile/{profile}/post/{rkey}")

    async def _resolve_reply_reference(
        self, post_uri: str
    ) -> dict[str, dict[str, str]]:
        """Resolve reply root/parent reference for replying to a post."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post: dict[str, Any] | None = None
        for attempt in range(BLUESKY_REPLY_REFERENCE_MAX_ATTEMPTS):
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.feed.getPosts",
                params={"uris": post_uri},
            )
            posts = response.data.get("posts", [])
            if posts:
                post = posts[0]
                break

            if attempt < BLUESKY_REPLY_REFERENCE_MAX_ATTEMPTS - 1:
                delay_seconds = BLUESKY_REPLY_REFERENCE_BASE_DELAY_SECONDS * (
                    attempt + 1
                )
                await asyncio.sleep(delay_seconds)

        if not post:
            raise PostNotFoundError(post_uri, self.platform_name)

        parent_uri = post.get("uri")
        parent_cid = post.get("cid")
        if not parent_uri or not parent_cid:
            raise PlatformError(
                "BlueSky reply target is missing uri/cid",
                platform=self.platform_name,
            )

        root = post.get("record", {}).get("reply", {}).get("root", {})
        root_uri = root.get("uri", parent_uri)
        root_cid = root.get("cid", parent_cid)

        return {
            "root": {"uri": root_uri, "cid": root_cid},
            "parent": {"uri": parent_uri, "cid": parent_cid},
        }

    async def create_post(self, request: PostCreateRequest) -> Post:
        """Create and publish a BlueSky post."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")
        if not self._media_manager:
            raise RuntimeError("Client must be used as async context manager")

        self._validate_create_post_request(request)

        langs: list[str] | None = None
        if (
            isinstance(request.additional_data.get("langs"), list)
            and request.additional_data["langs"]
        ):
            langs = [
                str(lang)
                for lang in request.additional_data["langs"]
                if isinstance(lang, str)
            ]

        reply_to_post_id = request.reply_to_post_id or request.additional_data.get(
            "reply_to_uri"
        )
        record: dict[str, Any] = {
            "$type": BLUESKY_POST_COLLECTION,
            "text": request.content,
            "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
        }
        if langs:
            record["langs"] = langs

        if reply_to_post_id:
            reply_uri = self._normalize_post_uri(str(reply_to_post_id))
            record["reply"] = await self._resolve_reply_reference(reply_uri)

        if request.media_urls:
            image_urls, video_urls = self._classify_media_urls(request.media_urls)

            if video_urls:
                alt_text = self._get_video_alt_text(request)
                aspect_ratio = self._get_video_aspect_ratio(request)
                blob = await self._upload_video_for_embed(video_urls[0])
                embed: dict[str, Any] = {
                    "$type": "app.bsky.embed.video",
                    "video": blob,
                }
                if alt_text:
                    embed["alt"] = alt_text
                if aspect_ratio:
                    embed["aspectRatio"] = aspect_ratio
                record["embed"] = embed
            else:
                alt_texts = request.additional_data.get("alt_texts", [])
                images: list[dict[str, Any]] = []
                for idx, media_url in enumerate(image_urls):
                    self._get_image_mime_type(media_url)
                    alt_text = (
                        alt_texts[idx]
                        if isinstance(alt_texts, list)
                        and idx < len(alt_texts)
                        and isinstance(alt_texts[idx], str)
                        else ""
                    )
                    upload_result = await self._media_manager.upload_image(media_url)
                    images.append({"alt": alt_text, "image": upload_result.blob})
                record["embed"] = {"$type": "app.bsky.embed.images", "images": images}

        payload = {
            "repo": self.repo_identifier,
            "collection": BLUESKY_POST_COLLECTION,
            "record": record,
        }

        try:
            response = await retry_async_func(
                self.api_client.post,
                "/com.atproto.repo.createRecord",
                data=payload,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to create BlueSky post: {e}",
                platform=self.platform_name,
            ) from e

        uri = response.data["uri"]

        return Post(
            post_id=uri,
            platform=self.platform_name,
            content=request.content,
            status=PostStatus.PUBLISHED,
            created_at=datetime.now(),
            author_id=self.repo_identifier,
            url=self._build_post_url(uri),
            raw_data=response.data,
        )

    async def create_thread(
        self,
        posts: list[PostCreateRequest],
        cancellation_check: Callable[[], Awaitable[bool]] | None = None,
    ) -> list[Post]:
        """Create a BlueSky thread by chaining replies to the thread head."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        if not posts:
            raise ValidationError(
                "At least one post is required for thread creation",
                platform=self.platform_name,
            )

        created_posts: list[Post] = []
        reply_to_id: str | None = None

        for idx, post_request in enumerate(posts):
            if cancellation_check is not None and await cancellation_check():
                raise ThreadCancelledException(
                    f"Thread cancelled after {len(created_posts)} of {len(posts)} posts",
                    platform=self.platform_name,
                    posted_tweets=created_posts,
                )

            self._validate_create_post_request(post_request)

            final_request = post_request
            if reply_to_id is not None:
                final_request = post_request.model_copy(
                    update={"reply_to_post_id": reply_to_id}
                )

            created_post = await self.create_post(final_request)
            created_posts.append(created_post)
            reply_to_id = created_post.post_id

            await self._emit_progress(
                operation="create_thread",
                status=ProgressStatus.PROCESSING,
                progress=idx + 1,
                total=len(posts),
                message=f"Post {idx + 1}/{len(posts)} created",
                entity_id=created_posts[-1].post_id,
            )

        return created_posts

    def _parse_post(self, post_data: dict[str, Any]) -> Post:
        """Parse BlueSky feed post payload to Post model."""
        record = post_data.get("record", {})
        created_at_raw = record.get("createdAt")

        created_at = datetime.now()
        if isinstance(created_at_raw, str):
            try:
                created_at = datetime.fromisoformat(
                    created_at_raw.replace("Z", "+00:00")
                )
            except ValueError:
                created_at = datetime.now()

        uri = post_data.get("uri", "")

        author = post_data.get("author", {})

        return Post(
            post_id=uri,
            platform=self.platform_name,
            content=record.get("text"),
            status=PostStatus.PUBLISHED,
            url=self._build_post_url(uri),
            created_at=created_at,
            author_id=author.get("did"),
            author_username=author.get("handle"),
            author_name=author.get("displayName"),
            likes_count=int(post_data.get("likeCount", 0) or 0),
            comments_count=int(post_data.get("replyCount", 0) or 0),
            shares_count=int(post_data.get("repostCount", 0) or 0),
            views_count=0,  # BlueSky API doesn't expose view counts
            raw_data=post_data,
        )

    async def _get_post_via_xrpc(self, post_uri: str) -> Post:
        """Retrieve a post through direct XRPC."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.feed.getPosts",
                params={"uris": post_uri},
            )
            posts = response.data.get("posts", [])
            if posts and isinstance(posts[0], dict):
                return self._parse_post(posts[0])
            raise PostNotFoundError(post_uri, self.platform_name, status_code=404)
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise PostNotFoundError(
                    post_uri, self.platform_name, status_code=404
                ) from e
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky post via XRPC: {e}",
                platform=self.platform_name,
            ) from e

    async def _delete_post_via_xrpc(self, post_uri: str) -> bool:
        """Delete a post through XRPC."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        parts = post_uri.split("/")
        if len(parts) < 5:
            raise ValidationError(
                f"Invalid BlueSky post URI: {post_uri}",
                platform=self.platform_name,
                field="post_id",
            )

        repo = parts[2]
        collection = parts[3]
        rkey = parts[4]
        try:
            await retry_async_func(
                self.api_client.post,
                "/com.atproto.repo.deleteRecord",
                data={
                    "repo": repo,
                    "collection": collection,
                    "rkey": rkey,
                },
            )
            return True
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            mapped = map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            )
            if isinstance(mapped, Exception):
                raise mapped from e
            raise PlatformError(
                f"Failed to delete BlueSky post via XRPC: {e.response.text}",
                platform=self.platform_name,
                status_code=e.response.status_code,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to delete BlueSky post via XRPC: {e}",
                platform=self.platform_name,
            ) from e
        except Exception as e:
            raise PlatformError(
                f"Failed to delete BlueSky post via XRPC: {e}",
                platform=self.platform_name,
            ) from e

    async def get_post(self, post_id: str) -> Post:
        """Retrieve a BlueSky post by URI or record key."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post_uri = self._normalize_post_uri(post_id)
        return await self._get_post_via_xrpc(post_uri)

    async def delete_post(self, post_id: str) -> bool:
        """Delete a BlueSky post by URI or record key."""
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post_uri = self._normalize_post_uri(post_id)
        return await self._delete_post_via_xrpc(post_uri)

    async def update_post(
        self,
        post_id: str,  # noqa: ARG002
        request: PostUpdateRequest,  # noqa: ARG002
    ) -> Post:
        """Update a BlueSky post.

        Note: BlueSky does not provide an edit flow in this client implementation.
        """
        raise PlatformError(
            "BlueSky post editing is not supported by this client.",
            platform=self.platform_name,
        )

    def _parse_comment(self, post_data: dict[str, Any], root_post_id: str) -> Comment:
        """Parse BlueSky reply post into Comment model."""
        record = post_data.get("record", {})
        created_at_raw = record.get("createdAt")

        created_at = datetime.now()
        if isinstance(created_at_raw, str):
            try:
                created_at = datetime.fromisoformat(
                    created_at_raw.replace("Z", "+00:00")
                )
            except ValueError:
                created_at = datetime.now()

        author = post_data.get("author", {})

        return Comment(
            comment_id=post_data.get("uri", ""),
            post_id=root_post_id,
            platform=self.platform_name,
            content=record.get("text", ""),
            author_id=author.get("did", ""),
            author_username=author.get("handle"),
            author_name=author.get("displayName"),
            created_at=created_at,
            likes_count=int(post_data.get("likeCount", 0) or 0),
            replies_count=int(post_data.get("replyCount", 0) or 0),
            status=CommentStatus.VISIBLE,
            raw_data=post_data,
        )

    async def get_comments(
        self,
        post_id: str,
        limit: int = 50,
        offset: int = 0,
    ) -> list[Comment]:
        """Retrieve comments (replies) for a BlueSky post."""
        if not post_id:
            raise ValueError("post_id is required to fetch comments")

        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post_uri = self._normalize_post_uri(post_id)

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.feed.getPostThread",
                params={"uri": post_uri, "depth": 2},
            )
            thread = response.data.get("thread", {})
            replies = thread.get("replies", []) if isinstance(thread, dict) else []

            comments: list[Comment] = []
            for reply in replies:
                if not isinstance(reply, dict):
                    continue
                reply_post = reply.get("post")
                if isinstance(reply_post, dict):
                    comments.append(self._parse_comment(reply_post, post_uri))

            return comments[offset : offset + limit]
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise PostNotFoundError(
                    post_id, self.platform_name, status_code=404
                ) from e
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky comments: {e}",
                platform=self.platform_name,
            ) from e

    async def create_comment(
        self, post_id: str, content: str, *, parent_comment_id: str | None = None
    ) -> Comment:
        """Create a reply comment on a BlueSky post.

        Args:
            post_id: BlueSky post URI or AT-URI.
            content: Text content of the reply.
            parent_comment_id: URI of parent comment for nested replies.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        root_post_uri = self._normalize_post_uri(post_id)
        reply_target = parent_comment_id or post_id
        reply_uri = self._normalize_post_uri(reply_target)
        reply_ref = await self._resolve_reply_reference(reply_uri)

        payload = {
            "repo": self.repo_identifier,
            "collection": BLUESKY_POST_COLLECTION,
            "record": {
                "$type": BLUESKY_POST_COLLECTION,
                "text": content,
                "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
                "reply": reply_ref,
            },
        }

        try:
            response = await retry_async_func(
                self.api_client.post,
                "/com.atproto.repo.createRecord",
                data=payload,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to create BlueSky comment: {e}",
                platform=self.platform_name,
            ) from e

        return Comment(
            comment_id=response.data["uri"],
            post_id=root_post_uri,
            platform=self.platform_name,
            content=content,
            author_id=self.repo_identifier,
            parent_comment_id=parent_comment_id,
            created_at=datetime.now(),
            status=CommentStatus.VISIBLE,
            raw_data=response.data,
        )

    async def delete_comment(
        self, comment_id: str, *, post_id: str | None = None
    ) -> bool:
        """Delete a BlueSky comment (reply post)."""
        del post_id
        return await self.delete_post(comment_id)

    async def upload_media(
        self,
        media_url: str,
        media_type: str,
        alt_text: str | None = None,
    ) -> MediaAttachment:
        """Upload media to BlueSky and return blob metadata."""
        if not self._media_manager:
            raise RuntimeError("Client must be used as async context manager")

        media_type_lower = media_type.lower()
        if media_type_lower not in {"image", "video"}:
            raise MediaUploadError(
                "BlueSkyClient supports only image or video uploads",
                platform=self.platform_name,
            )

        try:
            if media_type_lower == "image":
                result = await self._media_manager.upload_image(media_url)
            else:
                await self._resolve_pds_base_url()
                result = await self._media_manager.upload_video(
                    media_url,
                    did=self._resolve_video_upload_did(),
                )
        except MediaUploadError:
            raise
        except Exception as e:
            raise MediaUploadError(
                f"Failed to upload media to BlueSky: {e}",
                platform=self.platform_name,
            ) from e

        return MediaAttachment(
            media_id=result.media_id,
            media_type=(
                MediaType.IMAGE if media_type_lower == "image" else MediaType.VIDEO
            ),
            url=HttpUrl(media_url),
            size_bytes=result.size_bytes,
            alt_text=alt_text,
        )

    # ------------------------------------------------------------------
    # Chat / DM helpers
    # ------------------------------------------------------------------

    def _get_chat_headers(self) -> dict[str, str]:
        """Return auth headers with the BlueSky chat proxy header.

        Returns:
            Headers dict required for all chat.bsky.* endpoints.
        """
        return {
            "Authorization": f"{self.credentials.token_type} {self.credentials.access_token}",
            "atproto-proxy": "did:web:api.bsky.chat#bsky_chat",
        }

    async def _resolve_did(self, identifier: str) -> str:
        """Resolve a handle or DID to a canonical DID string.

        Args:
            identifier: A BlueSky handle (e.g. ``user.bsky.social``) or a
                DID that begins with ``did:``.

        Returns:
            The resolved DID string.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            PlatformError: If the handle resolution request fails.
        """
        if identifier.startswith("did:"):
            return identifier
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/com.atproto.identity.resolveHandle",
                params={"handle": identifier},
            )
            did = response.data.get("did")
            if not isinstance(did, str) or not did:
                raise PlatformError(
                    f"Could not resolve DID for handle '{identifier}'",
                    platform=self.platform_name,
                )
            return did
        except httpx.HTTPStatusError as e:
            raise PlatformError(
                f"Failed to resolve handle '{identifier}': {e.response.text}",
                platform=self.platform_name,
                status_code=e.response.status_code,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error resolving handle '{identifier}': {e}",
                platform=self.platform_name,
            ) from e

    async def _chat_request(
        self,
        method: str,
        endpoint: str,
        *,
        params: dict[str, Any] | None = None,
        json_data: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Make a request to a BlueSky chat endpoint via the proxy header.

        Uses the underlying httpx client from ``api_client`` so that the
        connection pool is shared, while injecting the chat proxy header on a
        per-request basis.

        Args:
            method: HTTP method — ``"GET"`` or ``"POST"``.
            endpoint: XRPC path starting with ``/`` (e.g.
                ``/chat.bsky.convo.listConvos``).
            params: Optional URL query parameters.
            json_data: Optional JSON request body (POST only).

        Returns:
            Parsed JSON response as a ``dict``.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            httpx.HTTPStatusError: Propagated to callers for per-method handling.
        """
        if not self.api_client or not self.api_client._client:
            raise RuntimeError("Client must be used as async context manager")

        headers = self._get_chat_headers()
        url = f"{self.base_url}{endpoint}"

        async def _do_request() -> httpx.Response:
            if not self.api_client or not self.api_client._client:
                raise RuntimeError("Client must be used as async context manager")
            if method == "GET":
                resp = await self.api_client._client.get(
                    url, headers=headers, params=params
                )
            else:
                resp = await self.api_client._client.post(
                    url, headers=headers, json=json_data, params=params
                )
            resp.raise_for_status()
            return resp

        response = await retry_async_func(_do_request)
        return response.json()  # type: ignore[no-any-return]

    # ------------------------------------------------------------------
    # DM public methods
    # ------------------------------------------------------------------

    async def send_direct_message(self, request: DMCreateRequest) -> DirectMessage:
        """Send a direct message on BlueSky.

        Either ``participant_id`` (for a new or existing 1-to-1 conversation)
        or ``conversation_id`` (to post into an existing conversation) must be
        supplied — but not both.

        Args:
            request: DM creation request.  ``text`` is required.
                Supply ``participant_id`` XOR ``conversation_id``.

        Returns:
            The sent :class:`~marqetive.core.models.DirectMessage`.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            ValidationError: If validation rules are violated.
            PlatformAuthError: On 401/403 responses.
            RateLimitError: On 429 responses.
            PlatformError: On all other API failures.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        if not request.text:
            raise ValidationError(
                "text is required for BlueSky DMs",
                platform=self.platform_name,
                field="text",
            )

        if request.participant_id and request.conversation_id:
            raise ValidationError(
                "Provide participant_id OR conversation_id, not both",
                platform=self.platform_name,
                field="participant_id",
            )

        if not request.participant_id and not request.conversation_id:
            raise ValidationError(
                "Either participant_id or conversation_id is required",
                platform=self.platform_name,
                field="participant_id",
            )

        if request.media_url or request.media_id:
            raise ValidationError(
                "BlueSky DMs do not support media attachments",
                platform=self.platform_name,
                field="media_url",
            )

        try:
            if request.participant_id:
                participant_did = await self._resolve_did(request.participant_id)
                sender_did = await self._resolve_did(self.repo_identifier)
                convo_data = await self._chat_request(
                    "GET",
                    "/chat.bsky.convo.getConvoForMembers",
                    params={"members": [sender_did, participant_did]},
                )
                convo_id: str = convo_data["convo"]["id"]
            else:
                convo_id = request.conversation_id  # type: ignore[assignment]

            message_data = await self._chat_request(
                "POST",
                "/chat.bsky.convo.sendMessage",
                json_data={
                    "convoId": convo_id,
                    "message": {"text": request.text},
                },
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky DM auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky DM rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to send BlueSky DM: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error sending BlueSky DM: {e}",
                platform=self.platform_name,
            ) from e

        msg = message_data.get("message", message_data)
        sent_at_raw = msg.get("sentAt")
        sent_at = datetime.now()
        if isinstance(sent_at_raw, str):
            with contextlib.suppress(ValueError):
                sent_at = datetime.fromisoformat(sent_at_raw.replace("Z", "+00:00"))

        return DirectMessage(
            message_id=msg.get("id", ""),
            conversation_id=convo_id,
            platform=self.platform_name,
            text=msg.get("text", request.text),
            sender_id=msg.get("sender", {}).get("did", self.repo_identifier),
            created_at=sent_at,
            raw_data=message_data,
        )

    async def create_group_conversation(
        self, request: GroupDMCreateRequest
    ) -> Conversation:
        """Create a group conversation and send an initial message.

        Args:
            request: Group DM creation request.  Requires at least two
                ``participant_ids`` and a non-empty ``text``.

        Returns:
            The resulting :class:`~marqetive.core.models.Conversation`.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            ValidationError: If validation rules are violated.
            PlatformAuthError: On 401/403 responses.
            RateLimitError: On 429 responses.
            PlatformError: On all other API failures.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        if len(request.participant_ids) < 2:
            raise ValidationError(
                "At least two participant_ids are required for a group conversation",
                platform=self.platform_name,
                field="participant_ids",
            )

        if not request.text:
            raise ValidationError(
                "text is required when creating a group conversation",
                platform=self.platform_name,
                field="text",
            )

        if request.media_url or request.media_id:
            raise ValidationError(
                "BlueSky DMs do not support media attachments",
                platform=self.platform_name,
                field="media_url",
            )

        try:
            sender_did = await self._resolve_did(self.repo_identifier)
            resolved_dids = [
                await self._resolve_did(pid) for pid in request.participant_ids
            ]
            all_members = [sender_did, *resolved_dids]

            convo_data = await self._chat_request(
                "GET",
                "/chat.bsky.convo.getConvoForMembers",
                params={"members": all_members},
            )
            convo_id: str = convo_data["convo"]["id"]
            convo_members: list[dict[str, Any]] = convo_data["convo"].get("members", [])

            await self._chat_request(
                "POST",
                "/chat.bsky.convo.sendMessage",
                json_data={
                    "convoId": convo_id,
                    "message": {"text": request.text},
                },
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky group DM auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky group DM rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to create BlueSky group conversation: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error creating BlueSky group conversation: {e}",
                platform=self.platform_name,
            ) from e

        participant_ids = [
            m.get("did", "") for m in convo_members if isinstance(m, dict)
        ] or all_members
        convo_type: str = "group" if len(participant_ids) > 2 else "one_to_one"

        return Conversation(
            conversation_id=convo_id,
            platform=self.platform_name,
            conversation_type=convo_type,  # type: ignore[arg-type]
            participant_ids=participant_ids,
            created_at=datetime.now(),
            raw_data=convo_data,
        )

    async def get_conversations(
        self,
        limit: int = 20,
        cursor: str | None = None,
    ) -> ConversationListResponse:
        """Retrieve the authenticated user's DM conversations.

        Args:
            limit: Maximum number of conversations to return (default 20).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated :class:`~marqetive.core.models.ConversationListResponse`.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            PlatformAuthError: On 401/403 responses.
            RateLimitError: On 429 responses.
            PlatformError: On all other API failures.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        params: dict[str, Any] = {"limit": limit}
        if cursor:
            params["cursor"] = cursor

        try:
            data = await self._chat_request(
                "GET",
                "/chat.bsky.convo.listConvos",
                params=params,
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky conversations auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky conversations rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to fetch BlueSky conversations: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error fetching BlueSky conversations: {e}",
                platform=self.platform_name,
            ) from e

        conversations: list[Conversation] = []
        for convo in data.get("convos", []):
            if not isinstance(convo, dict):
                continue
            members: list[dict[str, Any]] = convo.get("members", [])
            participant_ids = [m.get("did", "") for m in members if isinstance(m, dict)]
            convo_type: str = "group" if len(participant_ids) > 2 else "one_to_one"
            conversations.append(
                Conversation(
                    conversation_id=convo.get("id", ""),
                    platform=self.platform_name,
                    conversation_type=convo_type,  # type: ignore[arg-type]
                    participant_ids=participant_ids,
                    created_at=datetime.now(),
                    raw_data=convo,
                )
            )

        next_cursor = data.get("cursor")
        return ConversationListResponse(
            conversations=conversations,
            next_cursor=next_cursor,
            has_more=bool(next_cursor),
        )

    async def get_messages(
        self,
        conversation_id: str,
        limit: int = 20,
        cursor: str | None = None,
    ) -> MessageListResponse:
        """Retrieve messages from a BlueSky DM conversation.

        Args:
            conversation_id: The conversation to fetch messages from.
            limit: Maximum number of messages to return (default 20).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated :class:`~marqetive.core.models.MessageListResponse`.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            PlatformAuthError: On 401/403 responses.
            RateLimitError: On 429 responses.
            PlatformError: On all other API failures.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        params: dict[str, Any] = {"convoId": conversation_id, "limit": limit}
        if cursor:
            params["cursor"] = cursor

        try:
            data = await self._chat_request(
                "GET",
                "/chat.bsky.convo.getMessages",
                params=params,
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky messages auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky messages rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to fetch BlueSky messages: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error fetching BlueSky messages: {e}",
                platform=self.platform_name,
            ) from e

        messages: list[DirectMessage] = []
        for msg in data.get("messages", []):
            if not isinstance(msg, dict):
                continue
            sent_at_raw = msg.get("sentAt")
            sent_at = datetime.now()
            if isinstance(sent_at_raw, str):
                with contextlib.suppress(ValueError):
                    sent_at = datetime.fromisoformat(sent_at_raw.replace("Z", "+00:00"))
            sender = msg.get("sender", {})
            messages.append(
                DirectMessage(
                    message_id=msg.get("id", ""),
                    conversation_id=conversation_id,
                    platform=self.platform_name,
                    text=msg.get("text", ""),
                    sender_id=sender.get("did"),
                    sender_username=sender.get("handle"),
                    sender_name=sender.get("displayName"),
                    created_at=sent_at,
                    raw_data=msg,
                )
            )

        next_cursor = data.get("cursor")
        return MessageListResponse(
            messages=messages,
            next_cursor=next_cursor,
            has_more=bool(next_cursor),
            conversation_id=conversation_id,
        )

    async def delete_message(self, conversation_id: str, message_id: str) -> bool:
        """Delete a message from a BlueSky DM conversation (for self only).

        Args:
            conversation_id: The conversation the message belongs to.
            message_id: The platform-specific message ID to delete.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If the client is not used as an async context manager.
            PlatformAuthError: On 401/403 responses.
            RateLimitError: On 429 responses.
            PlatformError: On all other API failures.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.deleteMessageForSelf",
                json_data={
                    "convoId": conversation_id,
                    "messageId": message_id,
                },
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky delete message auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky delete message rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to delete BlueSky message: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error deleting BlueSky message: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def send_message_batch(
        self, items: list[BatchMessageItem]
    ) -> list[DirectMessage]:
        """Send multiple messages in a single batch request.

        Args:
            items: List of BatchMessageItem, each with conversation_id and text.

        Returns:
            List of DirectMessage objects for each sent message.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        batch_items = [
            {"convoId": item.conversation_id, "message": {"text": item.text}}
            for item in items
        ]

        try:
            data = await self._chat_request(
                "POST",
                "/chat.bsky.convo.sendMessageBatch",
                json_data={"items": batch_items},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky send message batch auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky send message batch rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to send BlueSky message batch: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error sending BlueSky message batch: {e}",
                platform=self.platform_name,
            ) from e

        messages: list[DirectMessage] = []
        for item_data, item_req in zip(data.get("items", []), items, strict=False):
            msg = item_data if isinstance(item_data, dict) else {}
            sent_at = datetime.now()
            sent_at_raw = msg.get("sentAt")
            if isinstance(sent_at_raw, str):
                with contextlib.suppress(ValueError):
                    sent_at = datetime.fromisoformat(sent_at_raw.replace("Z", "+00:00"))
            messages.append(
                DirectMessage(
                    message_id=msg.get("id", ""),
                    conversation_id=item_req.conversation_id,
                    platform=self.platform_name,
                    text=msg.get("text", item_req.text),
                    sender_id=msg.get("sender", {}).get("did", self.repo_identifier),
                    created_at=sent_at,
                    raw_data=msg,
                )
            )
        return messages

    async def add_reaction(
        self, conversation_id: str, message_id: str, value: str
    ) -> bool:
        """Add a reaction to a message in a conversation.

        Args:
            conversation_id: The conversation containing the message.
            message_id: The message to react to.
            value: The reaction value (e.g., emoji string).

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.addReaction",
                json_data={
                    "convoId": conversation_id,
                    "messageId": message_id,
                    "value": value,
                },
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky add reaction auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky add reaction rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to add BlueSky reaction: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error adding BlueSky reaction: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def remove_reaction(
        self, conversation_id: str, message_id: str, value: str
    ) -> bool:
        """Remove a reaction from a message in a conversation.

        Args:
            conversation_id: The conversation containing the message.
            message_id: The message to remove the reaction from.
            value: The reaction value to remove.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.removeReaction",
                json_data={
                    "convoId": conversation_id,
                    "messageId": message_id,
                    "value": value,
                },
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky remove reaction auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky remove reaction rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to remove BlueSky reaction: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error removing BlueSky reaction: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def mute_conversation(self, conversation_id: str) -> bool:
        """Mute a BlueSky DM conversation.

        Args:
            conversation_id: The conversation to mute.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.muteConvo",
                json_data={"convoId": conversation_id},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky mute conversation auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky mute conversation rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to mute BlueSky conversation: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error muting BlueSky conversation: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def unmute_conversation(self, conversation_id: str) -> bool:
        """Unmute a BlueSky DM conversation.

        Args:
            conversation_id: The conversation to unmute.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.unmuteConvo",
                json_data={"convoId": conversation_id},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky unmute conversation auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky unmute conversation rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to unmute BlueSky conversation: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error unmuting BlueSky conversation: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def leave_conversation(self, conversation_id: str) -> bool:
        """Leave a BlueSky DM conversation.

        Args:
            conversation_id: The conversation to leave.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.leaveConvo",
                json_data={"convoId": conversation_id},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky leave conversation auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky leave conversation rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to leave BlueSky conversation: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error leaving BlueSky conversation: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def mark_conversation_read(self, conversation_id: str) -> bool:
        """Mark a BlueSky DM conversation as read.

        Args:
            conversation_id: The conversation to mark as read.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.updateRead",
                json_data={"convoId": conversation_id},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky mark conversation read auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky mark conversation read rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to mark BlueSky conversation as read: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error marking BlueSky conversation as read: {e}",
                platform=self.platform_name,
            ) from e

        return True

    async def mark_all_conversations_read(self) -> bool:
        """Mark all BlueSky DM conversations as read.

        Returns:
            ``True`` on success.

        Raises:
            RuntimeError: If not used as async context manager.
            PlatformAuthError: On 401/403.
            RateLimitError: On 429.
            PlatformError: On other errors.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            await self._chat_request(
                "POST",
                "/chat.bsky.convo.updateAllRead",
                json_data={},
            )
        except httpx.HTTPStatusError as e:
            status = e.response.status_code
            if status in (401, 403):
                raise PlatformAuthError(
                    f"BlueSky mark all conversations read auth error: {e.response.text}",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            if status == 429:
                raise RateLimitError(
                    "BlueSky mark all conversations read rate limit exceeded",
                    platform=self.platform_name,
                    status_code=status,
                ) from e
            raise PlatformError(
                f"Failed to mark all BlueSky conversations as read: {e.response.text}",
                platform=self.platform_name,
                status_code=status,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Network error marking all BlueSky conversations as read: {e}",
                platform=self.platform_name,
            ) from e

        return True

    # ==================== User Interaction Methods ====================

    async def get_post_liking_users(
        self,
        post_id: str,
        limit: int = 50,
        cursor: str | None = None,
    ) -> InteractionUserListResponse:
        """Get users who liked a BlueSky post.

        Args:
            post_id: BlueSky post URI, AT-URI, or record key.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated list of users who liked the post.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post_uri = self._normalize_post_uri(post_id)
        params: dict[str, Any] = {
            "uri": post_uri,
            "limit": min(limit, 100),
        }
        if cursor:
            params["cursor"] = cursor

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.feed.getLikes",
                params=params,
            )
            users = [
                self._parse_bluesky_interaction_user(like["actor"])
                for like in response.data.get("likes", [])
                if isinstance(like, dict) and "actor" in like
            ]
            next_cursor = response.data.get("cursor")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_cursor,
                has_more=next_cursor is not None and len(users) > 0,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky liking users: {e}",
                platform=self.platform_name,
            ) from e

    async def get_post_reposted_by(
        self,
        post_id: str,
        limit: int = 50,
        cursor: str | None = None,
    ) -> InteractionUserListResponse:
        """Get users who reposted a BlueSky post.

        Args:
            post_id: BlueSky post URI, AT-URI, or record key.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated list of users who reposted.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        post_uri = self._normalize_post_uri(post_id)
        params: dict[str, Any] = {
            "uri": post_uri,
            "limit": min(limit, 100),
        }
        if cursor:
            params["cursor"] = cursor

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.feed.getRepostedBy",
                params=params,
            )
            users = [
                self._parse_bluesky_interaction_user(actor)
                for actor in response.data.get("repostedBy", [])
                if isinstance(actor, dict)
            ]
            next_cursor = response.data.get("cursor")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_cursor,
                has_more=next_cursor is not None and len(users) > 0,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky reposted by users: {e}",
                platform=self.platform_name,
            ) from e

    async def get_followers(
        self,
        user_id: str | None = None,
        limit: int = 50,
        cursor: str | None = None,
    ) -> InteractionUserListResponse:
        """Get followers of a BlueSky user.

        Args:
            user_id: BlueSky DID or handle. If None, uses the authenticated user.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated list of followers.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        actor = user_id or self.repo_identifier
        params: dict[str, Any] = {
            "actor": actor,
            "limit": min(limit, 100),
        }
        if cursor:
            params["cursor"] = cursor

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.graph.getFollowers",
                params=params,
            )
            users = [
                self._parse_bluesky_interaction_user(follower)
                for follower in response.data.get("followers", [])
                if isinstance(follower, dict)
            ]
            next_cursor = response.data.get("cursor")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_cursor,
                has_more=next_cursor is not None and len(users) > 0,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky followers: {e}",
                platform=self.platform_name,
            ) from e

    async def get_following(
        self,
        user_id: str | None = None,
        limit: int = 50,
        cursor: str | None = None,
    ) -> InteractionUserListResponse:
        """Get users followed by a BlueSky user.

        Args:
            user_id: BlueSky DID or handle. If None, uses the authenticated user.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination cursor from a previous response.

        Returns:
            Paginated list of followed users.
        """
        if not self.api_client:
            raise RuntimeError("Client must be used as async context manager")

        actor = user_id or self.repo_identifier
        params: dict[str, Any] = {
            "actor": actor,
            "limit": min(limit, 100),
        }
        if cursor:
            params["cursor"] = cursor

        try:
            response = await retry_async_func(
                self.api_client.get,
                "/app.bsky.graph.getFollows",
                params=params,
            )
            users = [
                self._parse_bluesky_interaction_user(follow)
                for follow in response.data.get("follows", [])
                if isinstance(follow, dict)
            ]
            next_cursor = response.data.get("cursor")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_cursor,
                has_more=next_cursor is not None and len(users) > 0,
            )
        except httpx.HTTPStatusError as e:
            response_data: dict[str, Any]
            try:
                response_data = e.response.json()
            except Exception:
                response_data = {}
            raise map_bluesky_error(
                e.response.status_code,
                response_data=response_data,
                error_message=e.response.text,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch BlueSky following: {e}",
                platform=self.platform_name,
            ) from e

    def _parse_bluesky_interaction_user(
        self, actor_data: dict[str, Any]
    ) -> BlueSkyInteractionUser:
        """Parse BlueSky actor object into BlueSkyInteractionUser."""
        did = actor_data.get("did", "")
        handle = actor_data.get("handle", "")
        return BlueSkyInteractionUser(
            user_id=did,
            username=handle,
            display_name=actor_data.get("displayName"),
            profile_url=(
                HttpUrl(f"https://bsky.app/profile/{handle}") if handle else None
            ),
            avatar_url=actor_data.get("avatar"),
            platform=self.platform_name,
            did=did,
            handle=handle,
            followers_count=actor_data.get("followersCount", 0),
            following_count=actor_data.get("followsCount", 0),
            posts_count=actor_data.get("postsCount", 0),
            description=actor_data.get("description"),
            indexed_at=actor_data.get("indexedAt"),
            raw_data=actor_data,
        )
