"""Twitter/X API v2 client implementation.

This module provides a concrete implementation of the SocialMediaPlatform
ABC for Twitter (X), using the Twitter API v2 via tweepy.

API Documentation: https://developer.x.com/en/docs/twitter-api
"""

import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any

import httpx
import tweepy
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,
    DirectMessage,
    DMCreateRequest,
    GroupDMCreateRequest,
    InteractionUserListResponse,
    MediaAttachment,
    MediaType,
    MessageListResponse,
    Post,
    PostCreateRequest,
    PostStatus,
    PostUpdateRequest,
    ProgressStatus,
    QuotePostListResponse,
)
from marqetive.platforms.twitter.exceptions import TwitterUnauthorizedError
from marqetive.platforms.twitter.media import MediaCategory, TwitterMediaManager
from marqetive.platforms.twitter.models import (
    TwitterDMRequest,
    TwitterInteractionUser,
    TwitterPostRequest,
)
from marqetive.utils.media import PLATFORM_MEDIA_TYPES
from marqetive.utils.retry import retry_async


class TwitterClient(SocialMediaPlatform):
    """Twitter/X API v2 client.

    This client implements the SocialMediaPlatform interface for Twitter (X),
    using the Twitter API v2. It supports tweets, replies, and media uploads.

    Note:
        - Requires Twitter Developer account and app credentials
        - Requires OAuth 2.0 or OAuth 1.0a authentication
        - Rate limits vary by endpoint and authentication method
        - Leverages tweepy library for API interactions

    Example:
        >>> credentials = AuthCredentials(
        ...     platform="twitter",
        ...     access_token="your_access_token",
        ...     additional_data={
        ...         "api_key": "your_api_key",
        ...         "api_secret": "your_api_secret",
        ...         "access_secret": "your_access_secret"
        ...     }
        ... )
        >>> async with TwitterClient(credentials) as client:
        ...     request = PostCreateRequest(content="Hello Twitter!")
        ...     tweet = await client.create_post(request)
    """

    def __init__(
        self,
        credentials: AuthCredentials,
        timeout: float = 30.0,
        progress_callback: ProgressCallback | None = None,
    ) -> None:
        """Initialize Twitter client.

        Args:
            credentials: Twitter authentication credentials
            timeout: Request timeout in seconds
            progress_callback: Optional callback for progress updates during
                long-running operations like media uploads.

        Raises:
            PlatformAuthError: If credentials are invalid
        """
        base_url = "https://api.x.com/2"
        super().__init__(
            platform_name="twitter",
            credentials=credentials,
            base_url=base_url,
            timeout=timeout,
            progress_callback=progress_callback,
        )

        # Initialize tweepy client
        self._tweepy_client: tweepy.Client | None = None
        self._setup_tweepy_client()

        # Initialize media manager
        self._media_manager: TwitterMediaManager | None = None

    def _setup_tweepy_client(self) -> None:
        """Setup tweepy Client with credentials."""
        # Extract credentials from additional_data

        self._tweepy_client = tweepy.Client(
            bearer_token=self.credentials.access_token,
        )

    async def _setup_managers(self) -> None:
        """Setup media manager."""
        if not self._tweepy_client:
            return

        # Initialize media manager
        self._media_manager = TwitterMediaManager(
            bearer_token=self.credentials.access_token,
            timeout=self.timeout,
        )

    async def _cleanup_managers(self) -> None:
        """Cleanup media manager."""
        if self._media_manager:
            await self._media_manager.__aexit__(None, None, None)
            self._media_manager = None

    async def __aenter__(self) -> "TwitterClient":
        """Async context manager entry."""
        await super().__aenter__()
        await self._setup_managers()
        return self

    async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Async context manager exit."""
        await self._cleanup_managers()
        await super().__aexit__(exc_type, exc_val, exc_tb)

    # ==================== Authentication Methods ====================

    async def authenticate(self) -> AuthCredentials:
        """Perform Twitter authentication flow.

        Note: This method assumes you already have valid OAuth credentials.
        For the full OAuth flow, use Twitter's OAuth implementation with tweepy.

        Returns:
            Current credentials if valid.

        Raises:
            PlatformAuthError: If authentication fails.
        """
        if await self.is_authenticated():
            return self.credentials

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

    async def refresh_token(self) -> AuthCredentials:
        """Refresh Twitter access token.

        Note: Twitter OAuth 1.0a tokens don't expire, so this method
        returns the current credentials. For OAuth 2.0, implement
        token refresh logic.

        Returns:
            Current credentials.
        """
        # OAuth 1.0a tokens don't expire
        return self.credentials

    async def is_authenticated(self) -> bool:
        """Check if Twitter credentials are valid.

        Returns:
            True if authenticated and token is valid.
        """
        if not self._tweepy_client:
            return False

        try:
            # Verify credentials by fetching authenticated user
            self._tweepy_client.get_me(user_auth=False)
            return True
        except tweepy.TweepyException:
            return False

    # ==================== Validation ====================

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

    # ==================== Post CRUD Methods ====================

    async def create_post(self, request: PostCreateRequest) -> Post:
        """Create and publish a tweet.

        Twitter Requirements:
            - Must have content OR media (at least one)
            - Content max 280 characters (enforced by Twitter API)
            - Max 4 images or 1 video per tweet

        Args:
            request: Post creation request. Supports:
                - content: Tweet text (max 280 chars)
                - media_urls: URLs of media to attach
                - reply_to_post_id: Tweet ID to reply to (for threads)
                - quote_post_id: Tweet ID to quote

        Returns:
            Post object with:
                - post_id: Twitter tweet ID
                - platform: "twitter"
                - content: Tweet text
                - status: PostStatus.PUBLISHED
                - created_at: Creation timestamp
                - author_id: None (not available without extra API call)
                - url: None (not returned in create response)
                - raw_data: {"tweet_id": ...}

        Raises:
            ValidationError: If request is invalid.
            MediaUploadError: If media upload fails.
            RateLimitError: If Twitter rate limit is exceeded.
            PlatformError: For other Twitter API errors.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        # Validate request
        self._validate_create_post_request(request)

        try:
            media_ids = list(request.media_ids) if request.media_ids else []

            # Upload media if provided
            if request.media_urls:
                for media_url in request.media_urls:
                    media_type = self._detect_media_type_from_url(media_url)
                    media_attachment = await self.upload_media(media_url, media_type)
                    media_ids.append(media_attachment.media_id)

            # Create tweet
            tweet_params: dict[str, Any] = {}
            if request.content:
                tweet_params["text"] = request.content
            if media_ids:
                tweet_params["media_ids"] = media_ids

            # Check for reply_to_post_id (used for threads and replies)
            reply_to_id = getattr(request, "reply_to_post_id", None)
            if reply_to_id:
                tweet_params["in_reply_to_tweet_id"] = reply_to_id

            # Check for quote_post_id (used for quote tweets)
            quote_id = getattr(request, "quote_post_id", None)
            if quote_id:
                tweet_params["quote_tweet_id"] = quote_id

            response = self._tweepy_client.create_tweet(**tweet_params, user_auth=False)
            tweet_id = response.data["id"]  # type: ignore[index]

            # Construct Twitter URL if username is available
            url = (
                HttpUrl(f"https://x.com/{self.credentials.username}/status/{tweet_id}")
                if self.credentials.username
                else HttpUrl(f"https://x.com/web/status/{tweet_id}")
            )

            # Return minimal Post object without fetching details
            return Post(
                post_id=tweet_id,
                platform=self.platform_name,
                content=request.content or "",
                status=PostStatus.PUBLISHED,
                created_at=datetime.now(),
                author_id=None,  # Not available without extra API call
                url=url,
                raw_data={"tweet_id": tweet_id},
            )

        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to create tweet: {e}",
                platform=self.platform_name,
            ) from e

    async def get_post(self, post_id: str) -> Post:
        """Retrieve a tweet by ID.

        Args:
            post_id: Twitter tweet ID.

        Returns:
            Post object with current data.

        Raises:
            PostNotFoundError: If tweet doesn't exist.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = self._tweepy_client.get_tweet(
                post_id,
                tweet_fields=[
                    "created_at",
                    "public_metrics",
                    "attachments",
                    "author_id",
                    "conversation_id",
                    "lang",
                    "entities",
                    "referenced_tweets",
                    "in_reply_to_user_id",
                ],
                expansions=["attachments.media_keys", "author_id"],
                media_fields=[
                    "url",
                    "type",
                    "width",
                    "height",
                    "alt_text",
                    "preview_image_url",
                    "duration_ms",
                    "variants",
                ],
                user_fields=["name", "username", "profile_image_url", "verified"],
                user_auth=False,
            )

            if not response.data:  # type: ignore[attr-defined]
                raise PostNotFoundError(
                    post_id=post_id,
                    platform=self.platform_name,
                    status_code=404,
                )

            return self._parse_tweet(response.data, response.includes)  # type: ignore[attr-defined]

        except tweepy.errors.NotFound as e:  # type: ignore[attr-defined]
            raise PostNotFoundError(
                post_id=post_id,
                platform=self.platform_name,
                status_code=404,
            ) from e
        except tweepy.TweepyException as e:
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch tweet: {e}",
                platform=self.platform_name,
            ) from e

    async def get_posts_batch(self, post_ids: list[str]) -> list[Post]:
        """Retrieve up to 100 tweets in a single API call.

        Uses GET /2/tweets?ids=... which consumes 1 request regardless
        of how many IDs are passed (up to the 100 ID limit).

        Args:
            post_ids: List of tweet IDs to fetch. Max 100 per call.
                    If more than 100 are passed, only the first 100 are fetched.

        Returns:
            List of Post objects. Posts that are not found or unavailable
            are silently omitted (Twitter does not error on missing IDs
            in batch calls, it simply excludes them from the response).

        Raises:
            RateLimitError: If Twitter rate limit is exceeded.
            PlatformAuthError: If not authenticated.
            PlatformError: For other Twitter API errors.
            RuntimeError: If client not used as async context manager.

        Example:
            >>> async with TwitterClient(credentials) as client:
            ...     posts = await client.get_posts_batch(["id1", "id2", "id3"])
            ...     for post in posts:
            ...         print(f"{post.post_id}: {post.likes_count} likes")
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")
        if not post_ids:
            return []

        try:
            response = self._tweepy_client.get_tweets(
                ids=post_ids[:100],  # hard cap enforced by API
                tweet_fields=[
                    "created_at",
                    "public_metrics",
                    "attachments",
                    "author_id",
                    "conversation_id",
                    "lang",
                    "entities",
                    "referenced_tweets",
                    "in_reply_to_user_id",
                ],
                expansions=["attachments.media_keys", "author_id"],
                media_fields=[
                    "url",
                    "type",
                    "width",
                    "height",
                    "alt_text",
                    "preview_image_url",
                    "duration_ms",
                    "variants",
                ],
                user_fields=["name", "username", "profile_image_url", "verified"],
                user_auth=False,
            )

            if not response.data:  # type: ignore[attr-defined]
                return []

            # Build media lookup keyed by media_key for _parse_tweet reuse
            includes = getattr(response, "includes", None) or {}

            return [
                self._parse_tweet(tweet, includes)
                for tweet in response.data  # type: ignore[attr-defined]
            ]

        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to batch fetch tweets: {e}",
                platform=self.platform_name,
            ) from e

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

        Note: Twitter does not support editing tweets (except for Twitter Blue
        subscribers with limited edit window). This method will raise an error.

        Args:
            post_id: Twitter tweet ID.
            request: Post update request.

        Raises:
            PlatformError: Twitter doesn't support tweet editing for most users.
        """
        raise PlatformError(
            "Twitter does not support editing tweets for most accounts",
            platform=self.platform_name,
        )

    async def delete_post(self, post_id: str) -> bool:
        """Delete a tweet.

        Args:
            post_id: Twitter tweet ID.

        Returns:
            True if deletion was successful.

        Raises:
            PostNotFoundError: If tweet doesn't exist.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            self._tweepy_client.delete_tweet(post_id, user_auth=False)
            return True

        except tweepy.errors.NotFound as e:  # type: ignore[attr-defined]
            raise PostNotFoundError(
                post_id=post_id,
                platform=self.platform_name,
                status_code=404,
            ) from e
        except tweepy.TweepyException as e:
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to delete tweet: {e}",
                platform=self.platform_name,
            ) from e

    # ==================== Comment Methods ====================

    async def get_comments(
        self,
        post_id: str,
        limit: int = 50,
        offset: int = 0,  # noqa: ARG002
    ) -> list[Comment]:
        """Retrieve replies to a tweet.

        Args:
            post_id: Twitter tweet ID.
            limit: Maximum number of replies to retrieve.
            offset: Number of replies to skip.

        Returns:
            List of Comment objects (replies).
        """
        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")

        try:
            # Use Twitter API v2 search to find replies
            response = await self.api_client.get(
                "/tweets/search/recent",
                params={
                    "query": f"conversation_id:{post_id}",
                    "max_results": min(limit, 100),
                    "tweet.fields": (
                        "created_at,author_id,public_metrics,"
                        "entities,in_reply_to_user_id,conversation_id,"
                        "referenced_tweets"
                    ),
                    "expansions": "author_id,attachments.media_keys",
                    "user.fields": "name,username,profile_image_url,verified",
                    "media.fields": ("url,type,alt_text,preview_image_url,duration_ms"),
                },
            )

            includes = response.data.get("includes", {})
            user_lookup = self._build_user_lookup(includes)

            comments = []
            for tweet_data in response.data.get("data", []):
                comments.append(self._parse_reply(tweet_data, post_id, user_lookup))

            return comments

        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch replies: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch replies: {e}",
                platform=self.platform_name,
            ) from e

    async def create_comment(
        self, post_id: str, content: str, *, parent_comment_id: str | None = None
    ) -> Comment:
        """Reply to a tweet.

        Args:
            post_id: Twitter tweet ID.
            content: Text content of the reply.
            parent_comment_id: Tweet ID to reply to instead of post_id (for nested replies).

        Returns:
            Created Comment object (reply).

        Raises:
            ValidationError: If reply content is invalid.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        reply_target = parent_comment_id or post_id

        try:
            response = self._tweepy_client.create_tweet(
                text=content,
                in_reply_to_tweet_id=reply_target,
                user_auth=False,
            )

            reply_id = response.data["id"]  # type: ignore[index]

            # Return minimal Comment object without fetching details
            return Comment(
                comment_id=reply_id,
                post_id=post_id,
                platform=self.platform_name,
                content=content,
                author_id="",
                parent_comment_id=parent_comment_id,
                created_at=datetime.now(),
                status=CommentStatus.VISIBLE,
            )

        except tweepy.TweepyException as e:
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to create reply: {e}",
                platform=self.platform_name,
            ) from e

    async def delete_comment(
        self, comment_id: str, *, post_id: str | None = None
    ) -> bool:
        """Delete a reply (tweet).

        Args:
            comment_id: Twitter tweet ID of the reply.
            post_id: Ignored for Twitter (tweets are deleted by ID only).

        Returns:
            True if deletion was successful.
        """
        del post_id
        # Replies are just tweets, so we can use delete_post
        return await self.delete_post(comment_id)

    # ==================== Media Methods ====================

    async def upload_media(
        self,
        media_url: str,
        media_type: str,
        alt_text: str | None = None,
    ) -> MediaAttachment:
        """Upload media to Twitter.

        Supports both local files and URLs. Automatically handles:
        - Chunked upload for large files (videos, GIFs)
        - Simple upload for images
        - Progress tracking
        - Alt text for accessibility

        Args:
            media_url: URL or file path of the media.
            media_type: Type of media (image or video).
            alt_text: Alternative text for accessibility.

        Returns:
            MediaAttachment object with media ID.

        Raises:
            MediaUploadError: If upload fails.
            RuntimeError: If client not used as context manager.

        Example:
            >>> async with TwitterClient(credentials) as client:
            ...     media = await client.upload_media(
            ...         "/path/to/image.jpg",
            ...         "image",
            ...         alt_text="A beautiful sunset"
            ...     )
            ...     print(f"Uploaded: {media.media_id}")
        """
        if not self._media_manager:
            raise RuntimeError("Client must be used as async context manager")

        try:
            # Upload media using media manager
            result = await self._media_manager.upload_media(
                media_url,
                alt_text=alt_text,
            )

            # Convert to MediaAttachment
            return MediaAttachment(
                media_id=result.media_id,
                media_type=(
                    MediaType.IMAGE
                    if media_type.lower() in ("image", "photo")
                    else MediaType.VIDEO
                ),
                url=HttpUrl(media_url),
            )

        except PlatformAuthError:
            # Re-raise auth errors (including TwitterUnauthorizedError) directly
            raise
        except Exception as e:
            raise MediaUploadError(
                f"Failed to upload media: {e}",
                platform=self.platform_name,
                media_type=media_type,
            ) from e

    # ==================== Retweet Methods ====================

    async def retweet(self, tweet_id: str) -> bool:
        """Retweet a tweet.

        Args:
            tweet_id: ID of the tweet to retweet.

        Returns:
            True if retweet was successful.

        Raises:
            PostNotFoundError: If tweet doesn't exist.
            PlatformError: If retweet fails.
            RuntimeError: If client not used as context manager.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = self._tweepy_client.retweet(tweet_id, user_auth=False)
            return response.data.get("retweeted", False)  # type: ignore[union-attr]

        except tweepy.errors.NotFound as e:  # type: ignore[attr-defined]
            raise PostNotFoundError(
                post_id=tweet_id,
                platform=self.platform_name,
                status_code=404,
            ) from e
        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to retweet: {e}",
                platform=self.platform_name,
            ) from e

    async def unretweet(self, tweet_id: str) -> bool:
        """Undo a retweet (unretweet).

        Args:
            tweet_id: ID of the tweet to unretweet.

        Returns:
            True if unretweet was successful.

        Raises:
            PostNotFoundError: If tweet doesn't exist.
            PlatformError: If unretweet fails.
            RuntimeError: If client not used as context manager.
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        try:
            response = self._tweepy_client.unretweet(tweet_id, user_auth=False)
            # Response indicates retweeted=False when successfully unretweeted
            return not response.data.get("retweeted", True)  # type: ignore[union-attr]

        except tweepy.errors.NotFound as e:  # type: ignore[attr-defined]
            raise PostNotFoundError(
                post_id=tweet_id,
                platform=self.platform_name,
                status_code=404,
            ) from e
        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to unretweet: {e}",
                platform=self.platform_name,
            ) from e

    # ==================== Thread Methods ====================

    async def create_thread(
        self,
        posts: list[PostCreateRequest],
        cancellation_check: Callable[[], Awaitable[bool]] | None = None,
    ) -> list[Post]:
        """Create a Twitter thread (multiple linked tweets).

        Each tweet in the list can have its own content, media, polls, and alt texts.
        Tweets are posted sequentially, with each tweet replying to the previous one.

        Args:
            posts: List of PostCreateRequest objects to create as a thread.
                   First tweet is the head of the thread.
                   Use TwitterPostRequest for Twitter-specific features.
            cancellation_check: Optional async callback that returns True if the
                   thread creation should be cancelled. Called before each tweet.

        Returns:
            List of Post objects for each tweet in the thread.

        Raises:
            ValidationError: If posts list is empty.
            PlatformAuthError: If not authenticated.
            MediaUploadError: If media upload fails.
            ThreadCancelledException: If cancelled mid-thread (includes posted tweets).
            RuntimeError: If client not used as context manager.

        Example:
            >>> from marqetive.platforms.twitter import TwitterPostRequest
            >>> tweets = [
            ...     TwitterPostRequest(content="Thread start! 1/3"),
            ...     TwitterPostRequest(content="Middle 2/3", media_urls=["..."]),
            ...     TwitterPostRequest(content="End 3/3", poll_options=["Yes", "No"]),
            ... ]
            >>> async with TwitterClient(credentials) as client:
            ...     thread_posts = await client.create_thread(tweets)
            ...     for post in thread_posts:
            ...         print(f"Tweet {post.post_id}: {post.content}")
        """
        if not posts:
            raise ValidationError(
                "At least one tweet 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):
            # Check cancellation BEFORE posting each tweet
            if cancellation_check is not None and await cancellation_check():
                raise ThreadCancelledException(
                    f"Thread cancelled after {len(created_posts)} of {len(posts)} tweets",
                    platform=self.platform_name,
                    posted_tweets=created_posts,
                )

            # Convert to TwitterPostRequest if needed and set reply chain
            if isinstance(post_request, TwitterPostRequest):
                if reply_to_id is not None:
                    post_request = post_request.model_copy(
                        update={"reply_to_post_id": reply_to_id}
                    )
                if post_request.quoted_tweet_id:
                    post_request = post_request.model_copy(
                        update={"quote_post_id": post_request.quoted_tweet_id}
                    )

                elif post_request.is_quoted and created_posts:
                    post_request = post_request.model_copy(
                        update={"quote_post_id": created_posts[0].post_id}
                    )

                # TwitterPostRequest works with create_post via duck typing
                final_request = post_request
            else:
                # Create TwitterPostRequest from generic PostCreateRequest
                request_data: dict[str, Any] = {
                    "content": post_request.content,
                    "media_urls": post_request.media_urls,
                    "media_ids": post_request.media_ids,
                }
                if reply_to_id is not None:
                    request_data["reply_to_post_id"] = reply_to_id

                if post_request.quoted_tweet_id:
                    request_data["quote_post_id"] = post_request.quoted_tweet_id

                elif post_request.is_quoted and created_posts:
                    request_data["quote_post_id"] = created_posts[0].post_id

                final_request = TwitterPostRequest(**request_data)

            # TwitterPostRequest has compatible interface with PostCreateRequest
            created_post = await self.create_post(final_request)  # type: ignore[arg-type]
            created_posts.append(created_post)
            reply_to_id = created_post.post_id

            # Emit progress
            await self._emit_progress(
                operation="create_thread",
                status=ProgressStatus.PROCESSING,
                progress=idx + 1,
                total=len(posts),
                message=f"Tweet {idx + 1}/{len(posts)} created",
                entity_id=created_post.post_id,
            )

        return created_posts

    # ==================== Direct Message Methods ====================

    def _validate_dm_request(
        self,
        request: DMCreateRequest | TwitterDMRequest,
    ) -> None:
        """Validate DM request.

        Twitter DM Requirements:
            - Must have text content (required)
            - Text max 10,000 characters
            - Must have either participant_id OR conversation_id (not both)
            - Max 1 media attachment

        Args:
            request: DM request to validate.

        Raises:
            ValidationError: If validation fails.
        """
        if not request.text:
            raise ValidationError(
                "DM text content is required",
                platform=self.platform_name,
                field="text",
            )

        # Must have exactly one of participant_id or conversation_id
        has_participant = request.participant_id is not None
        has_conversation = request.conversation_id is not None

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

        if has_participant and has_conversation:
            raise ValidationError(
                "Cannot specify both participant_id and conversation_id",
                platform=self.platform_name,
                field="conversation_id",
            )

    async def _upload_dm_media(
        self,
        media_url: str,
        alt_text: str | None = None,
    ) -> MediaAttachment:
        """Upload media for DM attachment.

        Uses DM-specific media category for proper Twitter processing.

        Args:
            media_url: URL or file path of media.
            alt_text: Optional alt text for accessibility.

        Returns:
            MediaAttachment with media ID.

        Raises:
            MediaUploadError: If upload fails.
        """
        if not self._media_manager:
            raise RuntimeError("Client must be used as async context manager")

        try:
            # Detect media type and use appropriate DM category
            from marqetive.utils.media import detect_mime_type

            # Determine DM-specific media category based on file type
            if media_url.startswith(("http://", "https://")):
                # Default to DM_IMAGE for URLs (will be validated by Twitter)
                category = MediaCategory.DM_IMAGE
            else:
                mime_type = detect_mime_type(media_url)
                if "video" in mime_type:
                    category = MediaCategory.DM_VIDEO
                elif "gif" in mime_type:
                    category = MediaCategory.DM_GIF
                else:
                    category = MediaCategory.DM_IMAGE

            result = await self._media_manager.upload_media(
                media_url,
                media_category=category,
                alt_text=alt_text,
            )

            return MediaAttachment(
                media_id=result.media_id,
                media_type=MediaType.IMAGE,  # Simplified type
                url=(
                    HttpUrl(media_url)
                    if media_url.startswith("http")
                    else HttpUrl(f"file://{media_url}")
                ),
            )

        except Exception as e:
            raise MediaUploadError(
                f"Failed to upload DM media: {e}",
                platform=self.platform_name,
            ) from e

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

        Supports both 1-to-1 DMs (new conversations) and messages to existing
        conversations (including groups). Optionally attach a single media file.

        Twitter Requirements:
            - Text required (max 10,000 characters)
            - Either participant_id (new 1-to-1) or conversation_id (existing)
            - Max 1 media attachment per DM

        Args:
            request: DM creation request. Supports:
                - text: Message content (required, max 10,000 chars)
                - participant_id: User ID for new 1-to-1 DM
                - conversation_id: Existing conversation ID
                - media_url: URL of media to attach
                - media_id: Pre-uploaded media ID

        Returns:
            DirectMessage object with:
                - message_id: Twitter DM event ID
                - conversation_id: Conversation ID
                - platform: "twitter"
                - text: Message content
                - sender_id: Authenticated user ID (if available)
                - created_at: Timestamp
                - media: Attached media (if any)
                - raw_data: Full API response

        Raises:
            ValidationError: If request is invalid.
            MediaUploadError: If media upload fails.
            RateLimitError: If Twitter rate limit exceeded.
            PlatformError: For other Twitter API errors.
            RuntimeError: If client not used as context manager.

        Example:
            >>> async with TwitterClient(credentials) as client:
            ...     request = DMCreateRequest(
            ...         text="Hello!",
            ...         participant_id="1234567890"
            ...     )
            ...     dm = await client.send_direct_message(request)
            ...     print(f"Sent DM: {dm.message_id}")
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        # Validate request
        self._validate_dm_request(request)

        try:
            # Handle media upload if provided
            media_id = request.media_id
            if request.media_url and not media_id:
                media_attachment = await self._upload_dm_media(request.media_url)
                media_id = media_attachment.media_id

            # Build DM parameters
            dm_params: dict[str, Any] = {"text": request.text}

            if request.participant_id:
                dm_params["participant_id"] = request.participant_id
            elif request.conversation_id:
                dm_params["dm_conversation_id"] = request.conversation_id

            if media_id:
                dm_params["media_id"] = media_id

            # Send DM using tweepy
            response = self._tweepy_client.create_direct_message(
                **dm_params,
                user_auth=False,
            )

            # Parse response
            dm_data = response.data  # type: ignore[attr-defined]

            return DirectMessage(
                message_id=str(dm_data["dm_event_id"]),
                conversation_id=str(dm_data["dm_conversation_id"]),
                platform=self.platform_name,
                text=request.text,
                sender_id=self.credentials.user_id,
                created_at=datetime.now(),
                media=None,  # Media info not returned in create response
                raw_data=dm_data,
            )

        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "403" in str(e) or "401" in str(e):
                raise PlatformAuthError(
                    f"Not authorized to send DM: {e}",
                    platform=self.platform_name,
                ) from e
            raise PlatformError(
                f"Failed to send direct message: {e}",
                platform=self.platform_name,
            ) from e

    async def create_group_conversation(
        self,
        request: GroupDMCreateRequest,
    ) -> Conversation:
        """Create a Twitter group DM conversation with initial message.

        Creates a new group conversation with 2-49 other participants
        and sends an initial message to the group.

        Args:
            request: Group DM request with:
                - participant_ids: List of user IDs (2-49 participants)
                - text: Initial message text (required, max 10,000 chars)
                - media_url: Optional media URL
                - media_id: Optional pre-uploaded media ID

        Returns:
            Conversation object with:
                - conversation_id: Twitter conversation ID
                - platform: "twitter"
                - conversation_type: "group"
                - participant_ids: List of participant IDs
                - created_at: Timestamp
                - raw_data: Full API response

        Raises:
            ValidationError: If request is invalid.
            MediaUploadError: If media upload fails.
            RateLimitError: If rate limit exceeded.
            PlatformError: For other API errors.
            RuntimeError: If client not used as context manager.

        Example:
            >>> async with TwitterClient(credentials) as client:
            ...     request = GroupDMCreateRequest(
            ...         participant_ids=["user1", "user2"],
            ...         text="Welcome to the group!"
            ...     )
            ...     conv = await client.create_group_conversation(request)
            ...     print(f"Created group: {conv.conversation_id}")
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        # Validate participant count
        if len(request.participant_ids) < 2:
            raise ValidationError(
                "Group conversation requires at least 2 participants",
                platform=self.platform_name,
                field="participant_ids",
            )

        if len(request.participant_ids) > 49:
            raise ValidationError(
                "Group conversation cannot exceed 49 participants",
                platform=self.platform_name,
                field="participant_ids",
            )

        # Validate text
        if not request.text:
            raise ValidationError(
                "Initial message text is required",
                platform=self.platform_name,
                field="text",
            )

        if len(request.text) > 10000:
            raise ValidationError(
                f"Message text exceeds 10,000 characters ({len(request.text)} chars)",
                platform=self.platform_name,
                field="text",
            )

        try:
            # Handle media upload if provided
            media_id = request.media_id
            if request.media_url and not media_id:
                media_attachment = await self._upload_dm_media(request.media_url)
                media_id = media_attachment.media_id

            # Create group conversation using tweepy
            conv_params: dict[str, Any] = {
                "conversation_type": "Group",
                "participant_ids": request.participant_ids,
                "text": request.text,
            }

            if media_id:
                conv_params["media_id"] = media_id

            response = self._tweepy_client.create_direct_message_conversation(
                **conv_params,
                user_auth=False,
            )

            conv_data = response.data  # type: ignore[attr-defined]

            return Conversation(
                conversation_id=str(conv_data["dm_conversation_id"]),
                platform=self.platform_name,
                conversation_type="group",
                participant_ids=request.participant_ids,
                created_at=datetime.now(),
                raw_data=conv_data,
            )

        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "401" in str(e):
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to create group conversation: {e}",
                platform=self.platform_name,
            ) from e

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

        Fetches DM events for the given conversation ID, filtered to
        ``MessageCreate`` events only, and maps them to ``DirectMessage``
        objects.

        Args:
            conversation_id: The Twitter DM conversation ID to fetch messages
                from (e.g. ``"123456789-987654321"``).
            limit: Maximum number of messages to return. Capped at 100 by the
                Twitter API. Defaults to 50.
            cursor: Pagination token returned by a previous call. Pass this to
                retrieve the next page of results. Defaults to None.

        Returns:
            List of ``DirectMessage`` objects ordered as returned by the API.
            Returns an empty list when no ``MessageCreate`` events are found.

        Raises:
            RateLimitError: If the Twitter rate limit (429) is exceeded.
            PlatformAuthError: If the access token is invalid or expired
                (401 / 403).
            PlatformError: For any other Tweepy / API error.
            RuntimeError: If the client is not used as an async context manager.

        Example:
            >>> async with TwitterClient(credentials) as client:
            ...     messages = await client.get_messages(
            ...         conversation_id="123456789-987654321",
            ...         limit=25,
            ...     )
            ...     for msg in messages:
            ...         print(f"{msg.sender_id}: {msg.text}")
        """
        if not self._tweepy_client:
            raise RuntimeError("Client must be used as async context manager")

        try:

            def _is_transient_tweepy(e: Exception) -> bool:
                return isinstance(e, tweepy.TooManyRequests | tweepy.TwitterServerError)

            @retry_async(error_classifier=_is_transient_tweepy)
            async def _fetch_events() -> Any:
                return await asyncio.to_thread(
                    self._tweepy_client.get_direct_message_events,  # type: ignore[union-attr]
                    dm_conversation_id=conversation_id,
                    user_auth=False,
                    max_results=min(limit, 100),
                    dm_event_fields=(
                        "id,text,dm_conversation_id,sender_id,"
                        "created_at,event_type,attachments"
                    ),
                    expansions=["sender_id", "attachments.media_keys"],
                    user_fields=["name", "username", "profile_image_url"],
                    media_fields=[
                        "url",
                        "type",
                        "alt_text",
                        "preview_image_url",
                        "duration_ms",
                    ],
                    pagination_token=cursor,
                )

            response = await _fetch_events()

            events = response.data  # type: ignore[attr-defined]
            includes = getattr(response, "includes", None) or {}
            user_lookup = self._build_user_lookup(includes)
            meta = getattr(response, "meta", None) or {}
            if events is None:
                return MessageListResponse(
                    messages=[],
                    conversation_id=conversation_id,
                )

            messages: list[DirectMessage] = []
            for event in events:
                if event.get("event_type") != "MessageCreate":
                    continue

                raw_created_at: str | None = event.get("created_at")
                if raw_created_at:
                    try:
                        created_at = datetime.fromisoformat(
                            raw_created_at.replace("Z", "+00:00")
                        )
                    except ValueError:
                        created_at = datetime.now()
                else:
                    created_at = datetime.now()

                sender_id = event.get("sender_id")
                sender_info = user_lookup.get(sender_id, {}) if sender_id else {}

                messages.append(
                    DirectMessage(
                        message_id=str(event["id"]),
                        conversation_id=str(
                            event.get("dm_conversation_id", conversation_id)
                        ),
                        platform=self.platform_name,
                        text=event.get("text", ""),
                        sender_id=sender_id,
                        sender_username=sender_info.get("username") or None,
                        sender_name=sender_info.get("name") or None,
                        created_at=created_at,
                    )
                )

            next_token = meta.get("next_token") if isinstance(meta, dict) else None
            return MessageListResponse(
                messages=messages,
                next_cursor=next_token,
                has_more=bool(next_token),
                conversation_id=conversation_id,
            )

        except tweepy.TweepyException as e:
            if "429" in str(e):
                raise RateLimitError(
                    "Twitter rate limit exceeded",
                    platform=self.platform_name,
                    status_code=429,
                ) from e
            if "403" in str(e) or "401" in str(e):
                raise PlatformAuthError(
                    f"Not authorized to retrieve messages: {e}",
                    platform=self.platform_name,
                ) from e
            raise PlatformError(
                f"Failed to retrieve messages: {e}",
                platform=self.platform_name,
            ) from e

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

    _USER_FIELDS = "name,username,profile_image_url,verified,public_metrics,description"

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

        Args:
            post_id: Twitter tweet ID.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination token from a previous response.

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

        params: dict[str, Any] = {
            "max_results": min(limit, 100),
            "user.fields": self._USER_FIELDS,
        }
        if cursor:
            params["pagination_token"] = cursor

        try:
            response = await self.api_client.get(
                f"/tweets/{post_id}/liking_users",
                params=params,
            )
            users = [
                self._parse_twitter_interaction_user(u)
                for u in response.data.get("data") or []
            ]
            meta = response.data.get("meta", {})
            next_token = meta.get("next_token")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_token,
                has_more=next_token is not None,
                total_count=meta.get("result_count"),
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch liking users: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch 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 retweeted a tweet.

        Args:
            post_id: Twitter tweet ID.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination token from a previous response.

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

        params: dict[str, Any] = {
            "max_results": min(limit, 100),
            "user.fields": self._USER_FIELDS,
        }
        if cursor:
            params["pagination_token"] = cursor

        try:
            response = await self.api_client.get(
                f"/tweets/{post_id}/retweeted_by",
                params=params,
            )
            users = [
                self._parse_twitter_interaction_user(u)
                for u in response.data.get("data") or []
            ]
            meta = response.data.get("meta", {})
            next_token = meta.get("next_token")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_token,
                has_more=next_token is not None,
                total_count=meta.get("result_count"),
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch retweeted by users: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch retweeted by users: {e}",
                platform=self.platform_name,
            ) from e

    async def get_post_quote_posts(
        self,
        post_id: str,
        limit: int = 50,
        cursor: str | None = None,
    ) -> QuotePostListResponse:
        """Get quote tweets of a tweet.

        Args:
            post_id: Twitter tweet ID.
            limit: Maximum number of quote tweets to return (max 100).
            cursor: Pagination token from a previous response.

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

        params: dict[str, Any] = {
            "max_results": min(limit, 100),
            "tweet.fields": (
                "created_at,author_id,public_metrics,entities,referenced_tweets"
            ),
            "expansions": "author_id,attachments.media_keys",
            "user.fields": "name,username,profile_image_url,verified",
            "media.fields": "url,type,alt_text,preview_image_url,duration_ms,variants",
        }
        if cursor:
            params["pagination_token"] = cursor

        try:
            response = await self.api_client.get(
                f"/tweets/{post_id}/quote_tweets",
                params=params,
            )
            includes = response.data.get("includes", {})
            user_lookup = self._build_user_lookup(includes)

            posts: list[Post] = []
            for tweet_data in response.data.get("data") or []:
                author_id = tweet_data.get("author_id", "")
                user_info = user_lookup.get(author_id, {})
                metrics = tweet_data.get("public_metrics", {})
                tweet_id = tweet_data["id"]
                username = user_info.get("username")
                url = (
                    HttpUrl(f"https://x.com/{username}/status/{tweet_id}")
                    if username
                    else HttpUrl(f"https://x.com/web/status/{tweet_id}")
                )
                posts.append(
                    Post(
                        post_id=tweet_id,
                        platform=self.platform_name,
                        content=tweet_data.get("text"),
                        url=url,
                        status=PostStatus.PUBLISHED,
                        created_at=datetime.fromisoformat(
                            tweet_data["created_at"].replace("Z", "+00:00")
                        ),
                        author_id=author_id,
                        author_name=user_info.get("name") or None,
                        author_username=username or None,
                        likes_count=metrics.get("like_count", 0),
                        comments_count=metrics.get("reply_count", 0),
                        shares_count=metrics.get("retweet_count", 0),
                        views_count=metrics.get("impression_count", 0),
                        raw_data=tweet_data,
                    )
                )

            meta = response.data.get("meta", {})
            next_token = meta.get("next_token")
            return QuotePostListResponse(
                posts=posts,
                next_cursor=next_token,
                has_more=next_token is not None,
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch quote tweets: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch quote tweets: {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 user.

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

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

        target_id = user_id or self.credentials.user_id
        if not target_id:
            raise ValidationError(
                "user_id is required (not available from credentials)",
                platform=self.platform_name,
                field="user_id",
            )

        params: dict[str, Any] = {
            "max_results": min(limit, 100),
            "user.fields": self._USER_FIELDS,
        }
        if cursor:
            params["pagination_token"] = cursor

        try:
            response = await self.api_client.get(
                f"/users/{target_id}/followers",
                params=params,
            )
            users = [
                self._parse_twitter_interaction_user(u)
                for u in response.data.get("data") or []
            ]
            meta = response.data.get("meta", {})
            next_token = meta.get("next_token")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_token,
                has_more=next_token is not None,
                total_count=meta.get("result_count"),
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch followers: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch 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 user.

        Args:
            user_id: Twitter user ID. If None, uses the authenticated user.
            limit: Maximum number of users to return (max 100).
            cursor: Pagination token 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")

        target_id = user_id or self.credentials.user_id
        if not target_id:
            raise ValidationError(
                "user_id is required (not available from credentials)",
                platform=self.platform_name,
                field="user_id",
            )

        params: dict[str, Any] = {
            "max_results": min(limit, 100),
            "user.fields": self._USER_FIELDS,
        }
        if cursor:
            params["pagination_token"] = cursor

        try:
            response = await self.api_client.get(
                f"/users/{target_id}/following",
                params=params,
            )
            users = [
                self._parse_twitter_interaction_user(u)
                for u in response.data.get("data") or []
            ]
            meta = response.data.get("meta", {})
            next_token = meta.get("next_token")
            return InteractionUserListResponse(
                users=users,
                next_cursor=next_token,
                has_more=next_token is not None,
                total_count=meta.get("result_count"),
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 401:
                raise TwitterUnauthorizedError(
                    "Unauthorized: Invalid or expired access token",
                    error_code=89,
                ) from e
            raise PlatformError(
                f"Failed to fetch following: {e}",
                platform=self.platform_name,
            ) from e
        except httpx.HTTPError as e:
            raise PlatformError(
                f"Failed to fetch following: {e}",
                platform=self.platform_name,
            ) from e

    # ==================== Helper Methods ====================

    def _parse_twitter_interaction_user(
        self, user_data: dict[str, Any]
    ) -> TwitterInteractionUser:
        """Parse Twitter API v2 user object into TwitterInteractionUser."""
        user_id = str(user_data.get("id", ""))
        username = user_data.get("username")
        metrics = user_data.get("public_metrics", {})
        profile_image = user_data.get("profile_image_url")
        return TwitterInteractionUser(
            user_id=user_id,
            username=username,
            display_name=user_data.get("name"),
            profile_url=(HttpUrl(f"https://x.com/{username}") if username else None),
            avatar_url=profile_image,
            platform=self.platform_name,
            verified=user_data.get("verified", False),
            followers_count=metrics.get("followers_count", 0),
            following_count=metrics.get("following_count", 0),
            tweet_count=metrics.get("tweet_count", 0),
            description=user_data.get("description"),
            profile_image_url=profile_image,
            raw_data=user_data,
        )

    @staticmethod
    def _detect_media_type_from_url(url: str) -> str:
        """Detect media type from a URL's file extension.

        Args:
            url: Media URL to inspect.

        Returns:
            "image" or "video" based on the file extension.
        """
        from urllib.parse import urlparse

        path = urlparse(url).path.lower()
        twitter_types = PLATFORM_MEDIA_TYPES.get("twitter", {})
        for media_type, extensions in twitter_types.items():
            if any(path.endswith(ext) for ext in extensions):
                return media_type
        return "image"

    @staticmethod
    def _build_user_lookup(
        includes: dict[str, Any] | None,
    ) -> dict[str, dict[str, str]]:
        """Build author_id -> user info mapping from API includes.

        Handles both tweepy User objects (from tweepy client) and raw dicts
        (from APIClient).

        Args:
            includes: The includes dict from Twitter API v2 response.

        Returns:
            Mapping of user ID string to dict with name, username keys.
        """
        if not includes or "users" not in includes:
            return {}
        lookup: dict[str, dict[str, str]] = {}
        for user in includes.get("users", []):
            if isinstance(user, dict):
                uid = str(user.get("id", ""))
                name = user.get("name", "")
                username = user.get("username", "")
            else:
                uid = str(user.id)
                name = user.name or ""
                username = user.username or ""
            lookup[uid] = {"name": name, "username": username}
        return lookup

    def _parse_tweet(
        self,
        tweet: tweepy.Tweet,
        includes: dict[str, Any] | None = None,
    ) -> Post:
        """Parse Twitter API response into Post model.

        Args:
            tweet: Tweepy Tweet object.
            includes: Additional data from API response.

        Returns:
            Post object.
        """
        media = []
        if includes and "media" in includes:
            for media_item in includes["media"]:
                item_url = media_item.get("url", "")
                # For videos, pick the highest bitrate variant URL
                variants = media_item.get("variants", [])
                if variants and not item_url:
                    best = max(
                        (v for v in variants if v.get("bit_rate")),
                        key=lambda v: v.get("bit_rate", 0),
                        default=None,
                    )
                    if best:
                        item_url = best.get("url", "")

                media.append(
                    MediaAttachment(
                        media_id=media_item.get("media_key", ""),
                        media_type=(
                            MediaType.IMAGE
                            if media_item.get("type") == "photo"
                            else MediaType.VIDEO
                        ),
                        url=item_url,
                        width=media_item.get("width"),
                        height=media_item.get("height"),
                        alt_text=media_item.get("alt_text"),
                        preview_image_url=media_item.get("preview_image_url"),
                        duration_seconds=(
                            media_item["duration_ms"] / 1000.0
                            if media_item.get("duration_ms")
                            else None
                        ),
                    )
                )

        metrics = tweet.public_metrics or {}

        # Construct Twitter URL if username is available
        tweet_id = str(tweet.id)
        url = (
            HttpUrl(f"https://x.com/{self.credentials.username}/status/{tweet_id}")
            if self.credentials.username
            else HttpUrl(f"https://x.com/web/status/{tweet_id}")
        )

        # Resolve author info from includes
        author_id_str = str(tweet.author_id) if tweet.author_id else None
        user_lookup = self._build_user_lookup(includes)
        user_info = user_lookup.get(author_id_str, {}) if author_id_str else {}

        return Post(
            post_id=tweet_id,
            platform=self.platform_name,
            content=tweet.text,
            url=url,
            media=media,
            status=PostStatus.PUBLISHED,
            created_at=tweet.created_at or datetime.now(),
            author_id=author_id_str,
            author_name=user_info.get("name") or None,
            author_username=user_info.get("username") or None,
            likes_count=metrics.get("like_count", 0),
            comments_count=metrics.get("reply_count", 0),
            shares_count=metrics.get("retweet_count", 0),
            views_count=metrics.get("impression_count", 0),
            raw_data=tweet.data,
        )

    def _parse_reply(
        self,
        tweet_data: dict[str, Any],
        post_id: str,
        user_lookup: dict[str, dict[str, str]] | None = None,
    ) -> Comment:
        """Parse Twitter API response into Comment model.

        Args:
            tweet_data: Raw tweet data.
            post_id: ID of the original tweet.
            user_lookup: Optional mapping of user IDs to user info dicts.

        Returns:
            Comment object.
        """
        metrics = tweet_data.get("public_metrics", {})
        author_id = tweet_data.get("author_id", "")
        user_info = (user_lookup or {}).get(author_id, {})

        return Comment(
            comment_id=tweet_data["id"],
            post_id=post_id,
            platform=self.platform_name,
            content=tweet_data["text"],
            author_id=author_id,
            author_username=user_info.get("username") or None,
            author_name=user_info.get("name") or None,
            created_at=datetime.fromisoformat(
                tweet_data["created_at"].replace("Z", "+00:00")
            ),
            likes_count=metrics.get("like_count", 0),
            replies_count=metrics.get("reply_count", 0),
            status=CommentStatus.VISIBLE,
            raw_data=tweet_data,
        )
