"""
Mock implementations for testing.

This module provides mock implementations of SDK clients that can be
used for local testing without connecting to real services.
"""

from datetime import datetime, timezone
from typing import Any

from torivers_sdk.tools.http import (
    DEFAULT_TIMEOUT,
    MAX_REQUEST_SIZE,
    HttpBlockedIPError,
    HttpClient,
    HttpRequestTooLargeError,
    HttpResponse,
    is_blocked_ip,
    validate_url,
)
from torivers_sdk.tools.llm import (
    APPROVED_CHAT_MODELS,
    APPROVED_EMBEDDING_MODELS,
    DEFAULT_CHAT_MODEL,
    DEFAULT_EMBEDDING_MODEL,
    EmbeddingResponse,
    LLMClient,
    LLMMessage,
    LLMResponse,
    ModelNotApprovedError,
)
from torivers_sdk.tools.storage import (
    DEFAULT_MAX_FILE_SIZE,
    DEFAULT_MAX_TOTAL_SIZE,
    StorageClient,
    StorageFile,
    StorageFileNotFoundError,
    StorageFileTooLargeError,
    StorageQuota,
    StorageQuotaExceededError,
    guess_content_type,
)


class MockLLMClient(LLMClient):
    """
    Mock LLM client for testing.

    Supports model validation, custom responses, and token tracking.

    Example:
        llm = MockLLMClient()
        llm.add_response("This is a test response")

        response = await llm.chat([
            LLMMessage(role="user", content="Hello")
        ])

        assert response.content == "This is a test response"
        assert llm.total_tokens_used > 0

    With model validation:
        llm = MockLLMClient(validate_models=True)

        # This will raise ModelNotApprovedError
        await llm.chat([...], model="invalid-model")
    """

    def __init__(
        self,
        validate_models: bool = False,
        default_prompt_tokens: int = 10,
        default_completion_tokens: int = 20,
        embedding_dimensions: int = 1536,
    ) -> None:
        """
        Initialize mock LLM client.

        Args:
            validate_models: If True, validates models against approved list
            default_prompt_tokens: Default prompt tokens for responses
            default_completion_tokens: Default completion tokens for responses
            embedding_dimensions: Dimension of mock embedding vectors
        """
        self._responses: list[str] = []
        self._embedding_responses: list[list[list[float]]] = []
        self._calls: list[dict[str, Any]] = []
        self._default_response = "Mock LLM response"
        self._validate_models = validate_models
        self._default_prompt_tokens = default_prompt_tokens
        self._default_completion_tokens = default_completion_tokens
        self._embedding_dimensions = embedding_dimensions
        self._total_tokens_used = 0

    def add_response(self, content: str) -> None:
        """Add a response to the queue."""
        self._responses.append(content)

    def add_responses(self, contents: list[str]) -> None:
        """Add multiple responses to the queue."""
        self._responses.extend(contents)

    def add_embedding_response(self, embeddings: list[list[float]]) -> None:
        """Add a custom embedding response to the queue."""
        self._embedding_responses.append(embeddings)

    def set_default_response(self, content: str) -> None:
        """Set the default response when queue is empty."""
        self._default_response = content

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get recorded calls."""
        return list(self._calls)

    @property
    def chat_calls(self) -> list[dict[str, Any]]:
        """Get only chat calls."""
        return [c for c in self._calls if c["method"] == "chat"]

    @property
    def complete_calls(self) -> list[dict[str, Any]]:
        """Get only complete calls."""
        return [c for c in self._calls if c["method"] == "complete"]

    @property
    def embed_calls(self) -> list[dict[str, Any]]:
        """Get only embed calls."""
        return [c for c in self._calls if c["method"] == "embed"]

    @property
    def total_tokens_used(self) -> int:
        """Get total tokens used across all calls."""
        return self._total_tokens_used

    def clear_calls(self) -> None:
        """Clear recorded calls and reset token counter."""
        self._calls.clear()
        self._total_tokens_used = 0

    def clear_responses(self) -> None:
        """Clear queued responses."""
        self._responses.clear()
        self._embedding_responses.clear()

    def assert_called(self, method: str | None = None) -> None:
        """
        Assert that the client was called.

        Args:
            method: Optional method name to check ("chat", "complete", "embed")

        Raises:
            AssertionError: If no calls were made
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert len(calls) > 0, f"Expected at least one {method} call"
        else:
            assert len(self._calls) > 0, "Expected at least one call"

    def assert_not_called(self, method: str | None = None) -> None:
        """
        Assert that the client was not called.

        Args:
            method: Optional method name to check

        Raises:
            AssertionError: If calls were made
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert len(calls) == 0, f"Expected no {method} calls, got {len(calls)}"
        else:
            assert len(self._calls) == 0, f"Expected no calls, got {len(self._calls)}"

    def assert_call_count(self, expected: int, method: str | None = None) -> None:
        """
        Assert the number of calls made.

        Args:
            expected: Expected number of calls
            method: Optional method name to check

        Raises:
            AssertionError: If call count doesn't match
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert (
                len(calls) == expected
            ), f"Expected {expected} {method} calls, got {len(calls)}"
        else:
            assert (
                len(self._calls) == expected
            ), f"Expected {expected} calls, got {len(self._calls)}"

    def get_last_call(self, method: str | None = None) -> dict[str, Any] | None:
        """
        Get the last call made.

        Args:
            method: Optional method name to filter by

        Returns:
            Last call dict or None if no calls
        """
        calls = (
            self._calls
            if method is None
            else [c for c in self._calls if c["method"] == method]
        )
        return calls[-1] if calls else None

    async def chat(
        self,
        messages: list[LLMMessage],
        model: str | None = None,
        temperature: float = 0.7,
        max_tokens: int | None = None,
    ) -> LLMResponse:
        """Send a chat completion request."""
        resolved_model = model or DEFAULT_CHAT_MODEL

        if self._validate_models and model is not None:
            if model not in APPROVED_CHAT_MODELS:
                raise ModelNotApprovedError(model, "chat")

        self._calls.append(
            {
                "method": "chat",
                "messages": messages,
                "model": model,
                "resolved_model": resolved_model,
                "temperature": temperature,
                "max_tokens": max_tokens,
            }
        )

        content = self._responses.pop(0) if self._responses else self._default_response

        usage = {
            "prompt_tokens": self._default_prompt_tokens,
            "completion_tokens": self._default_completion_tokens,
            "total_tokens": self._default_prompt_tokens
            + self._default_completion_tokens,
        }
        self._total_tokens_used += usage["total_tokens"]

        return LLMResponse(
            content=content,
            model=resolved_model,
            usage=usage,
            finish_reason="stop",
        )

    async def complete(
        self,
        prompt: str,
        model: str | None = None,
        temperature: float = 0.7,
        max_tokens: int | None = None,
    ) -> LLMResponse:
        """Send a text completion request."""
        resolved_model = model or DEFAULT_CHAT_MODEL

        if self._validate_models and model is not None:
            if model not in APPROVED_CHAT_MODELS:
                raise ModelNotApprovedError(model, "chat")

        self._calls.append(
            {
                "method": "complete",
                "prompt": prompt,
                "model": model,
                "resolved_model": resolved_model,
                "temperature": temperature,
                "max_tokens": max_tokens,
            }
        )

        content = self._responses.pop(0) if self._responses else self._default_response

        usage = {
            "prompt_tokens": self._default_prompt_tokens,
            "completion_tokens": self._default_completion_tokens,
            "total_tokens": self._default_prompt_tokens
            + self._default_completion_tokens,
        }
        self._total_tokens_used += usage["total_tokens"]

        return LLMResponse(
            content=content,
            model=resolved_model,
            usage=usage,
            finish_reason="stop",
        )

    async def embed(
        self,
        texts: list[str],
        model: str | None = None,
    ) -> list[list[float]]:
        """Generate embeddings for texts."""
        resolved_model = model or DEFAULT_EMBEDDING_MODEL

        if self._validate_models and model is not None:
            if model not in APPROVED_EMBEDDING_MODELS:
                raise ModelNotApprovedError(model, "embedding")

        # Calculate tokens (rough estimate: ~4 chars per token)
        total_tokens = sum(len(text) // 4 + 1 for text in texts)

        self._calls.append(
            {
                "method": "embed",
                "texts": texts,
                "model": model,
                "resolved_model": resolved_model,
                "tokens_used": total_tokens,
            }
        )
        self._total_tokens_used += total_tokens

        # Return custom embeddings if available
        if self._embedding_responses:
            return self._embedding_responses.pop(0)

        # Return mock embeddings
        return [[0.1] * self._embedding_dimensions for _ in texts]

    async def embed_with_metadata(
        self,
        texts: list[str],
        model: str | None = None,
    ) -> EmbeddingResponse:
        """Generate embeddings with usage metadata."""
        resolved_model = model or DEFAULT_EMBEDDING_MODEL

        if self._validate_models and model is not None:
            if model not in APPROVED_EMBEDDING_MODELS:
                raise ModelNotApprovedError(model, "embedding")

        embeddings = await self.embed(texts, model)

        # Get tokens from the last call
        last_call = self._calls[-1] if self._calls else {}
        tokens_used = last_call.get("tokens_used", 0)

        return EmbeddingResponse(
            embeddings=embeddings,
            model=resolved_model,
            usage={"total_tokens": tokens_used},
        )


class MockStorageClient(StorageClient):
    """
    Mock storage client for testing.

    Supports file size limits, quota tracking, and URL generation.

    Example:
        storage = MockStorageClient()

        # Store a file
        path = await storage.put("test.txt", b"Hello, World!")

        # Retrieve it
        content = await storage.get("test.txt")
        assert content == b"Hello, World!"

    With limits:
        storage = MockStorageClient(
            max_file_size=1024,  # 1 KB
            max_total_size=10240,  # 10 KB
        )

        # This will raise StorageFileTooLargeError
        await storage.put("big.bin", b"x" * 2048)
    """

    def __init__(
        self,
        execution_id: str = "mock-execution",
        base_url: str = "https://storage.example.com",
        max_file_size: int = DEFAULT_MAX_FILE_SIZE,
        max_total_size: int = DEFAULT_MAX_TOTAL_SIZE,
        enforce_limits: bool = False,
    ) -> None:
        """
        Initialize mock storage client.

        Args:
            execution_id: Execution ID for storage isolation
            base_url: Base URL for generated file URLs
            max_file_size: Maximum file size in bytes
            max_total_size: Maximum total storage size in bytes
            enforce_limits: If True, enforce file and quota limits
        """
        self._execution_id = execution_id
        self._base_url = base_url
        self._max_file_size = max_file_size
        self._max_total_size = max_total_size
        self._enforce_limits = enforce_limits
        self._files: dict[str, bytes] = {}
        self._metadata: dict[str, StorageFile] = {}
        self._calls: list[dict[str, Any]] = []

    @property
    def execution_id(self) -> str:
        """Get the execution ID."""
        return self._execution_id

    @property
    def files(self) -> dict[str, bytes]:
        """Get all stored files (for testing assertions)."""
        return dict(self._files)

    @property
    def file_count(self) -> int:
        """Get number of stored files."""
        return len(self._files)

    @property
    def total_size(self) -> int:
        """Get total size of all stored files."""
        return sum(len(data) for data in self._files.values())

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get recorded method calls."""
        return list(self._calls)

    def clear(self) -> None:
        """Clear all stored files and metadata."""
        self._files.clear()
        self._metadata.clear()

    def clear_calls(self) -> None:
        """Clear recorded method calls."""
        self._calls.clear()

    def add_file(
        self,
        path: str,
        content: bytes,
        content_type: str | None = None,
    ) -> None:
        """
        Add a file directly (for test setup).

        Args:
            path: File path
            content: File content
            content_type: MIME type (auto-detected if not provided)
        """
        if content_type is None:
            content_type = guess_content_type(path)

        now = datetime.now(timezone.utc)
        self._files[path] = content
        self._metadata[path] = StorageFile(
            path=path,
            size_bytes=len(content),
            content_type=content_type,
            created_at=now,
            updated_at=now,
            metadata={},
            url=self._generate_url(path),
        )

    def assert_file_exists(self, path: str) -> None:
        """Assert that a file exists."""
        assert path in self._files, f"File '{path}' does not exist"

    def assert_file_not_exists(self, path: str) -> None:
        """Assert that a file does not exist."""
        assert path not in self._files, f"File '{path}' exists but should not"

    def assert_file_content(self, path: str, expected: bytes) -> None:
        """Assert that a file has the expected content."""
        self.assert_file_exists(path)
        actual = self._files[path]
        assert actual == expected, f"File content mismatch for '{path}'"

    def assert_file_count(self, expected: int) -> None:
        """Assert the number of stored files."""
        actual = len(self._files)
        assert actual == expected, f"Expected {expected} files, got {actual}"

    def _generate_url(self, path: str) -> str:
        """Generate a public URL for a file."""
        return f"{self._base_url}/{self._execution_id}/{path}"

    def _check_limits(self, path: str, size: int) -> None:
        """Check file size and quota limits."""
        if not self._enforce_limits:
            return

        if size > self._max_file_size:
            raise StorageFileTooLargeError(path, size, self._max_file_size)

        new_total = self.total_size + size
        if new_total > self._max_total_size:
            raise StorageQuotaExceededError(
                f"Storage quota exceeded. Used: {self.total_size}, "
                f"Requested: {size}, Max: {self._max_total_size}",
                used_bytes=self.total_size,
                max_bytes=self._max_total_size,
            )

    async def put(
        self,
        path: str,
        data: bytes,
        content_type: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> str:
        """Store a file."""
        self._calls.append(
            {
                "method": "put",
                "path": path,
                "size": len(data),
                "content_type": content_type,
            }
        )

        self._check_limits(path, len(data))

        if content_type is None:
            content_type = guess_content_type(path)

        now = datetime.now(timezone.utc)
        self._files[path] = data
        self._metadata[path] = StorageFile(
            path=path,
            size_bytes=len(data),
            content_type=content_type,
            created_at=now,
            updated_at=now,
            metadata=metadata or {},
            url=self._generate_url(path),
        )
        return path

    async def get(self, path: str) -> bytes:
        """Retrieve a file."""
        self._calls.append({"method": "get", "path": path})

        if path not in self._files:
            raise StorageFileNotFoundError(path)
        return self._files[path]

    async def get_info(self, path: str) -> StorageFile:
        """Get file metadata."""
        self._calls.append({"method": "get_info", "path": path})

        if path not in self._metadata:
            raise StorageFileNotFoundError(path)
        return self._metadata[path]

    async def delete(self, path: str) -> bool:
        """Delete a file."""
        self._calls.append({"method": "delete", "path": path})

        if path in self._files:
            del self._files[path]
            del self._metadata[path]
            return True
        return False

    async def list(
        self,
        prefix: str = "",
        limit: int = 100,
    ) -> list[StorageFile]:
        """List files with optional prefix filter."""
        self._calls.append({"method": "list", "prefix": prefix, "limit": limit})

        matches = [
            meta for path, meta in self._metadata.items() if path.startswith(prefix)
        ]
        return matches[:limit]

    async def exists(self, path: str) -> bool:
        """Check if a file exists."""
        self._calls.append({"method": "exists", "path": path})
        return path in self._files

    async def get_url(self, path: str) -> str:
        """Get public URL for a file."""
        self._calls.append({"method": "get_url", "path": path})

        if path not in self._metadata:
            raise StorageFileNotFoundError(path)
        return self._generate_url(path)

    async def get_quota(self) -> StorageQuota:
        """Get current storage quota information."""
        self._calls.append({"method": "get_quota"})

        return StorageQuota(
            used_bytes=self.total_size,
            max_bytes=self._max_total_size,
            file_count=self.file_count,
            max_file_size=self._max_file_size,
        )


class MockGoogleSheetsClient:
    """
    Mock Google Sheets client for testing.

    Provides mock implementations for spreadsheet operations with
    configurable mock data and call recording.

    Example:
        sheets = MockGoogleSheetsClient()
        sheets.set_spreadsheet_data("https://docs.google.com/spreadsheets/d/123", {
            "Sheet1": [["Name", "Age"], ["Alice", "30"], ["Bob", "25"]]
        })

        data = await sheets.get_spreadsheet_data("https://docs.google.com/spreadsheets/d/123")
        assert data["Sheet1"][0] == ["Name", "Age"]

        await sheets.update_range(
            "https://docs.google.com/spreadsheets/d/123",
            "Sheet1!A1:B1",
            [["Header1", "Header2"]]
        )
        assert sheets.update_count == 1
    """

    def __init__(
        self, mock_data: dict[str, dict[str, list[list[Any]]]] | None = None
    ) -> None:
        """
        Initialize mock Google Sheets client.

        Args:
            mock_data: Optional dict mapping spreadsheet URLs to sheet data.
                       Format: {url: {sheet_name: [[row1], [row2], ...]}}
        """
        self._data: dict[str, dict[str, list[list[Any]]]] = mock_data or {}
        self._calls: list[dict[str, Any]] = []
        self._default_data = self._create_default_data()

    def _create_default_data(self) -> dict[str, list[list[Any]]]:
        """Create default spreadsheet data for testing."""
        return {
            "Sheet1": [
                ["Name", "Email", "Status"],
                ["Alice", "alice@example.com", "Active"],
                ["Bob", "bob@example.com", "Inactive"],
            ]
        }

    def set_spreadsheet_data(
        self,
        url: str,
        data: dict[str, list[list[Any]]],
    ) -> None:
        """
        Set mock data for a spreadsheet URL.

        Args:
            url: Spreadsheet URL
            data: Sheet data as {sheet_name: [[row1], [row2], ...]}
        """
        self._data[url] = data

    def add_sheet(
        self,
        url: str,
        sheet_name: str,
        rows: list[list[Any]],
    ) -> None:
        """
        Add a sheet to a spreadsheet.

        Args:
            url: Spreadsheet URL
            sheet_name: Name of the sheet
            rows: Row data
        """
        if url not in self._data:
            self._data[url] = {}
        self._data[url][sheet_name] = rows

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get recorded calls."""
        return list(self._calls)

    @property
    def read_calls(self) -> list[dict[str, Any]]:
        """Get only read calls."""
        return [c for c in self._calls if c["method"] == "get_spreadsheet_data"]

    @property
    def update_calls(self) -> list[dict[str, Any]]:
        """Get only update calls."""
        return [c for c in self._calls if c["method"] == "update_range"]

    @property
    def update_count(self) -> int:
        """Get number of update calls."""
        return len(self.update_calls)

    def clear_calls(self) -> None:
        """Clear recorded calls."""
        self._calls.clear()

    def assert_called(self) -> None:
        """Assert that the client was called."""
        assert len(self._calls) > 0, "Expected at least one call"

    def assert_not_called(self) -> None:
        """Assert that the client was not called."""
        assert len(self._calls) == 0, f"Expected no calls, got {len(self._calls)}"

    def assert_spreadsheet_read(self, url: str) -> None:
        """Assert that a specific spreadsheet was read."""
        urls = [c["url"] for c in self.read_calls]
        assert url in urls, f"Spreadsheet '{url}' was not read. Read URLs: {urls}"

    def get_last_call(self) -> dict[str, Any] | None:
        """Get the last call made."""
        return self._calls[-1] if self._calls else None

    async def get_spreadsheet_data(
        self,
        url: str,
        sheet_name: str | None = None,
        range_notation: str | None = None,
    ) -> dict[str, list[list[Any]]]:
        """
        Get spreadsheet data.

        Args:
            url: Spreadsheet URL
            sheet_name: Optional specific sheet name
            range_notation: Optional range like "A1:C10"

        Returns:
            Sheet data as {sheet_name: [[row1], [row2], ...]}
        """
        self._calls.append(
            {
                "method": "get_spreadsheet_data",
                "url": url,
                "sheet_name": sheet_name,
                "range": range_notation,
            }
        )

        data = self._data.get(url, self._default_data)

        if sheet_name:
            if sheet_name in data:
                return {sheet_name: data[sheet_name]}
            return {sheet_name: []}

        return data

    async def update_range(
        self,
        url: str,
        range_notation: str,
        values: list[list[Any]],
    ) -> dict[str, Any]:
        """
        Update a range of cells.

        Args:
            url: Spreadsheet URL
            range_notation: Range like "Sheet1!A1:B2"
            values: Values to write

        Returns:
            Update response
        """
        self._calls.append(
            {
                "method": "update_range",
                "url": url,
                "range": range_notation,
                "values": values,
            }
        )

        # Parse sheet name from range
        if "!" in range_notation:
            sheet_name = range_notation.split("!")[0]
        else:
            sheet_name = "Sheet1"

        # Store the update (simple implementation)
        if url not in self._data:
            self._data[url] = {}
        if sheet_name not in self._data[url]:
            self._data[url][sheet_name] = []

        return {
            "spreadsheetId": url.split("/")[-1] if "/" in url else url,
            "updatedRange": range_notation,
            "updatedRows": len(values),
            "updatedColumns": len(values[0]) if values else 0,
            "updatedCells": sum(len(row) for row in values),
        }

    async def append_rows(
        self,
        url: str,
        sheet_name: str,
        rows: list[list[Any]],
    ) -> dict[str, Any]:
        """
        Append rows to a sheet.

        Args:
            url: Spreadsheet URL
            sheet_name: Target sheet name
            rows: Rows to append

        Returns:
            Append response
        """
        self._calls.append(
            {
                "method": "append_rows",
                "url": url,
                "sheet_name": sheet_name,
                "rows": rows,
            }
        )

        # Actually append to mock data
        if url not in self._data:
            self._data[url] = {}
        if sheet_name not in self._data[url]:
            self._data[url][sheet_name] = []
        self._data[url][sheet_name].extend(rows)

        return {
            "spreadsheetId": url.split("/")[-1] if "/" in url else url,
            "tableRange": f"{sheet_name}!A1",
            "updates": {
                "updatedRows": len(rows),
                "updatedCells": sum(len(row) for row in rows),
            },
        }

    async def clear_range(
        self,
        url: str,
        range_notation: str,
    ) -> dict[str, Any]:
        """
        Clear a range of cells.

        Args:
            url: Spreadsheet URL
            range_notation: Range to clear

        Returns:
            Clear response
        """
        self._calls.append(
            {
                "method": "clear_range",
                "url": url,
                "range": range_notation,
            }
        )

        return {
            "spreadsheetId": url.split("/")[-1] if "/" in url else url,
            "clearedRange": range_notation,
        }


class MockGmailClient:
    """
    Mock Gmail client for testing.

    Provides mock implementations for email operations with
    configurable mock inbox and call recording.

    Example:
        gmail = MockGmailClient()
        gmail.add_message({
            "id": "msg-123",
            "subject": "Test Email",
            "from": "sender@example.com",
            "body": "Hello, World!"
        })

        messages = await gmail.list_messages()
        assert len(messages) == 1

        await gmail.send_email(
            to="recipient@example.com",
            subject="Hello",
            body="Test message"
        )
        assert gmail.sent_count == 1
    """

    def __init__(
        self,
        mock_messages: list[dict[str, Any]] | None = None,
        user_email: str = "test@example.com",
    ) -> None:
        """
        Initialize mock Gmail client.

        Args:
            mock_messages: Optional list of mock message dicts
            user_email: Email address of the mock user
        """
        self._messages: list[dict[str, Any]] = mock_messages or []
        self._sent: list[dict[str, Any]] = []
        self._drafts: list[dict[str, Any]] = []
        self._calls: list[dict[str, Any]] = []
        self._user_email = user_email
        self._next_message_id = 1

    def add_message(
        self,
        message: dict[str, Any],
    ) -> str:
        """
        Add a mock message to the inbox.

        Args:
            message: Message dict with id, subject, from, body, etc.

        Returns:
            Message ID
        """
        if "id" not in message:
            message["id"] = f"msg-{self._next_message_id}"
            self._next_message_id += 1
        if "labels" not in message:
            message["labels"] = ["INBOX"]
        if "date" not in message:
            message["date"] = datetime.now(timezone.utc).isoformat()
        self._messages.append(message)
        return message["id"]

    def add_messages(self, messages: list[dict[str, Any]]) -> list[str]:
        """Add multiple messages to the inbox."""
        return [self.add_message(msg) for msg in messages]

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get recorded calls."""
        return list(self._calls)

    @property
    def send_calls(self) -> list[dict[str, Any]]:
        """Get only send email calls."""
        return [c for c in self._calls if c["method"] == "send_email"]

    @property
    def sent_messages(self) -> list[dict[str, Any]]:
        """Get all sent messages."""
        return list(self._sent)

    @property
    def sent_count(self) -> int:
        """Get number of sent emails."""
        return len(self._sent)

    @property
    def inbox_count(self) -> int:
        """Get number of inbox messages."""
        return len([m for m in self._messages if "INBOX" in m.get("labels", [])])

    def clear_calls(self) -> None:
        """Clear recorded calls."""
        self._calls.clear()

    def clear_sent(self) -> None:
        """Clear sent messages."""
        self._sent.clear()

    def assert_email_sent(
        self, to: str | None = None, subject: str | None = None
    ) -> None:
        """
        Assert that an email was sent.

        Args:
            to: Optional recipient to check
            subject: Optional subject to check
        """
        assert len(self._sent) > 0, "No emails were sent"
        if to:
            recipients = [m["to"] for m in self._sent]
            assert to in recipients, f"No email sent to {to}. Recipients: {recipients}"
        if subject:
            subjects = [m["subject"] for m in self._sent]
            assert (
                subject in subjects
            ), f"No email with subject '{subject}'. Subjects: {subjects}"

    def assert_no_emails_sent(self) -> None:
        """Assert that no emails were sent."""
        assert len(self._sent) == 0, f"Expected no sent emails, got {len(self._sent)}"

    def get_last_sent(self) -> dict[str, Any] | None:
        """Get the last sent email."""
        return self._sent[-1] if self._sent else None

    async def list_messages(
        self,
        query: str | None = None,
        max_results: int = 100,
        label_ids: list[str] | None = None,
    ) -> list[dict[str, Any]]:
        """
        List messages in the inbox.

        Args:
            query: Optional search query
            max_results: Maximum number of results
            label_ids: Optional label IDs to filter by

        Returns:
            List of message summaries
        """
        self._calls.append(
            {
                "method": "list_messages",
                "query": query,
                "max_results": max_results,
                "label_ids": label_ids,
            }
        )

        messages = self._messages

        # Filter by labels if specified
        if label_ids:
            messages = [
                m
                for m in messages
                if any(label in m.get("labels", []) for label in label_ids)
            ]

        # Simple query filtering (subject and from)
        if query:
            query_lower = query.lower()
            messages = [
                m
                for m in messages
                if query_lower in m.get("subject", "").lower()
                or query_lower in m.get("from", "").lower()
                or query_lower in m.get("body", "").lower()
            ]

        return messages[:max_results]

    async def get_message(self, message_id: str) -> dict[str, Any] | None:
        """
        Get a specific message.

        Args:
            message_id: Message ID

        Returns:
            Message dict or None if not found
        """
        self._calls.append(
            {
                "method": "get_message",
                "message_id": message_id,
            }
        )

        for message in self._messages:
            if message.get("id") == message_id:
                return message
        return None

    async def send_email(
        self,
        to: str | list[str],
        subject: str,
        body: str,
        cc: str | list[str] | None = None,
        bcc: str | list[str] | None = None,
        html_body: str | None = None,
        attachments: list[dict[str, Any]] | None = None,
    ) -> dict[str, Any]:
        """
        Send an email.

        Args:
            to: Recipient(s)
            subject: Email subject
            body: Plain text body
            cc: CC recipient(s)
            bcc: BCC recipient(s)
            html_body: Optional HTML body
            attachments: Optional attachments

        Returns:
            Send response with message ID
        """
        self._calls.append(
            {
                "method": "send_email",
                "to": to,
                "subject": subject,
                "body": body,
                "cc": cc,
                "bcc": bcc,
                "has_attachments": bool(attachments),
            }
        )

        message_id = f"sent-{len(self._sent) + 1}"
        sent_message = {
            "id": message_id,
            "to": to if isinstance(to, str) else ", ".join(to),
            "from": self._user_email,
            "subject": subject,
            "body": body,
            "html_body": html_body,
            "cc": cc,
            "bcc": bcc,
            "attachments": attachments,
            "sent_at": datetime.now(timezone.utc).isoformat(),
        }
        self._sent.append(sent_message)

        return {
            "id": message_id,
            "threadId": f"thread-{message_id}",
            "labelIds": ["SENT"],
        }

    async def create_draft(
        self,
        to: str | list[str],
        subject: str,
        body: str,
    ) -> dict[str, Any]:
        """
        Create a draft email.

        Args:
            to: Recipient(s)
            subject: Email subject
            body: Email body

        Returns:
            Draft response
        """
        self._calls.append(
            {
                "method": "create_draft",
                "to": to,
                "subject": subject,
            }
        )

        draft_id = f"draft-{len(self._drafts) + 1}"
        draft = {
            "id": draft_id,
            "to": to,
            "subject": subject,
            "body": body,
        }
        self._drafts.append(draft)

        return {"id": draft_id, "message": {"id": f"msg-{draft_id}"}}

    async def delete_message(self, message_id: str) -> bool:
        """
        Delete a message.

        Args:
            message_id: Message ID to delete

        Returns:
            True if deleted
        """
        self._calls.append(
            {
                "method": "delete_message",
                "message_id": message_id,
            }
        )

        for i, message in enumerate(self._messages):
            if message.get("id") == message_id:
                del self._messages[i]
                return True
        return False

    async def modify_labels(
        self,
        message_id: str,
        add_labels: list[str] | None = None,
        remove_labels: list[str] | None = None,
    ) -> dict[str, Any]:
        """
        Modify message labels.

        Args:
            message_id: Message ID
            add_labels: Labels to add
            remove_labels: Labels to remove

        Returns:
            Modified message
        """
        self._calls.append(
            {
                "method": "modify_labels",
                "message_id": message_id,
                "add_labels": add_labels,
                "remove_labels": remove_labels,
            }
        )

        for message in self._messages:
            if message.get("id") == message_id:
                labels = set(message.get("labels", []))
                if add_labels:
                    labels.update(add_labels)
                if remove_labels:
                    labels -= set(remove_labels)
                message["labels"] = list(labels)
                return message

        return {"id": message_id, "labels": []}


class MockSlackClient:
    """
    Mock Slack client for testing.

    Provides mock implementations for Slack operations with
    configurable channels, messages, and call recording.

    Example:
        slack = MockSlackClient()
        slack.add_channel("general", "C123456")

        await slack.post_message(
            channel="general",
            text="Hello, World!"
        )
        assert slack.message_count == 1

        messages = slack.get_channel_messages("general")
        assert messages[0]["text"] == "Hello, World!"
    """

    def __init__(
        self,
        workspace_name: str = "test-workspace",
        bot_user_id: str = "U_BOT123",
    ) -> None:
        """
        Initialize mock Slack client.

        Args:
            workspace_name: Name of the mock workspace
            bot_user_id: Bot user ID
        """
        self._workspace_name = workspace_name
        self._bot_user_id = bot_user_id
        self._channels: dict[str, dict[str, Any]] = {}
        self._messages: dict[str, list[dict[str, Any]]] = {}
        self._users: dict[str, dict[str, Any]] = {}
        self._calls: list[dict[str, Any]] = []
        self._next_ts = 1000000

    def add_channel(
        self,
        name: str,
        channel_id: str | None = None,
        is_private: bool = False,
    ) -> str:
        """
        Add a mock channel.

        Args:
            name: Channel name
            channel_id: Optional channel ID
            is_private: Whether channel is private

        Returns:
            Channel ID
        """
        if channel_id is None:
            channel_id = f"C{name.upper()[:8]}"
        self._channels[name] = {
            "id": channel_id,
            "name": name,
            "is_private": is_private,
            "is_member": True,
        }
        self._messages[name] = []
        return channel_id

    def add_user(
        self,
        user_id: str,
        name: str,
        email: str | None = None,
        is_bot: bool = False,
    ) -> None:
        """
        Add a mock user.

        Args:
            user_id: User ID
            name: Display name
            email: User email
            is_bot: Whether user is a bot
        """
        self._users[user_id] = {
            "id": user_id,
            "name": name,
            "real_name": name,
            "email": email,
            "is_bot": is_bot,
        }

    def get_channel_messages(self, channel: str) -> list[dict[str, Any]]:
        """Get all messages in a channel."""
        return list(self._messages.get(channel, []))

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get recorded calls."""
        return list(self._calls)

    @property
    def post_message_calls(self) -> list[dict[str, Any]]:
        """Get only post_message calls."""
        return [c for c in self._calls if c["method"] == "post_message"]

    @property
    def message_count(self) -> int:
        """Get total number of messages posted."""
        return sum(len(msgs) for msgs in self._messages.values())

    def clear_calls(self) -> None:
        """Clear recorded calls."""
        self._calls.clear()

    def clear_messages(self) -> None:
        """Clear all messages."""
        for channel in self._messages:
            self._messages[channel] = []

    def assert_message_posted(
        self,
        channel: str | None = None,
        text_contains: str | None = None,
    ) -> None:
        """
        Assert that a message was posted.

        Args:
            channel: Optional channel to check
            text_contains: Optional text substring to check
        """
        assert self.message_count > 0, "No messages were posted"

        if channel:
            channel_msgs = self._messages.get(channel, [])
            assert len(channel_msgs) > 0, f"No messages in channel '{channel}'"

            if text_contains:
                texts = [m["text"] for m in channel_msgs]
                assert any(
                    text_contains in t for t in texts
                ), f"No message containing '{text_contains}' in {channel}"

    def assert_no_messages(self) -> None:
        """Assert that no messages were posted."""
        assert (
            self.message_count == 0
        ), f"Expected no messages, got {self.message_count}"

    def get_last_message(self, channel: str | None = None) -> dict[str, Any] | None:
        """Get the last posted message."""
        if channel:
            msgs = self._messages.get(channel, [])
            return msgs[-1] if msgs else None

        # Get last message across all channels
        all_msgs = []
        for msgs in self._messages.values():
            all_msgs.extend(msgs)
        if not all_msgs:
            return None
        return sorted(all_msgs, key=lambda m: m.get("ts", "0"))[-1]

    async def post_message(
        self,
        channel: str,
        text: str,
        blocks: list[dict[str, Any]] | None = None,
        attachments: list[dict[str, Any]] | None = None,
        thread_ts: str | None = None,
        reply_broadcast: bool = False,
    ) -> dict[str, Any]:
        """
        Post a message to a channel.

        Args:
            channel: Channel name or ID
            text: Message text
            blocks: Optional Block Kit blocks
            attachments: Optional attachments
            thread_ts: Optional thread timestamp for replies
            reply_broadcast: Whether to broadcast reply to channel

        Returns:
            Post response
        """
        self._calls.append(
            {
                "method": "post_message",
                "channel": channel,
                "text": text,
                "blocks": blocks,
                "thread_ts": thread_ts,
            }
        )

        # Ensure channel exists
        if channel not in self._messages:
            self._messages[channel] = []

        ts = str(self._next_ts)
        self._next_ts += 1

        message = {
            "ts": ts,
            "channel": channel,
            "text": text,
            "blocks": blocks,
            "attachments": attachments,
            "thread_ts": thread_ts,
            "user": self._bot_user_id,
        }
        self._messages[channel].append(message)

        return {
            "ok": True,
            "channel": channel,
            "ts": ts,
            "message": message,
        }

    async def update_message(
        self,
        channel: str,
        ts: str,
        text: str,
        blocks: list[dict[str, Any]] | None = None,
    ) -> dict[str, Any]:
        """
        Update an existing message.

        Args:
            channel: Channel name or ID
            ts: Message timestamp
            text: New message text
            blocks: Optional new blocks

        Returns:
            Update response
        """
        self._calls.append(
            {
                "method": "update_message",
                "channel": channel,
                "ts": ts,
                "text": text,
            }
        )

        for msg in self._messages.get(channel, []):
            if msg.get("ts") == ts:
                msg["text"] = text
                if blocks:
                    msg["blocks"] = blocks
                return {"ok": True, "ts": ts, "channel": channel}

        return {"ok": False, "error": "message_not_found"}

    async def delete_message(
        self,
        channel: str,
        ts: str,
    ) -> dict[str, Any]:
        """
        Delete a message.

        Args:
            channel: Channel name or ID
            ts: Message timestamp

        Returns:
            Delete response
        """
        self._calls.append(
            {
                "method": "delete_message",
                "channel": channel,
                "ts": ts,
            }
        )

        msgs = self._messages.get(channel, [])
        for i, msg in enumerate(msgs):
            if msg.get("ts") == ts:
                del msgs[i]
                return {"ok": True, "ts": ts, "channel": channel}

        return {"ok": False, "error": "message_not_found"}

    async def add_reaction(
        self,
        channel: str,
        ts: str,
        emoji: str,
    ) -> dict[str, Any]:
        """
        Add a reaction to a message.

        Args:
            channel: Channel name or ID
            ts: Message timestamp
            emoji: Reaction emoji name (without colons)

        Returns:
            Reaction response
        """
        self._calls.append(
            {
                "method": "add_reaction",
                "channel": channel,
                "ts": ts,
                "emoji": emoji,
            }
        )

        for msg in self._messages.get(channel, []):
            if msg.get("ts") == ts:
                if "reactions" not in msg:
                    msg["reactions"] = []
                msg["reactions"].append({"name": emoji, "count": 1})
                return {"ok": True}

        return {"ok": False, "error": "message_not_found"}

    async def list_channels(
        self,
        types: str = "public_channel,private_channel",
        limit: int = 100,
    ) -> list[dict[str, Any]]:
        """
        List channels in the workspace.

        Args:
            types: Channel types to include
            limit: Maximum results

        Returns:
            List of channel info
        """
        self._calls.append(
            {
                "method": "list_channels",
                "types": types,
                "limit": limit,
            }
        )

        return list(self._channels.values())[:limit]

    async def get_user_info(self, user_id: str) -> dict[str, Any] | None:
        """
        Get user information.

        Args:
            user_id: User ID

        Returns:
            User info or None
        """
        self._calls.append(
            {
                "method": "get_user_info",
                "user_id": user_id,
            }
        )

        return self._users.get(user_id)

    async def upload_file(
        self,
        channels: str | list[str],
        content: bytes | str,
        filename: str,
        title: str | None = None,
        initial_comment: str | None = None,
    ) -> dict[str, Any]:
        """
        Upload a file to channel(s).

        Args:
            channels: Target channel(s)
            content: File content
            filename: File name
            title: Optional file title
            initial_comment: Optional comment

        Returns:
            Upload response
        """
        self._calls.append(
            {
                "method": "upload_file",
                "channels": channels,
                "filename": filename,
                "title": title,
            }
        )

        file_id = f"F{self._next_ts}"
        self._next_ts += 1

        return {
            "ok": True,
            "file": {
                "id": file_id,
                "name": filename,
                "title": title or filename,
                "channels": channels if isinstance(channels, list) else [channels],
            },
        }


class MockCredentials:
    """
    Factory class for creating mock credential clients.

    Provides convenient static methods to create mock clients for
    various services during testing.

    Example:
        sheets = MockCredentials.google_sheets()
        gmail = MockCredentials.gmail()
        slack = MockCredentials.slack()

        # Use mocks in tests
        data = await sheets.get_spreadsheet_data("https://...")
        await gmail.send_email(to="test@example.com", subject="Test", body="Hello")
        await slack.post_message(channel="general", text="Hello")
    """

    @staticmethod
    def google_sheets(
        mock_data: dict[str, dict[str, list[list[Any]]]] | None = None,
    ) -> MockGoogleSheetsClient:
        """
        Create a mock Google Sheets client.

        Args:
            mock_data: Optional initial spreadsheet data

        Returns:
            MockGoogleSheetsClient instance
        """
        return MockGoogleSheetsClient(mock_data=mock_data)

    @staticmethod
    def gmail(
        mock_messages: list[dict[str, Any]] | None = None,
        user_email: str = "test@example.com",
    ) -> MockGmailClient:
        """
        Create a mock Gmail client.

        Args:
            mock_messages: Optional initial inbox messages
            user_email: Mock user's email address

        Returns:
            MockGmailClient instance
        """
        return MockGmailClient(mock_messages=mock_messages, user_email=user_email)

    @staticmethod
    def slack(
        workspace_name: str = "test-workspace",
        bot_user_id: str = "U_BOT123",
    ) -> MockSlackClient:
        """
        Create a mock Slack client.

        Args:
            workspace_name: Mock workspace name
            bot_user_id: Mock bot user ID

        Returns:
            MockSlackClient instance
        """
        return MockSlackClient(workspace_name=workspace_name, bot_user_id=bot_user_id)

    @staticmethod
    def llm(
        validate_models: bool = False,
        default_prompt_tokens: int = 10,
        default_completion_tokens: int = 20,
    ) -> "MockLLMClient":
        """
        Create a mock LLM client.

        Args:
            validate_models: Whether to validate model names
            default_prompt_tokens: Default prompt tokens per response
            default_completion_tokens: Default completion tokens per response

        Returns:
            MockLLMClient instance
        """
        return MockLLMClient(
            validate_models=validate_models,
            default_prompt_tokens=default_prompt_tokens,
            default_completion_tokens=default_completion_tokens,
        )

    @staticmethod
    def storage(
        execution_id: str = "mock-execution",
        enforce_limits: bool = False,
    ) -> "MockStorageClient":
        """
        Create a mock storage client.

        Args:
            execution_id: Mock execution ID
            enforce_limits: Whether to enforce storage limits

        Returns:
            MockStorageClient instance
        """
        return MockStorageClient(
            execution_id=execution_id,
            enforce_limits=enforce_limits,
        )

    @staticmethod
    def http(
        validate_urls: bool = False,
        enforce_size_limits: bool = False,
    ) -> "MockHttpClient":
        """
        Create a mock HTTP client.

        Args:
            validate_urls: Whether to validate URLs
            enforce_size_limits: Whether to enforce request size limits

        Returns:
            MockHttpClient instance
        """
        return MockHttpClient(
            validate_urls=validate_urls,
            enforce_size_limits=enforce_size_limits,
        )


class MockHttpClient(HttpClient):
    """
    Mock HTTP client for testing.

    Supports URL validation, security checks, response queues, and assertions.

    Example:
        http = MockHttpClient()
        http.add_response(
            url="https://api.example.com/data",
            response=HttpResponse(
                status_code=200,
                headers={"Content-Type": "application/json"},
                body=b'{"status": "ok"}',
                url="https://api.example.com/data"
            )
        )

        response = await http.get("https://api.example.com/data")
        assert response.status_code == 200

    With security validation:
        http = MockHttpClient(validate_urls=True)

        # This will raise HttpSchemeNotAllowedError
        await http.get("http://insecure.example.com")

    With request size limits:
        http = MockHttpClient(enforce_size_limits=True)

        # This will raise HttpRequestTooLargeError
        await http.post("https://api.example.com", data=large_data)
    """

    def __init__(
        self,
        validate_urls: bool = False,
        enforce_size_limits: bool = False,
        max_request_size: int = MAX_REQUEST_SIZE,
        simulated_ips: dict[str, str] | None = None,
    ) -> None:
        """
        Initialize mock HTTP client.

        Args:
            validate_urls: If True, validates URLs (HTTPS requirement)
            enforce_size_limits: If True, enforces request size limits
            max_request_size: Maximum request body size in bytes
            simulated_ips: Dict mapping hostnames to IPs for blocked IP testing
        """
        self._validate_urls = validate_urls
        self._enforce_size_limits = enforce_size_limits
        self._max_request_size = max_request_size
        self._simulated_ips = simulated_ips or {}
        self._responses: dict[str, HttpResponse] = {}
        self._response_queue: list[HttpResponse] = []
        self._calls: list[dict[str, Any]] = []
        self._default_response = HttpResponse(
            status_code=200,
            headers={"Content-Type": "application/json"},
            body=b'{"mock": true}',
            url="",
        )

    def add_response(self, url: str, response: HttpResponse) -> None:
        """Add a mock response for a specific URL."""
        self._responses[url] = response

    def add_queued_response(self, response: HttpResponse) -> None:
        """Add a response to the queue (returned in order regardless of URL)."""
        self._response_queue.append(response)

    def add_queued_responses(self, responses: list[HttpResponse]) -> None:
        """Add multiple responses to the queue."""
        self._response_queue.extend(responses)

    def set_default_response(self, response: HttpResponse) -> None:
        """Set the default response when no matching URL or queue response."""
        self._default_response = response

    def simulate_ip(self, hostname: str, ip: str) -> None:
        """
        Simulate an IP address for a hostname.

        Use this to test blocked IP detection.

        Args:
            hostname: The hostname to simulate
            ip: The IP address to return for this hostname
        """
        self._simulated_ips[hostname] = ip

    @property
    def calls(self) -> list[dict[str, Any]]:
        """Get all recorded calls."""
        return list(self._calls)

    @property
    def get_calls(self) -> list[dict[str, Any]]:
        """Get only GET calls."""
        return [c for c in self._calls if c["method"] == "GET"]

    @property
    def post_calls(self) -> list[dict[str, Any]]:
        """Get only POST calls."""
        return [c for c in self._calls if c["method"] == "POST"]

    @property
    def put_calls(self) -> list[dict[str, Any]]:
        """Get only PUT calls."""
        return [c for c in self._calls if c["method"] == "PUT"]

    @property
    def delete_calls(self) -> list[dict[str, Any]]:
        """Get only DELETE calls."""
        return [c for c in self._calls if c["method"] == "DELETE"]

    @property
    def patch_calls(self) -> list[dict[str, Any]]:
        """Get only PATCH calls."""
        return [c for c in self._calls if c["method"] == "PATCH"]

    def clear_calls(self) -> None:
        """Clear recorded calls."""
        self._calls.clear()

    def clear_responses(self) -> None:
        """Clear all configured responses."""
        self._responses.clear()
        self._response_queue.clear()

    def assert_called(self, method: str | None = None) -> None:
        """
        Assert that the client was called.

        Args:
            method: Optional HTTP method to filter by (GET, POST, etc.)

        Raises:
            AssertionError: If no calls were made
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert len(calls) > 0, f"Expected at least one {method} call"
        else:
            assert len(self._calls) > 0, "Expected at least one call"

    def assert_not_called(self, method: str | None = None) -> None:
        """
        Assert that the client was not called.

        Args:
            method: Optional HTTP method to filter by

        Raises:
            AssertionError: If calls were made
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert len(calls) == 0, f"Expected no {method} calls, got {len(calls)}"
        else:
            assert len(self._calls) == 0, f"Expected no calls, got {len(self._calls)}"

    def assert_call_count(self, expected: int, method: str | None = None) -> None:
        """
        Assert the number of calls made.

        Args:
            expected: Expected number of calls
            method: Optional HTTP method to filter by

        Raises:
            AssertionError: If call count doesn't match
        """
        if method:
            calls = [c for c in self._calls if c["method"] == method]
            assert (
                len(calls) == expected
            ), f"Expected {expected} {method} calls, got {len(calls)}"
        else:
            assert (
                len(self._calls) == expected
            ), f"Expected {expected} calls, got {len(self._calls)}"

    def assert_called_with_url(self, url: str, method: str | None = None) -> None:
        """
        Assert that a specific URL was called.

        Args:
            url: Expected URL
            method: Optional HTTP method to filter by

        Raises:
            AssertionError: If URL was not called
        """
        calls = (
            self._calls
            if method is None
            else [c for c in self._calls if c["method"] == method]
        )
        urls = [c["url"] for c in calls]
        assert url in urls, f"URL '{url}' was not called. Called URLs: {urls}"

    def get_last_call(self, method: str | None = None) -> dict[str, Any] | None:
        """
        Get the last call made.

        Args:
            method: Optional HTTP method to filter by

        Returns:
            Last call dict or None if no calls
        """
        calls = (
            self._calls
            if method is None
            else [c for c in self._calls if c["method"] == method]
        )
        return calls[-1] if calls else None

    def _validate_request(
        self,
        url: str,
        data: bytes | dict[str, Any] | None = None,
    ) -> None:
        """
        Validate request against security rules.

        Args:
            url: Request URL
            data: Request body

        Raises:
            HttpSchemeNotAllowedError: If URL is not HTTPS
            HttpBlockedIPError: If IP is blocked
            HttpRequestTooLargeError: If request body is too large
        """
        if self._validate_urls:
            # This will raise HttpSchemeNotAllowedError for non-HTTPS
            _, hostname, _ = validate_url(url)

            # Check for blocked IPs if hostname is simulated
            if hostname in self._simulated_ips:
                ip = self._simulated_ips[hostname]
                if is_blocked_ip(ip):
                    raise HttpBlockedIPError(url, ip)

        if self._enforce_size_limits and data:
            if isinstance(data, bytes):
                size = len(data)
            elif isinstance(data, dict):
                import json as json_module

                size = len(json_module.dumps(data).encode())
            else:
                size = 0

            if size > self._max_request_size:
                raise HttpRequestTooLargeError(size, self._max_request_size, url)

    def _get_response(self, url: str, method: str) -> HttpResponse:
        """Get the appropriate response for a request."""
        # First check the queue
        if self._response_queue:
            response = self._response_queue.pop(0)
            return HttpResponse(
                status_code=response.status_code,
                headers=response.headers,
                body=response.body,
                url=url,
                elapsed_ms=response.elapsed_ms,
                request_method=method,
            )

        # Then check URL-specific responses
        response = self._responses.get(url, self._default_response)
        return HttpResponse(
            status_code=response.status_code,
            headers=response.headers,
            body=response.body,
            url=url,
            elapsed_ms=response.elapsed_ms,
            request_method=method,
        )

    async def get(
        self,
        url: str,
        headers: dict[str, str] | None = None,
        params: dict[str, Any] | None = None,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> HttpResponse:
        """Make a GET request."""
        self._validate_request(url)

        self._calls.append(
            {
                "method": "GET",
                "url": url,
                "headers": headers,
                "params": params,
                "timeout": timeout,
            }
        )

        return self._get_response(url, "GET")

    async def post(
        self,
        url: str,
        data: bytes | dict[str, Any] | None = None,
        json: Any | None = None,
        headers: dict[str, str] | None = None,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> HttpResponse:
        """Make a POST request."""
        self._validate_request(url, data)

        self._calls.append(
            {
                "method": "POST",
                "url": url,
                "data": data,
                "json": json,
                "headers": headers,
                "timeout": timeout,
            }
        )

        return self._get_response(url, "POST")

    async def put(
        self,
        url: str,
        data: bytes | dict[str, Any] | None = None,
        json: Any | None = None,
        headers: dict[str, str] | None = None,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> HttpResponse:
        """Make a PUT request."""
        self._validate_request(url, data)

        self._calls.append(
            {
                "method": "PUT",
                "url": url,
                "data": data,
                "json": json,
                "headers": headers,
                "timeout": timeout,
            }
        )

        return self._get_response(url, "PUT")

    async def delete(
        self,
        url: str,
        headers: dict[str, str] | None = None,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> HttpResponse:
        """Make a DELETE request."""
        self._validate_request(url)

        self._calls.append(
            {
                "method": "DELETE",
                "url": url,
                "headers": headers,
                "timeout": timeout,
            }
        )

        return self._get_response(url, "DELETE")

    async def patch(
        self,
        url: str,
        data: bytes | dict[str, Any] | None = None,
        json: Any | None = None,
        headers: dict[str, str] | None = None,
        timeout: float = DEFAULT_TIMEOUT,
    ) -> HttpResponse:
        """Make a PATCH request."""
        self._validate_request(url, data)

        self._calls.append(
            {
                "method": "PATCH",
                "url": url,
                "data": data,
                "json": json,
                "headers": headers,
                "timeout": timeout,
            }
        )

        return self._get_response(url, "PATCH")
