"""Unit tests for VisionMixin class in llm/vision.py.

Tests cover:
- analyze_image() - Single image analysis with cache
- analyze_images_batch() - Batch image analysis
- analyze_batch() - Internal batch processing
- _analyze_image_with_fallback() - Fallback strategies
- _analyze_with_instructor() - Instructor-based analysis
- _analyze_with_json_mode() - JSON mode fallback
- _analyze_with_two_calls() - Two-call fallback
- extract_page_content() - Page content extraction
"""

from __future__ import annotations

import asyncio
import base64
import hashlib
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from markitai.llm.models import context_display_name
from markitai.llm.types import (
    BatchImageAnalysisResult,
    ImageAnalysis,
    ImageAnalysisResult,
    LLMResponse,
    SingleImageResult,
)
from markitai.llm.vision import VisionMixin, _vision_cache_content_key

# =============================================================================
# Test Helper: context_display_name (formerly _context_display_name in vision.py)
# =============================================================================


class TestContextDisplayName:
    """Tests for context_display_name helper function."""

    def test_empty_context(self):
        """Empty context returns empty string."""
        assert context_display_name("") == ""

    def test_simple_filename(self):
        """Simple filename is returned as-is."""
        assert context_display_name("file.pdf") == "file.pdf"

    def test_unix_path(self):
        """Unix path extracts filename."""
        assert context_display_name("/path/to/file.pdf") == "file.pdf"

    def test_relative_path(self):
        """Relative path extracts filename."""
        assert context_display_name("subdir/file.pdf") == "file.pdf"


# =============================================================================
# Mock VisionMixin Implementation
# =============================================================================


class MockVisionProcessor(VisionMixin):
    """Mock processor that includes VisionMixin for testing.

    Provides mock implementations of LLMProcessor attributes that
    VisionMixin expects.
    """

    def __init__(
        self,
        *,
        concurrency: int = 2,
        cache_enabled: bool = True,
        cached_result: dict[str, Any] | None = None,
    ):
        """Initialize mock processor.

        Args:
            concurrency: Semaphore value for concurrent operations
            cache_enabled: Whether cache returns hits
            cached_result: Result to return from cache on hit
        """
        self._concurrency = concurrency
        self._semaphore: asyncio.Semaphore | None = None
        self._cache_enabled = cache_enabled
        self._cached_result = cached_result
        self._cache_storage: dict[str, Any] = {}
        self._image_cache: dict[str, tuple[bytes, str]] = {}
        self._usage: dict[str, dict[str, Any]] = {}
        self._context_usage: dict[str, dict[str, dict[str, Any]]] = {}

        # Mock config for analyze_images_batch
        self.config = MagicMock()
        self.config.concurrency = concurrency

        # Setup mock objects
        self._setup_mocks()

    def _setup_mocks(self):
        """Setup mock objects for testing."""
        # Mock persistent cache
        self._persistent_cache = MagicMock()
        if self._cache_enabled and self._cached_result is not None:
            self._persistent_cache.get.return_value = self._cached_result
        else:
            self._persistent_cache.get.return_value = None
        self._persistent_cache.set = MagicMock()

        # Mock prompt manager
        self._prompt_manager = MagicMock()
        self._prompt_manager.get_prompt.side_effect = self._get_mock_prompt

        # Mock vision router
        self.vision_router = MagicMock()
        self.vision_router.acompletion = AsyncMock()
        self.vision_router.model_list = [
            {"litellm_params": {"model": "test/vision-model"}}
        ]

    def _get_mock_prompt(self, prompt_name: str, **kwargs: Any) -> str:
        """Return mock prompts for testing with variable substitution."""
        prompts = {
            "image_analysis_system": f"Analyze image in {kwargs.get('language', 'English')}",
            "image_analysis_user": "Describe this image{document_context}",
            "image_caption_system": f"Generate caption in {kwargs.get('language', 'English')}",
            "image_caption_user": "What is this image?{document_context}",
            "image_description_system": f"Describe in {kwargs.get('language', 'English')}",
            "image_description_user": "Describe the image in detail{document_context}",
            "page_content_system": f"Extract content in {kwargs.get('language', 'English')}",
            "page_content_user": "Extract all text from this page",
        }
        template = prompts.get(prompt_name, f"Mock prompt: {prompt_name}")
        for key, value in kwargs.items():
            template = template.replace(f"{{{key}}}", str(value))
        return template

    @property
    def semaphore(self) -> asyncio.Semaphore:
        """Get or create semaphore."""
        if self._semaphore is None:
            self._semaphore = asyncio.Semaphore(self._concurrency)
        return self._semaphore

    def _get_cached_image(self, image_path: Path) -> tuple[bytes, str]:
        """Mock method to get cached image data."""
        path_str = str(image_path)
        if path_str in self._image_cache:
            return self._image_cache[path_str]

        # Read file and cache
        if image_path.exists():
            data = image_path.read_bytes()
        else:
            # Use mock data for testing
            data = b"mock_image_data"

        base64_str = base64.b64encode(data).decode()
        self._image_cache[path_str] = (data, base64_str)
        return data, base64_str

    def _calculate_dynamic_max_tokens(
        self, messages: list[dict[str, Any]], router: Any = None
    ) -> int:
        """Mock method to calculate max tokens."""
        return 4096

    def _track_usage(
        self,
        model: str,
        input_tokens: int,
        output_tokens: int,
        cost: float,
        context: str = "",
    ):
        """Mock method to track usage."""
        if model not in self._usage:
            self._usage[model] = {
                "requests": 0,
                "input_tokens": 0,
                "output_tokens": 0,
                "cost_usd": 0.0,
            }
        self._usage[model]["requests"] += 1
        self._usage[model]["input_tokens"] += input_tokens
        self._usage[model]["output_tokens"] += output_tokens
        self._usage[model]["cost_usd"] += cost

    async def _call_llm(
        self,
        model: str,
        messages: list[dict[str, Any]],
        context: str = "",
    ) -> LLMResponse:
        """Mock LLM call for two-call fallback."""
        return LLMResponse(
            content="Mock response",
            model="test/model",
            input_tokens=100,
            output_tokens=50,
            cost_usd=0.001,
        )


# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def mock_processor() -> MockVisionProcessor:
    """Create a mock processor for testing."""
    return MockVisionProcessor()


@pytest.fixture
def mock_processor_with_cache() -> MockVisionProcessor:
    """Create a mock processor with cache hit."""
    return MockVisionProcessor(
        cache_enabled=True,
        cached_result={
            "caption": "Cached caption",
            "description": "Cached description",
            "extracted_text": "Cached text",
        },
    )


@pytest.fixture
def sample_png_file(tmp_path: Path, sample_png_bytes: bytes) -> Path:
    """Create a sample PNG file for testing."""
    img_path = tmp_path / "test_image.png"
    img_path.write_bytes(sample_png_bytes)
    return img_path


@pytest.fixture
def sample_svg_file(tmp_path: Path) -> Path:
    """Create a sample SVG file for testing."""
    svg_path = tmp_path / "test_image.svg"
    svg_path.write_text("<svg></svg>")
    return svg_path


@pytest.fixture
def sample_jpg_file(tmp_path: Path) -> Path:
    """Create a sample JPEG file for testing."""
    jpg_path = tmp_path / "test_image.jpg"
    # Minimal JPEG header
    jpg_data = bytes(
        [
            0xFF,
            0xD8,
            0xFF,
            0xE0,
            0x00,
            0x10,
            0x4A,
            0x46,
            0x49,
            0x46,
            0x00,
            0x01,
            0x01,
            0x00,
            0x00,
            0x01,
            0x00,
            0x01,
            0x00,
            0x00,
            0xFF,
            0xD9,
        ]
    )
    jpg_path.write_bytes(jpg_data)
    return jpg_path


@pytest.fixture
def multiple_png_files(tmp_path: Path, sample_png_bytes: bytes) -> list[Path]:
    """Create multiple PNG files for batch testing."""
    files = []
    for i in range(5):
        img_path = tmp_path / f"image_{i}.png"
        img_path.write_bytes(sample_png_bytes)
        files.append(img_path)
    return files


# =============================================================================
# Test analyze_image
# =============================================================================


class TestAnalyzeImage:
    """Tests for analyze_image method."""

    @pytest.mark.asyncio
    async def test_unsupported_format_returns_placeholder(
        self, mock_processor: MockVisionProcessor, sample_svg_file: Path
    ):
        """SVG returns a placeholder result when rasterization support is unavailable."""
        with patch("markitai.utils.mime.importlib.util.find_spec", return_value=None):
            result = await mock_processor.analyze_image(sample_svg_file)

        assert result.caption == "test_image"  # stem of filename
        assert "not supported" in result.description.lower()
        # Cache should not be checked for unsupported formats
        mock_processor._persistent_cache.get.assert_not_called()

    @pytest.mark.asyncio
    async def test_cache_hit_returns_cached_result(
        self,
        mock_processor_with_cache: MockVisionProcessor,
        sample_png_file: Path,
    ):
        """Cache hit returns cached result without calling LLM."""
        result = await mock_processor_with_cache.analyze_image(sample_png_file)

        assert result.caption == "Cached caption"
        assert result.description == "Cached description"
        assert result.extracted_text == "Cached text"
        # Vision router should not be called on cache hit
        mock_processor_with_cache.vision_router.acompletion.assert_not_called()  # type: ignore[reportAttributeAccessIssue]

    @pytest.mark.asyncio
    async def test_cache_miss_calls_llm(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Cache miss triggers LLM call."""
        # Setup mock instructor response
        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="Test caption",
                description="Test description",
            )

            result = await mock_processor.analyze_image(sample_png_file)

            assert result.caption == "Test caption"
            mock_fallback.assert_called_once()

    @pytest.mark.asyncio
    async def test_caches_result_after_analysis(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Result is stored in cache after analysis."""
        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="New caption",
                description="New description",
                extracted_text="New text",
            )

            await mock_processor.analyze_image(sample_png_file, context="test.pdf")

            # Verify cache.set was called
            mock_processor._persistent_cache.set.assert_called_once()
            call_args = mock_processor._persistent_cache.set.call_args
            assert call_args[0][0] == "image_analysis"  # cache_key
            assert call_args[1]["model"] == "vision"

    @pytest.mark.asyncio
    async def test_document_context_included_in_user_message(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Document context is appended to user message for language hinting."""
        chinese_context = "这是一篇关于人生哲学的文章，探讨人是什么单位。"

        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="测试标题",
                description="测试描述",
            )

            await mock_processor.analyze_image(
                sample_png_file,
                context="test.md",
                document_context=chinese_context,
            )

            # Verify the user message passed to _analyze_image_with_fallback
            # contains the document context
            call_args = mock_fallback.call_args
            messages = call_args[0][0]  # first positional arg
            user_content = messages[1]["content"]
            user_text = user_content[0]["text"]
            assert chinese_context in user_text

    @pytest.mark.asyncio
    async def test_document_context_sets_system_language_hint(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Chinese document context should set a Chinese fallback language hint."""
        chinese_context = "这是一篇关于人生哲学的文章，探讨人是什么单位。"

        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="测试标题",
                description="测试描述",
            )

            await mock_processor.analyze_image(
                sample_png_file,
                context="test.md",
                document_context=chinese_context,
            )

            call_args = mock_fallback.call_args
            messages = call_args[0][0]
            assert messages[0]["content"] == "Analyze image in Chinese"

    @pytest.mark.asyncio
    async def test_retries_when_non_text_image_ignores_chinese_context(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """A no-text image should be retried when the model ignores Chinese context."""
        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.side_effect = [
                ImageAnalysis(
                    caption="An English caption",
                    description="An English description",
                    extracted_text=None,
                ),
                ImageAnalysis(
                    caption="一幅中文图片描述",
                    description="一段中文详细描述",
                    extracted_text=None,
                ),
            ]

            result = await mock_processor.analyze_image(
                sample_png_file,
                context="test.md",
                document_context="这是一篇中文文章。",
            )

            assert mock_fallback.await_count == 2
            assert result.caption == "一幅中文图片描述"

    @pytest.mark.asyncio
    async def test_rewrites_to_document_language_after_failed_retry(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """A second English result should be rewritten into Chinese."""
        with patch.object(
            mock_processor,
            "_analyze_image_with_fallback",
            new_callable=AsyncMock,
        ) as mock_fallback:
            mock_fallback.side_effect = [
                ImageAnalysis(
                    caption="An English caption",
                    description="An English description",
                    extracted_text=None,
                ),
                ImageAnalysis(
                    caption="Still English",
                    description="Still English description",
                    extracted_text=None,
                ),
            ]

            async def mock_call_llm(model, messages, context=""):
                del model, context
                system_prompt = messages[0]["content"]
                if "alt text" in system_prompt:
                    return LLMResponse(
                        content="一幅中文图片描述",
                        model="test/model",
                        input_tokens=10,
                        output_tokens=5,
                        cost_usd=0.0001,
                    )
                return LLMResponse(
                    content="## 中文详细描述",
                    model="test/model",
                    input_tokens=10,
                    output_tokens=5,
                    cost_usd=0.0001,
                )

            mock_processor._call_llm = mock_call_llm

            result = await mock_processor.analyze_image(
                sample_png_file,
                context="test.md",
                document_context="这是一篇中文文章。",
            )

            assert result.caption == "一幅中文图片描述"
            assert result.description == "## 中文详细描述"


# =============================================================================
# Test analyze_images_batch
# =============================================================================


class TestAnalyzeImagesBatch:
    """Tests for analyze_images_batch method."""

    @pytest.mark.asyncio
    async def test_empty_list_returns_empty(self, mock_processor: MockVisionProcessor):
        """Empty image list returns empty results."""
        result = await mock_processor.analyze_images_batch([])
        assert result == []

    @pytest.mark.asyncio
    async def test_single_image_batch(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Single image is processed correctly."""
        with patch.object(
            mock_processor,
            "analyze_batch",
            new_callable=AsyncMock,
        ) as mock_batch:
            mock_batch.return_value = [
                ImageAnalysis(caption="Test", description="Test desc")
            ]

            result = await mock_processor.analyze_images_batch([sample_png_file])

            assert len(result) == 1
            assert result[0].caption == "Test"

    @pytest.mark.asyncio
    async def test_respects_max_images_per_batch(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Large batches are split according to max_images_per_batch."""
        with patch.object(
            mock_processor,
            "analyze_batch",
            new_callable=AsyncMock,
        ) as mock_batch:
            mock_batch.return_value = [
                ImageAnalysis(caption="Image", description="Desc")
            ]

            # 5 images with max 2 per batch = 3 batches
            await mock_processor.analyze_images_batch(
                multiple_png_files, max_images_per_batch=2
            )

            # Should be called 3 times (5 images / 2 per batch = 3)
            assert mock_batch.call_count == 3

    @pytest.mark.asyncio
    async def test_handles_batch_failure(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Failed batches return placeholder results."""
        with patch.object(
            mock_processor,
            "analyze_batch",
            new_callable=AsyncMock,
        ) as mock_batch:
            mock_batch.side_effect = Exception("Batch failed")

            result = await mock_processor.analyze_images_batch(
                multiple_png_files[:2], max_images_per_batch=2
            )

            # Should return placeholder results
            assert len(result) == 2
            assert all("failed" in r.description.lower() for r in result)

    @pytest.mark.asyncio
    async def test_results_in_correct_order(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Results maintain original order."""
        call_count = 0

        async def mock_analyze(
            paths: list[Path], context: str = "", document_context: str = ""
        ):
            nonlocal call_count
            call_count += 1
            return [
                ImageAnalysis(caption=f"Batch{call_count}_Img{i}", description="Desc")
                for i in range(len(paths))
            ]

        with patch.object(
            mock_processor,
            "analyze_batch",
            side_effect=mock_analyze,
        ):
            result = await mock_processor.analyze_images_batch(
                multiple_png_files, max_images_per_batch=2
            )

            # First batch images should come first
            assert result[0].caption.startswith("Batch")
            assert len(result) == 5


# =============================================================================
# Test analyze_batch
# =============================================================================


class TestAnalyzeBatch:
    """Tests for analyze_batch method."""

    @pytest.mark.asyncio
    async def test_all_unsupported_formats(
        self, mock_processor: MockVisionProcessor, tmp_path: Path
    ):
        """Unsupported SVG inputs return placeholders when rasterization is unavailable."""
        svg1 = tmp_path / "img1.svg"
        svg2 = tmp_path / "img2.svg"
        svg1.write_text("<svg>1</svg>")
        svg2.write_text("<svg>2</svg>")

        with patch("markitai.utils.mime.importlib.util.find_spec", return_value=None):
            result = await mock_processor.analyze_batch([svg1, svg2])

        assert len(result) == 2
        assert all("not supported" in r.description.lower() for r in result)

    @pytest.mark.asyncio
    async def test_all_cached_returns_cached(
        self,
        mock_processor_with_cache: MockVisionProcessor,
        multiple_png_files: list[Path],
    ):
        """All cached images return cached results."""
        result = await mock_processor_with_cache.analyze_batch(multiple_png_files[:2])

        assert len(result) == 2
        assert all(r.caption == "Cached caption" for r in result)
        # Vision router should not be called
        mock_processor_with_cache.vision_router.acompletion.assert_not_called()  # type: ignore[reportAttributeAccessIssue]

    @pytest.mark.asyncio
    async def test_mixed_cached_and_new(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Mix of cached and new images processes correctly."""
        # First image is cached
        first_img_data = multiple_png_files[0].read_bytes()
        first_fingerprint = hashlib.sha256(
            base64.b64encode(first_img_data).decode().encode()
        ).hexdigest()
        first_cache_key = _vision_cache_content_key(first_fingerprint)

        def mock_get(key: str, fingerprint: str, context: str = ""):
            if fingerprint == first_cache_key:
                return {
                    "caption": "Cached",
                    "description": "Cached desc",
                    "extracted_text": None,
                }
            return None

        mock_processor._persistent_cache.get.side_effect = mock_get

        # Mock instructor for new images
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            # Create mock response
            mock_response = BatchImageAnalysisResult(
                images=[
                    SingleImageResult(
                        image_index=i + 1,
                        caption=f"New{i}",
                        description=f"New desc {i}",
                        extracted_text=None,
                    )
                    for i in range(len(multiple_png_files) - 1)
                ]
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
            mock_raw.choices = [MagicMock(finish_reason="stop")]

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            result = await mock_processor.analyze_batch(
                multiple_png_files, context="test"
            )

            assert len(result) == 5
            # First should be cached
            assert result[0].caption == "Cached"

    @pytest.mark.asyncio
    async def test_instructor_failure_triggers_fallback(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Instructor failure triggers individual fallback."""
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            async def mock_fail(*args, **kwargs):
                raise ValueError("Instructor failed")

            mock_client.chat.completions.create_with_completion = mock_fail

            # Mock individual analysis
            with patch.object(
                mock_processor, "analyze_image", new_callable=AsyncMock
            ) as mock_single:
                mock_single.return_value = ImageAnalysis(
                    caption="Fallback",
                    description="Fallback desc",
                )

                _ = await mock_processor.analyze_batch(multiple_png_files[:2])

                # Should fall back to individual analysis
                assert mock_single.call_count == 2

    @pytest.mark.asyncio
    async def test_truncation_raises_error(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Truncated output (finish_reason=length) triggers fallback."""
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            mock_response = BatchImageAnalysisResult(
                images=[
                    SingleImageResult(
                        image_index=1,
                        caption="Truncated",
                        description="...",
                        extracted_text=None,
                    )
                ]
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
            mock_raw.choices = [MagicMock(finish_reason="length")]  # Truncated!

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            with patch.object(
                mock_processor, "analyze_image", new_callable=AsyncMock
            ) as mock_single:
                mock_single.return_value = ImageAnalysis(
                    caption="Fallback",
                    description="Fallback desc",
                )

                await mock_processor.analyze_batch([sample_png_file])

                # Should fall back due to truncation
                mock_single.assert_called_once()


# =============================================================================
# Test _analyze_image_with_fallback
# =============================================================================


class TestAnalyzeImageWithFallback:
    """Tests for _analyze_image_with_fallback method."""

    @pytest.mark.asyncio
    async def test_instructor_success(self, mock_processor: MockVisionProcessor):
        """Successful instructor analysis returns result."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch.object(
            mock_processor, "_analyze_with_instructor", new_callable=AsyncMock
        ) as mock_inst:
            mock_inst.return_value = ImageAnalysis(
                caption="Success",
                description="Instructor worked",
            )

            result = await mock_processor._analyze_image_with_fallback(
                messages, "default", "test.png"
            )

            assert result.caption == "Success"
            mock_inst.assert_called_once()

    @pytest.mark.asyncio
    async def test_instructor_fails_json_mode_succeeds(
        self, mock_processor: MockVisionProcessor
    ):
        """Instructor failure falls back to JSON mode."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch.object(
            mock_processor, "_analyze_with_instructor", new_callable=AsyncMock
        ) as mock_inst:
            mock_inst.side_effect = ValueError("Instructor failed")

            with patch.object(
                mock_processor, "_analyze_with_json_mode", new_callable=AsyncMock
            ) as mock_json:
                mock_json.return_value = ImageAnalysis(
                    caption="JSON mode",
                    description="JSON worked",
                )

                result = await mock_processor._analyze_image_with_fallback(
                    messages, "default", "test.png"
                )

                assert result.caption == "JSON mode"
                mock_json.assert_called_once()

    @pytest.mark.asyncio
    async def test_all_fail_uses_two_calls(self, mock_processor: MockVisionProcessor):
        """All methods fail, falls back to two-call method."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch.object(
            mock_processor, "_analyze_with_instructor", new_callable=AsyncMock
        ) as mock_inst:
            mock_inst.side_effect = ValueError("Instructor failed")

            with patch.object(
                mock_processor, "_analyze_with_json_mode", new_callable=AsyncMock
            ) as mock_json:
                mock_json.side_effect = ValueError("JSON mode failed")

                with patch.object(
                    mock_processor, "_analyze_with_two_calls", new_callable=AsyncMock
                ) as mock_two:
                    mock_two.return_value = ImageAnalysis(
                        caption="Two calls",
                        description="Two calls worked",
                    )

                    result = await mock_processor._analyze_image_with_fallback(
                        messages, "default", "test.png"
                    )

                    assert result.caption == "Two calls"
                    mock_two.assert_called_once()


# =============================================================================
# Test _analyze_with_instructor
# =============================================================================


class TestAnalyzeWithInstructor:
    """Tests for _analyze_with_instructor method."""

    @pytest.mark.asyncio
    async def test_successful_analysis(self, mock_processor: MockVisionProcessor):
        """Successful instructor analysis returns ImageAnalysis."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            mock_response = ImageAnalysisResult(
                caption="Test caption  ",  # with trailing spaces
                description="Test description",
                extracted_text="Some text",
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
            mock_raw.choices = [MagicMock(finish_reason="stop")]
            mock_raw._hidden_params = {"total_cost_usd": 0.001}

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            result = await mock_processor._analyze_with_instructor(
                messages, "default", context="test"
            )

            assert result.caption == "Test caption"  # stripped
            assert result.description == "Test description"
            assert result.extracted_text == "Some text"
            assert result.llm_usage is not None

    @pytest.mark.asyncio
    async def test_truncation_raises_error(self, mock_processor: MockVisionProcessor):
        """Truncated output raises ValueError."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            mock_response = ImageAnalysisResult(
                caption="Truncated",
                description="...",
                extracted_text=None,
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=4096)
            mock_raw.choices = [MagicMock(finish_reason="length")]

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            with pytest.raises(ValueError, match="truncated"):
                await mock_processor._analyze_with_instructor(
                    messages, "default", context="test"
                )

    @pytest.mark.asyncio
    async def test_tracks_usage(self, mock_processor: MockVisionProcessor):
        """Usage is tracked after successful analysis."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {"role": "user", "content": [{"type": "text", "text": "Describe"}]},
        ]

        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            mock_response = ImageAnalysisResult(
                caption="Test",
                description="Test",
                extracted_text=None,
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/vision-model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
            mock_raw.choices = [MagicMock(finish_reason="stop")]
            mock_raw._hidden_params = {"total_cost_usd": 0.002}

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            await mock_processor._analyze_with_instructor(
                messages, "default", context="file.pdf"
            )

            # Check usage was tracked
            assert "test/vision-model" in mock_processor._usage
            assert mock_processor._usage["test/vision-model"]["requests"] == 1


# =============================================================================
# Test _analyze_with_json_mode
# =============================================================================


class TestAnalyzeWithJsonMode:
    """Tests for _analyze_with_json_mode method."""

    @pytest.mark.asyncio
    async def test_successful_json_mode(self, mock_processor: MockVisionProcessor):
        """Successful JSON mode returns ImageAnalysis."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        mock_response = MagicMock()
        mock_response.choices = [
            MagicMock(
                message=MagicMock(
                    content=json.dumps(
                        {
                            "caption": "JSON caption  ",
                            "description": "JSON description",
                            "extracted_text": "JSON text",
                        }
                    )
                )
            )
        ]
        mock_response.model = "test/model"
        mock_response.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
        mock_response._hidden_params = {"total_cost_usd": 0.001}

        mock_processor.vision_router.acompletion.return_value = mock_response  # type: ignore[reportAttributeAccessIssue]

        result = await mock_processor._analyze_with_json_mode(
            messages, "default", context="test"
        )

        assert result.caption == "JSON caption"  # stripped
        assert result.description == "JSON description"
        assert result.llm_usage is not None

    @pytest.mark.asyncio
    async def test_handles_control_characters(
        self, mock_processor: MockVisionProcessor
    ):
        """Control characters in JSON are cleaned."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        # JSON with control characters
        dirty_json = (
            '{"caption": "Test\\u0000caption", "description": "Test\\u0001desc"}'
        )
        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content=dirty_json))]
        mock_response.model = "test/model"
        mock_response.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
        mock_response._hidden_params = {}

        mock_processor.vision_router.acompletion.return_value = mock_response  # type: ignore[reportAttributeAccessIssue]

        # Should not raise, control chars are cleaned
        result = await mock_processor._analyze_with_json_mode(
            messages, "default", context="test"
        )

        assert "caption" in result.caption.lower() or result.caption == "Testcaption"

    @pytest.mark.asyncio
    async def test_correct_message_index_with_real_format(
        self, mock_processor: MockVisionProcessor
    ):
        """_analyze_with_json_mode modifies messages[1] (user), not messages[0] (system).

        The real message format from analyze_image() is:
          messages[0] = {"role": "system", "content": "string"}
          messages[1] = {"role": "user", "content": [text_part, image_part]}

        The bug was indexing messages[0]["content"][0]["text"] which fails
        on a plain string with TypeError.
        """
        messages = [
            {"role": "system", "content": "Analyze image in English"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe this image"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,abc123"},
                    },
                ],
            },
        ]

        mock_response = MagicMock()
        mock_response.choices = [
            MagicMock(
                message=MagicMock(
                    content=json.dumps(
                        {
                            "caption": "Test caption",
                            "description": "Test description",
                        }
                    )
                )
            )
        ]
        mock_response.model = "test/model"
        mock_response.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
        mock_response._hidden_params = {"total_cost_usd": 0.001}

        mock_processor.vision_router.acompletion.return_value = mock_response  # type: ignore[reportAttributeAccessIssue]

        result = await mock_processor._analyze_with_json_mode(
            messages, "default", context="test"
        )

        assert result.caption == "Test caption"
        assert result.description == "Test description"

        # Verify the actual messages sent to the LLM have JSON instruction
        # appended to the USER message (index 1), not the system message
        call_args = mock_processor.vision_router.acompletion.call_args  # type: ignore[reportAttributeAccessIssue]
        sent_messages = call_args.kwargs["messages"]
        # System message should remain a plain string
        assert isinstance(sent_messages[0]["content"], str)
        # User message text should have the JSON instruction appended
        user_text = sent_messages[1]["content"][0]["text"]
        assert "Return a JSON object" in user_text
        assert "Describe this image" in user_text

    @pytest.mark.asyncio
    async def test_invalid_json_raises(self, mock_processor: MockVisionProcessor):
        """Invalid JSON raises JSONDecodeError."""
        messages = [
            {"role": "system", "content": "Analyze"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        mock_response = MagicMock()
        mock_response.choices = [MagicMock(message=MagicMock(content="not valid json"))]
        mock_response.model = "test/model"
        mock_response.usage = MagicMock(prompt_tokens=100, completion_tokens=50)

        mock_processor.vision_router.acompletion.return_value = mock_response  # type: ignore[reportAttributeAccessIssue]

        with pytest.raises(json.JSONDecodeError):
            await mock_processor._analyze_with_json_mode(messages, "default")


# =============================================================================
# Test _analyze_with_two_calls
# =============================================================================


class TestAnalyzeWithTwoCalls:
    """Tests for _analyze_with_two_calls method."""

    @pytest.mark.asyncio
    async def test_two_calls_made(self, mock_processor: MockVisionProcessor):
        """Two separate LLM calls are made for caption and description."""
        messages = [
            {"role": "system", "content": "Analyze in English"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        call_count = 0

        async def mock_call_llm(model, messages, context=""):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return LLMResponse(
                    content="Caption result",
                    model="test/model",
                    input_tokens=50,
                    output_tokens=10,
                    cost_usd=0.0005,
                )
            else:
                return LLMResponse(
                    content="Description result",
                    model="test/model",
                    input_tokens=50,
                    output_tokens=30,
                    cost_usd=0.001,
                )

        mock_processor._call_llm = mock_call_llm

        result = await mock_processor._analyze_with_two_calls(messages, context="test")

        assert call_count == 2
        assert result.caption == "Caption result"
        assert result.description == "Description result"

    @pytest.mark.asyncio
    async def test_aggregates_usage(self, mock_processor: MockVisionProcessor):
        """Usage from both calls is aggregated."""
        messages = [
            {"role": "system", "content": "Analyze in English"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        async def mock_call_llm(model, messages, context=""):
            return LLMResponse(
                content="Result",
                model="test/model",
                input_tokens=100,
                output_tokens=50,
                cost_usd=0.001,
            )

        mock_processor._call_llm = mock_call_llm

        result = await mock_processor._analyze_with_two_calls(messages, context="test")

        assert result.llm_usage is not None
        assert result.llm_usage["test/model"]["requests"] == 2
        assert result.llm_usage["test/model"]["input_tokens"] == 200
        assert result.llm_usage["test/model"]["output_tokens"] == 100

    @pytest.mark.asyncio
    async def test_old_message_format(self, mock_processor: MockVisionProcessor):
        """Old message format (no system role) is handled."""
        messages = [
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]

        async def mock_call_llm(model, messages, context=""):
            return LLMResponse(
                content="Result",
                model="test/model",
                input_tokens=50,
                output_tokens=20,
                cost_usd=0.0005,
            )

        mock_processor._call_llm = mock_call_llm

        result = await mock_processor._analyze_with_two_calls(messages)

        assert result.caption == "Result"

    @pytest.mark.asyncio
    async def test_two_calls_uses_document_language_hint(
        self, mock_processor: MockVisionProcessor
    ):
        """Two-call fallback should pass document language into both prompts."""
        messages = [
            {"role": "system", "content": "Analyze in English"},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "Describe"},
                    {
                        "type": "image_url",
                        "image_url": {"url": "data:image/png;base64,x"},
                    },
                ],
            },
        ]
        seen_system_prompts: list[str] = []

        async def mock_call_llm(model, messages, context=""):
            del model, context
            seen_system_prompts.append(messages[0]["content"])
            return LLMResponse(
                content="结果",
                model="test/model",
                input_tokens=50,
                output_tokens=20,
                cost_usd=0.0005,
            )

        mock_processor._call_llm = mock_call_llm

        await mock_processor._analyze_with_two_calls(
            messages,
            context="test",
            document_context="这是一篇中文文章。",
        )

        assert seen_system_prompts == [
            "Generate caption in Chinese",
            "Describe in Chinese",
        ]


# =============================================================================
# Test extract_page_content
# =============================================================================


class TestExtractPageContent:
    """Tests for extract_page_content method."""

    @pytest.mark.asyncio
    async def test_extracts_content(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Content is extracted from page image."""

        async def mock_call_llm(model, messages, context=""):
            return LLMResponse(
                content="# Page Title\n\nExtracted content here.",
                model="test/model",
                input_tokens=500,
                output_tokens=100,
                cost_usd=0.005,
            )

        mock_processor._call_llm = mock_call_llm

        result = await mock_processor.extract_page_content(sample_png_file)

        assert "# Page Title" in result
        assert "Extracted content" in result

    @pytest.mark.asyncio
    async def test_uses_context_for_logging(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Context is passed to LLM call for logging."""
        call_contexts = []

        async def mock_call_llm(model, messages, context=""):
            call_contexts.append(context)
            return LLMResponse(
                content="Content",
                model="test/model",
                input_tokens=100,
                output_tokens=50,
                cost_usd=0.001,
            )

        mock_processor._call_llm = mock_call_llm

        await mock_processor.extract_page_content(
            sample_png_file, context="document.pdf"
        )

        assert "document.pdf" in call_contexts

    @pytest.mark.asyncio
    async def test_uses_filename_as_default_context(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Filename is used as context when not provided."""
        call_contexts = []

        async def mock_call_llm(model, messages, context=""):
            call_contexts.append(context)
            return LLMResponse(
                content="Content",
                model="test/model",
                input_tokens=100,
                output_tokens=50,
                cost_usd=0.001,
            )

        mock_processor._call_llm = mock_call_llm

        await mock_processor.extract_page_content(sample_png_file)

        assert sample_png_file.name in call_contexts[0]


# =============================================================================
# Test Edge Cases and Error Handling
# =============================================================================


class TestEdgeCases:
    """Tests for edge cases and error handling."""

    @pytest.mark.asyncio
    async def test_concurrent_access(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Concurrent access respects config.concurrency semaphore."""
        # Set config.concurrency which controls batch-level concurrency
        mock_processor.config.concurrency = 2

        in_progress = 0
        max_concurrent = 0

        async def mock_analyze(paths, context="", document_context=""):
            nonlocal in_progress, max_concurrent
            in_progress += 1
            max_concurrent = max(max_concurrent, in_progress)
            await asyncio.sleep(0.01)  # Simulate work
            in_progress -= 1
            return [ImageAnalysis(caption="Test", description="Desc") for _ in paths]

        with patch.object(mock_processor, "analyze_batch", side_effect=mock_analyze):
            await mock_processor.analyze_images_batch(
                multiple_png_files, max_images_per_batch=1
            )

        # Due to batch semaphore, should not exceed config.concurrency
        assert max_concurrent <= mock_processor.config.concurrency

    @pytest.mark.asyncio
    async def test_empty_cache_value(self, mock_processor: MockVisionProcessor):
        """Empty cache values are handled."""
        mock_processor._persistent_cache.get.return_value = {
            "caption": "",
            "description": "",
            "extracted_text": None,
        }

        with patch.object(
            mock_processor, "_analyze_image_with_fallback", new_callable=AsyncMock
        ) as _:
            # Should still return the empty cached result (it's a cache hit)
            pass  # The real test is that it returns the cached empty values

    @pytest.mark.asyncio
    async def test_bmp_format_converted_to_png(
        self, mock_processor: MockVisionProcessor, tmp_path: Path
    ):
        """BMP format is auto-converted to PNG for LLM vision."""
        # Create a valid 1x1 BMP file
        from PIL import Image

        bmp_file = tmp_path / "image.bmp"
        img = Image.new("RGB", (1, 1), color="red")
        img.save(bmp_file, format="BMP")

        result = await mock_processor.analyze_image(bmp_file)

        # Should go through normal analysis (not blocked as unsupported)
        assert result.caption != ""
        assert "not supported" not in result.description.lower()

    @pytest.mark.asyncio
    async def test_ico_format_unsupported(
        self, mock_processor: MockVisionProcessor, tmp_path: Path
    ):
        """ICO format is not supported for LLM vision."""
        ico_file = tmp_path / "icon.ico"
        ico_file.write_bytes(b"\x00\x00\x01\x00" + b"\x00" * 50)

        result = await mock_processor.analyze_image(ico_file)

        assert "not supported" in result.description.lower()

    @pytest.mark.asyncio
    async def test_webp_format_supported(
        self, mock_processor: MockVisionProcessor, tmp_path: Path
    ):
        """WebP format is supported for LLM vision."""
        webp_file = tmp_path / "image.webp"
        # Minimal WebP header
        webp_file.write_bytes(b"RIFF" + b"\x00" * 4 + b"WEBP" + b"\x00" * 50)

        with patch.object(
            mock_processor, "_analyze_image_with_fallback", new_callable=AsyncMock
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="WebP image",
                description="WebP works",
            )

            result = await mock_processor.analyze_image(webp_file)

            assert result.caption == "WebP image"
            mock_fallback.assert_called_once()

    @pytest.mark.asyncio
    async def test_gif_format_supported(
        self, mock_processor: MockVisionProcessor, tmp_path: Path
    ):
        """GIF format is supported for LLM vision."""
        gif_file = tmp_path / "image.gif"
        # Minimal GIF header
        gif_file.write_bytes(b"GIF89a" + b"\x00" * 50)

        with patch.object(
            mock_processor, "_analyze_image_with_fallback", new_callable=AsyncMock
        ) as mock_fallback:
            mock_fallback.return_value = ImageAnalysis(
                caption="GIF image",
                description="GIF works",
            )

            result = await mock_processor.analyze_image(gif_file)

            assert result.caption == "GIF image"


class TestBatchResultPadding:
    """Tests for batch result padding when LLM returns fewer results."""

    @pytest.mark.asyncio
    async def test_pads_missing_results(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Missing results are padded with placeholders."""
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            # Return fewer results than images
            mock_response = BatchImageAnalysisResult(
                images=[
                    SingleImageResult(
                        image_index=1,
                        caption="Only one",
                        description="Only one result",
                        extracted_text=None,
                    )
                ]
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=100, completion_tokens=50)
            mock_raw.choices = [MagicMock(finish_reason="stop")]

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            result = await mock_processor.analyze_batch(
                multiple_png_files[:3], context="test"
            )

            # Should have 3 results (padded)
            assert len(result) == 3
            assert result[0].caption == "Only one"
            # Remaining should be padded
            assert result[1].caption == "Image"
            assert result[2].caption == "Image"


class TestUsageTracking:
    """Tests for LLM usage tracking."""

    @pytest.mark.asyncio
    async def test_tracks_usage_on_batch_success(
        self, mock_processor: MockVisionProcessor, sample_png_file: Path
    ):
        """Usage is tracked on successful batch analysis."""
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            mock_response = BatchImageAnalysisResult(
                images=[
                    SingleImageResult(
                        image_index=1,
                        caption="Test",
                        description="Test",
                        extracted_text=None,
                    )
                ]
            )
            mock_raw = MagicMock()
            mock_raw.model = "gpt-4-vision"
            mock_raw.usage = MagicMock(prompt_tokens=1000, completion_tokens=200)
            mock_raw.choices = [MagicMock(finish_reason="stop")]
            mock_raw._hidden_params = {"total_cost_usd": 0.05}

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            await mock_processor.analyze_batch([sample_png_file], context="test.pdf")

            # Check usage was tracked
            assert "gpt-4-vision" in mock_processor._usage
            usage = mock_processor._usage["gpt-4-vision"]
            assert usage["input_tokens"] == 1000
            assert usage["output_tokens"] == 200

    @pytest.mark.asyncio
    async def test_per_image_usage_calculation(
        self, mock_processor: MockVisionProcessor, multiple_png_files: list[Path]
    ):
        """Per-image usage is calculated correctly for batch."""
        with patch("markitai.llm.vision.instructor") as mock_instructor:
            mock_client = MagicMock()
            mock_instructor.from_litellm.return_value = mock_client
            mock_instructor.Mode.MD_JSON = "MD_JSON"

            # 3 images processed
            mock_response = BatchImageAnalysisResult(
                images=[
                    SingleImageResult(
                        image_index=i + 1,
                        caption=f"Test{i}",
                        description=f"Desc{i}",
                        extracted_text=None,
                    )
                    for i in range(3)
                ]
            )
            mock_raw = MagicMock()
            mock_raw.model = "test/model"
            mock_raw.usage = MagicMock(prompt_tokens=3000, completion_tokens=300)
            mock_raw.choices = [MagicMock(finish_reason="stop")]
            mock_raw._hidden_params = {"total_cost_usd": 0.03}

            async def mock_create(*args, **kwargs):
                return mock_response, mock_raw

            mock_client.chat.completions.create_with_completion = mock_create

            results = await mock_processor.analyze_batch(
                multiple_png_files[:3], context="test"
            )

            # Each result should have per-image usage
            for r in results:
                assert r.llm_usage is not None
                assert r.llm_usage["test/model"]["input_tokens"] == 1000  # 3000/3
                assert r.llm_usage["test/model"]["output_tokens"] == 100  # 300/3
