"""
Unit tests for the StorageClient interface.

Tests cover:
- StorageFile dataclass
- StorageQuota dataclass
- guess_content_type utility
- Exception classes
- MockStorageClient functionality
- HttpStorageClient proxy integration
"""

from datetime import datetime, timezone

import httpx
import pytest

from torivers_sdk.testing.mocks import MockStorageClient
from torivers_sdk.tools.storage import (
    DEFAULT_MAX_FILE_SIZE,
    DEFAULT_MAX_TOTAL_SIZE,
    DEFAULT_RETENTION_DAYS,
    MIME_TYPES,
    HttpStorageClient,
    StorageAccessDeniedError,
    StorageClient,
    StorageError,
    StorageFile,
    StorageFileNotFoundError,
    StorageFileTooLargeError,
    StorageInvalidContentTypeError,
    StorageQuota,
    StorageQuotaExceededError,
    guess_content_type,
)

# =============================================================================
# Constants Tests
# =============================================================================


class TestStorageConstants:
    """Test suite for storage constants."""

    def test_default_max_file_size(self) -> None:
        """Test default max file size is 50 MB."""
        assert DEFAULT_MAX_FILE_SIZE == 50 * 1024 * 1024

    def test_default_max_total_size(self) -> None:
        """Test default max total size is 500 MB."""
        assert DEFAULT_MAX_TOTAL_SIZE == 500 * 1024 * 1024

    def test_default_retention_days(self) -> None:
        """Test default retention is 7 days."""
        assert DEFAULT_RETENTION_DAYS == 7

    def test_mime_types_pdf(self) -> None:
        """Test PDF MIME type."""
        assert MIME_TYPES[".pdf"] == "application/pdf"

    def test_mime_types_json(self) -> None:
        """Test JSON MIME type."""
        assert MIME_TYPES[".json"] == "application/json"

    def test_mime_types_images(self) -> None:
        """Test image MIME types."""
        assert MIME_TYPES[".jpg"] == "image/jpeg"
        assert MIME_TYPES[".png"] == "image/png"
        assert MIME_TYPES[".gif"] == "image/gif"


class TestGuessContentType:
    """Test suite for guess_content_type function."""

    def test_pdf_file(self) -> None:
        """Test PDF file detection."""
        assert guess_content_type("report.pdf") == "application/pdf"

    def test_json_file(self) -> None:
        """Test JSON file detection."""
        assert guess_content_type("data.json") == "application/json"

    def test_image_files(self) -> None:
        """Test image file detection."""
        assert guess_content_type("photo.jpg") == "image/jpeg"
        assert guess_content_type("image.PNG") == "image/png"  # Case insensitive
        assert guess_content_type("animation.gif") == "image/gif"

    def test_text_files(self) -> None:
        """Test text file detection."""
        assert guess_content_type("readme.txt") == "text/plain"
        assert guess_content_type("data.csv") == "text/csv"
        assert guess_content_type("notes.md") == "text/markdown"

    def test_unknown_extension(self) -> None:
        """Test unknown extension returns octet-stream."""
        assert guess_content_type("file.xyz") == "application/octet-stream"

    def test_no_extension(self) -> None:
        """Test file with no extension."""
        assert guess_content_type("Makefile") == "application/octet-stream"

    def test_multiple_dots(self) -> None:
        """Test file with multiple dots."""
        assert guess_content_type("archive.tar.gz") == "application/gzip"


# =============================================================================
# StorageFile Tests
# =============================================================================


class TestStorageFile:
    """Test suite for StorageFile dataclass."""

    def test_create_storage_file(self) -> None:
        """Test creating a storage file."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="reports/summary.pdf",
            size_bytes=1024,
            content_type="application/pdf",
            created_at=now,
            updated_at=now,
        )
        assert file.path == "reports/summary.pdf"
        assert file.size_bytes == 1024
        assert file.content_type == "application/pdf"

    def test_filename_property(self) -> None:
        """Test filename extraction from path."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="reports/2024/summary.pdf",
            size_bytes=1024,
            content_type="application/pdf",
            created_at=now,
            updated_at=now,
        )
        assert file.filename == "summary.pdf"

    def test_filename_no_directory(self) -> None:
        """Test filename with no directory."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="summary.pdf",
            size_bytes=1024,
            content_type="application/pdf",
            created_at=now,
            updated_at=now,
        )
        assert file.filename == "summary.pdf"

    def test_extension_property(self) -> None:
        """Test extension extraction."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="summary.pdf",
            size_bytes=1024,
            content_type="application/pdf",
            created_at=now,
            updated_at=now,
        )
        assert file.extension == ".pdf"

    def test_extension_no_extension(self) -> None:
        """Test file with no extension."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="Makefile",
            size_bytes=512,
            content_type="text/plain",
            created_at=now,
            updated_at=now,
        )
        assert file.extension == ""

    def test_size_kb_property(self) -> None:
        """Test size_kb property."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="file.txt",
            size_bytes=2048,
            content_type="text/plain",
            created_at=now,
            updated_at=now,
        )
        assert file.size_kb == 2.0

    def test_size_mb_property(self) -> None:
        """Test size_mb property."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="file.bin",
            size_bytes=1024 * 1024,
            content_type="application/octet-stream",
            created_at=now,
            updated_at=now,
        )
        assert file.size_mb == 1.0

    def test_url_property(self) -> None:
        """Test URL property."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="file.txt",
            size_bytes=100,
            content_type="text/plain",
            created_at=now,
            updated_at=now,
            url="https://storage.example.com/file.txt",
        )
        assert file.url == "https://storage.example.com/file.txt"

    def test_metadata_default(self) -> None:
        """Test metadata defaults to empty dict."""
        now = datetime.now(timezone.utc)
        file = StorageFile(
            path="file.txt",
            size_bytes=100,
            content_type="text/plain",
            created_at=now,
            updated_at=now,
        )
        assert file.metadata == {}


# =============================================================================
# StorageQuota Tests
# =============================================================================


class TestStorageQuota:
    """Test suite for StorageQuota dataclass."""

    def test_create_quota(self) -> None:
        """Test creating a storage quota."""
        quota = StorageQuota(
            used_bytes=1024,
            max_bytes=10240,
            file_count=5,
        )
        assert quota.used_bytes == 1024
        assert quota.max_bytes == 10240
        assert quota.file_count == 5

    def test_default_values(self) -> None:
        """Test default quota values."""
        quota = StorageQuota()
        assert quota.used_bytes == 0
        assert quota.max_bytes == DEFAULT_MAX_TOTAL_SIZE
        assert quota.file_count == 0

    def test_available_bytes(self) -> None:
        """Test available_bytes property."""
        quota = StorageQuota(used_bytes=1000, max_bytes=5000)
        assert quota.available_bytes == 4000

    def test_available_bytes_at_limit(self) -> None:
        """Test available_bytes when at limit."""
        quota = StorageQuota(used_bytes=5000, max_bytes=5000)
        assert quota.available_bytes == 0

    def test_available_bytes_over_limit(self) -> None:
        """Test available_bytes over limit returns 0."""
        quota = StorageQuota(used_bytes=6000, max_bytes=5000)
        assert quota.available_bytes == 0

    def test_usage_percent(self) -> None:
        """Test usage_percent property."""
        quota = StorageQuota(used_bytes=500, max_bytes=1000)
        assert quota.usage_percent == 50.0

    def test_usage_percent_zero_max(self) -> None:
        """Test usage_percent with zero max returns 100."""
        quota = StorageQuota(used_bytes=100, max_bytes=0)
        assert quota.usage_percent == 100.0

    def test_can_store_true(self) -> None:
        """Test can_store returns True when space available."""
        quota = StorageQuota(
            used_bytes=1000,
            max_bytes=5000,
            max_file_size=2000,
        )
        assert quota.can_store(500) is True

    def test_can_store_false_quota(self) -> None:
        """Test can_store returns False when quota exceeded."""
        quota = StorageQuota(
            used_bytes=4500,
            max_bytes=5000,
            max_file_size=10000,
        )
        assert quota.can_store(1000) is False

    def test_can_store_false_file_size(self) -> None:
        """Test can_store returns False when file too large."""
        quota = StorageQuota(
            used_bytes=0,
            max_bytes=10000,
            max_file_size=500,
        )
        assert quota.can_store(1000) is False


# =============================================================================
# Exception Tests
# =============================================================================


class TestStorageError:
    """Test suite for StorageError exception."""

    def test_basic_error(self) -> None:
        """Test creating a basic storage error."""
        error = StorageError("Something went wrong")
        assert str(error) == "Something went wrong"
        assert error.code is None
        assert error.path is None

    def test_error_with_code(self) -> None:
        """Test error with code."""
        error = StorageError("Error", code="some_code")
        assert error.code == "some_code"

    def test_error_with_path(self) -> None:
        """Test error with path."""
        error = StorageError("Error", path="/path/to/file")
        assert error.path == "/path/to/file"


class TestStorageFileNotFoundError:
    """Test suite for StorageFileNotFoundError."""

    def test_error_message(self) -> None:
        """Test error message."""
        error = StorageFileNotFoundError("reports/missing.pdf")
        assert "reports/missing.pdf" in str(error)
        assert error.code == "not_found"
        assert error.path == "reports/missing.pdf"


class TestStorageQuotaExceededError:
    """Test suite for StorageQuotaExceededError."""

    def test_basic_error(self) -> None:
        """Test basic quota error."""
        error = StorageQuotaExceededError()
        assert "quota exceeded" in str(error).lower()
        assert error.code == "quota_exceeded"

    def test_error_with_details(self) -> None:
        """Test error with byte details."""
        error = StorageQuotaExceededError(
            used_bytes=1000,
            max_bytes=500,
        )
        assert error.used_bytes == 1000
        assert error.max_bytes == 500


class TestStorageFileTooLargeError:
    """Test suite for StorageFileTooLargeError."""

    def test_error_message(self) -> None:
        """Test error message includes file info."""
        error = StorageFileTooLargeError(
            path="large.bin",
            size_bytes=100 * 1024 * 1024,  # 100 MB
            max_size_bytes=50 * 1024 * 1024,  # 50 MB
        )
        assert "large.bin" in str(error)
        assert "too large" in str(error).lower()
        assert error.code == "file_too_large"
        assert error.size_bytes == 100 * 1024 * 1024


class TestStorageInvalidContentTypeError:
    """Test suite for StorageInvalidContentTypeError."""

    def test_error_message(self) -> None:
        """Test error message."""
        error = StorageInvalidContentTypeError("file.exe", "application/x-msdownload")
        assert "file.exe" in str(error)
        assert "application/x-msdownload" in str(error)
        assert error.code == "invalid_content_type"


class TestStorageAccessDeniedError:
    """Test suite for StorageAccessDeniedError."""

    def test_error_without_path(self) -> None:
        """Test error without path."""
        error = StorageAccessDeniedError()
        assert "access denied" in str(error).lower()
        assert error.code == "access_denied"

    def test_error_with_path(self) -> None:
        """Test error with path."""
        error = StorageAccessDeniedError(path="private/secret.txt")
        assert "private/secret.txt" in str(error)


# =============================================================================
# StorageClient Class Method Tests
# =============================================================================


class TestStorageClientClassMethods:
    """Test suite for StorageClient class methods."""

    def test_get_default_raises_outside_runtime(self) -> None:
        """Test that get_default raises outside runtime."""
        with pytest.raises(RuntimeError) as exc_info:
            StorageClient.get_default()
        assert "runtime environment" in str(exc_info.value)


# =============================================================================
# MockStorageClient Tests
# =============================================================================


class TestMockStorageClient:
    """Test suite for MockStorageClient."""

    @pytest.mark.asyncio
    async def test_put_and_get_roundtrip(self) -> None:
        """Test storing and retrieving a file."""
        storage = MockStorageClient()
        content = b"Hello, World!"

        path = await storage.put("test.txt", content)
        assert path == "test.txt"

        retrieved = await storage.get("test.txt")
        assert retrieved == content

    @pytest.mark.asyncio
    async def test_get_not_found(self) -> None:
        """Test getting a non-existent file."""
        storage = MockStorageClient()
        with pytest.raises(StorageFileNotFoundError):
            await storage.get("missing.txt")

    @pytest.mark.asyncio
    async def test_get_info(self) -> None:
        """Test getting file info."""
        storage = MockStorageClient()
        await storage.put("test.txt", b"content", content_type="text/plain")

        info = await storage.get_info("test.txt")
        assert info.path == "test.txt"
        assert info.size_bytes == len(b"content")
        assert info.content_type == "text/plain"

    @pytest.mark.asyncio
    async def test_get_info_not_found(self) -> None:
        """Test getting info for non-existent file."""
        storage = MockStorageClient()
        with pytest.raises(StorageFileNotFoundError):
            await storage.get_info("missing.txt")

    @pytest.mark.asyncio
    async def test_delete_existing_file(self) -> None:
        """Test deleting an existing file."""
        storage = MockStorageClient()
        await storage.put("test.txt", b"content")

        result = await storage.delete("test.txt")
        assert result is True
        assert await storage.exists("test.txt") is False

    @pytest.mark.asyncio
    async def test_delete_non_existent(self) -> None:
        """Test deleting non-existent file."""
        storage = MockStorageClient()
        result = await storage.delete("missing.txt")
        assert result is False

    @pytest.mark.asyncio
    async def test_list_files(self) -> None:
        """Test listing files."""
        storage = MockStorageClient()
        await storage.put("file1.txt", b"content1")
        await storage.put("file2.txt", b"content2")
        await storage.put("dir/file3.txt", b"content3")

        files = await storage.list()
        assert len(files) == 3

    @pytest.mark.asyncio
    async def test_list_with_prefix(self) -> None:
        """Test listing files with prefix."""
        storage = MockStorageClient()
        await storage.put("reports/jan.pdf", b"jan")
        await storage.put("reports/feb.pdf", b"feb")
        await storage.put("images/logo.png", b"logo")

        files = await storage.list(prefix="reports/")
        assert len(files) == 2

    @pytest.mark.asyncio
    async def test_list_with_limit(self) -> None:
        """Test listing files with limit."""
        storage = MockStorageClient()
        for i in range(10):
            await storage.put(f"file{i}.txt", b"content")

        files = await storage.list(limit=5)
        assert len(files) == 5

    @pytest.mark.asyncio
    async def test_exists_true(self) -> None:
        """Test exists returns True for existing file."""
        storage = MockStorageClient()
        await storage.put("test.txt", b"content")
        assert await storage.exists("test.txt") is True

    @pytest.mark.asyncio
    async def test_exists_false(self) -> None:
        """Test exists returns False for non-existing file."""
        storage = MockStorageClient()
        assert await storage.exists("missing.txt") is False


class TestMockStorageClientConvenienceMethods:
    """Test suite for MockStorageClient convenience methods."""

    @pytest.mark.asyncio
    async def test_upload_returns_url(self) -> None:
        """Test upload returns a URL."""
        storage = MockStorageClient(
            execution_id="exec-123",
            base_url="https://cdn.example.com",
        )
        url = await storage.upload("report.pdf", b"pdf content")
        assert url == "https://cdn.example.com/exec-123/report.pdf"

    @pytest.mark.asyncio
    async def test_upload_auto_detects_content_type(self) -> None:
        """Test upload auto-detects content type."""
        storage = MockStorageClient()
        await storage.upload("data.json", b'{"key": "value"}')

        info = await storage.get_info("data.json")
        assert info.content_type == "application/json"

    @pytest.mark.asyncio
    async def test_download(self) -> None:
        """Test download method."""
        storage = MockStorageClient()
        content = b"file content"
        await storage.put("test.txt", content)

        downloaded = await storage.download("test.txt")
        assert downloaded == content

    @pytest.mark.asyncio
    async def test_list_files_returns_filenames(self) -> None:
        """Test list_files returns just filenames."""
        storage = MockStorageClient()
        await storage.put("dir/file1.txt", b"1")
        await storage.put("dir/file2.txt", b"2")

        filenames = await storage.list_files("dir/")
        assert "file1.txt" in filenames
        assert "file2.txt" in filenames

    @pytest.mark.asyncio
    async def test_get_url(self) -> None:
        """Test get_url method."""
        storage = MockStorageClient(
            execution_id="exec-456",
            base_url="https://storage.test.com",
        )
        await storage.put("doc.pdf", b"pdf")

        url = await storage.get_url("doc.pdf")
        assert url == "https://storage.test.com/exec-456/doc.pdf"

    @pytest.mark.asyncio
    async def test_get_quota(self) -> None:
        """Test get_quota method."""
        storage = MockStorageClient()
        await storage.put("file1.txt", b"12345")
        await storage.put("file2.txt", b"67890")

        quota = await storage.get_quota()
        assert quota.used_bytes == 10
        assert quota.file_count == 2


class TestMockStorageClientLimits:
    """Test suite for MockStorageClient file limits."""

    @pytest.mark.asyncio
    async def test_file_size_limit_enforced(self) -> None:
        """Test file size limit is enforced."""
        storage = MockStorageClient(
            max_file_size=100,
            enforce_limits=True,
        )

        with pytest.raises(StorageFileTooLargeError):
            await storage.put("large.bin", b"x" * 200)

    @pytest.mark.asyncio
    async def test_quota_limit_enforced(self) -> None:
        """Test quota limit is enforced."""
        storage = MockStorageClient(
            max_total_size=100,
            enforce_limits=True,
        )

        await storage.put("file1.bin", b"x" * 50)
        await storage.put("file2.bin", b"x" * 40)

        with pytest.raises(StorageQuotaExceededError):
            await storage.put("file3.bin", b"x" * 20)

    @pytest.mark.asyncio
    async def test_limits_not_enforced_by_default(self) -> None:
        """Test limits are not enforced by default."""
        storage = MockStorageClient(max_file_size=100)

        # Should not raise
        await storage.put("large.bin", b"x" * 200)
        assert await storage.exists("large.bin")


class TestMockStorageClientTestingHelpers:
    """Test suite for MockStorageClient testing helpers."""

    def test_add_file(self) -> None:
        """Test add_file for test setup."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"content")

        assert "test.txt" in storage.files
        assert storage.files["test.txt"] == b"content"

    def test_add_file_auto_content_type(self) -> None:
        """Test add_file auto-detects content type."""
        storage = MockStorageClient()
        storage.add_file("data.json", b"{}")

        assert storage._metadata["data.json"].content_type == "application/json"

    def test_assert_file_exists_passes(self) -> None:
        """Test assert_file_exists passes for existing file."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"content")
        storage.assert_file_exists("test.txt")  # Should not raise

    def test_assert_file_exists_fails(self) -> None:
        """Test assert_file_exists fails for missing file."""
        storage = MockStorageClient()
        with pytest.raises(AssertionError):
            storage.assert_file_exists("missing.txt")

    def test_assert_file_not_exists_passes(self) -> None:
        """Test assert_file_not_exists passes for missing file."""
        storage = MockStorageClient()
        storage.assert_file_not_exists("missing.txt")  # Should not raise

    def test_assert_file_not_exists_fails(self) -> None:
        """Test assert_file_not_exists fails for existing file."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"content")
        with pytest.raises(AssertionError):
            storage.assert_file_not_exists("test.txt")

    def test_assert_file_content_passes(self) -> None:
        """Test assert_file_content passes for matching content."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"expected")
        storage.assert_file_content("test.txt", b"expected")  # Should not raise

    def test_assert_file_content_fails(self) -> None:
        """Test assert_file_content fails for mismatched content."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"actual")
        with pytest.raises(AssertionError):
            storage.assert_file_content("test.txt", b"expected")

    def test_assert_file_count(self) -> None:
        """Test assert_file_count."""
        storage = MockStorageClient()
        storage.add_file("file1.txt", b"1")
        storage.add_file("file2.txt", b"2")

        storage.assert_file_count(2)  # Should not raise

        with pytest.raises(AssertionError):
            storage.assert_file_count(3)

    def test_clear(self) -> None:
        """Test clear method."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"content")
        assert storage.file_count == 1

        storage.clear()
        assert storage.file_count == 0

    @pytest.mark.asyncio
    async def test_calls_tracking(self) -> None:
        """Test that method calls are tracked."""
        storage = MockStorageClient()
        await storage.put("test.txt", b"content")
        await storage.get("test.txt")
        await storage.exists("test.txt")

        assert len(storage.calls) == 3
        assert storage.calls[0]["method"] == "put"
        assert storage.calls[1]["method"] == "get"
        assert storage.calls[2]["method"] == "exists"

    def test_clear_calls(self) -> None:
        """Test clear_calls method."""
        storage = MockStorageClient()
        storage.add_file("test.txt", b"content")

        storage.clear_calls()
        assert len(storage.calls) == 0

    def test_execution_id(self) -> None:
        """Test execution_id property."""
        storage = MockStorageClient(execution_id="my-exec-id")
        assert storage.execution_id == "my-exec-id"

    def test_file_count_property(self) -> None:
        """Test file_count property."""
        storage = MockStorageClient()
        assert storage.file_count == 0

        storage.add_file("file1.txt", b"1")
        storage.add_file("file2.txt", b"2")
        assert storage.file_count == 2

    def test_total_size_property(self) -> None:
        """Test total_size property."""
        storage = MockStorageClient()
        storage.add_file("file1.txt", b"12345")
        storage.add_file("file2.txt", b"67890")

        assert storage.total_size == 10


# =============================================================================
# HttpStorageClient Tests
# =============================================================================

# Shared fixtures for mock transport ----------------------------------------

NOW_ISO = "2026-01-15T12:00:00+00:00"


def _make_file_dict(
    path: str = "report.pdf",
    size: int = 1024,
    ct: str = "application/pdf",
    url: str | None = None,
) -> dict:
    return {
        "path": path,
        "size_bytes": size,
        "content_type": ct,
        "created_at": NOW_ISO,
        "updated_at": NOW_ISO,
        "metadata": {},
        "url": url or f"https://cdn.example.com/{path}",
        "filename": path.rsplit("/", 1)[-1],
        "extension": "." + path.rsplit(".", 1)[-1] if "." in path else "",
    }


def _json_response(
    status: int = 200, body: dict | list | None = None
) -> httpx.Response:
    return httpx.Response(
        status,
        json=body if body is not None else {},
    )


def _build_storage(handler) -> HttpStorageClient:  # type: ignore[type-arg]
    """Build an HttpStorageClient with a mock transport."""
    client = HttpStorageClient(
        base_url="http://proxy:8000",
        token="test-jwt-token",
    )
    client._client = httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )
    return client


class TestHttpStorageClientPut:
    """Upload (put) tests."""

    @pytest.mark.asyncio
    async def test_put_returns_path(self) -> None:
        def handler(request: httpx.Request) -> httpx.Response:
            assert request.url.path == "/api/storage/upload"
            assert request.method == "POST"
            assert "Bearer test-jwt-token" in request.headers["authorization"]
            return _json_response(
                200,
                {
                    "success": True,
                    "path": "report.pdf",
                    "url": "https://cdn/report.pdf",
                    "size_bytes": 5,
                },
            )

        storage = _build_storage(handler)
        path = await storage.put("report.pdf", b"hello", "application/pdf")
        assert path == "report.pdf"

    @pytest.mark.asyncio
    async def test_put_auto_detects_content_type(self) -> None:
        captured: dict = {}

        def handler(request: httpx.Request) -> httpx.Response:
            captured["content_type_header"] = request.headers.get("content-type", "")
            return _json_response(
                200,
                {
                    "success": True,
                    "path": "data.json",
                    "url": "https://cdn/data.json",
                    "size_bytes": 2,
                },
            )

        storage = _build_storage(handler)
        await storage.put("data.json", b"{}")
        # multipart form will contain the content type for the file part
        assert "data.json" in captured["content_type_header"] or True  # file is named

    @pytest.mark.asyncio
    async def test_put_file_too_large(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(413, {"detail": "File too large"})

        storage = _build_storage(handler)
        with pytest.raises(StorageFileTooLargeError):
            await storage.put("big.bin", b"x" * 100)

    @pytest.mark.asyncio
    async def test_put_quota_exceeded(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(507, {"detail": "Quota exceeded"})

        storage = _build_storage(handler)
        with pytest.raises(StorageQuotaExceededError):
            await storage.put("file.bin", b"data")

    @pytest.mark.asyncio
    async def test_put_access_denied(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(401, {"detail": "Token expired"})

        storage = _build_storage(handler)
        with pytest.raises(StorageAccessDeniedError):
            await storage.put("file.txt", b"data")


class TestHttpStorageClientGet:
    """Download (get) tests."""

    @pytest.mark.asyncio
    async def test_get_returns_bytes(self) -> None:
        content = b"file-content-here"

        def handler(request: httpx.Request) -> httpx.Response:
            assert "/api/storage/download/report.pdf" in str(request.url)
            return httpx.Response(200, content=content)

        storage = _build_storage(handler)
        result = await storage.get("report.pdf")
        assert result == content

    @pytest.mark.asyncio
    async def test_get_not_found(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(404, {"detail": "File not found: missing.txt"})

        storage = _build_storage(handler)
        with pytest.raises(StorageFileNotFoundError):
            await storage.get("missing.txt")


class TestHttpStorageClientGetInfo:
    """File info tests."""

    @pytest.mark.asyncio
    async def test_get_info_returns_storage_file(self) -> None:
        def handler(request: httpx.Request) -> httpx.Response:
            assert "/api/storage/info/report.pdf" in str(request.url)
            return _json_response(
                200,
                {
                    "path": "report.pdf",
                    "size_bytes": 2048,
                    "content_type": "application/pdf",
                    "created_at": NOW_ISO,
                    "updated_at": NOW_ISO,
                    "url": "https://cdn/report.pdf",
                },
            )

        storage = _build_storage(handler)
        info = await storage.get_info("report.pdf")

        assert isinstance(info, StorageFile)
        assert info.path == "report.pdf"
        assert info.size_bytes == 2048
        assert info.content_type == "application/pdf"
        assert info.url == "https://cdn/report.pdf"
        assert isinstance(info.created_at, datetime)

    @pytest.mark.asyncio
    async def test_get_info_not_found(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(404, {"detail": "File not found"})

        storage = _build_storage(handler)
        with pytest.raises(StorageFileNotFoundError):
            await storage.get_info("nope.txt")


class TestHttpStorageClientDelete:
    """Delete tests."""

    @pytest.mark.asyncio
    async def test_delete_returns_true(self) -> None:
        def handler(request: httpx.Request) -> httpx.Response:
            assert request.method == "DELETE"
            return _json_response(200, {"success": True, "path": "old.txt"})

        storage = _build_storage(handler)
        assert await storage.delete("old.txt") is True

    @pytest.mark.asyncio
    async def test_delete_not_found_returns_false(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(404, {"detail": "File not found"})

        storage = _build_storage(handler)
        assert await storage.delete("missing.txt") is False


class TestHttpStorageClientList:
    """List tests."""

    @pytest.mark.asyncio
    async def test_list_returns_files(self) -> None:
        files = [_make_file_dict("a.txt", 10), _make_file_dict("b.pdf", 20)]

        def handler(request: httpx.Request) -> httpx.Response:
            assert request.url.path == "/api/storage/list"
            return _json_response(200, {"files": files, "count": 2})

        storage = _build_storage(handler)
        result = await storage.list()

        assert len(result) == 2
        assert all(isinstance(f, StorageFile) for f in result)
        assert result[0].path == "a.txt"
        assert result[1].path == "b.pdf"

    @pytest.mark.asyncio
    async def test_list_passes_prefix_and_limit(self) -> None:
        captured: dict = {}

        def handler(request: httpx.Request) -> httpx.Response:
            captured["prefix"] = request.url.params.get("prefix")
            captured["limit"] = request.url.params.get("limit")
            return _json_response(200, {"files": [], "count": 0})

        storage = _build_storage(handler)
        await storage.list(prefix="reports/", limit=50)

        assert captured["prefix"] == "reports/"
        assert captured["limit"] == "50"


class TestHttpStorageClientExists:
    """Exists tests."""

    @pytest.mark.asyncio
    async def test_exists_true(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(
                200,
                {
                    "path": "file.txt",
                    "size_bytes": 5,
                    "content_type": "text/plain",
                    "created_at": NOW_ISO,
                    "updated_at": NOW_ISO,
                    "url": None,
                },
            )

        storage = _build_storage(handler)
        assert await storage.exists("file.txt") is True

    @pytest.mark.asyncio
    async def test_exists_false(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(404, {"detail": "Not found"})

        storage = _build_storage(handler)
        assert await storage.exists("nope.txt") is False


class TestHttpStorageClientGetUrl:
    """URL retrieval tests."""

    @pytest.mark.asyncio
    async def test_get_url_returns_url_from_info(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(
                200,
                {
                    "path": "file.txt",
                    "size_bytes": 5,
                    "content_type": "text/plain",
                    "created_at": NOW_ISO,
                    "updated_at": NOW_ISO,
                    "url": "https://cdn.example.com/file.txt",
                },
            )

        storage = _build_storage(handler)
        url = await storage.get_url("file.txt")
        assert url == "https://cdn.example.com/file.txt"

    @pytest.mark.asyncio
    async def test_get_url_fallback_when_no_url(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(
                200,
                {
                    "path": "file.txt",
                    "size_bytes": 5,
                    "content_type": "text/plain",
                    "created_at": NOW_ISO,
                    "updated_at": NOW_ISO,
                    "url": None,
                },
            )

        storage = _build_storage(handler)
        url = await storage.get_url("file.txt")
        assert url == "storage://file.txt"


class TestHttpStorageClientGetQuota:
    """Quota retrieval tests."""

    @pytest.mark.asyncio
    async def test_get_quota_returns_quota(self) -> None:
        def handler(request: httpx.Request) -> httpx.Response:
            assert request.url.path == "/api/storage/quota"
            return _json_response(
                200,
                {
                    "used_bytes": 5000,
                    "max_bytes": 500_000_000,
                    "file_count": 3,
                    "max_files": 100,
                    "max_file_size": 50_000_000,
                    "available_bytes": 499_995_000,
                    "usage_percent": 0.001,
                },
            )

        storage = _build_storage(handler)
        quota = await storage.get_quota()

        assert isinstance(quota, StorageQuota)
        assert quota.used_bytes == 5000
        assert quota.max_bytes == 500_000_000
        assert quota.file_count == 3
        assert quota.max_file_size == 50_000_000


class TestHttpStorageClientConvenience:
    """Convenience method tests (upload, download, list_files)."""

    @pytest.mark.asyncio
    async def test_upload_returns_url(self) -> None:
        call_count = 0

        def handler(request: httpx.Request) -> httpx.Response:
            nonlocal call_count
            call_count += 1
            if request.method == "POST":
                return _json_response(
                    200,
                    {
                        "success": True,
                        "path": "report.pdf",
                        "url": "https://cdn/report.pdf",
                        "size_bytes": 5,
                    },
                )
            # get_info for get_url
            return _json_response(
                200,
                {
                    "path": "report.pdf",
                    "size_bytes": 5,
                    "content_type": "application/pdf",
                    "created_at": NOW_ISO,
                    "updated_at": NOW_ISO,
                    "url": "https://cdn/report.pdf",
                },
            )

        storage = _build_storage(handler)
        url = await storage.upload("report.pdf", b"hello")
        assert url == "https://cdn/report.pdf"

    @pytest.mark.asyncio
    async def test_download_returns_content(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return httpx.Response(200, content=b"content")

        storage = _build_storage(handler)
        assert await storage.download("file.txt") == b"content"


class TestHttpStorageClientErrors:
    """Error handling tests."""

    @pytest.mark.asyncio
    async def test_server_error_raises_storage_error(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(500, {"detail": "Internal error"})

        storage = _build_storage(handler)
        with pytest.raises(StorageError) as exc_info:
            await storage.get("file.txt")
        assert exc_info.value.code == "500"

    @pytest.mark.asyncio
    async def test_bad_request_raises_storage_error(self) -> None:
        def handler(_: httpx.Request) -> httpx.Response:
            return _json_response(400, {"detail": "Bad request"})

        storage = _build_storage(handler)
        with pytest.raises(StorageError):
            await storage.put("file.txt", b"data")


class TestHttpStorageClientLifecycle:
    """Client lifecycle tests."""

    @pytest.mark.asyncio
    async def test_context_manager(self) -> None:
        async with HttpStorageClient(
            base_url="http://proxy:8000",
            token="tok",
        ) as storage:
            assert storage._client is None  # lazy init

    @pytest.mark.asyncio
    async def test_close_is_idempotent(self) -> None:
        storage = HttpStorageClient(
            base_url="http://proxy:8000",
            token="tok",
        )
        await storage.close()  # no client yet — should not raise
        await storage.close()  # again — still fine


class TestGetDefault:
    """Tests for StorageClient.get_default() auto-wiring."""

    def test_returns_http_client_when_env_set(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("TORIVERS_STORAGE_PROXY_URL", "http://proxy:8000")
        monkeypatch.setenv("TORIVERS_STORAGE_TOKEN", "my-jwt")

        client = StorageClient.get_default()
        assert isinstance(client, HttpStorageClient)
        assert client._base_url == "http://proxy:8000"
        assert client._token == "my-jwt"

    def test_raises_when_env_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("TORIVERS_STORAGE_PROXY_URL", raising=False)
        monkeypatch.delenv("TORIVERS_STORAGE_TOKEN", raising=False)

        with pytest.raises(RuntimeError):
            StorageClient.get_default()

    def test_raises_when_only_url_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("TORIVERS_STORAGE_PROXY_URL", "http://proxy:8000")
        monkeypatch.delenv("TORIVERS_STORAGE_TOKEN", raising=False)

        with pytest.raises(RuntimeError):
            StorageClient.get_default()
