"""
Unit tests for OCR document processing tasks.

These tests verify OCR processing logic with mocked external AI/ML services
(OpenAI, Azure Document Intelligence). Tests cover typical and edge cases.
"""

from unittest.mock import MagicMock, Mock, patch

import pytest
from azure.ai.documentintelligence.models import AnalyzeResult

from app.tasks.process_with_azure_document_intelligence import (
    AZURE_DOC_INTELLIGENCE_LIMITS,
    check_page_rotation,
    get_pdf_page_count,
    process_with_azure_document_intelligence,
)
from app.tasks.refine_text_with_gpt import refine_text_with_gpt
from app.tasks.rotate_pdf_pages import determine_rotation_angle, rotate_pdf_pages


@pytest.mark.unit
class TestProcessWithAzureDocumentIntelligence:
    """Tests for Azure Document Intelligence OCR processing."""

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_successful_ocr_processing(self, mock_log, tmp_path):
        """Test successful OCR processing with Azure Document Intelligence."""
        # Create tmp directory and test PDF file
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<<
/Size 4
/Root 1 0 R
>>
startxref
197
%%EOF
"""
        test_pdf.write_bytes(pdf_content)

        # Mock Azure Document Intelligence client and response
        mock_page = Mock()
        mock_page.angle = 0

        mock_result = Mock(spec=AnalyzeResult)
        mock_result.content = "This is extracted text from OCR"
        mock_result.model_id = "prebuilt-read"
        mock_result.pages = [mock_page]

        mock_poller = Mock()
        mock_poller.result.return_value = mock_result
        mock_poller.details = {"operation_id": "test-operation-123"}

        mock_client = Mock()
        mock_client.begin_analyze_document.return_value = mock_poller
        mock_client.get_analyze_result_pdf.return_value = iter([b"searchable pdf content"])

        with (
            patch(
                "app.tasks.process_with_azure_document_intelligence.document_intelligence_client",
                mock_client,
            ),
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
            patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages") as mock_rotate,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_rotate.delay = MagicMock()

            # Run the task
            result = process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=1)

            # Verify results
            assert result["file"] == test_pdf.name
            assert result["cleaned_text"] == "This is extracted text from OCR"
            assert "searchable_pdf" in result

            # Verify Azure client was called correctly
            mock_client.begin_analyze_document.assert_called_once()
            mock_client.get_analyze_result_pdf.assert_called_once_with(
                model_id="prebuilt-read", result_id="test-operation-123"
            )

            # Verify rotate_pdf_pages was queued
            mock_rotate.delay.assert_called_once()
            call_args = mock_rotate.delay.call_args
            assert call_args[0][0] == test_pdf.name
            assert call_args[0][1] == "This is extracted text from OCR"
            assert call_args[0][3] == 1  # file_id

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_file_not_found_error(self, mock_log, tmp_path):
        """Test that FileNotFoundError is raised when file doesn't exist."""
        with patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings:
            mock_settings.workdir = str(tmp_path)

            with pytest.raises(FileNotFoundError) as exc_info:
                process_with_azure_document_intelligence.run(filename="nonexistent.pdf", file_id=1)

            assert "Local file not found" in str(exc_info.value)

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_file_size_limit_exceeded(self, mock_log, tmp_path):
        """Test file size limit validation (500 MB)."""
        # Create tmp directory and test PDF file
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "large.pdf"
        test_pdf.write_bytes(b"dummy content")

        with (
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
            patch("app.tasks.process_with_azure_document_intelligence.os.path.getsize") as mock_getsize,
        ):
            mock_settings.workdir = str(tmp_path)
            # Mock file size to be larger than 500 MB
            mock_getsize.return_value = AZURE_DOC_INTELLIGENCE_LIMITS["max_file_size_bytes"] + 1024

            result = process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=1)

            # Verify error response
            assert "error" in result
            assert "Size limit exceeded" in result["status"]
            assert "500 MB" in result["error"]

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_page_count_limit_exceeded(self, mock_log, tmp_path):
        """Test page count limit validation (2000 pages)."""
        # Create tmp directory and test PDF file
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "many_pages.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")  # Minimal valid PDF

        with (
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
            patch("app.tasks.process_with_azure_document_intelligence.get_pdf_page_count") as mock_page_count,
        ):
            mock_settings.workdir = str(tmp_path)
            # Mock page count to exceed limit
            mock_page_count.return_value = AZURE_DOC_INTELLIGENCE_LIMITS["max_pages"] + 1

            result = process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=1)

            # Verify error response
            assert "error" in result
            assert "Page limit exceeded" in result["status"]
            assert "2000 pages" in result["error"]

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_page_count_unknown_proceeds_with_processing(self, mock_log, tmp_path):
        """Test that processing continues when page count cannot be determined."""
        # Create tmp directory and test PDF file
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        mock_page = Mock()
        mock_page.angle = 0

        mock_result = Mock(spec=AnalyzeResult)
        mock_result.content = "Test content"
        mock_result.model_id = "prebuilt-read"
        mock_result.pages = [mock_page]

        mock_poller = Mock()
        mock_poller.result.return_value = mock_result
        mock_poller.details = {"operation_id": "test-123"}

        mock_client = Mock()
        mock_client.begin_analyze_document.return_value = mock_poller
        mock_client.get_analyze_result_pdf.return_value = iter([b"content"])

        with (
            patch(
                "app.tasks.process_with_azure_document_intelligence.document_intelligence_client",
                mock_client,
            ),
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
            patch("app.tasks.process_with_azure_document_intelligence.get_pdf_page_count") as mock_page_count,
            patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages"),
        ):
            mock_settings.workdir = str(tmp_path)
            # Return None to simulate page count determination failure
            mock_page_count.return_value = None

            # Should not raise an error
            result = process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=1)

            # Verify processing continued
            assert "error" not in result
            assert result["file"] == test_pdf.name

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_page_rotation_detection(self, mock_log, tmp_path):
        """Test detection and logging of page rotation."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "rotated.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        # Mock pages with different rotation angles
        mock_page1 = Mock()
        mock_page1.angle = 90

        mock_page2 = Mock()
        mock_page2.angle = 0

        mock_page3 = Mock()
        mock_page3.angle = 180

        mock_result = Mock(spec=AnalyzeResult)
        mock_result.content = "Rotated document text"
        mock_result.model_id = "prebuilt-read"
        mock_result.pages = [mock_page1, mock_page2, mock_page3]

        mock_poller = Mock()
        mock_poller.result.return_value = mock_result
        mock_poller.details = {"operation_id": "test-456"}

        mock_client = Mock()
        mock_client.begin_analyze_document.return_value = mock_poller
        mock_client.get_analyze_result_pdf.return_value = iter([b"content"])

        with (
            patch(
                "app.tasks.process_with_azure_document_intelligence.document_intelligence_client",
                mock_client,
            ),
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
            patch("app.tasks.process_with_azure_document_intelligence.rotate_pdf_pages") as mock_rotate,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_rotate.delay = MagicMock()

            result = process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=2)

            # Verify rotation data was passed correctly
            mock_rotate.delay.assert_called_once()
            call_args = mock_rotate.delay.call_args
            rotation_data = call_args[0][2]

            # Check rotation data structure (page indices as integers)
            assert 0 in rotation_data and rotation_data[0] == 90
            assert 1 not in rotation_data  # Page 2 has no rotation
            assert 2 in rotation_data and rotation_data[2] == 180

    @patch("app.tasks.process_with_azure_document_intelligence.log_task_progress")
    def test_azure_api_error_handling(self, mock_log, tmp_path):
        """Test error handling when Azure API fails."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        mock_client = Mock()
        mock_client.begin_analyze_document.side_effect = Exception("Azure API connection failed")

        with (
            patch(
                "app.tasks.process_with_azure_document_intelligence.document_intelligence_client",
                mock_client,
            ),
            patch("app.tasks.process_with_azure_document_intelligence.settings") as mock_settings,
        ):
            mock_settings.workdir = str(tmp_path)

            # Should raise the exception
            with pytest.raises(Exception) as exc_info:
                process_with_azure_document_intelligence.run(filename=test_pdf.name, file_id=1)

            assert "Azure API connection failed" in str(exc_info.value)

    def test_get_pdf_page_count_success(self, tmp_path):
        """Test successful page count extraction from PDF."""
        # Create a PDF with text content
        test_pdf = tmp_path / "test.pdf"
        pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R 4 0 R]
/Count 2
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
>>
endobj
4 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
>>
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000119 00000 n
0000000182 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
245
%%EOF
"""
        test_pdf.write_bytes(pdf_content)

        page_count = get_pdf_page_count(str(test_pdf))
        assert page_count == 2

    def test_get_pdf_page_count_error(self, tmp_path):
        """Test page count returns None on error."""
        # Create an invalid PDF file
        test_pdf = tmp_path / "invalid.pdf"
        test_pdf.write_bytes(b"not a valid pdf")

        page_count = get_pdf_page_count(str(test_pdf))
        assert page_count is None

    def test_check_page_rotation_with_rotated_pages(self):
        """Test check_page_rotation with rotated pages."""
        # Mock pages with rotation
        mock_page1 = Mock()
        mock_page1.angle = 90

        mock_page2 = Mock()
        mock_page2.angle = 0

        mock_result = Mock()
        mock_result.pages = [mock_page1, mock_page2]

        rotation_data = check_page_rotation(mock_result, "test.pdf")

        assert 0 in rotation_data
        assert rotation_data[0] == 90
        assert 1 not in rotation_data  # No rotation for page 2

    def test_check_page_rotation_no_pages(self):
        """Test check_page_rotation with no page information."""
        mock_result = Mock()
        mock_result.pages = None

        rotation_data = check_page_rotation(mock_result, "test.pdf")

        assert rotation_data == {}

    def test_check_page_rotation_missing_angle_attribute(self):
        """Test check_page_rotation when pages don't have angle attribute."""
        mock_page = Mock(spec=[])  # Page without 'angle' attribute

        mock_result = Mock()
        mock_result.pages = [mock_page]

        rotation_data = check_page_rotation(mock_result, "test.pdf")

        assert rotation_data == {}


@pytest.mark.unit
class TestRefineTextWithGPT:
    """Tests for AI provider text refinement task."""

    @patch("app.tasks.refine_text_with_gpt.log_task_progress")
    def test_successful_text_refinement(self, mock_log):
        """Test successful text refinement with AI provider."""
        raw_text = "This is s0me text with OCR err0rs"
        filename = "test.pdf"
        cleaned = "This is some text with OCR errors"

        # Import the module to patch the correct function
        from app.tasks import extract_metadata_with_gpt as metadata_module

        mock_provider = MagicMock()
        mock_provider.chat_completion.return_value = cleaned

        with (
            patch("app.tasks.refine_text_with_gpt.get_ai_provider", return_value=mock_provider),
            patch.object(metadata_module, "extract_metadata_with_gpt") as mock_extract,
            patch("app.tasks.refine_text_with_gpt.settings") as mock_settings,
        ):
            mock_settings.openai_model = "gpt-4"
            mock_settings.ai_model = None
            mock_extract.delay = MagicMock()

            result = refine_text_with_gpt.run(filename, raw_text)

            # Verify results
            assert result["filename"] == filename
            assert result["cleaned_text"] == cleaned

            # Verify AI provider was called correctly
            mock_provider.chat_completion.assert_called_once()
            call_kwargs = mock_provider.chat_completion.call_args[1]
            assert len(call_kwargs["messages"]) == 2
            assert call_kwargs["messages"][1]["content"] == raw_text

            # Verify metadata extraction was queued
            mock_extract.delay.assert_called_once_with(filename, cleaned)

    @patch("app.tasks.refine_text_with_gpt.log_task_progress")
    def test_openai_api_error(self, mock_log):
        """Test error handling when AI provider call fails."""
        raw_text = "Test text"
        filename = "test.pdf"

        mock_provider = MagicMock()
        mock_provider.chat_completion.side_effect = Exception("OpenAI API error")

        with (
            patch("app.tasks.refine_text_with_gpt.get_ai_provider", return_value=mock_provider),
            patch("app.tasks.refine_text_with_gpt.settings") as mock_settings,
        ):
            mock_settings.openai_model = "gpt-4"
            mock_settings.ai_model = None

            # Should raise the exception
            with pytest.raises(Exception) as exc_info:
                refine_text_with_gpt.run(filename, raw_text)

            assert "OpenAI API error" in str(exc_info.value)


@pytest.mark.unit
class TestRotatePdfPages:
    """Tests for PDF page rotation task."""

    def test_determine_rotation_angle_exact_90(self):
        """Test rotation angle determination for exact 90 degrees."""
        angle = determine_rotation_angle(90)
        assert angle == 270  # PyPDF2 uses clockwise, so 360 - 90 = 270

    def test_determine_rotation_angle_exact_180(self):
        """Test rotation angle determination for exact 180 degrees."""
        angle = determine_rotation_angle(180)
        assert angle == 180  # 360 - 180 = 180

    def test_determine_rotation_angle_exact_270(self):
        """Test rotation angle determination for exact 270 degrees."""
        angle = determine_rotation_angle(270)
        assert angle == 90  # 360 - 270 = 90

    def test_determine_rotation_angle_near_90(self):
        """Test rotation angle determination for angle near 90 degrees."""
        angle = determine_rotation_angle(88)  # Within 5° of 90
        assert angle == 270

        angle = determine_rotation_angle(93)  # Within 5° of 90
        assert angle == 270

    def test_determine_rotation_angle_small_angle(self):
        """Test that very small angles are not rotated."""
        angle = determine_rotation_angle(0.5)
        assert angle == 0

        angle = determine_rotation_angle(359.5)
        assert angle == 0

    def test_determine_rotation_angle_arbitrary(self):
        """Test rotation angle determination for arbitrary angles."""
        # 45 degrees should round to nearest 90-degree increment
        angle = determine_rotation_angle(45)
        # Round(45/90) = Round(0.5) = 0, so 0 * 90 = 0
        assert angle == 0

        # 135 degrees should round to 180
        angle = determine_rotation_angle(135)
        # Round(135/90) = Round(1.5) = 2, so 2 * 90 = 180
        assert angle == 180

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_with_rotation(self, mock_log, tmp_path):
        """Test PDF rotation with rotation data."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<<
/Size 4
/Root 1 0 R
>>
startxref
197
%%EOF
"""
        test_pdf.write_bytes(pdf_content)

        rotation_data = {0: 90}  # Rotate first page by 90 degrees
        extracted_text = "Test text"

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            result = rotate_pdf_pages.run(
                filename=test_pdf.name,
                extracted_text=extracted_text,
                rotation_data=rotation_data,
                file_id=1,
            )

            # Verify results
            assert result["file"] == test_pdf.name
            assert result["status"] == "rotated"
            assert "applied_rotations" in result
            assert "0" in result["applied_rotations"]

            # Verify metadata extraction was queued
            mock_extract.delay.assert_called_once_with(test_pdf.name, extracted_text, 1)

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_no_rotation_needed(self, mock_log, tmp_path):
        """Test PDF rotation when no rotation is needed."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        extracted_text = "Test text"

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            # Call with no rotation data
            result = rotate_pdf_pages.run(
                filename=test_pdf.name,
                extracted_text=extracted_text,
                rotation_data=None,
                file_id=1,
            )

            # Verify results
            assert result["status"] == "no_rotation_needed"

            # Verify metadata extraction was still queued
            mock_extract.delay.assert_called_once()

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_zero_rotations(self, mock_log, tmp_path):
        """Test PDF rotation when rotation data contains only zero angles."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        test_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        rotation_data = {0: 0, 1: 0}  # All pages have 0 rotation
        extracted_text = "Test text"

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            result = rotate_pdf_pages.run(
                filename=test_pdf.name,
                extracted_text=extracted_text,
                rotation_data=rotation_data,
                file_id=1,
            )

            # Verify no rotation was applied
            assert result["status"] == "no_rotation_needed"

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_file_not_found(self, mock_log, tmp_path):
        """Test error handling when PDF file is not found."""
        # Create tmp directory but no PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            # The function catches FileNotFoundError and returns error status
            result = rotate_pdf_pages.run(
                filename="nonexistent.pdf",
                extracted_text="text",
                rotation_data={0: 90},
                file_id=1,
            )

            # Verify error handling
            assert result["status"] == "rotation_failed"
            assert "error" in result
            assert "PDF file not found" in result["error"]

            # Verify metadata extraction was still queued
            mock_extract.delay.assert_called_once()

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_continues_on_error(self, mock_log, tmp_path):
        """Test that metadata extraction continues even if rotation fails."""
        # Create tmp directory and invalid PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        test_pdf.write_bytes(b"invalid pdf")

        rotation_data = {0: 90}
        extracted_text = "Test text"

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            result = rotate_pdf_pages.run(
                filename=test_pdf.name,
                extracted_text=extracted_text,
                rotation_data=rotation_data,
                file_id=1,
            )

            # Verify error was handled
            assert result["status"] == "rotation_failed"
            assert "error" in result

            # Verify metadata extraction was still queued
            mock_extract.delay.assert_called_once()

    @patch("app.tasks.rotate_pdf_pages.log_task_progress")
    def test_rotate_pdf_pages_with_string_keys(self, mock_log, tmp_path):
        """Test that rotation data with string keys is properly handled."""
        # Create tmp directory and test PDF
        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        test_pdf = tmp_dir / "test.pdf"
        pdf_content = b"""%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
>>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<<
/Size 4
/Root 1 0 R
>>
startxref
197
%%EOF
"""
        test_pdf.write_bytes(pdf_content)

        # Use string keys instead of integer keys
        rotation_data = {"0": "90"}
        extracted_text = "Test text"

        with (
            patch("app.tasks.rotate_pdf_pages.settings") as mock_settings,
            patch("app.tasks.rotate_pdf_pages.extract_metadata_with_gpt") as mock_extract,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_extract.delay = MagicMock()

            result = rotate_pdf_pages.run(
                filename=test_pdf.name,
                extracted_text=extracted_text,
                rotation_data=rotation_data,
                file_id=1,
            )

            # Verify rotation was applied despite string keys
            assert result["status"] == "rotated"
            assert "applied_rotations" in result


@pytest.mark.unit
class TestMistralOCRProvider:
    """Tests for MistralOCRProvider – Mistral native OCR API integration."""

    def _make_provider(self):
        from app.utils.ocr_provider import MistralOCRProvider

        return MistralOCRProvider()

    # ------------------------------------------------------------------
    # Helpers: build mock response objects
    # ------------------------------------------------------------------

    def _mock_response(self, json_data: dict, status_code: int = 200):
        mock_resp = Mock()
        mock_resp.status_code = status_code
        mock_resp.json.return_value = json_data
        mock_resp.raise_for_status = Mock()
        return mock_resp

    def _mock_error_response(self, status_code: int = 422):
        from requests import HTTPError

        mock_resp = Mock()
        mock_resp.status_code = status_code
        mock_resp.raise_for_status.side_effect = HTTPError(response=mock_resp)
        return mock_resp

    # ------------------------------------------------------------------
    # Missing API key
    # ------------------------------------------------------------------

    def test_missing_api_key_raises(self, tmp_path):
        """ValueError is raised when MISTRAL_API_KEY is not configured."""
        provider = self._make_provider()
        dummy_pdf = tmp_path / "doc.pdf"
        dummy_pdf.write_bytes(b"%PDF-1.4\n%%EOF")

        with patch("app.utils.ocr_provider.settings") as mock_settings:
            mock_settings.mistral_api_key = None
            mock_settings.mistral_ocr_model = None

            with pytest.raises(ValueError, match="MISTRAL_API_KEY must be set"):
                provider.process(str(dummy_pdf))

    # ------------------------------------------------------------------
    # PDF workflow
    # ------------------------------------------------------------------

    def test_pdf_uses_document_url_workflow(self, tmp_path):
        """PDF files are uploaded then processed via document_url."""
        provider = self._make_provider()
        pdf_file = tmp_path / "sample.pdf"
        pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")

        upload_resp = self._mock_response({"id": "file-abc123"})
        signed_url_resp = self._mock_response({"url": "https://signed.example.com/doc"})
        ocr_resp = self._mock_response({"pages": [{"markdown": "Hello PDF World"}]})

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", side_effect=[upload_resp, ocr_resp]) as mock_post,
            patch("requests.get", return_value=signed_url_resp) as mock_get,
        ):
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            result = provider.process(str(pdf_file))

        assert result.provider == "mistral"
        assert result.text == "Hello PDF World"

        # First POST should be the file upload
        upload_call = mock_post.call_args_list[0]
        assert "/files" in upload_call[0][0]
        assert upload_call[1]["data"] == {"purpose": "ocr"}

        # GET should fetch the signed URL
        get_call = mock_get.call_args_list[0]
        assert "file-abc123" in get_call[0][0]

        # Second POST should be the OCR call
        ocr_call = mock_post.call_args_list[1]
        assert "/ocr" in ocr_call[0][0]
        ocr_json = ocr_call[1]["json"]
        assert ocr_json["document"]["type"] == "document_url"
        assert ocr_json["document"]["document_url"] == "https://signed.example.com/doc"

    def test_pdf_multi_page_text_joined(self, tmp_path):
        """Text from multiple pages is joined with double newlines."""
        provider = self._make_provider()
        pdf_file = tmp_path / "multi.pdf"
        pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")

        upload_resp = self._mock_response({"id": "file-xyz"})
        signed_url_resp = self._mock_response({"url": "https://signed.example.com/multi"})
        ocr_resp = self._mock_response({"pages": [{"markdown": "Page one text"}, {"markdown": "Page two text"}]})

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", side_effect=[upload_resp, ocr_resp]),
            patch("requests.get", return_value=signed_url_resp),
        ):
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            result = provider.process(str(pdf_file))

        assert "Page one text" in result.text
        assert "Page two text" in result.text

    # ------------------------------------------------------------------
    # Image workflow
    # ------------------------------------------------------------------

    def test_image_uses_image_url_workflow(self, tmp_path):
        """Image files are base64-encoded and passed as image_url (not document_url)."""
        provider = self._make_provider()
        img_file = tmp_path / "photo.jpg"
        img_file.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)  # minimal JPEG bytes

        ocr_resp = self._mock_response({"pages": [{"markdown": "Image text here"}]})

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", return_value=ocr_resp) as mock_post,
            patch("requests.get") as mock_get,
        ):
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            result = provider.process(str(img_file))

        assert result.provider == "mistral"
        assert result.text == "Image text here"

        # Should only make a single POST (no file upload step)
        assert mock_post.call_count == 1
        assert mock_get.call_count == 0

        ocr_call = mock_post.call_args_list[0]
        assert "/ocr" in ocr_call[0][0]
        ocr_json = ocr_call[1]["json"]
        assert ocr_json["document"]["type"] == "image_url"
        assert ocr_json["document"]["image_url"].startswith("data:image/jpeg;base64,")

    # ------------------------------------------------------------------
    # Unsupported file type
    # ------------------------------------------------------------------

    def test_unsupported_mime_type_raises(self, tmp_path):
        """ValueError is raised for unsupported file types."""
        provider = self._make_provider()
        txt_file = tmp_path / "document.txt"
        txt_file.write_text("hello world")

        with patch("app.utils.ocr_provider.settings") as mock_settings:
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            with pytest.raises(ValueError, match="Unsupported file type"):
                provider.process(str(txt_file))

    def test_unknown_extension_pdf_magic_bytes_detected(self, tmp_path):
        """Files without extension are identified as PDF via magic bytes."""
        provider = self._make_provider()
        no_ext_file = tmp_path / "nodotfile"
        no_ext_file.write_bytes(b"%PDF-1.4\n%%EOF")

        upload_resp = self._mock_response({"id": "file-magic"})
        signed_url_resp = self._mock_response({"url": "https://signed.example.com/magic"})
        ocr_resp = self._mock_response({"pages": [{"markdown": "Magic PDF"}]})

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", side_effect=[upload_resp, ocr_resp]),
            patch("requests.get", return_value=signed_url_resp),
        ):
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            result = provider.process(str(no_ext_file))

        assert result.text == "Magic PDF"

    def test_unknown_extension_non_pdf_magic_bytes_raises(self, tmp_path):
        """Files without extension that aren't PDFs raise ValueError."""
        provider = self._make_provider()
        unknown_file = tmp_path / "unknownfile"
        unknown_file.write_bytes(b"\x00\x01\x02\x03\x04")

        with patch("app.utils.ocr_provider.settings") as mock_settings:
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            with pytest.raises(ValueError, match="Cannot determine file type"):
                provider.process(str(unknown_file))

    # ------------------------------------------------------------------
    # Upload / API error propagation
    # ------------------------------------------------------------------

    def test_upload_failure_raises_http_error(self, tmp_path):
        """HTTPError from the Files API upload is propagated to the caller."""
        from requests import HTTPError

        provider = self._make_provider()
        pdf_file = tmp_path / "bad.pdf"
        pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", return_value=self._mock_error_response(status_code=401)),
        ):
            mock_settings.mistral_api_key = "bad-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            with pytest.raises(HTTPError):
                provider.process(str(pdf_file))

    def test_ocr_api_failure_raises_http_error(self, tmp_path):
        """HTTPError from the /ocr endpoint is propagated to the caller."""
        from requests import HTTPError

        provider = self._make_provider()
        pdf_file = tmp_path / "bad.pdf"
        pdf_file.write_bytes(b"%PDF-1.4\n%%EOF")

        upload_resp = self._mock_response({"id": "file-err"})
        signed_url_resp = self._mock_response({"url": "https://signed.example.com/err"})

        with (
            patch("app.utils.ocr_provider.settings") as mock_settings,
            patch("requests.post", side_effect=[upload_resp, self._mock_error_response(status_code=422)]),
            patch("requests.get", return_value=signed_url_resp),
        ):
            mock_settings.mistral_api_key = "test-key"
            mock_settings.mistral_ocr_model = "mistral-ocr-latest"

            with pytest.raises(HTTPError):
                provider.process(str(pdf_file))


@pytest.mark.unit
class TestEmbedTextLayer:
    """Tests for the embed_text_layer utility function."""

    def _make_pdf(self, tmp_path, name: str = "test.pdf") -> str:
        """Create a minimal but valid-ish PDF file for testing."""
        pdf_path = tmp_path / name
        pdf_path.write_bytes(
            b"%PDF-1.4\n"
            b"1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n"
            b"2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n"
            b"3 0 obj\n<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>>\nendobj\n"
            b"xref\n0 4\n"
            b"0000000000 65535 f \n"
            b"0000000009 00000 n \n"
            b"0000000058 00000 n \n"
            b"0000000115 00000 n \n"
            b"trailer\n<</Size 4 /Root 1 0 R>>\n"
            b"startxref\n190\n%%EOF\n"
        )
        return str(pdf_path)

    def test_missing_input_raises_file_not_found(self, tmp_path):
        """FileNotFoundError is raised when the input PDF does not exist."""
        from app.utils.ocr_provider import embed_text_layer

        with pytest.raises(FileNotFoundError, match="embed_text_layer"):
            embed_text_layer("/nonexistent/path.pdf", str(tmp_path / "out.pdf"))

    def test_returns_false_when_ocrmypdf_not_on_path(self, tmp_path):
        """Returns False (and logs a warning) when ocrmypdf is not installed."""
        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)

        with patch("shutil.which", return_value=None):
            result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))

        assert result is False

    def test_returns_true_on_success(self, tmp_path):
        """Returns True when ocrmypdf exits with code 0."""
        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)
        output = str(tmp_path / "out.pdf")

        mock_proc = Mock()
        mock_proc.returncode = 0
        mock_proc.stderr = ""

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
            patch("subprocess.run", return_value=mock_proc) as mock_run,
        ):
            result = embed_text_layer(pdf, output, language="eng")

        assert result is True
        args = mock_run.call_args[0][0]
        assert "--skip-text" in args
        assert "-l" in args
        assert "eng" in args

    def test_returns_false_on_nonzero_exit(self, tmp_path):
        """Returns False when ocrmypdf exits with a non-zero code."""
        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)

        mock_proc = Mock()
        mock_proc.returncode = 1
        mock_proc.stderr = "some error"

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
            patch("subprocess.run", return_value=mock_proc),
        ):
            result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))

        assert result is False

    def test_returns_false_on_timeout(self, tmp_path):
        """Returns False when ocrmypdf times out."""
        import subprocess

        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
            patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)),
        ):
            result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))

        assert result is False

    def test_in_place_overwrite_on_success(self, tmp_path):
        """When input == output the original file is replaced in-place."""
        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)
        original_content = b"ORIGINAL"
        new_content = b"OCRMYPDF_OUTPUT"

        mock_proc = Mock()
        mock_proc.returncode = 0
        mock_proc.stderr = ""

        def fake_run(cmd: list[str], **kwargs: object) -> Mock:
            # Simulate ocrmypdf writing to the temp output path.
            out_path = cmd[-1]
            with open(out_path, "wb") as fh:
                fh.write(new_content)
            return mock_proc

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
            patch("subprocess.run", side_effect=fake_run),
        ):
            result = embed_text_layer(pdf, pdf)

        assert result is True
        with open(pdf, "rb") as fh:
            assert fh.read() == new_content

    def test_in_place_cleans_up_temp_file_on_failure(self, tmp_path):
        """Temp file created during in-place processing is removed on failure."""
        from app.utils.ocr_provider import embed_text_layer

        pdf = self._make_pdf(tmp_path)

        mock_proc = Mock()
        mock_proc.returncode = 1
        mock_proc.stderr = "error"

        created_tmp: list[str] = []

        real_mkstemp = __import__("tempfile").mkstemp

        def fake_mkstemp(**kwargs: object) -> tuple[int, str]:
            fd, path = real_mkstemp(**kwargs)
            created_tmp.append(path)
            # Write something so the cleanup code can find the file.
            with open(path, "wb") as fh:
                fh.write(b"temp")
            return fd, path

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
            patch("subprocess.run", return_value=mock_proc),
            patch("tempfile.mkstemp", side_effect=fake_mkstemp),
        ):
            result = embed_text_layer(pdf, pdf)

        assert result is False
        # The temporary file should have been cleaned up.
        for tmp in created_tmp:
            assert not __import__("os").path.exists(tmp)


@pytest.mark.unit
class TestProcessWithOCRTextLayerEmbedding:
    """Tests for the embed_text_layer step in process_with_ocr."""

    _MINIMAL_PDF = (
        b"%PDF-1.4\n"
        b"1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n"
        b"2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n"
        b"3 0 obj\n<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>>\nendobj\n"
        b"xref\n0 4\n"
        b"0000000000 65535 f \n"
        b"0000000009 00000 n \n"
        b"0000000058 00000 n \n"
        b"0000000115 00000 n \n"
        b"trailer\n<</Size 4 /Root 1 0 R>>\n"
        b"startxref\n190\n%%EOF\n"
    )

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_embed_text_layer_called_when_no_searchable_pdf(self, mock_rotate, mock_log, tmp_path):
        """embed_text_layer is called when no provider returns a searchable PDF."""
        from app.tasks.process_with_ocr import process_with_ocr
        from app.utils.ocr_provider import OCRResult

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        pdf_file = tmp_dir / "scan.pdf"
        pdf_file.write_bytes(self._MINIMAL_PDF)

        mock_result = OCRResult(provider="mistral", text="Hello from Mistral")

        mock_rotate.delay = Mock()

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
            patch("app.tasks.process_with_ocr.merge_ocr_results", return_value=("Hello from Mistral", None, {})),
            patch("app.tasks.process_with_ocr.embed_text_layer", return_value=True) as mock_embed,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.tesseract_language = "eng"
            provider_mock = Mock()
            provider_mock.name = "mistral"
            provider_mock.process.return_value = mock_result
            mock_providers.return_value = [provider_mock]

            process_with_ocr.run("scan.pdf", file_id=None)

        mock_embed.assert_called_once_with(str(pdf_file), str(pdf_file), language="eng")

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_embed_text_layer_skipped_when_searchable_pdf_exists(self, mock_rotate, mock_log, tmp_path):
        """embed_text_layer is NOT called when a provider already returned a searchable PDF."""
        from app.tasks.process_with_ocr import process_with_ocr
        from app.utils.ocr_provider import OCRResult

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        pdf_file = tmp_dir / "scan.pdf"
        pdf_file.write_bytes(self._MINIMAL_PDF)

        # Azure returns searchable_pdf_path set
        mock_result = OCRResult(provider="azure", text="Hello Azure", searchable_pdf_path=str(pdf_file))

        mock_rotate.delay = Mock()

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
            patch(
                "app.tasks.process_with_ocr.merge_ocr_results",
                return_value=("Hello Azure", str(pdf_file), {}),
            ),
            patch("app.tasks.process_with_ocr.embed_text_layer") as mock_embed,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.tesseract_language = "eng"
            provider_mock = Mock()
            provider_mock.name = "azure"
            provider_mock.process.return_value = mock_result
            mock_providers.return_value = [provider_mock]

            process_with_ocr.run("scan.pdf", file_id=None)

        mock_embed.assert_not_called()

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_embed_text_layer_unavailable_is_handled_gracefully(self, mock_rotate, mock_log, tmp_path):
        """When embed_text_layer returns False the task still succeeds."""
        from app.tasks.process_with_ocr import process_with_ocr
        from app.utils.ocr_provider import OCRResult

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        pdf_file = tmp_dir / "scan.pdf"
        pdf_file.write_bytes(self._MINIMAL_PDF)

        mock_result = OCRResult(provider="tesseract", text="Hello Tesseract")
        mock_rotate.delay = Mock()

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
            patch("app.tasks.process_with_ocr.merge_ocr_results", return_value=("Hello Tesseract", None, {})),
            patch("app.tasks.process_with_ocr.embed_text_layer", return_value=False),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.tesseract_language = "eng+deu"
            provider_mock = Mock()
            provider_mock.name = "tesseract"
            provider_mock.process.return_value = mock_result
            mock_providers.return_value = [provider_mock]

            result = process_with_ocr.run("scan.pdf", file_id=None)

        # Task should succeed and return the original file path as searchable_pdf
        assert result["cleaned_text"] == "Hello Tesseract"
        assert result["searchable_pdf"] == str(pdf_file)


@pytest.mark.unit
class TestProcessWithOCRMissingCoverage:
    """Tests targeting specific uncovered lines in process_with_ocr.py."""

    _MINIMAL_PDF = (
        b"%PDF-1.4\n"
        b"1 0 obj\n<</Type /Catalog /Pages 2 0 R>>\nendobj\n"
        b"2 0 obj\n<</Type /Pages /Kids [3 0 R] /Count 1>>\nendobj\n"
        b"3 0 obj\n<</Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]>>\nendobj\n"
        b"xref\n0 4\n"
        b"0000000000 65535 f \n"
        b"0000000009 00000 n \n"
        b"0000000058 00000 n \n"
        b"0000000115 00000 n \n"
        b"trailer\n<</Size 4 /Root 1 0 R>>\n"
        b"startxref\n190\n%%EOF\n"
    )

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_file_not_found_raises_and_logs_failure(self, mock_rotate, mock_log, tmp_path):
        """Covers line 52: FileNotFoundError when file doesn't exist, and lines 160-170 (outer except)."""
        from app.tasks.process_with_ocr import process_with_ocr

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        # Do NOT create the file so it triggers FileNotFoundError

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_providers.return_value = []

            with pytest.raises(FileNotFoundError):
                process_with_ocr.run("missing.pdf", file_id=42)

        # Verify the outer exception handler logged a failure (lines 162-169)
        failure_calls = [c for c in mock_log.call_args_list if c[0][2] == "failure"]
        assert len(failure_calls) >= 1

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_provider_exception_partial_success(self, mock_rotate, mock_log, tmp_path):
        """Covers lines 75-77 (provider exception) and 92 (warning when partial failures)."""
        from app.tasks.process_with_ocr import process_with_ocr
        from app.utils.ocr_provider import OCRResult

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        pdf_file = tmp_dir / "doc.pdf"
        pdf_file.write_bytes(self._MINIMAL_PDF)

        good_result = OCRResult(provider="azure", text="azure text", searchable_pdf_path=str(pdf_file))
        mock_rotate.delay = Mock()

        # First provider fails, second succeeds
        failing_provider = Mock()
        failing_provider.name = "tesseract"
        failing_provider.__class__.__name__ = "TesseractOCRProvider"
        failing_provider.process.side_effect = RuntimeError("Tesseract unavailable")

        good_provider = Mock()
        good_provider.name = "azure"
        good_provider.__class__.__name__ = "AzureOCRProvider"
        good_provider.process.return_value = good_result

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
            patch("app.tasks.process_with_ocr.merge_ocr_results", return_value=("azure text", str(pdf_file), {})),
        ):
            mock_settings.workdir = str(tmp_path)
            mock_settings.tesseract_language = "eng"
            mock_providers.return_value = [failing_provider, good_provider]

            result = process_with_ocr.run("doc.pdf", file_id=None)

        assert result["cleaned_text"] == "azure text"
        # errors list should be non-empty so line 92 was executed
        assert result["providers_used"] == ["azure"]

    @patch("app.tasks.process_with_ocr.log_task_progress")
    @patch("app.tasks.process_with_ocr.rotate_pdf_pages")
    def test_all_providers_fail_raises_runtime_error(self, mock_rotate, mock_log, tmp_path):
        """Covers lines 80-89: all providers fail → RuntimeError logged and raised."""
        from app.tasks.process_with_ocr import process_with_ocr

        tmp_dir = tmp_path / "tmp"
        tmp_dir.mkdir()
        pdf_file = tmp_dir / "fail.pdf"
        pdf_file.write_bytes(self._MINIMAL_PDF)

        fail_provider = Mock()
        fail_provider.name = "azure"
        fail_provider.__class__.__name__ = "AzureOCRProvider"
        fail_provider.process.side_effect = RuntimeError("Azure down")

        with (
            patch("app.tasks.process_with_ocr.settings") as mock_settings,
            patch("app.tasks.process_with_ocr.get_ocr_providers") as mock_providers,
        ):
            mock_settings.workdir = str(tmp_path)
            mock_providers.return_value = [fail_provider]

            with pytest.raises(RuntimeError, match="All OCR providers failed"):
                process_with_ocr.run("fail.pdf", file_id=None)

        # Verify failure was logged (lines 81-88)
        failure_calls = [c for c in mock_log.call_args_list if c[0][2] == "failure"]
        assert len(failure_calls) >= 1
