"""
Tests for Docling Serve SDK v2.0 — Custom Logic Only.

Tests ONLY behaviour the SDK implements itself, NOT what Pydantic guarantees.
This means: form-data construction, credential redaction, resource lifecycle,
header wiring, and enterprise hook storage.

Removed (Pydantic guarantees these):
  - Enum value listings, model defaults, roundtrip serialization,
    extra="forbid" rejection, exception issubclass checks, type coercion.
"""

from __future__ import annotations

import json
from typing import Any, Dict, Optional

import pytest
from pydantic import SecretStr

from docling_serve_sdk import DoclingClient
from docling_serve_sdk.models import (
    ConvertDocumentsRequestOptions,
    ImageRefMode,
    InputFormat,
    OutputFormat,
    PdfBackend,
    ProcessingPipeline,
    InBodyTarget,
    ZipTarget,
    S3SourceRequest,
    S3Target,
    PictureDescriptionLocal,
    HierarchicalChunkerOptions,
    HybridChunkerOptions,
)


# ===================================================================
# 1. Form-data builder — the heart of the SDK
#
# _build_form_data() converts Pydantic models into the flat multipart
# dict that Docling Serve expects.  Bugs here cause SILENT API
# failures (server ignores unknown keys / wrong types).
# ===================================================================

class TestFormDataBuilder:
    """Tests for _build_form_data() key naming, value serialization, and structural integrity."""

    # -- Key naming (prefix logic) --

    def test_convert_mode_uses_unprefixed_keys(self) -> None:
        opts = ConvertDocumentsRequestOptions(
            from_formats=[InputFormat.PDF],
            to_formats=[OutputFormat.MD, OutputFormat.JSON],
            do_ocr=False,
        )
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=False)  # pyright: ignore[reportPrivateUsage]

        assert "from_formats" in form
        assert "to_formats" in form
        assert "do_ocr" in form
        assert "convert_from_formats" not in form
        assert "convert_do_ocr" not in form

    def test_chunk_mode_uses_convert_prefixed_keys(self) -> None:
        opts = ConvertDocumentsRequestOptions(
            from_formats=[InputFormat.PDF],
            do_ocr=True,
        )
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=True)  # pyright: ignore[reportPrivateUsage]

        assert "convert_from_formats" in form
        assert "convert_do_ocr" in form
        assert "from_formats" not in form
        assert "do_ocr" not in form

    def test_to_formats_included_in_convert_mode(self) -> None:
        opts = ConvertDocumentsRequestOptions(to_formats=[OutputFormat.MD])
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=False)  # pyright: ignore[reportPrivateUsage]
        assert "to_formats" in form

    def test_to_formats_excluded_in_chunk_mode(self) -> None:
        opts = ConvertDocumentsRequestOptions(to_formats=[OutputFormat.MD])
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=True)  # pyright: ignore[reportPrivateUsage]
        assert "to_formats" not in form
        assert "convert_to_formats" not in form

    def test_target_type_inbody(self) -> None:
        form = DoclingClient._build_form_data(  # pyright: ignore[reportPrivateUsage]
            ConvertDocumentsRequestOptions(), InBodyTarget(),
        )
        assert form["target_type"] == "inbody"

    def test_target_type_zip(self) -> None:
        form = DoclingClient._build_form_data(  # pyright: ignore[reportPrivateUsage]
            ConvertDocumentsRequestOptions(), ZipTarget(),
        )
        assert form["target_type"] == "zip"

    # -- Value serialization --

    def test_lists_sent_as_native_python_lists(self) -> None:
        """Lists must stay as Python lists so httpx expands them into repeated
        form-data fields.  Sending json.dumps() strings was a real production bug."""
        opts = ConvertDocumentsRequestOptions(
            from_formats=[InputFormat.PDF, InputFormat.DOCX],
        )
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        value = form["from_formats"]
        assert isinstance(value, list)
        assert "pdf" in value
        assert "docx" in value

    def test_enums_serialized_as_plain_strings(self) -> None:
        """mode='json' must produce 'embedded', not 'ImageRefMode.EMBEDDED'.
        Python 3.11+ str(StrEnum) returns 'ClassName.VALUE' — this caught a
        real production bug."""
        opts = ConvertDocumentsRequestOptions(
            image_export_mode=ImageRefMode.EMBEDDED,
            pipeline=ProcessingPipeline.VLM,
            pdf_backend=PdfBackend.DOCLING_PARSE,
        )
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        assert form["image_export_mode"] == "embedded"
        assert form["pipeline"] == "vlm"
        assert form["pdf_backend"] == "docling_parse"

    def test_none_values_omitted(self) -> None:
        opts = ConvertDocumentsRequestOptions(ocr_lang=None)
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        assert "ocr_lang" not in form

    def test_dict_values_serialized_as_json_string(self) -> None:
        opts = ConvertDocumentsRequestOptions(
            vlm_pipeline_custom_config={"key": "value"},
        )
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        raw = form["vlm_pipeline_custom_config"]
        assert isinstance(raw, str)
        assert json.loads(raw) == {"key": "value"}

    def test_basemodel_values_serialized_as_json_string(self) -> None:
        """PictureDescriptionLocal is a BaseModel; mode='json' turns it into
        a dict, which _build_form_data then json.dumps()."""
        local = PictureDescriptionLocal(repo_id="org/model")
        opts = ConvertDocumentsRequestOptions(picture_description_local=local)
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        raw = form["picture_description_local"]
        assert isinstance(raw, str)
        assert json.loads(raw)["repo_id"] == "org/model"

    def test_page_range_tuple_becomes_list(self) -> None:
        """Tuple[int, int] → list via mode='json'; httpx expands into repeated fields."""
        opts = ConvertDocumentsRequestOptions(page_range=(1, 100))
        form = DoclingClient._build_form_data(opts, InBodyTarget())  # pyright: ignore[reportPrivateUsage]
        assert form["page_range"] == [1, 100]

    def test_chunking_prefixes_structured_fields(self) -> None:
        opts = ConvertDocumentsRequestOptions(vlm_pipeline_custom_config={"k": 1})
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=True)  # pyright: ignore[reportPrivateUsage]
        assert "convert_vlm_pipeline_custom_config" in form
        assert "vlm_pipeline_custom_config" not in form

    # -- Structural integrity --

    def test_option_fields_to_model_fields(self) -> None:
        """Every entry in _OPTION_FIELDS must exist in ConvertDocumentsRequestOptions."""
        model_fields = set(ConvertDocumentsRequestOptions.model_fields.keys())
        for name in DoclingClient._OPTION_FIELDS:  # pyright: ignore[reportPrivateUsage]
            assert name in model_fields, f"_OPTION_FIELDS has unknown field: {name}"

    def test_model_fields_to_option_fields(self) -> None:
        """Every model field (except to_formats) must be in _OPTION_FIELDS,
        so new fields aren't silently ignored by _build_form_data."""
        model_fields = set(ConvertDocumentsRequestOptions.model_fields.keys())
        option_fields = set(DoclingClient._OPTION_FIELDS)  # pyright: ignore[reportPrivateUsage]
        missing = model_fields - option_fields - {"to_formats"}
        assert missing == set(), f"Model fields missing from _OPTION_FIELDS: {missing}"

    def test_chunker_discriminator_excluded(self) -> None:
        """The 'chunker' literal field must NOT appear as chunking_chunker."""
        opts = ConvertDocumentsRequestOptions()
        form = DoclingClient._build_form_data(opts, InBodyTarget(), for_chunking=True)  # pyright: ignore[reportPrivateUsage]

        hybrid = HybridChunkerOptions()
        co = hybrid.model_dump(exclude_none=True, exclude={"chunker"})
        for k, v in co.items():
            form[f"chunking_{k}"] = v

        assert "chunking_chunker" not in form
        assert "chunking_use_markdown_tables" in form

    def test_hierarchical_chunker_discriminator_excluded(self) -> None:
        hier = HierarchicalChunkerOptions()
        co = hier.model_dump(exclude_none=True, exclude={"chunker"})
        keys = {f"chunking_{k}" for k in co}
        assert "chunking_chunker" not in keys
        assert "chunking_use_markdown_tables" in keys


# ===================================================================
# 2. Security — credential redaction, custom model_dump, headers
# ===================================================================

class TestSecurity:
    """Verify secrets are never leaked and custom model_dump overrides work."""

    def test_s3_source_credentials_hidden_in_repr(self) -> None:
        src = S3SourceRequest(
            endpoint="s3.example.com",
            access_key=SecretStr("AKIA_SECRET"),
            secret_key=SecretStr("wJalrXUtnFEMI"),
            bucket="my-bucket",
        )
        assert "AKIA_SECRET" not in repr(src)
        assert "wJalrXUtnFEMI" not in repr(src)

    def test_s3_source_model_dump_exposes_plain_strings(self) -> None:
        """Custom model_dump() override converts SecretStr → str for API calls."""
        src = S3SourceRequest(
            endpoint="s3.example.com",
            access_key=SecretStr("AKIA_KEY"),
            secret_key=SecretStr("SECRET_KEY"),
            bucket="bucket",
        )
        data = src.model_dump()
        assert data["access_key"] == "AKIA_KEY"
        assert data["secret_key"] == "SECRET_KEY"

    def test_s3_target_model_dump_exposes_plain_strings(self) -> None:
        """S3Target has the same custom model_dump() override."""
        target = S3Target(
            endpoint="s3.example.com",
            access_key=SecretStr("AK"),
            secret_key=SecretStr("SK"),
            bucket="b",
        )
        data = target.model_dump()
        assert data["access_key"] == "AK"
        assert data["secret_key"] == "SK"
        assert "AK" not in repr(target)

    def test_client_repr_redacts_api_key(self) -> None:
        c = DoclingClient(base_url="http://localhost:5001", api_key="super-secret-123")
        assert "super-secret-123" not in repr(c)
        assert "***" in repr(c)

    def test_api_key_sent_in_header(self) -> None:
        c = DoclingClient(base_url="http://localhost:5001", api_key="test-key")
        assert c.headers.get("X-Api-Key") == "test-key"

    def test_no_api_key_means_no_header(self) -> None:
        c = DoclingClient(base_url="http://localhost:5001")
        assert "X-Api-Key" not in c.headers


# ===================================================================
# 3. Client initialization — trailing slash, User-Agent
# ===================================================================

class TestClientInit:
    """Verify constructor-level custom logic (not simple attribute storage)."""

    def test_trailing_slash_stripped(self) -> None:
        c = DoclingClient(base_url="https://api.example.com:8080/")
        assert c.base_url == "https://api.example.com:8080"

    def test_user_agent_header(self) -> None:
        c = DoclingClient()
        assert "docling-serve-sdk" in c.headers["User-Agent"]


# ===================================================================
# 4. Resource management — close / aclose lifecycle
# ===================================================================

class TestResourceManagement:
    """Verify connection pool creation and teardown."""

    def test_close_releases_sync_client(self) -> None:
        c = DoclingClient()
        c.__enter__()
        assert c._sync_client is not None  # pyright: ignore[reportPrivateUsage]
        c.close()
        assert c._sync_client is None  # pyright: ignore[reportPrivateUsage]

    @pytest.mark.asyncio
    async def test_aclose_releases_async_client(self) -> None:
        c = DoclingClient()
        await c.__aenter__()
        assert c._async_client is not None  # pyright: ignore[reportPrivateUsage]
        await c.aclose()
        assert c._async_client is None  # pyright: ignore[reportPrivateUsage]

    def test_double_close_is_safe(self) -> None:
        c = DoclingClient()
        c.__enter__()
        c.close()
        c.close()  # must not raise

    def test_close_without_enter_is_safe(self) -> None:
        c = DoclingClient()
        c.close()  # must not raise


# ===================================================================
# 5. Enterprise hook wiring
# ===================================================================

class TestHookWiring:
    """Verify lifecycle hooks are stored correctly on the client."""

    def test_hooks_stored_on_client(self) -> None:
        def req(rid: str, method: str, path: str, body: Optional[Dict[str, Any]]) -> None: pass
        def res(rid: str, method: str, path: str, status: int, dur: float) -> None: pass
        def err(rid: str, method: str, path: str, exc: Exception) -> None: pass

        c = DoclingClient(on_request=req, on_response=res, on_error=err)
        assert c._on_request is req  # pyright: ignore[reportPrivateUsage]
        assert c._on_response is res  # pyright: ignore[reportPrivateUsage]
        assert c._on_error is err  # pyright: ignore[reportPrivateUsage]

    def test_default_hooks_are_none(self) -> None:
        c = DoclingClient()
        assert c._on_request is None  # pyright: ignore[reportPrivateUsage]
        assert c._on_response is None  # pyright: ignore[reportPrivateUsage]
        assert c._on_error is None  # pyright: ignore[reportPrivateUsage]
