"""
Unit tests for app/utils/ocr_provider.py to boost coverage to 90%+.

Tests cover:
- embed_text_layer() – all branches (file not found, no ocrmypdf, in-place mode,
  timeout, non-zero returncode, success)
- OCRResult and OCRProvider base class
- AzureOCRProvider.process()
- TesseractOCRProvider.process()
- EasyOCRProvider.process()
- MistralOCRProvider.process() + _upload_pdf_and_get_document()
- GoogleDocAIOCRProvider.process()
- AWSTextractOCRProvider.process()
- get_ocr_providers() – default, multiple providers, unknown name, fallback
- merge_ocr_results() – empty, single, primary, longest, ai_merge, ai_merge failure
"""

import json
import subprocess
import sys
from unittest.mock import Mock, patch

import pytest

from app.utils.ocr_provider import (
    KNOWN_OCR_PROVIDERS,
    AWSTextractOCRProvider,
    AzureOCRProvider,
    EasyOCRProvider,
    GoogleDocAIOCRProvider,
    MistralOCRProvider,
    OCRResult,
    TesseractOCRProvider,
    embed_text_layer,
    get_ocr_providers,
    merge_ocr_results,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_pdf(tmp_path, name="test.pdf") -> str:
    """Create a minimal valid file path for testing."""
    p = tmp_path / name
    p.write_bytes(b"%PDF-1.4 minimal")
    return str(p)


# ---------------------------------------------------------------------------
# embed_text_layer
# ---------------------------------------------------------------------------


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

    def test_file_not_found_raises(self, tmp_path):
        """Raises FileNotFoundError when input file does not exist."""
        with pytest.raises(FileNotFoundError, match="input file not found"):
            embed_text_layer(str(tmp_path / "nonexistent.pdf"), str(tmp_path / "out.pdf"))

    def test_no_ocrmypdf_returns_false(self, tmp_path):
        """Returns False when ocrmypdf is not on PATH."""
        pdf = _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_success_different_output(self, tmp_path):
        """Returns True when ocrmypdf exits 0 and output != input."""
        pdf = _make_pdf(tmp_path)
        out = str(tmp_path / "out.pdf")
        mock_proc = Mock()
        mock_proc.returncode = 0
        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("subprocess.run", return_value=mock_proc),
        ):
            result = embed_text_layer(pdf, out)
        assert result is True

    def test_in_place_success(self, tmp_path):
        """Returns True and replaces file in-place when input == output."""
        pdf = _make_pdf(tmp_path)
        mock_proc = Mock()
        mock_proc.returncode = 0
        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("subprocess.run", return_value=mock_proc),
            patch("os.replace") as mock_replace,
        ):
            result = embed_text_layer(pdf, pdf)
        assert result is True
        assert mock_replace.called

    def test_timeout_returns_false(self, tmp_path):
        """Returns False when subprocess times out."""
        pdf = _make_pdf(tmp_path)
        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            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_timeout_in_place_removes_tmp(self, tmp_path):
        """Removes tmp file on timeout when in-place mode."""
        pdf = _make_pdf(tmp_path)
        # Create a fake tmp file that would be cleaned up
        tmp_out = tmp_path / "tmp_ocr.pdf"
        tmp_out.write_bytes(b"")

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)),
            patch("tempfile.mkstemp", return_value=(99, str(tmp_out))),
            patch("os.close"),
        ):
            result = embed_text_layer(pdf, pdf)
        assert result is False

    def test_nonzero_returncode_returns_false(self, tmp_path):
        """Returns False when ocrmypdf exits with non-zero return code."""
        pdf = _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("subprocess.run", return_value=mock_proc),
        ):
            result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
        assert result is False

    def test_nonzero_returncode_inplace_removes_tmp(self, tmp_path):
        """Removes tmp file on nonzero returncode when in-place mode."""
        pdf = _make_pdf(tmp_path)
        tmp_out = tmp_path / "tmp_ocr.pdf"
        tmp_out.write_bytes(b"partial")

        mock_proc = Mock()
        mock_proc.returncode = 2
        mock_proc.stderr = "fail"
        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("subprocess.run", return_value=mock_proc),
            patch("tempfile.mkstemp", return_value=(99, str(tmp_out))),
            patch("os.close"),
        ):
            result = embed_text_layer(pdf, pdf)
        assert result is False

    def test_custom_language(self, tmp_path):
        """Passes custom language code to ocrmypdf."""
        pdf = _make_pdf(tmp_path)
        mock_proc = Mock()
        mock_proc.returncode = 0
        captured_cmd = {}

        def fake_run(cmd, **kwargs):
            captured_cmd["cmd"] = cmd
            return mock_proc

        with (
            patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
            patch("subprocess.run", side_effect=fake_run),
        ):
            embed_text_layer(pdf, str(tmp_path / "out.pdf"), language="deu")
        assert "deu" in captured_cmd["cmd"]


# ---------------------------------------------------------------------------
# OCRResult
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestOCRResult:
    """Tests for OCRResult data class."""

    def test_defaults(self):
        r = OCRResult(provider="test", text="hello")
        assert r.provider == "test"
        assert r.text == "hello"
        assert r.searchable_pdf_path is None
        assert r.rotation_data == {}
        assert r.metadata == {}

    def test_repr(self):
        r = OCRResult(provider="azure", text="hello world", searchable_pdf_path="/tmp/x.pdf")
        s = repr(r)
        assert "azure" in s
        assert "has_pdf=True" in s

    def test_with_rotation_and_metadata(self):
        r = OCRResult(
            provider="azure",
            text="text",
            rotation_data={0: 90.0},
            metadata={"confidence": 0.99},
        )
        assert r.rotation_data == {0: 90.0}
        assert r.metadata == {"confidence": 0.99}


# ---------------------------------------------------------------------------
# AzureOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAzureOCRProvider:
    """Tests for AzureOCRProvider.process()."""

    def test_missing_azure_key_raises(self, tmp_path):
        """Raises ValueError when AZURE_AI_KEY is not set."""
        pdf = _make_pdf(tmp_path)
        provider = AzureOCRProvider()
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.azure_ai_key = None
            ms.azure_endpoint = "https://example.cognitiveservices.azure.com"
            with pytest.raises(ValueError, match="AZURE_AI_KEY"):
                provider.process(pdf)

    def test_missing_azure_endpoint_raises(self, tmp_path):
        """Raises ValueError when AZURE_ENDPOINT is not set."""
        pdf = _make_pdf(tmp_path)
        provider = AzureOCRProvider()
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.azure_ai_key = "test-key"
            ms.azure_endpoint = None
            with pytest.raises(ValueError, match="AZURE_ENDPOINT"):
                provider.process(pdf)

    def test_successful_processing(self, tmp_path):
        """Returns OCRResult with extracted text and searchable PDF path."""
        pdf = _make_pdf(tmp_path)
        provider = AzureOCRProvider()

        mock_page = Mock()
        mock_page.angle = 1.5
        mock_result = Mock()
        mock_result.content = "extracted text"
        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": "op-123"}

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

        mock_doc_intelligence = Mock()
        mock_doc_intelligence.DocumentIntelligenceClient.return_value = mock_client
        mock_analyze_output = Mock()
        mock_analyze_output.PDF = "pdf"
        mock_doc_intelligence.models.AnalyzeOutputOption = mock_analyze_output
        mock_azure_cred = Mock()

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch.dict(
                sys.modules,
                {
                    "azure.ai.documentintelligence": mock_doc_intelligence,
                    "azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_analyze_output),
                    "azure.core.credentials": Mock(AzureKeyCredential=mock_azure_cred),
                },
            ),
        ):
            ms.azure_ai_key = "test-key"
            ms.azure_endpoint = "https://example.com"
            result = provider.process(pdf)

        assert isinstance(result, OCRResult)
        assert result.provider == "azure"
        assert result.text == "extracted text"
        assert result.searchable_pdf_path == pdf
        assert 0 in result.rotation_data

    def test_no_rotation_data(self, tmp_path):
        """Works when pages have no rotation angle."""
        pdf = _make_pdf(tmp_path)
        provider = AzureOCRProvider()

        mock_page = Mock()
        mock_page.angle = 0
        mock_result = Mock()
        mock_result.content = "text"
        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": "op-123"}

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

        mock_di = Mock()
        mock_ao = Mock()
        mock_ao.PDF = "pdf"
        mock_di.DocumentIntelligenceClient.return_value = mock_client

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch.dict(
                sys.modules,
                {
                    "azure.ai.documentintelligence": mock_di,
                    "azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_ao),
                    "azure.core.credentials": Mock(AzureKeyCredential=Mock()),
                },
            ),
        ):
            ms.azure_ai_key = "key"
            ms.azure_endpoint = "https://x.com"
            result = provider.process(pdf)
        assert result.rotation_data == {}

    def test_no_pages_attribute(self, tmp_path):
        """Works when result has no pages attribute."""
        pdf = _make_pdf(tmp_path)
        provider = AzureOCRProvider()

        mock_result = Mock(spec=["content", "model_id"])
        mock_result.content = "text"
        mock_result.model_id = "prebuilt-read"

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

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

        mock_di = Mock()
        mock_ao = Mock()
        mock_ao.PDF = "pdf"
        mock_di.DocumentIntelligenceClient.return_value = mock_client

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch.dict(
                sys.modules,
                {
                    "azure.ai.documentintelligence": mock_di,
                    "azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_ao),
                    "azure.core.credentials": Mock(AzureKeyCredential=Mock()),
                },
            ),
        ):
            ms.azure_ai_key = "key"
            ms.azure_endpoint = "https://x.com"
            result = provider.process(pdf)
        assert result.rotation_data == {}


# ---------------------------------------------------------------------------
# TesseractOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestTesseractOCRProvider:
    """Tests for TesseractOCRProvider.process()."""

    def test_import_error_raises_runtime(self, tmp_path):
        """Raises RuntimeError when pytesseract/pdf2image are not installed."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider()
        with patch.dict(sys.modules, {"pytesseract": None, "pdf2image": None}):
            with pytest.raises(RuntimeError, match="pytesseract and pdf2image"):
                provider.process(pdf)

    def test_missing_language_raises_runtime(self, tmp_path):
        """Raises RuntimeError when required language data files are missing."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider()

        mock_pytesseract = Mock()
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
            patch(
                "app.utils.ocr_language_manager.ensure_tesseract_languages",
                return_value=["deu"],
            ),
        ):
            ms.tesseract_cmd = None
            ms.tesseract_language = "deu"
            with pytest.raises(RuntimeError, match="deu"):
                provider.process(pdf)

    def test_success(self, tmp_path):
        """Returns OCRResult with extracted text on success."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider()

        mock_pytesseract = Mock()
        mock_pytesseract.image_to_string.return_value = "page text"
        mock_pytesseract.pytesseract = Mock()
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
            patch(
                "app.utils.ocr_language_manager.ensure_tesseract_languages",
                return_value=[],
            ),
        ):
            ms.tesseract_cmd = None
            ms.tesseract_language = "eng"
            result = provider.process(pdf)

        assert result.provider == "tesseract"
        assert "page text" in result.text

    def test_tesseract_cmd_set(self, tmp_path):
        """Sets pytesseract.tesseract_cmd when configured."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider()

        mock_pytesseract = Mock()
        mock_pytesseract.pytesseract = Mock()
        mock_pytesseract.image_to_string.return_value = ""
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = []

        with (
            patch.dict(
                sys.modules,
                {"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
            patch(
                "app.utils.ocr_language_manager.ensure_tesseract_languages",
                return_value=[],
            ),
        ):
            ms.tesseract_cmd = "/usr/local/bin/tesseract"
            ms.tesseract_language = "eng"
            result = provider.process(pdf)
        assert mock_pytesseract.pytesseract.tesseract_cmd == "/usr/local/bin/tesseract"
        assert result.provider == "tesseract"


# ---------------------------------------------------------------------------
# EasyOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestEasyOCRProvider:
    """Tests for EasyOCRProvider.process()."""

    def test_import_error_raises_runtime(self, tmp_path):
        """Raises RuntimeError when easyocr/pdf2image are not installed."""
        pdf = _make_pdf(tmp_path)
        provider = EasyOCRProvider()
        with patch.dict(sys.modules, {"easyocr": None, "pdf2image": None}):
            with pytest.raises(RuntimeError, match="easyocr and pdf2image"):
                provider.process(pdf)

    def test_success(self, tmp_path):
        """Returns OCRResult with extracted text on success."""
        pdf = _make_pdf(tmp_path)
        provider = EasyOCRProvider()

        mock_reader = Mock()
        mock_reader.readtext.return_value = ["line1", "line2"]

        mock_easyocr = Mock()
        mock_easyocr.Reader.return_value = mock_reader

        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.easyocr_languages = "en,fr"
            ms.easyocr_gpu = False
            result = provider.process(pdf)

        assert result.provider == "easyocr"
        assert "line1" in result.text


# ---------------------------------------------------------------------------
# MistralOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestMistralOCRProvider:
    """Tests for MistralOCRProvider.process() and _upload_pdf_and_get_document()."""

    def test_missing_api_key_raises(self, tmp_path):
        """Raises ValueError when MISTRAL_API_KEY is not set."""
        pdf = _make_pdf(tmp_path)
        provider = MistralOCRProvider()
        mock_requests = Mock()
        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = None
            with pytest.raises(ValueError, match="MISTRAL_API_KEY"):
                provider.process(pdf)

    def test_pdf_path_success(self, tmp_path):
        """Processes PDF via upload + OCR endpoint."""
        pdf = _make_pdf(tmp_path)
        provider = MistralOCRProvider()

        upload_resp = Mock()
        upload_resp.json.return_value = {"id": "file-123"}
        upload_resp.raise_for_status = Mock()

        url_resp = Mock()
        url_resp.json.return_value = {"url": "https://signed.url/file"}
        url_resp.raise_for_status = Mock()

        ocr_resp = Mock()
        ocr_resp.json.return_value = {"pages": [{"markdown": "page content"}]}
        ocr_resp.raise_for_status = Mock()

        mock_requests = Mock()
        mock_requests.post.side_effect = [upload_resp, ocr_resp]
        mock_requests.get.return_value = url_resp

        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = "test-key"
            ms.mistral_ocr_model = "mistral-ocr-latest"
            result = provider.process(pdf)

        assert result.provider == "mistral"
        assert result.text == "page content"

    def test_image_path_success(self, tmp_path):
        """Processes JPEG image via base64 encoding."""
        img = tmp_path / "photo.jpg"
        img.write_bytes(b"\xff\xd8\xff\xe0test image")
        provider = MistralOCRProvider()

        ocr_resp = Mock()
        ocr_resp.json.return_value = {"pages": [{"markdown": "image text"}]}
        ocr_resp.raise_for_status = Mock()

        mock_requests = Mock()
        mock_requests.post.return_value = ocr_resp

        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = "test-key"
            ms.mistral_ocr_model = "mistral-ocr-latest"
            result = provider.process(str(img))

        assert result.provider == "mistral"
        assert result.text == "image text"

    def test_unknown_mime_no_magic_raises(self, tmp_path):
        """Raises ValueError for file whose MIME type is None and has non-PDF magic bytes."""
        # Use a filename with no extension so mimetypes.guess_type returns None
        unknown = tmp_path / "unknownfile"
        unknown.write_bytes(b"\x00\x01\x02\x03\x04")
        provider = MistralOCRProvider()

        mock_requests = Mock()
        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = "test-key"
            ms.mistral_ocr_model = "mistral-ocr-latest"
            with pytest.raises(ValueError, match="Cannot determine file type"):
                provider.process(str(unknown))

    def test_unsupported_mime_raises(self, tmp_path):
        """Raises ValueError for unsupported MIME type (e.g. .csv)."""
        csv_file = tmp_path / "data.csv"
        csv_file.write_text("col1,col2\n1,2")
        provider = MistralOCRProvider()

        mock_requests = Mock()
        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = "test-key"
            ms.mistral_ocr_model = "mistral-ocr-latest"
            with pytest.raises(ValueError, match="Unsupported file type"):
                provider.process(str(csv_file))

    def test_pdf_detected_via_magic_bytes(self, tmp_path):
        """Detects PDF via magic bytes when extension is missing."""
        no_ext = tmp_path / "document"
        no_ext.write_bytes(b"%PDF-1.4 minimal content")
        provider = MistralOCRProvider()

        upload_resp = Mock()
        upload_resp.json.return_value = {"id": "file-456"}
        upload_resp.raise_for_status = Mock()

        url_resp = Mock()
        url_resp.json.return_value = {"url": "https://signed.url/doc"}
        url_resp.raise_for_status = Mock()

        ocr_resp = Mock()
        ocr_resp.json.return_value = {"pages": [{"markdown": "doc text"}]}
        ocr_resp.raise_for_status = Mock()

        mock_requests = Mock()
        mock_requests.post.side_effect = [upload_resp, ocr_resp]
        mock_requests.get.return_value = url_resp

        with (
            patch.dict(sys.modules, {"requests": mock_requests}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.mistral_api_key = "key"
            ms.mistral_ocr_model = "mistral-ocr-latest"
            result = provider.process(str(no_ext))
        assert result.text == "doc text"


# ---------------------------------------------------------------------------
# GoogleDocAIOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestGoogleDocAIOCRProvider:
    """Tests for GoogleDocAIOCRProvider.process()."""

    def test_import_error_raises_runtime(self, tmp_path):
        """Raises RuntimeError when google-cloud-documentai is not installed."""
        pdf = _make_pdf(tmp_path)
        provider = GoogleDocAIOCRProvider()
        with patch.dict(sys.modules, {"google.cloud": None, "google.cloud.documentai": None}):
            with pytest.raises((RuntimeError, ImportError)):
                provider.process(pdf)

    def test_missing_project_id_raises(self, tmp_path):
        """Raises ValueError when project_id is missing."""
        pdf = _make_pdf(tmp_path)
        provider = GoogleDocAIOCRProvider()

        mock_documentai = Mock()
        mock_service_account = Mock()
        mock_google_cloud = Mock()
        mock_google_cloud.documentai = mock_documentai

        with (
            patch.dict(
                sys.modules,
                {
                    "google": mock_google_cloud,
                    "google.cloud": mock_google_cloud,
                    "google.cloud.documentai": mock_documentai,
                    "google.oauth2": Mock(),
                    "google.oauth2.service_account": mock_service_account,
                },
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.google_docai_project_id = None
            ms.google_docai_processor_id = "proc-123"
            ms.google_docai_location = "us"
            ms.google_docai_credentials_json = None
            ms.google_drive_credentials_json = None
            with pytest.raises(ValueError, match="GOOGLE_DOCAI_PROJECT_ID"):
                provider.process(pdf)

    def test_success_with_credentials(self, tmp_path):
        """Processes PDF via Google Document AI with service account credentials."""
        pdf = _make_pdf(tmp_path)
        provider = GoogleDocAIOCRProvider()

        mock_document = Mock()
        mock_document.text = "google extracted text"

        mock_result = Mock()
        mock_result.document = mock_document

        mock_client = Mock()
        mock_client.process_document.return_value = mock_result
        mock_client.processor_path.return_value = "projects/p/locations/us/processors/proc"

        mock_documentai = Mock()
        mock_documentai.DocumentProcessorServiceClient.return_value = mock_client
        mock_documentai.RawDocument.return_value = Mock()
        mock_documentai.ProcessRequest.return_value = Mock()

        mock_creds = Mock()
        mock_service_account = Mock()
        mock_service_account.Credentials.from_service_account_info.return_value = mock_creds

        creds_dict = {"type": "service_account", "project_id": "test"}

        with (
            patch.dict(
                sys.modules,
                {
                    "google.cloud.documentai": mock_documentai,
                    "google.oauth2.service_account": mock_service_account,
                },
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.google_docai_project_id = "my-project"
            ms.google_docai_processor_id = "proc-123"
            ms.google_docai_location = "us"
            ms.google_docai_credentials_json = json.dumps(creds_dict)
            ms.google_drive_credentials_json = None
            result = provider.process(pdf)

        assert result.provider == "google_docai"
        assert result.text == "google extracted text"

    def test_success_no_credentials_json(self, tmp_path):
        """Processes PDF via ADC when no credentials JSON is configured."""
        pdf = _make_pdf(tmp_path)
        provider = GoogleDocAIOCRProvider()

        mock_document = Mock()
        mock_document.text = "adc text"
        mock_result = Mock()
        mock_result.document = mock_document

        mock_client = Mock()
        mock_client.process_document.return_value = mock_result
        mock_client.processor_path.return_value = "projects/p/locations/us/processors/proc"

        mock_documentai = Mock()
        mock_documentai.DocumentProcessorServiceClient.return_value = mock_client
        mock_documentai.RawDocument.return_value = Mock()
        mock_documentai.ProcessRequest.return_value = Mock()

        mock_service_account = Mock()

        with (
            patch.dict(
                sys.modules,
                {
                    "google.cloud.documentai": mock_documentai,
                    "google.oauth2.service_account": mock_service_account,
                },
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.google_docai_project_id = "project"
            ms.google_docai_processor_id = "proc"
            ms.google_docai_location = "eu"
            ms.google_docai_credentials_json = None
            ms.google_drive_credentials_json = None
            result = provider.process(pdf)

        assert result.provider == "google_docai"


# ---------------------------------------------------------------------------
# AWSTextractOCRProvider
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAWSTextractOCRProvider:
    """Tests for AWSTextractOCRProvider.process()."""

    def test_import_error_raises_runtime(self, tmp_path):
        """Raises RuntimeError when boto3 is not installed."""
        pdf = _make_pdf(tmp_path)
        provider = AWSTextractOCRProvider()
        with patch.dict(sys.modules, {"boto3": None}):
            with pytest.raises(RuntimeError, match="boto3"):
                provider.process(pdf)

    def test_missing_credentials_raises(self, tmp_path):
        """Raises ValueError when AWS credentials are missing."""
        pdf = _make_pdf(tmp_path)
        provider = AWSTextractOCRProvider()
        mock_boto3 = Mock()
        with (
            patch.dict(sys.modules, {"boto3": mock_boto3}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.aws_access_key_id = None
            ms.aws_secret_access_key = None
            ms.aws_region = "us-east-1"
            with pytest.raises(ValueError, match="AWS_ACCESS_KEY_ID"):
                provider.process(pdf)

    def test_success(self, tmp_path):
        """Returns OCRResult with extracted text on success."""
        pdf = _make_pdf(tmp_path)
        provider = AWSTextractOCRProvider()

        mock_textract = Mock()
        mock_textract.detect_document_text.return_value = {
            "Blocks": [
                {"BlockType": "LINE", "Text": "first line"},
                {"BlockType": "PAGE", "Text": "ignored"},
                {"BlockType": "LINE", "Text": "second line"},
            ]
        }
        mock_boto3 = Mock()
        mock_boto3.client.return_value = mock_textract

        with (
            patch.dict(sys.modules, {"boto3": mock_boto3}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.aws_access_key_id = "AKIATEST"
            ms.aws_secret_access_key = "secret"
            ms.aws_region = "us-west-2"
            result = provider.process(pdf)

        assert result.provider == "aws_textract"
        assert "first line" in result.text
        assert "second line" in result.text
        assert "ignored" not in result.text

    def test_default_region(self, tmp_path):
        """Uses us-east-1 as default region when not configured."""
        pdf = _make_pdf(tmp_path)
        provider = AWSTextractOCRProvider()

        mock_textract = Mock()
        mock_textract.detect_document_text.return_value = {"Blocks": []}
        mock_boto3 = Mock()
        mock_boto3.client.return_value = mock_textract

        with (
            patch.dict(sys.modules, {"boto3": mock_boto3}),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.aws_access_key_id = "key"
            ms.aws_secret_access_key = "secret"
            ms.aws_region = None
            result = provider.process(pdf)

        mock_boto3.client.assert_called_once()
        call_kwargs = mock_boto3.client.call_args[1]
        assert call_kwargs["region_name"] == "us-east-1"


# ---------------------------------------------------------------------------
# get_ocr_providers
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestGetOCRProviders:
    """Tests for get_ocr_providers() factory function."""

    def test_returns_azure_by_default(self):
        """Returns AzureOCRProvider when no setting is configured."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = None
            providers = get_ocr_providers()
        assert len(providers) == 1
        assert isinstance(providers[0], AzureOCRProvider)

    def test_single_provider(self):
        """Returns a single configured provider."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "tesseract"
            providers = get_ocr_providers()
        assert len(providers) == 1
        assert isinstance(providers[0], TesseractOCRProvider)

    def test_multiple_providers(self):
        """Returns multiple configured providers."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "azure,tesseract"
            providers = get_ocr_providers()
        assert len(providers) == 2
        assert isinstance(providers[0], AzureOCRProvider)
        assert isinstance(providers[1], TesseractOCRProvider)

    def test_unknown_provider_skipped(self):
        """Skips unknown provider names with a warning."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "unknown_engine,azure"
            providers = get_ocr_providers()
        # unknown_engine is skipped, azure is included
        assert len(providers) == 1
        assert isinstance(providers[0], AzureOCRProvider)

    def test_all_unknown_falls_back_to_azure(self):
        """Falls back to Azure when all providers are unknown."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "no_such_provider"
            providers = get_ocr_providers()
        assert len(providers) == 1
        assert isinstance(providers[0], AzureOCRProvider)

    def test_known_providers_list(self):
        """All providers listed in KNOWN_OCR_PROVIDERS can be instantiated."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = ",".join(KNOWN_OCR_PROVIDERS)
            providers = get_ocr_providers()
        assert len(providers) == len(KNOWN_OCR_PROVIDERS)

    def test_whitespace_stripped(self):
        """Strips whitespace from provider names."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = " azure , tesseract "
            providers = get_ocr_providers()
        assert len(providers) == 2


# ---------------------------------------------------------------------------
# merge_ocr_results
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestMergeOCRResults:
    """Tests for merge_ocr_results() orchestration function."""

    def test_empty_results(self):
        """Returns empty string for empty result list."""
        text, pdf, rot = merge_ocr_results([], "test.pdf")
        assert text == ""
        assert pdf is None
        assert rot == {}

    def test_single_result(self):
        """Returns single result as-is."""
        r = OCRResult("azure", "hello", searchable_pdf_path="/tmp/x.pdf", rotation_data={0: 90.0})
        text, pdf, rot = merge_ocr_results([r], "test.pdf")
        assert text == "hello"
        assert pdf == "/tmp/x.pdf"
        assert rot == {0: 90.0}

    def test_primary_strategy(self):
        """Returns first result's text with 'primary' strategy."""
        r1 = OCRResult("azure", "first text")
        r2 = OCRResult("tesseract", "second text")
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_merge_strategy = "primary"
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            text, pdf, rot = merge_ocr_results([r1, r2], "doc.pdf")
        assert text == "first text"

    def test_longest_strategy(self):
        """Returns longest text with 'longest' strategy."""
        r1 = OCRResult("azure", "short")
        r2 = OCRResult("tesseract", "much longer text from tesseract")
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_merge_strategy = "longest"
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
        assert text == "much longer text from tesseract"

    def test_searchable_pdf_from_first_provider_with_pdf(self):
        """Picks searchable_pdf_path from first provider that has one."""
        r1 = OCRResult("tesseract", "t1", searchable_pdf_path=None)
        r2 = OCRResult("azure", "t2", searchable_pdf_path="/tmp/azure.pdf")
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_merge_strategy = "primary"
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            _, pdf, _ = merge_ocr_results([r1, r2], "doc.pdf")
        assert pdf == "/tmp/azure.pdf"

    def test_rotation_data_from_first_provider_with_rotation(self):
        """Picks rotation_data from first provider that has it."""
        r1 = OCRResult("tesseract", "t1", rotation_data={})
        r2 = OCRResult("azure", "t2", rotation_data={0: 90.0})
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_merge_strategy = "longest"
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            _, _, rot = merge_ocr_results([r1, r2], "doc.pdf")
        assert rot == {0: 90.0}

    def test_ai_merge_strategy_success(self):
        """AI merge strategy calls AI provider and returns merged text."""
        r1 = OCRResult("azure", "azure text")
        r2 = OCRResult("tesseract", "tesseract text")

        mock_provider = Mock()
        mock_provider.chat_completion.return_value = "merged text"

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch("app.utils.ai_provider.get_ai_provider", return_value=mock_provider),
        ):
            ms.ocr_merge_strategy = "ai_merge"
            ms.ai_model = "gpt-4o"
            ms.openai_model = "gpt-4o"
            text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
        assert text == "merged text"

    def test_ai_merge_is_default_strategy(self):
        """ai_merge is used when no strategy is configured."""
        r1 = OCRResult("azure", "text one")
        r2 = OCRResult("tesseract", "text two which is longer")

        mock_provider = Mock()
        mock_provider.chat_completion.return_value = "ai result"

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch("app.utils.ai_provider.get_ai_provider", return_value=mock_provider),
        ):
            ms.ocr_merge_strategy = None
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
        assert text == "ai result"

    def test_ai_merge_failure_falls_back_to_longest(self):
        """Falls back to longest text when AI merge raises an exception."""
        r1 = OCRResult("azure", "short")
        r2 = OCRResult("tesseract", "this is the longer text from tesseract engine")

        with (
            patch("app.utils.ocr_provider.settings") as ms,
            patch("app.utils.ai_provider.get_ai_provider", side_effect=RuntimeError("AI unavailable")),
        ):
            ms.ocr_merge_strategy = "ai_merge"
            ms.ai_model = "gpt-4"
            ms.openai_model = "gpt-4"
            text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
        assert text == "this is the longer text from tesseract engine"


# ---------------------------------------------------------------------------
# Multi-language OCR support
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestOCRLanguageConstants:
    """Tests for the OCR_LANGUAGES constant and TESSERACT_TO_EASYOCR mapping."""

    def test_ocr_languages_has_20_plus_entries(self):
        """OCR_LANGUAGES contains at least 20 language options (excluding 'auto')."""
        from app.utils.ocr_provider import OCR_LANGUAGES

        language_entries = {k: v for k, v in OCR_LANGUAGES.items() if v != "auto"}
        assert len(language_entries) >= 20, f"Expected ≥20 languages, got {len(language_entries)}"

    def test_ocr_languages_includes_auto(self):
        """OCR_LANGUAGES includes 'auto' as the first option."""
        from app.utils.ocr_provider import OCR_LANGUAGES

        assert "auto" in OCR_LANGUAGES.values()

    def test_ocr_languages_common_languages(self):
        """OCR_LANGUAGES includes the most common European and Asian languages."""
        from app.utils.ocr_provider import OCR_LANGUAGES

        expected_codes = {"eng", "deu", "fra", "spa", "ita", "por", "rus", "chi_sim", "jpn", "kor"}
        all_codes = set(OCR_LANGUAGES.values())
        missing = expected_codes - all_codes
        assert not missing, f"Missing expected language codes: {missing}"

    def test_tesseract_to_easyocr_mapping(self):
        """TESSERACT_TO_EASYOCR maps common Tesseract codes to EasyOCR codes."""
        from app.utils.ocr_provider import TESSERACT_TO_EASYOCR

        assert TESSERACT_TO_EASYOCR["eng"] == "en"
        assert TESSERACT_TO_EASYOCR["deu"] == "de"
        assert TESSERACT_TO_EASYOCR["fra"] == "fr"
        assert TESSERACT_TO_EASYOCR["chi_sim"] == "ch_sim"

    def test_tesseract_codes_to_easyocr_single(self):
        """_tesseract_codes_to_easyocr converts a single Tesseract code."""
        from app.utils.ocr_provider import _tesseract_codes_to_easyocr

        result = _tesseract_codes_to_easyocr("eng")
        assert result == ["en"]

    def test_tesseract_codes_to_easyocr_multi(self):
        """_tesseract_codes_to_easyocr splits '+'-separated Tesseract codes."""
        from app.utils.ocr_provider import _tesseract_codes_to_easyocr

        result = _tesseract_codes_to_easyocr("eng+deu")
        assert result == ["en", "de"]

    def test_tesseract_codes_to_easyocr_passthrough_unknown(self):
        """_tesseract_codes_to_easyocr passes through codes not in the mapping."""
        from app.utils.ocr_provider import _tesseract_codes_to_easyocr

        # EasyOCR-native codes are passed through unchanged
        result = _tesseract_codes_to_easyocr("en")
        assert result == ["en"]


@pytest.mark.unit
class TestTesseractLanguageOverride:
    """Tests for per-call language override in TesseractOCRProvider."""

    def test_language_override_used_in_process(self, tmp_path):
        """Language override is used instead of global setting."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider(language="deu")

        mock_pytesseract = Mock()
        mock_pytesseract.image_to_string.return_value = "Deutsches Text"
        mock_pytesseract.pytesseract = Mock()
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
        ):
            ms.tesseract_cmd = None
            ms.tesseract_language = "eng"  # global setting; should be overridden
            result = provider.process(pdf)

        # Ensure image_to_string was called with the override language ("deu"), not global "eng"
        mock_pytesseract.image_to_string.assert_called_once()
        call_kwargs = mock_pytesseract.image_to_string.call_args
        assert call_kwargs[1].get("lang") == "deu" or (call_kwargs[0] and call_kwargs[0][1] == "deu")
        assert result.provider == "tesseract"

    def test_auto_language_falls_back_to_global(self, tmp_path):
        """'auto' language override falls back to global tesseract_language setting."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider(language="auto")

        mock_pytesseract = Mock()
        mock_pytesseract.image_to_string.return_value = ""
        mock_pytesseract.pytesseract = Mock()
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
            patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
        ):
            ms.tesseract_cmd = None
            ms.tesseract_language = "fra"
            provider.process(pdf)

        # Should use global setting "fra" since "auto" means no override
        mock_pytesseract.image_to_string.assert_called_once()
        call_kwargs = mock_pytesseract.image_to_string.call_args
        lang_used = call_kwargs[1].get("lang") if call_kwargs[1] else call_kwargs[0][1]
        assert lang_used == "fra"

    def test_none_language_falls_back_to_global(self, tmp_path):
        """None language override falls back to global setting."""
        pdf = _make_pdf(tmp_path)
        provider = TesseractOCRProvider(language=None)
        assert provider._language_override is None


@pytest.mark.unit
class TestEasyOCRLanguageOverride:
    """Tests for per-call language override in EasyOCRProvider."""

    def test_language_override_converted_and_used(self, tmp_path):
        """Tesseract-style language override is converted to EasyOCR codes."""
        pdf = _make_pdf(tmp_path)
        provider = EasyOCRProvider(language="deu")

        mock_reader = Mock()
        mock_reader.readtext.return_value = ["Deutsches Text"]
        mock_easyocr = Mock()
        mock_easyocr.Reader.return_value = mock_reader
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.easyocr_languages = "en"  # global; should be overridden
            ms.easyocr_gpu = False
            provider.process(pdf)

        # Should call Reader with ["de"] (converted from "deu"), not global ["en"]
        mock_easyocr.Reader.assert_called_once()
        langs_arg = mock_easyocr.Reader.call_args[0][0]
        assert langs_arg == ["de"]

    def test_auto_language_uses_global_setting(self, tmp_path):
        """'auto' language override falls back to global easyocr_languages setting."""
        pdf = _make_pdf(tmp_path)
        provider = EasyOCRProvider(language="auto")

        mock_reader = Mock()
        mock_reader.readtext.return_value = []
        mock_easyocr = Mock()
        mock_easyocr.Reader.return_value = mock_reader
        mock_pdf2image = Mock()
        mock_pdf2image.convert_from_path.return_value = [Mock()]

        with (
            patch.dict(
                sys.modules,
                {"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
            ),
            patch("app.utils.ocr_provider.settings") as ms,
        ):
            ms.easyocr_languages = "fr,es"
            ms.easyocr_gpu = False
            provider.process(pdf)

        langs_arg = mock_easyocr.Reader.call_args[0][0]
        assert langs_arg == ["fr", "es"]


@pytest.mark.unit
class TestGetOCRProvidersWithLanguage:
    """Tests for get_ocr_providers(language=...) factory."""

    def test_language_passed_to_tesseract_provider(self):
        """Language override is passed to TesseractOCRProvider."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "tesseract"
            providers = get_ocr_providers(language="deu")
        assert len(providers) == 1
        assert isinstance(providers[0], TesseractOCRProvider)
        assert providers[0]._language_override == "deu"

    def test_language_passed_to_easyocr_provider(self):
        """Language override is passed to EasyOCRProvider."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "easyocr"
            providers = get_ocr_providers(language="fra")
        assert len(providers) == 1
        assert isinstance(providers[0], EasyOCRProvider)
        assert providers[0]._language_override == "fra"

    def test_language_not_passed_to_azure(self):
        """Language override is NOT passed to AzureOCRProvider (it auto-detects)."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "azure"
            providers = get_ocr_providers(language="deu")
        assert len(providers) == 1
        assert isinstance(providers[0], AzureOCRProvider)
        # AzureOCRProvider has no _language_override attribute
        assert not hasattr(providers[0], "_language_override")

    def test_auto_language_not_passed_as_override(self):
        """'auto' language is treated as no override for Tesseract."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "tesseract"
            providers = get_ocr_providers(language="auto")
        assert providers[0]._language_override is None

    def test_none_language_no_override(self):
        """None language results in no override."""
        with patch("app.utils.ocr_provider.settings") as ms:
            ms.ocr_providers = "tesseract"
            providers = get_ocr_providers(language=None)
        assert providers[0]._language_override is None
