"""Tests for the Presa Python SDK."""

from __future__ import annotations

import json
from io import BytesIO
from unittest.mock import MagicMock, patch
from urllib.error import HTTPError

import pytest

from presa import PDF, ApiError, AuthenticationError, Presa, PresaError, TimeoutError
from presa.client import _build_request_body
from presa.types import (
    AssetInfo,
    AssetListResponse,
    AsyncJobResponse,
    BatchItem,
    BatchResult,
    BatchStatus,
    CustomFormat,
    GenerateOptions,
    GenerateResult,
    JobStatus,
    Margins,
    PdfMetadata,
    PresaConfig,
    StoredResponse,
)


class TestPresaConstructor:
    def test_raises_when_no_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        with pytest.raises(AuthenticationError, match="PRESA_API_KEY"):
            Presa()

    def test_error_message_includes_signup_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        with pytest.raises(AuthenticationError, match="https://presa.dev"):
            Presa()

    def test_error_message_includes_vercel(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        with pytest.raises(AuthenticationError, match="Vercel Marketplace"):
            Presa()

    def test_reads_api_key_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("PRESA_API_KEY", "test-key")
        client = Presa()
        assert client._api_key == "test-key"

    def test_accepts_api_key_via_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        client = Presa(PresaConfig(api_key="config-key"))
        assert client._api_key == "config-key"

    def test_config_api_key_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setenv("PRESA_API_KEY", "env-key")
        client = Presa(PresaConfig(api_key="config-key"))
        assert client._api_key == "config-key"

    def test_strips_trailing_slashes_from_base_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        client = Presa(PresaConfig(api_key="key", base_url="http://localhost:8080///"))
        assert client._base_url == "http://localhost:8080"


def _mock_urlopen(data: bytes) -> MagicMock:
    """Create a mock for urllib.request.urlopen that returns given data."""
    mock_response = MagicMock()
    mock_response.read.return_value = data
    mock_response.__enter__ = MagicMock(return_value=mock_response)
    mock_response.__exit__ = MagicMock(return_value=False)
    return mock_response


def _mock_json_urlopen(data: dict) -> MagicMock:
    """Create a mock for urllib.request.urlopen that returns JSON data."""
    return _mock_urlopen(json.dumps(data).encode("utf-8"))


class TestPresaGenerate:
    @patch("presa.client.urllib.request.urlopen")
    def test_sends_correct_request(self, mock_urlopen: MagicMock) -> None:
        pdf_bytes = b"%PDF-1.4 test"
        mock_urlopen.return_value = _mock_urlopen(pdf_bytes)

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.generate(GenerateOptions(html="<h1>Hello</h1>"))

        assert mock_urlopen.called
        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/pdf/from-html"
        assert request.get_header("Content-type") == "application/json"
        assert request.get_header("Authorization") == "Bearer test-key"
        assert request.method == "POST"

        body = json.loads(request.data.decode("utf-8"))
        assert body == {"html": "<h1>Hello</h1>"}

        assert result.buffer == pdf_bytes
        assert result.size == len(pdf_bytes)

    @patch("presa.client.urllib.request.urlopen")
    def test_sends_all_options(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_urlopen(b"%PDF")

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        client.generate(
            GenerateOptions(
                html="<h1>Hello</h1>",
                format="A4",
                landscape=True,
                margins=Margins(top="2cm", right="1cm", bottom="2cm", left="1cm"),
                print_background=False,
                scale=1.5,
            )
        )

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body == {
            "html": "<h1>Hello</h1>",
            "format": "A4",
            "landscape": True,
            "margins": {"top": "2cm", "right": "1cm", "bottom": "2cm", "left": "1cm"},
            "printBackground": False,
            "scale": 1.5,
        }

    @patch("presa.client.urllib.request.urlopen")
    def test_custom_format(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_urlopen(b"%PDF")

        client = Presa(PresaConfig(api_key="test-key"))
        client.generate(
            GenerateOptions(
                html="<h1>Hello</h1>",
                format=CustomFormat(width="210mm", height="297mm"),
            )
        )

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["format"] == {"width": "210mm", "height": "297mm"}

    @patch("presa.client.urllib.request.urlopen")
    def test_api_error_response(self, mock_urlopen: MagicMock) -> None:
        error_body = json.dumps({
            "error": {
                "code": "QUOTA_EXCEEDED",
                "message": "Monthly quota exceeded",
                "status": 403,
            }
        }).encode("utf-8")
        mock_urlopen.side_effect = HTTPError(
            url="http://localhost/v1/pdf/from-html",
            code=403,
            msg="Forbidden",
            hdrs=MagicMock(),  # type: ignore[arg-type]
            fp=BytesIO(error_body),
        )

        client = Presa(PresaConfig(api_key="test-key"))
        with pytest.raises(ApiError) as exc_info:
            client.generate(GenerateOptions(html="<h1>Hello</h1>"))

        assert exc_info.value.code == "QUOTA_EXCEEDED"
        assert exc_info.value.status == 403
        assert "Monthly quota exceeded" in str(exc_info.value)

    @patch("presa.client.urllib.request.urlopen")
    def test_401_raises_authentication_error(self, mock_urlopen: MagicMock) -> None:
        error_body = json.dumps({
            "error": {
                "code": "UNAUTHORIZED",
                "message": "Invalid API key",
                "status": 401,
            }
        }).encode("utf-8")
        mock_urlopen.side_effect = HTTPError(
            url="http://localhost/v1/pdf/from-html",
            code=401,
            msg="Unauthorized",
            hdrs=MagicMock(),  # type: ignore[arg-type]
            fp=BytesIO(error_body),
        )

        client = Presa(PresaConfig(api_key="bad-key"))
        with pytest.raises(AuthenticationError):
            client.generate(GenerateOptions(html="<h1>Hello</h1>"))

    @patch("presa.client.urllib.request.urlopen")
    def test_omits_undefined_optional_fields(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_urlopen(b"%PDF")

        client = Presa(PresaConfig(api_key="test-key"))
        client.generate(GenerateOptions(html="<h1>Hello</h1>"))

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert list(body.keys()) == ["html"]


class TestPDFNamespace:
    @patch("presa.client.urllib.request.urlopen")
    def test_generates_pdf_from_env(
        self, mock_urlopen: MagicMock, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("PRESA_API_KEY", "test-key")
        pdf_bytes = b"%PDF-1.4"
        mock_urlopen.return_value = _mock_urlopen(pdf_bytes)

        result = PDF.generate(html="<h1>Hello</h1>")
        assert result.buffer == pdf_bytes
        assert result.size == len(pdf_bytes)

    def test_raises_when_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv("PRESA_API_KEY", raising=False)
        with pytest.raises(AuthenticationError):
            PDF.generate(html="<h1>Hello</h1>")


class TestBuildRequestBody:
    def test_minimal(self) -> None:
        body = _build_request_body(GenerateOptions(html="<h1>Hello</h1>"))
        assert body == {"html": "<h1>Hello</h1>"}

    def test_margins_omits_none_values(self) -> None:
        body = _build_request_body(
            GenerateOptions(html="<h1>Hello</h1>", margins=Margins(top="1cm"))
        )
        assert body["margins"] == {"top": "1cm"}

    def test_metadata(self) -> None:
        body = _build_request_body(
            GenerateOptions(
                html="<h1>Hello</h1>",
                metadata=PdfMetadata(title="Test", author="Author"),
            )
        )
        assert body["metadata"] == {"title": "Test", "author": "Author"}

    def test_metadata_omits_none_values(self) -> None:
        body = _build_request_body(
            GenerateOptions(
                html="<h1>Hello</h1>",
                metadata=PdfMetadata(title="Test"),
            )
        )
        assert body["metadata"] == {"title": "Test"}
        assert "author" not in body["metadata"]

    def test_async_mode(self) -> None:
        body = _build_request_body(
            GenerateOptions(html="<h1>Hello</h1>", async_mode=True)
        )
        assert body["async"] is True

    def test_store(self) -> None:
        body = _build_request_body(
            GenerateOptions(html="<h1>Hello</h1>", store=True)
        )
        assert body["store"] is True


class TestErrorClasses:
    def test_presa_error_is_exception(self) -> None:
        err = PresaError("test")
        assert isinstance(err, Exception)

    def test_authentication_error_extends_presa_error(self) -> None:
        err = AuthenticationError()
        assert isinstance(err, PresaError)

    def test_api_error_includes_code_and_status(self) -> None:
        err = ApiError(code="TEST_ERROR", message="Test message", status=422)
        assert err.code == "TEST_ERROR"
        assert err.status == 422
        assert "Test message" in str(err)

    def test_timeout_error_includes_duration(self) -> None:
        err = TimeoutError(5.0)
        assert "5.0s" in str(err)


class TestPdfMetadata:
    @patch("presa.client.urllib.request.urlopen")
    def test_generate_with_metadata(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_urlopen(b"%PDF")

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        client.generate(
            GenerateOptions(
                html="<h1>Hello</h1>",
                metadata=PdfMetadata(
                    title="My Doc",
                    author="Test Author",
                    subject="Testing",
                    keywords="test,pdf",
                    creator="Presa SDK",
                ),
            )
        )

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["metadata"] == {
            "title": "My Doc",
            "author": "Test Author",
            "subject": "Testing",
            "keywords": "test,pdf",
            "creator": "Presa SDK",
        }

    @patch("presa.client.urllib.request.urlopen")
    def test_from_url_with_metadata(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_urlopen(b"%PDF")

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        client.from_url(
            GenerateOptions(
                html="",
                url="https://example.com",
                metadata=PdfMetadata(title="URL Doc"),
            )
        )

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["url"] == "https://example.com"
        assert body["metadata"] == {"title": "URL Doc"}


class TestAsyncGeneration:
    @patch("presa.client.urllib.request.urlopen")
    def test_generate_async(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "jobId": "job_123",
                "status": "pending",
                "pollUrl": "/v1/jobs/job_123",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.generate(
            GenerateOptions(html="<h1>Hello</h1>", async_mode=True)
        )

        assert isinstance(result, AsyncJobResponse)
        assert result.job_id == "job_123"
        assert result.status == "pending"
        assert result.poll_url == "/v1/jobs/job_123"

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["async"] is True

    @patch("presa.client.urllib.request.urlopen")
    def test_from_url_async(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "jobId": "job_456",
                "status": "pending",
                "pollUrl": "/v1/jobs/job_456",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.from_url(
            GenerateOptions(html="", url="https://example.com", async_mode=True)
        )

        assert isinstance(result, AsyncJobResponse)
        assert result.job_id == "job_456"


class TestJobsClient:
    @patch("presa.client.urllib.request.urlopen")
    def test_get_job(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "job_123",
                "status": "completed",
                "storageUrl": "https://storage.example.com/file.pdf",
                "expiresAt": "2026-04-01T00:00:00Z",
                "contentType": "application/pdf",
                "sizeBytes": 12345,
                "createdAt": "2026-03-15T00:00:00Z",
                "completedAt": "2026-03-15T00:01:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        status = client.jobs.get("job_123")

        assert isinstance(status, JobStatus)
        assert status.id == "job_123"
        assert status.status == "completed"
        assert status.storage_url == "https://storage.example.com/file.pdf"
        assert status.size_bytes == 12345
        assert status.completed_at == "2026-03-15T00:01:00Z"

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/jobs/job_123"
        assert request.method == "GET"

    @patch("presa.client.time.sleep")
    @patch("presa.client.urllib.request.urlopen")
    def test_poll_job_completed(self, mock_urlopen: MagicMock, mock_sleep: MagicMock) -> None:
        pending_resp = _mock_json_urlopen({
            "data": {
                "id": "job_123",
                "status": "pending",
                "createdAt": "2026-03-15T00:00:00Z",
            }
        })
        completed_resp = _mock_json_urlopen({
            "data": {
                "id": "job_123",
                "status": "completed",
                "storageUrl": "https://storage.example.com/file.pdf",
                "expiresAt": "2026-04-01T00:00:00Z",
                "contentType": "application/pdf",
                "sizeBytes": 12345,
                "createdAt": "2026-03-15T00:00:00Z",
                "completedAt": "2026-03-15T00:01:00Z",
            }
        })
        mock_urlopen.side_effect = [pending_resp, completed_resp]

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        status = client.jobs.poll("job_123", interval=0.1)

        assert status.status == "completed"
        assert status.storage_url == "https://storage.example.com/file.pdf"
        assert mock_urlopen.call_count == 2
        mock_sleep.assert_called_once_with(0.1)

    @patch("presa.client.time.sleep")
    @patch("presa.client.urllib.request.urlopen")
    def test_poll_job_failed(self, mock_urlopen: MagicMock, mock_sleep: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "job_123",
                "status": "failed",
                "errorCode": "RENDER_ERROR",
                "errorMessage": "Invalid HTML",
                "createdAt": "2026-03-15T00:00:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        status = client.jobs.poll("job_123")

        assert status.status == "failed"
        assert status.error_code == "RENDER_ERROR"
        assert status.error_message == "Invalid HTML"


class TestStoredResponse:
    @patch("presa.client.urllib.request.urlopen")
    def test_generate_with_store(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "storageUrl": "https://storage.example.com/file.pdf",
                "expiresAt": "2026-04-01T00:00:00Z",
                "contentType": "application/pdf",
                "sizeBytes": 12345,
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.generate(
            GenerateOptions(html="<h1>Hello</h1>", store=True)
        )

        assert isinstance(result, StoredResponse)
        assert result.storage_url == "https://storage.example.com/file.pdf"
        assert result.expires_at == "2026-04-01T00:00:00Z"
        assert result.content_type == "application/pdf"
        assert result.size_bytes == 12345

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["store"] is True

    @patch("presa.client.urllib.request.urlopen")
    def test_from_url_with_store(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "storageUrl": "https://storage.example.com/file.pdf",
                "expiresAt": "2026-04-01T00:00:00Z",
                "contentType": "application/pdf",
                "sizeBytes": 5678,
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.from_url(
            GenerateOptions(html="", url="https://example.com", store=True)
        )

        assert isinstance(result, StoredResponse)
        assert result.size_bytes == 5678


class TestBatchClient:
    @patch("presa.client.urllib.request.urlopen")
    def test_create_batch(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "batch_123",
                "status": "processing",
                "totalItems": 2,
                "completedItems": 0,
                "failedItems": 0,
                "createdAt": "2026-03-15T00:00:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        items = [
            BatchItem(html="<h1>Page 1</h1>"),
            BatchItem(url="https://example.com", landscape=True),
        ]
        result = client.batch.create(items)

        assert isinstance(result, BatchStatus)
        assert result.id == "batch_123"
        assert result.total_items == 2
        assert result.completed_items == 0

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/batch"
        body = json.loads(request.data.decode("utf-8"))
        assert len(body["items"]) == 2
        assert body["items"][0] == {"html": "<h1>Page 1</h1>"}
        assert body["items"][1] == {"url": "https://example.com", "landscape": True}

    @patch("presa.client.urllib.request.urlopen")
    def test_get_batch(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "batch_123",
                "status": "completed",
                "totalItems": 2,
                "completedItems": 2,
                "failedItems": 0,
                "createdAt": "2026-03-15T00:00:00Z",
                "completedAt": "2026-03-15T00:01:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.batch.get("batch_123")

        assert result.status == "completed"
        assert result.completed_items == 2
        assert result.completed_at == "2026-03-15T00:01:00Z"

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/batch/batch_123"

    @patch("presa.client.urllib.request.urlopen")
    def test_batch_results(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": [
                {
                    "itemIndex": 0,
                    "jobId": "job_1",
                    "status": "completed",
                    "storageUrl": "https://storage.example.com/1.pdf",
                },
                {
                    "itemIndex": 1,
                    "jobId": "job_2",
                    "status": "failed",
                    "errorCode": "RENDER_ERROR",
                    "errorMessage": "Invalid HTML",
                },
            ]
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        results = client.batch.results("batch_123")

        assert len(results) == 2
        assert isinstance(results[0], BatchResult)
        assert results[0].item_index == 0
        assert results[0].status == "completed"
        assert results[0].storage_url == "https://storage.example.com/1.pdf"
        assert results[1].status == "failed"
        assert results[1].error_code == "RENDER_ERROR"

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/batch/batch_123/results"

    @patch("presa.client.urllib.request.urlopen")
    def test_batch_item_with_metadata(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "batch_456",
                "status": "processing",
                "totalItems": 1,
                "completedItems": 0,
                "failedItems": 0,
                "createdAt": "2026-03-15T00:00:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        items = [
            BatchItem(
                html="<h1>Hello</h1>",
                metadata=PdfMetadata(title="Batch Doc"),
            ),
        ]
        client.batch.create(items)

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["items"][0]["metadata"] == {"title": "Batch Doc"}


class TestAssetsClient:
    @patch("presa.client.urllib.request.urlopen")
    def test_upload_asset(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "id": "asset_123",
                "name": "logo.png",
                "uri": "asset://logo.png",
                "contentType": "image/png",
                "sizeBytes": 4096,
                "createdAt": "2026-03-15T00:00:00Z",
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.assets.upload(
            file_data=b"\x89PNG\r\n",
            name="logo.png",
            content_type="image/png",
        )

        assert isinstance(result, AssetInfo)
        assert result.id == "asset_123"
        assert result.name == "logo.png"
        assert result.uri == "asset://logo.png"
        assert result.content_type == "image/png"
        assert result.size_bytes == 4096

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/assets"
        assert request.method == "POST"
        content_type = request.get_header("Content-type")
        assert "multipart/form-data" in content_type

    @patch("presa.client.urllib.request.urlopen")
    def test_list_assets(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": [
                {
                    "id": "asset_1",
                    "name": "logo.png",
                    "uri": "asset://logo.png",
                    "contentType": "image/png",
                    "sizeBytes": 4096,
                    "createdAt": "2026-03-15T00:00:00Z",
                },
            ],
            "total": 1,
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.assets.list(limit=10, offset=0)

        assert isinstance(result, AssetListResponse)
        assert len(result.data) == 1
        assert result.data[0].name == "logo.png"
        assert result.total == 1

        request = mock_urlopen.call_args[0][0]
        assert "limit=10" in request.full_url
        assert "offset=0" in request.full_url

    @patch("presa.client.urllib.request.urlopen")
    def test_delete_asset(self, mock_urlopen: MagicMock) -> None:
        mock_response = MagicMock()
        mock_response.status = 204
        mock_response.read.return_value = b""
        mock_response.__enter__ = MagicMock(return_value=mock_response)
        mock_response.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_response

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        client.assets.delete("asset_123")

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/assets/asset_123"
        assert request.method == "DELETE"


class TestMerge:
    @patch("presa.client.urllib.request.urlopen")
    def test_merge_returns_binary(self, mock_urlopen: MagicMock) -> None:
        pdf_bytes = b"%PDF-merged"
        mock_urlopen.return_value = _mock_urlopen(pdf_bytes)

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.merge(sources=[
            "https://example.com/a.pdf",
            "https://example.com/b.pdf",
        ])

        assert isinstance(result, GenerateResult)
        assert result.buffer == pdf_bytes

        request = mock_urlopen.call_args[0][0]
        assert request.full_url == "http://localhost:8080/v1/pdf/merge"
        body = json.loads(request.data.decode("utf-8"))
        assert body == {
            "sources": [
                "https://example.com/a.pdf",
                "https://example.com/b.pdf",
            ]
        }

    @patch("presa.client.urllib.request.urlopen")
    def test_merge_with_store(self, mock_urlopen: MagicMock) -> None:
        mock_urlopen.return_value = _mock_json_urlopen({
            "data": {
                "storageUrl": "https://storage.example.com/merged.pdf",
                "expiresAt": "2026-04-01T00:00:00Z",
                "contentType": "application/pdf",
                "sizeBytes": 99999,
            }
        })

        client = Presa(PresaConfig(api_key="test-key", base_url="http://localhost:8080"))
        result = client.merge(
            sources=["https://example.com/a.pdf"],
            store=True,
        )

        assert isinstance(result, StoredResponse)
        assert result.storage_url == "https://storage.example.com/merged.pdf"
        assert result.size_bytes == 99999

        request = mock_urlopen.call_args[0][0]
        body = json.loads(request.data.decode("utf-8"))
        assert body["store"] is True


class TestSubClientProperties:
    def test_jobs_property(self) -> None:
        client = Presa(PresaConfig(api_key="test-key"))
        assert client.jobs is client.jobs  # same instance

    def test_batch_property(self) -> None:
        client = Presa(PresaConfig(api_key="test-key"))
        assert client.batch is client.batch

    def test_assets_property(self) -> None:
        client = Presa(PresaConfig(api_key="test-key"))
        assert client.assets is client.assets
