"""Tests for workflow/single.py module."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from markitai.workflow.single import ImageAnalysisResult, SingleFileWorkflow


class TestImageAnalysisResult:
    """Tests for ImageAnalysisResult dataclass."""

    def test_basic_creation(self):
        """Test basic creation with required fields."""
        result = ImageAnalysisResult(
            source_file="/path/to/file.pdf",
            assets=[{"asset": "/path/to/image.png", "alt": "Test"}],
        )
        assert result.source_file == "/path/to/file.pdf"
        assert len(result.assets) == 1

    def test_empty_assets(self):
        """Test creation with empty assets."""
        result = ImageAnalysisResult(source_file="file.pdf", assets=[])
        assert result.assets == []

    def test_multiple_assets(self):
        """Test creation with multiple assets."""
        assets = [
            {"asset": "img1.png", "alt": "Image 1"},
            {"asset": "img2.png", "alt": "Image 2"},
        ]
        result = ImageAnalysisResult(source_file="doc.pdf", assets=assets)
        assert len(result.assets) == 2


class TestSingleFileWorkflow:
    """Tests for SingleFileWorkflow class."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(
            return_value={"gpt-4": {"requests": 1, "input_tokens": 100}}
        )
        return processor

    def test_init_with_processor(self, mock_config, mock_processor):
        """Test initialization with existing processor."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)
        assert workflow._processor is mock_processor

    def test_init_without_processor(self, mock_config):
        """Test initialization without processor (lazy creation)."""
        workflow = SingleFileWorkflow(mock_config)
        assert workflow._processor is None

    def test_llm_cost_initial(self, mock_config, mock_processor):
        """Test initial LLM cost is zero."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)
        assert workflow._llm_cost == 0.0

    def test_llm_usage_initial(self, mock_config, mock_processor):
        """Test initial LLM usage is empty."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)
        assert workflow._llm_usage == {}

    def test_processor_property_creates_on_demand(self, mock_config):
        """Test that processor property creates processor on demand."""
        workflow = SingleFileWorkflow(mock_config)

        with patch("markitai.workflow.helpers.create_llm_processor") as mock_create:
            mock_processor = MagicMock()
            mock_create.return_value = mock_processor

            # Access processor property
            processor = workflow.processor

            # Should have created the processor
            mock_create.assert_called_once()
            assert processor is mock_processor

    def test_merge_usage(self, mock_config, mock_processor):
        """Test _merge_usage method."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        # Add some usage
        workflow._merge_usage(
            {
                "gpt-4": {
                    "requests": 1,
                    "input_tokens": 100,
                    "output_tokens": 50,
                    "cost_usd": 0.01,
                }
            }
        )

        assert "gpt-4" in workflow._llm_usage
        assert workflow._llm_usage["gpt-4"]["requests"] == 1

        # Add more usage
        workflow._merge_usage(
            {
                "gpt-4": {
                    "requests": 2,
                    "input_tokens": 200,
                    "output_tokens": 100,
                    "cost_usd": 0.02,
                }
            }
        )

        assert workflow._llm_usage["gpt-4"]["requests"] == 3


class TestSingleFileWorkflowProcessDocument:
    """Tests for SingleFileWorkflow.process_document_with_llm method."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        config.llm.document_mode = "process"
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()
        processor.process_document = AsyncMock()
        processor.process_document.return_value = MagicMock(
            frontmatter="title: Test\nsource: doc.pdf",
            markdown="# Processed Content",
            llm_usage={"gpt-4": {"requests": 1}},
        )
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(return_value={})
        return processor

    @pytest.mark.asyncio
    async def test_process_document_basic(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test basic document processing."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        output_file = tmp_path / "output.md"
        output_file.write_text("# Original Content")

        _result, cost, _usage = await workflow.process_document_with_llm(
            markdown="# Original Content",
            source="doc.pdf",
            output_file=output_file,
        )

        assert mock_processor.process_document.called
        assert cost >= 0

    @pytest.mark.asyncio
    async def test_process_document_forwards_explicit_title(
        self, mock_config, tmp_path: Path
    ) -> None:
        """Explicit titles should be forwarded to the LLM document processor."""
        processor = MagicMock()
        processor.process_document = AsyncMock(
            return_value=("# Processed Content", "title: Test EPUB Document")
        )
        processor.format_llm_output = MagicMock(
            return_value="---\ntitle: Test EPUB Document\n---\n\n# Processed Content"
        )
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(mock_config, processor=processor)

        output_file = tmp_path / "sample.epub.md"
        output_file.write_text("placeholder")

        await workflow.process_document_with_llm(
            markdown=(
                "**Title:** Test EPUB Document\n\n"
                "## Test EPUB Document\n\n"
                "# Chapter 1: Test Content"
            ),
            source="sample.epub",
            output_file=output_file,
            title="Test EPUB Document",
        )

        processor.process_document.assert_awaited_once_with(
            (
                "**Title:** Test EPUB Document\n\n"
                "## Test EPUB Document\n\n"
                "# Chapter 1: Test Content"
            ),
            "sample.epub",
            title="Test EPUB Document",
        )

        llm_output = output_file.with_suffix(".llm.md")
        assert llm_output.exists()
        assert "title: Test EPUB Document" in llm_output.read_text(encoding="utf-8")

    @pytest.mark.asyncio
    async def test_process_document_does_not_stabilize_internally(
        self, mock_config, tmp_path: Path
    ) -> None:
        """process_document_with_llm should write LLM output as-is, without internal stabilization.

        Stabilization is now done externally via stabilize_written_llm_output
        in process_with_standard_llm, not inside process_document_with_llm.
        """
        processor = MagicMock()
        original = """<!-- Page number: 1 -->

# Page 1

Alpha.

<!-- Page number: 2 -->

Tail.
"""
        drifted = (
            "<!-- Page number: 1 -->\n\n# Page 1\n\nAlpha.\n\n"
            "<!-- Page number: 2 -->\n\nBorrowed paragraph.\n\nTail."
        )
        processor.process_document = AsyncMock(
            return_value=(drifted, "title: Test\nsource: doc.pdf")
        )
        processor._stabilize_paged_markdown = MagicMock(return_value=original)

        def _format(markdown: str, frontmatter: str) -> str:
            return f"---\n{frontmatter}\n---\n\n{markdown}"

        processor.format_llm_output = MagicMock(side_effect=_format)
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(mock_config, processor=processor)

        output_file = tmp_path / "doc.pdf.md"
        output_file.write_text(f"---\ntitle: Base\n---\n\n{original}")

        await workflow.process_document_with_llm(
            markdown=original,
            source="doc.pdf",
            output_file=output_file,
        )

        # _stabilize_paged_markdown should NOT be called within process_document_with_llm
        processor._stabilize_paged_markdown.assert_not_called()

        # .llm.md contains the LLM output as-is (drifted content is not fixed here)
        llm_output = output_file.with_suffix(".llm.md")
        assert llm_output.exists()
        assert "Borrowed paragraph." in llm_output.read_text(encoding="utf-8")

    @pytest.mark.asyncio
    async def test_process_document_appends_reference_image_comments(
        self, mock_config, tmp_path: Path
    ) -> None:
        """Reference-only extracted images should be appended as comments."""
        processor = MagicMock()
        processor.process_document = AsyncMock(
            return_value=("# Processed Content", "title: Test\nsource: doc.pdf")
        )
        processor.format_llm_output = MagicMock(
            return_value="---\ntitle: Test\nsource: doc.pdf\n---\n\n# Processed Content"
        )
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(mock_config, processor=processor)

        output_file = tmp_path / "doc.pdf.md"
        output_file.write_text("# Original Content")

        await workflow.process_document_with_llm(
            markdown="# Original Content",
            source="doc.pdf",
            output_file=output_file,
            reference_images=[
                {
                    "page": 2,
                    "name": "doc.pdf-0002-04.jpg",
                    "rel_path": ".markitai/assets/doc.pdf-0002-04.jpg",
                }
            ],
        )

        llm_output = output_file.with_suffix(".llm.md")
        content = llm_output.read_text(encoding="utf-8")
        assert "<!-- Page images for reference -->" in content
        assert "<!-- ![Page 2](.markitai/assets/doc.pdf-0002-04.jpg) -->" in content


class TestSingleFileWorkflowAnalyzeImages:
    """Tests for SingleFileWorkflow.analyze_images method."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 2
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()

        # Mock image analysis
        @dataclass
        class MockImageAnalysis:
            caption: str = "Test caption"
            description: str = "Test description"
            extracted_text: str = ""
            llm_usage: dict[str, Any] | None = None

        processor.analyze_image = AsyncMock(return_value=MockImageAnalysis())
        processor.get_context_cost = MagicMock(return_value=0.02)
        processor.get_context_usage = MagicMock(return_value={"gpt-4": {"requests": 1}})
        return processor

    @pytest.mark.asyncio
    async def test_analyze_images_empty_list(self, mock_config, mock_processor):
        """Test analyzing empty image list returns original markdown."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        markdown, _cost, _usage, result = await workflow.analyze_images(
            markdown="# Content",
            image_paths=[],
            output_file=Path("/tmp/test.md"),
        )

        # With empty image list, markdown should be unchanged
        assert markdown == "# Content"
        # Result should be None (no images to analyze)
        assert result is None
        # Cost and usage come from processor's context tracking
        # (may be non-zero if processor tracks any overhead)

    @pytest.mark.asyncio
    async def test_analyze_images_with_images(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test analyzing images."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        # Create test image file
        assets_dir = tmp_path / ".markitai" / "assets"
        assets_dir.mkdir(parents=True)
        image_file = assets_dir / "test.png"
        image_file.write_bytes(b"fake image data")

        output_file = tmp_path / "output.md"
        output_file.write_text("# Content\n\n![](.markitai/assets/test.png)")

        _markdown, cost, _usage, _result = await workflow.analyze_images(
            markdown="# Content\n\n![](.markitai/assets/test.png)",
            image_paths=[image_file],
            output_file=output_file,
        )

        # Should have called analyze_image
        assert mock_processor.analyze_image.called
        assert cost > 0

    @pytest.mark.asyncio
    async def test_analyze_images_standalone_image(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test analyzing a standalone image file."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        # Create test image file
        assets_dir = tmp_path / ".markitai" / "assets"
        assets_dir.mkdir(parents=True)
        image_file = assets_dir / "standalone.png"
        image_file.write_bytes(b"fake image data")

        input_path = tmp_path / "standalone.png"
        input_path.write_bytes(b"fake image data")

        output_file = tmp_path / "standalone.png.md"
        output_file.write_text("# standalone\n\n![](.markitai/assets/standalone.png)")

        _markdown, _cost, _usage, _result = await workflow.analyze_images(
            markdown="# standalone\n\n![](.markitai/assets/standalone.png)",
            image_paths=[image_file],
            output_file=output_file,
            input_path=input_path,  # Pass original image as input
        )

        # Should have called analyze_image
        assert mock_processor.analyze_image.called

        # For standalone images with desc_enabled, result should have data
        if mock_config.image.desc_enabled:
            # Check that .llm.md was created with rich content
            llm_file = output_file.with_suffix(".llm.md")
            if llm_file.exists():
                content = llm_file.read_text()
                assert "---" in content  # Has frontmatter


class TestSingleFileWorkflowEnhanceWithVision:
    """Tests for SingleFileWorkflow.enhance_with_vision method."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()
        # enhance_with_vision calls enhance_document_complete, not enhance_document_with_vision
        processor.enhance_document_complete = AsyncMock(
            return_value=(
                "# Enhanced Content",  # cleaned_content
                "title: Enhanced\nmarkitai_processed: 2026-01-01",  # frontmatter
            )
        )

        def _stabilize(
            original: str,
            cleaned: str,
            source: str,
        ) -> str:
            del original, source
            return cleaned

        processor._stabilize_paged_markdown = MagicMock(side_effect=_stabilize)
        processor.get_context_cost = MagicMock(return_value=0.10)
        processor.get_context_usage = MagicMock(return_value={"gpt-4": {"requests": 1}})
        return processor

    @pytest.mark.asyncio
    async def test_enhance_with_vision(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test document enhancement with vision."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        # Create temp image files
        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image 1")
        img2 = tmp_path / "page2.png"
        img2.write_bytes(b"fake image 2")

        page_images = [
            {"path": str(img1), "page": 1},
            {"path": str(img2), "page": 2},
        ]

        markdown, frontmatter, _cost, _usage = await workflow.enhance_with_vision(
            extracted_text="Original text",
            page_images=page_images,
            source="doc.pdf",
        )

        assert mock_processor.enhance_document_complete.called
        assert "Enhanced" in frontmatter
        assert "Enhanced Content" in markdown

    @pytest.mark.asyncio
    async def test_enhance_with_vision_forwards_original_title(
        self, mock_config, mock_processor, tmp_path: Path
    ) -> None:
        """Vision workflow should forward explicit title to document enhancer."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image 1")

        page_images = [{"path": str(img1), "page": 1}]

        await workflow.enhance_with_vision(
            extracted_text="# Chapter 1",
            page_images=page_images,
            source="doc.pdf",
            original_title="Canonical Document Title",
        )

        mock_processor.enhance_document_complete.assert_awaited_once_with(
            "# Chapter 1",
            [img1],
            source="doc.pdf",
            original_title="Canonical Document Title",
        )

    @pytest.mark.asyncio
    async def test_enhance_with_vision_sorts_by_page(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test that page images are sorted by page number."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        # Create temp image files
        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image 1")
        img2 = tmp_path / "page2.png"
        img2.write_bytes(b"fake image 2")
        img3 = tmp_path / "page3.png"
        img3.write_bytes(b"fake image 3")

        # Provide in unsorted order
        page_images = [
            {"path": str(img3), "page": 3},
            {"path": str(img1), "page": 1},
            {"path": str(img2), "page": 2},
        ]

        await workflow.enhance_with_vision(
            extracted_text="Original text",
            page_images=page_images,
            source="doc.pdf",
        )

        # Verify enhance_document_complete was called with sorted paths
        call_args = mock_processor.enhance_document_complete.call_args
        image_paths = call_args[0][1]  # Second positional arg
        assert len(image_paths) == 3
        assert image_paths[0].name == "page1.png"
        assert image_paths[1].name == "page2.png"
        assert image_paths[2].name == "page3.png"

    @pytest.mark.asyncio
    async def test_enhance_with_vision_does_not_stabilize_internally(
        self, mock_config, tmp_path: Path
    ) -> None:
        """enhance_with_vision should return LLM output as-is, without internal stabilization.

        Stabilization is now done externally via stabilize_written_llm_output
        in convert_document_core, not inside enhance_with_vision.
        """
        processor = MagicMock()
        drifted = (
            "<!-- Page number: 1 -->\n\n# Page 1\n\nAlpha.\n\n"
            "<!-- Page number: 2 -->\n\nBorrowed paragraph.\n\nTail."
        )
        processor.enhance_document_complete = AsyncMock(
            return_value=(drifted, "title: Enhanced\nmarkitai_processed: 2026-01-01")
        )
        original = """<!-- Page number: 1 -->

# Page 1

Alpha.

<!-- Page number: 2 -->

Tail.
"""
        processor._stabilize_paged_markdown = MagicMock(return_value=original)
        processor.get_context_cost = MagicMock(return_value=0.10)
        processor.get_context_usage = MagicMock(return_value={"gpt-4": {"requests": 1}})

        workflow = SingleFileWorkflow(mock_config, processor=processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image 1")
        img2 = tmp_path / "page2.png"
        img2.write_bytes(b"fake image 2")

        markdown, _, _, _ = await workflow.enhance_with_vision(
            extracted_text=original,
            page_images=[
                {"path": str(img1), "page": 1},
                {"path": str(img2), "page": 2},
            ],
            source="doc.pdf",
        )

        # _stabilize_paged_markdown should NOT be called within enhance_with_vision
        processor._stabilize_paged_markdown.assert_not_called()

        # The returned markdown is the raw LLM output (drifted content not fixed here)
        assert markdown == drifted

    @pytest.mark.asyncio
    async def test_enhance_with_vision_handles_error(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test error handling in enhance_with_vision."""
        # Make processor raise an error
        mock_processor.enhance_document_complete = AsyncMock(
            side_effect=RuntimeError("LLM API error")
        )
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image")

        page_images = [{"path": str(img1), "page": 1}]

        markdown, frontmatter, cost, usage = await workflow.enhance_with_vision(
            extracted_text="Original text",
            page_images=page_images,
            source="doc.pdf",
        )

        # Should return original text on error
        assert markdown == "Original text"
        assert "doc.pdf" in frontmatter
        assert cost == 0.0
        assert usage == {}


class TestSingleFileWorkflowExtractFromScreenshots:
    """Tests for SingleFileWorkflow.extract_from_screenshots method."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()
        processor.extract_from_screenshot = AsyncMock(
            return_value=("# Page content", "title: Test")
        )
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(return_value={"gpt-4": {"requests": 1}})
        return processor

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_empty_list(
        self, mock_config, mock_processor
    ):
        """Test extraction with empty page images list."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        markdown, frontmatter, cost, usage = await workflow.extract_from_screenshots(
            page_images=[],
            source="doc.pdf",
        )

        assert markdown == ""
        assert "doc.pdf" in frontmatter
        assert cost == 0.0
        assert usage == {}

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_single_page(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test extraction from a single page."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image")

        page_images = [{"path": str(img1), "page": 1}]

        markdown, _frontmatter, _cost, _usage = await workflow.extract_from_screenshots(
            page_images=page_images,
            source="doc.pdf",
        )

        assert mock_processor.extract_from_screenshot.called
        assert "Page 1" in markdown
        assert "Page content" in markdown

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_preserves_original_title(
        self, mock_config, mock_processor, tmp_path: Path
    ) -> None:
        """Screenshot-only extraction should preserve explicit document title."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image")

        page_images = [{"path": str(img1), "page": 1}]

        markdown, frontmatter, cost, usage = await workflow.extract_from_screenshots(
            page_images=page_images,
            source="doc.pdf",
            original_title="Canonical Document Title",
        )

        assert "Canonical Document Title" in frontmatter
        mock_processor.extract_from_screenshot.assert_awaited_once_with(
            img1,
            context="doc.pdf:page1",
            original_title="Canonical Document Title",
        )
        assert "Page content" in markdown
        assert cost == 0.05
        assert usage == {"gpt-4": {"requests": 1}}

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_multiple_pages(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test extraction from multiple pages."""
        # Make each call return different content
        call_count = [0]

        async def mock_extract(image_path, context=None, original_title=None):
            call_count[0] += 1
            return (f"Content from page {call_count[0]}", "")

        mock_processor.extract_from_screenshot = mock_extract
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image 1")
        img2 = tmp_path / "page2.png"
        img2.write_bytes(b"fake image 2")
        img3 = tmp_path / "page3.png"
        img3.write_bytes(b"fake image 3")

        page_images = [
            {"path": str(img2), "page": 2},
            {"path": str(img1), "page": 1},
            {"path": str(img3), "page": 3},
        ]

        markdown, _frontmatter, _cost, _usage = await workflow.extract_from_screenshots(
            page_images=page_images,
            source="doc.pdf",
        )

        # Should contain all page markers (sorted order)
        assert "Page 1" in markdown
        assert "Page 2" in markdown
        assert "Page 3" in markdown

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_handles_error(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test error handling in extract_from_screenshots."""
        mock_processor.extract_from_screenshot = AsyncMock(
            side_effect=RuntimeError("Vision API error")
        )
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        img1 = tmp_path / "page1.png"
        img1.write_bytes(b"fake image")

        page_images = [{"path": str(img1), "page": 1}]

        markdown, frontmatter, cost, _usage = await workflow.extract_from_screenshots(
            page_images=page_images,
            source="doc.pdf",
        )

        # Should return empty on error
        assert markdown == ""
        assert "doc.pdf" in frontmatter
        assert cost == 0.0


class TestSingleFileWorkflowNoCacheOptions:
    """Tests for SingleFileWorkflow cache options."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = False
        config.image.desc_enabled = False
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        config.model_copy = MagicMock(return_value=config)
        return config

    def test_init_with_no_cache(self, mock_config):
        """Test initialization with no_cache option."""
        workflow = SingleFileWorkflow(mock_config, no_cache=True)
        assert workflow._no_cache is True

    def test_init_with_no_cache_patterns(self, mock_config):
        """Test initialization with no_cache_patterns."""
        patterns = ["*.pdf", "docs/*"]
        workflow = SingleFileWorkflow(mock_config, no_cache_patterns=patterns)
        assert workflow._no_cache_patterns == patterns

    def test_processor_applies_no_cache_to_config(self, mock_config):
        """Test that processor creation applies no_cache settings."""
        with patch("markitai.workflow.helpers.create_llm_processor") as mock_create:
            mock_processor = MagicMock()
            mock_create.return_value = mock_processor

            workflow = SingleFileWorkflow(
                mock_config,
                no_cache=True,
                no_cache_patterns=["*.pdf"],
            )

            # Access processor to trigger creation
            _ = workflow.processor

            # Verify create_llm_processor was called
            mock_create.assert_called_once()
            # The temp_config should have no_cache settings applied
            call_config = mock_create.call_args[0][0]
            assert call_config.cache.no_cache is True
            assert call_config.cache.no_cache_patterns == ["*.pdf"]


class TestSingleFileWorkflowAnalyzeImagesAltTextUpdate:
    """Tests for analyze_images alt text update behavior."""

    @pytest.fixture
    def mock_config(self):
        """Create a mock configuration."""
        config = MagicMock()
        config.llm.concurrency = 2
        config.image.alt_enabled = True
        config.image.desc_enabled = False
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        """Create a mock LLM processor."""
        processor = MagicMock()

        @dataclass
        class MockImageAnalysis:
            caption: str = "A beautiful sunset"
            description: str = "The image shows a sunset"
            extracted_text: str = ""
            llm_usage: dict[str, Any] | None = None

        processor.analyze_image = AsyncMock(return_value=MockImageAnalysis())
        processor.get_context_cost = MagicMock(return_value=0.02)
        processor.get_context_usage = MagicMock(return_value={"gpt-4": {"requests": 1}})
        return processor

    @pytest.mark.asyncio
    async def test_updates_alt_text_in_markdown(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test that alt text is updated in returned markdown."""
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        assets_dir = tmp_path / ".markitai" / "assets"
        assets_dir.mkdir(parents=True)
        image_file = assets_dir / "sunset.png"
        image_file.write_bytes(b"fake image")

        output_file = tmp_path / "output.md"
        output_file.write_text("# Content\n\n![](.markitai/assets/sunset.png)")

        markdown, _cost, _usage, _result = await workflow.analyze_images(
            markdown="# Content\n\n![](.markitai/assets/sunset.png)",
            image_paths=[image_file],
            output_file=output_file,
        )

        # Alt text should be updated in the returned markdown
        assert "![A beautiful sunset](.markitai/assets/sunset.png)" in markdown

    @pytest.mark.asyncio
    async def test_handles_failed_analysis(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test handling when image analysis fails."""
        # Make analysis return None to simulate failure
        mock_processor.analyze_image = AsyncMock(return_value=None)
        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        assets_dir = tmp_path / ".markitai" / "assets"
        assets_dir.mkdir(parents=True)
        image_file = assets_dir / "broken.png"
        image_file.write_bytes(b"fake")

        output_file = tmp_path / "output.md"
        output_file.write_text("# Content\n\n![](.markitai/assets/broken.png)")

        _markdown, _cost, _usage, result = await workflow.analyze_images(
            markdown="# Content\n\n![](.markitai/assets/broken.png)",
            image_paths=[image_file],
            output_file=output_file,
        )

        # Should still return result with default values
        assert result is not None
        assert len(result.assets) == 1
        # Default caption should be used
        assert result.assets[0]["alt"] == "Image"

    @pytest.mark.asyncio
    async def test_empty_caption_falls_back_to_default(
        self, mock_config, mock_processor, tmp_path: Path
    ):
        """Test that empty caption from successful analysis falls back to 'Image'.

        When LLM returns a successful ImageAnalysis but with empty caption,
        the alt text should not be empty — it should fall back to 'Image'.
        This prevents empty ![]() references in the output markdown.
        """

        @dataclass
        class EmptyCaptionAnalysis:
            caption: str = ""
            description: str = "A chart showing data"
            extracted_text: str = ""
            llm_usage: dict[str, Any] | None = None

        mock_processor.analyze_image = AsyncMock(return_value=EmptyCaptionAnalysis())
        mock_processor.get_context_cost = MagicMock(return_value=0.01)
        mock_processor.get_context_usage = MagicMock(
            return_value={"gpt-4": {"requests": 1}}
        )

        workflow = SingleFileWorkflow(mock_config, processor=mock_processor)

        assets_dir = tmp_path / ".markitai" / "assets"
        assets_dir.mkdir(parents=True)
        image_file = assets_dir / "chart.png"
        image_file.write_bytes(b"fake image")

        output_file = tmp_path / "output.md"
        output_file.write_text("# Report\n\n![](.markitai/assets/chart.png)")

        markdown, _cost, _usage, result = await workflow.analyze_images(
            markdown="# Report\n\n![](.markitai/assets/chart.png)",
            image_paths=[image_file],
            output_file=output_file,
        )

        # Alt text should NOT be empty — should fall back to "Image"
        assert "![](.markitai/assets/chart.png)" not in markdown
        assert "![Image](.markitai/assets/chart.png)" in markdown

        # Asset description should also have non-empty alt
        assert result is not None
        assert result.assets[0]["alt"] == "Image"


class TestEnhanceWithVisionNoDoubleStabilize:
    """Verify enhance_with_vision does not call maybe_stabilize_markdown."""

    async def test_no_redundant_stabilize_call(self, tmp_path: Path):
        """enhance_with_vision should not call maybe_stabilize_markdown."""
        from markitai.config import MarkitaiConfig
        from markitai.workflow.single import SingleFileWorkflow

        config = MarkitaiConfig()
        mock_processor = MagicMock()
        mock_processor.enhance_document_complete = AsyncMock(
            return_value=("cleaned md", "---\ntitle: test\n---")
        )
        mock_processor.get_context_cost = MagicMock(return_value=0.001)
        mock_processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(config, processor=mock_processor)

        page_images = [{"path": str(tmp_path / "page1.png"), "page": 1}]
        (tmp_path / "page1.png").write_bytes(b"fake")

        with patch(
            "markitai.workflow.helpers.maybe_stabilize_markdown"
        ) as mock_stabilize:
            result = await workflow.enhance_with_vision(
                "original md", page_images, source="test.pptx"
            )
            mock_stabilize.assert_not_called()

        assert result[0] == "cleaned md"


class TestProcessDocumentNoDoubleStabilize:
    """Verify process_document_with_llm does not call maybe_stabilize_markdown."""

    async def test_no_redundant_stabilize_call(self, tmp_path: Path):
        """process_document_with_llm should not call maybe_stabilize_markdown."""
        from markitai.config import MarkitaiConfig
        from markitai.workflow.single import SingleFileWorkflow

        config = MarkitaiConfig()
        mock_processor = MagicMock()
        mock_processor.process_document = AsyncMock(
            return_value=("cleaned md", "---\ntitle: test\n---")
        )
        mock_processor.format_llm_output = MagicMock(
            return_value="---\ntitle: test\n---\n\ncleaned md"
        )
        mock_processor.get_context_cost = MagicMock(return_value=0.001)
        mock_processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(config, processor=mock_processor)

        output_file = tmp_path / "test.md"
        output_file.write_text("# original", encoding="utf-8")

        with patch(
            "markitai.workflow.helpers.maybe_stabilize_markdown"
        ) as mock_stabilize:
            await workflow.process_document_with_llm(
                "original md", "test.txt", output_file
            )
            mock_stabilize.assert_not_called()


class TestWorkflowResult:
    """Tests for WorkflowResult dataclass."""

    def test_basic_creation(self):
        """Test basic WorkflowResult creation."""
        from markitai.workflow.single import WorkflowResult

        result = WorkflowResult(markdown="# Test")
        assert result.markdown == "# Test"
        assert result.llm_cost == 0.0
        assert result.llm_usage == {}
        assert result.image_analysis is None

    def test_with_all_fields(self):
        """Test WorkflowResult with all fields."""
        from markitai.workflow.single import ImageAnalysisResult, WorkflowResult

        analysis = ImageAnalysisResult(
            source_file="test.pdf",
            assets=[{"asset": "img.png", "alt": "Test"}],
        )
        result = WorkflowResult(
            markdown="# Test",
            llm_cost=0.05,
            llm_usage={"gpt-4": {"requests": 1}},
            image_analysis=analysis,
        )

        assert result.llm_cost == 0.05
        assert result.llm_usage["gpt-4"]["requests"] == 1
        assert result.image_analysis is not None
        assert result.image_analysis.source_file == "test.pdf"


class TestProcessDocumentPure:
    """Test process_document_pure method."""

    async def test_pure_writes_llm_output_directly(self, tmp_path: Path):
        """process_document_pure should write LLM response directly to .llm.md."""
        from unittest.mock import AsyncMock, MagicMock

        from markitai.config import MarkitaiConfig
        from markitai.workflow.single import SingleFileWorkflow

        config = MarkitaiConfig()
        mock_processor = MagicMock()
        mock_processor.clean_document_pure = AsyncMock(
            return_value="LLM cleaned output"
        )
        mock_processor.get_context_cost = MagicMock(return_value=0.001)
        mock_processor.get_context_usage = MagicMock(
            return_value={"model": {"requests": 1}}
        )

        workflow = SingleFileWorkflow(config, processor=mock_processor)

        output_file = tmp_path / "test.md"
        output_file.write_text("# original", encoding="utf-8")

        markdown, cost, usage = await workflow.process_document_pure(
            "# original markdown", "test.txt", output_file
        )

        # Verify .llm.md written with raw LLM output (no frontmatter wrapping)
        llm_file = tmp_path / "test.llm.md"
        assert llm_file.exists()
        content = llm_file.read_text(encoding="utf-8")
        assert content == "LLM cleaned output"

        # Verify return values
        assert markdown == "# original markdown"  # Original returned unchanged
        assert cost == 0.001
        assert usage == {"model": {"requests": 1}}

    async def test_pure_does_not_call_format_llm_output(self, tmp_path: Path):
        """process_document_pure should NOT call format_llm_output."""
        from unittest.mock import AsyncMock, MagicMock

        from markitai.config import MarkitaiConfig
        from markitai.workflow.single import SingleFileWorkflow

        config = MarkitaiConfig()
        mock_processor = MagicMock()
        mock_processor.clean_document_pure = AsyncMock(return_value="output")
        mock_processor.get_context_cost = MagicMock(return_value=0.0)
        mock_processor.get_context_usage = MagicMock(return_value={})

        workflow = SingleFileWorkflow(config, processor=mock_processor)

        output_file = tmp_path / "test.md"
        output_file.write_text("# x", encoding="utf-8")

        await workflow.process_document_pure("# x", "test.txt", output_file)
        mock_processor.format_llm_output.assert_not_called()


class TestLLMFailureErrorLogging:
    """Tests that LLM failures emit logger.error visible even in quiet mode.

    The logging_config adds an ERROR-level console handler in quiet mode,
    so logger.error() is the mechanism for user-visible failure notices.
    """

    @pytest.fixture
    def mock_config(self):
        config = MagicMock()
        config.llm.concurrency = 5
        config.image.alt_enabled = True
        config.image.desc_enabled = True
        config.cache.no_cache = False
        config.cache.no_cache_patterns = []
        return config

    @pytest.fixture
    def mock_processor(self):
        processor = MagicMock()
        processor.get_context_cost = MagicMock(return_value=0.05)
        processor.get_context_usage = MagicMock(
            return_value={"gpt-4": {"requests": 1, "input_tokens": 100}}
        )
        return processor

    @pytest.mark.asyncio
    async def test_process_document_pure_logs_error_on_failure(
        self, mock_config, mock_processor
    ) -> None:
        """process_document_pure should log error on exception."""
        mock_processor.clean_document_pure = AsyncMock(
            side_effect=RuntimeError("API error")
        )
        workflow = SingleFileWorkflow(mock_config, mock_processor)
        with patch("markitai.workflow.single.logger") as mock_logger:
            await workflow.process_document_pure(
                "# test", "test.md", Path("/tmp/test.md")
            )
            mock_logger.error.assert_called_once()
            assert "failed" in mock_logger.error.call_args[0][0].lower()

    @pytest.mark.asyncio
    async def test_analyze_images_outer_except_logs_error(
        self, mock_config, mock_processor
    ) -> None:
        """analyze_images outer except should log error."""
        # Make get_context_cost raise to trigger the outer except block.
        # Note: per-image failures are caught inside analyze_single_image and
        # returned as None — they don't reach this outer handler.
        mock_processor.analyze_image = AsyncMock(return_value=None)
        mock_processor.get_context_cost = MagicMock(
            side_effect=RuntimeError("cost lookup error")
        )
        workflow = SingleFileWorkflow(mock_config, mock_processor)
        with patch("markitai.workflow.single.logger") as mock_logger:
            await workflow.analyze_images(
                [Path("/tmp/img.png")], "# test", Path("/tmp/test.md")
            )
            mock_logger.error.assert_called_once()
            assert "failed" in mock_logger.error.call_args[0][0].lower()

    @pytest.mark.asyncio
    async def test_extract_from_screenshots_logs_error_on_failure(
        self, mock_config, mock_processor
    ) -> None:
        """extract_from_screenshots should log error on exception."""
        # Mock the singular method that the production code actually calls
        mock_processor.extract_from_screenshot = AsyncMock(
            side_effect=RuntimeError("Screenshot extraction error")
        )
        workflow = SingleFileWorkflow(mock_config, mock_processor)
        with patch("markitai.workflow.single.logger") as mock_logger:
            await workflow.extract_from_screenshots(
                page_images=[{"path": "/tmp/page1.png", "page": 1}],
                source="test.pdf",
            )
            mock_logger.error.assert_called_once()
            assert "failed" in mock_logger.error.call_args[0][0].lower()
