"""The PaddleOCR adapter.

Two kinds of test here, and the difference matters.

Most of these exercise the translation this module owns — engine output into
ordered, filtered TextBlocks — using result shapes taken from PaddleOCR's
documented format. That logic is ours and is fully testable.

`TestAgainstTheRealEngine` runs the actual library on a generated image. It skips
when PaddleOCR is not installed, and it is the only thing here that proves the
adapter reads PaddleOCR's real output rather than my belief about it.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from app.ocr.config import OcrConfig
from app.ocr.engine import OcrResult
from app.ocr.paddle import PaddleOcrEngine, _line_confidence, _unpack
from app.ocr.layout import Region


def box(top: float, left: float, width: float = 100, height: float = 20):
    return [[left, top], [left + width, top], [left + width, top + height], [left, top + height]]


def page(*regions: tuple[str, float, list]) -> dict:
    """A PaddleOCR 3.x page result."""
    return {
        "rec_texts": [r[0] for r in regions],
        "rec_scores": [r[1] for r in regions],
        "rec_polys": [r[2] for r in regions],
    }


class FakeReader:
    """Stands in for PaddleOCR itself, returning shapes it documents."""

    def __init__(self, pages: list) -> None:
        self._pages = pages
        self.calls: list[str] = []

    def predict(self, path: str):
        self.calls.append(path)

        return self._pages


def engine_with(pages: list, config: OcrConfig | None = None) -> PaddleOcrEngine:
    engine = PaddleOcrEngine(config)
    engine._reader = FakeReader(pages)  # noqa: SLF001

    return engine


@pytest.fixture
def document(tmp_path) -> Path:
    path = tmp_path / "cnic.jpg"
    path.write_bytes(b"pretend-image")

    return path


class TestReadingOutput:
    def test_text_comes_back_in_reading_order(self, document):
        result = engine_with([page(
            ("35202-1234567-1", 0.95, box(top=140, left=200)),
            ("Name: Muhammad Ali", 0.93, box(top=100, left=10)),
            ("Identity Number:", 0.91, box(top=140, left=10)),
        )]).read(document)

        # Detection order is not reading order, and every extraction pattern
        # downstream runs over this joined text.
        assert result.text == "Name: Muhammad Ali\nIdentity Number: 35202-1234567-1"

    def test_confidence_is_weighted_by_how_much_text_each_region_held(self, document):
        result = engine_with([page(
            ("A", 0.20, box(top=100, left=10)),
            ("a much longer and cleanly read line", 1.0, box(top=140, left=10)),
        )]).read(document)

        # A short uncertain label beside a long clean value is a good read.
        assert result.confidence > 0.9

    def test_the_engine_names_itself(self, document):
        assert engine_with([page(("x", 0.9, box(0, 0)))]).read(document).engine == "paddleocr"

    def test_multiple_pages_are_counted_and_joined(self, document):
        result = engine_with([
            page(("page one", 0.9, box(top=10, left=10))),
            page(("page two", 0.9, box(top=10, left=10))),
        ]).read(document)

        assert result.pages == 2
        assert "page one" in result.text and "page two" in result.text


class TestFiltering:
    def test_low_confidence_regions_are_dropped(self, document):
        result = engine_with([page(
            ("Name: Muhammad Ali", 0.95, box(top=100, left=10)),
            ("l1I|", 0.05, box(top=400, left=900)),
        )]).read(document)

        # Below roughly this level the engine emits noise from page edges and
        # shadows. One hallucinated line of digits inside a CNIC is worse than a
        # slightly shorter read.
        assert "l1I|" not in result.text

    def test_the_threshold_is_configurable(self, document):
        pages = [page(("borderline", 0.4, box(top=100, left=10)))]

        assert "borderline" in engine_with(pages).read(document).text
        assert "borderline" not in engine_with(
            pages, OcrConfig(minimum_confidence=0.8)
        ).read(document).text

    def test_blank_regions_are_dropped(self, document):
        result = engine_with([page(
            ("   ", 0.99, box(top=100, left=10)),
            ("real text", 0.99, box(top=140, left=10)),
        )]).read(document)

        assert result.text == "real text"

    def test_a_page_of_only_noise_reads_as_empty(self, document):
        result = engine_with([page(("~", 0.01, box(top=10, left=10)))]).read(document)

        # Which every caller already handles by asking a human.
        assert result.is_empty


class TestNeverRaises:
    """The port's contract: an unreadable document is an answer, not an error."""

    def test_a_missing_file_returns_an_empty_result(self, tmp_path):
        result = engine_with([]).read(tmp_path / "gone.jpg")

        assert isinstance(result, OcrResult)
        assert result.is_empty

    def test_an_engine_that_throws_returns_an_empty_result(self, document):
        class Exploding:
            def predict(self, path):
                raise RuntimeError("CUDA is on fire")

        engine = PaddleOcrEngine()
        engine._reader = Exploding()  # noqa: SLF001

        # An exception here would end a workflow that should have gone to the
        # Approval Queue for a human to look at.
        assert engine.read(document).is_empty

    def test_a_missing_library_returns_an_empty_result(self, document, monkeypatch):
        engine = PaddleOcrEngine()

        def unavailable():
            from app.ocr.paddle import PaddleOcrUnavailable

            raise PaddleOcrUnavailable("not installed")

        monkeypatch.setattr(engine, "_build_reader", unavailable)

        # A deployment without PaddleOCR still runs; every document goes to a
        # human unread, which is degraded rather than broken.
        assert engine.read(document).is_empty

    def test_a_malformed_page_does_not_crash(self, document):
        assert engine_with([{"unexpected": "shape"}]).read(document).is_empty


class TestOutputShapes:
    def test_the_three_x_mapping_is_read(self):
        texts, scores, polys = _unpack(page(("hello", 0.9, box(0, 0))))

        assert texts == ["hello"] and scores == [0.9] and len(polys) == 1

    def test_the_two_x_list_is_still_read(self):
        # An installation pinned to the older library should degrade to working,
        # not to a crash nobody can interpret.
        legacy = [[box(0, 0), ("hello", 0.9)]]

        texts, scores, _ = _unpack(legacy)

        assert texts == ["hello"] and scores == [0.9]

    def test_a_malformed_entry_is_skipped_not_fatal(self):
        texts, _, _ = _unpack([[box(0, 0), ("good", 0.9)], "nonsense", []])

        assert texts == ["good"]

    def test_an_empty_page_is_empty(self):
        assert _unpack(page()) == ([], [], [])


class TestLineConfidence:
    def test_an_empty_line_is_zero_not_a_division_error(self):
        assert _line_confidence([]) == 0.0

    def test_regions_of_only_whitespace_do_not_divide_by_zero(self):
        blank = Region(text="", confidence=0.5, left=0, top=0, right=1, bottom=1)

        assert _line_confidence([blank]) == 0.0

    def test_confidence_is_clamped_into_range(self, document):
        # TextBlock refuses anything outside 0–1, and float arithmetic drifts.
        result = engine_with([page(("x", 1.0000000002, box(0, 0)))]).read(document)

        assert 0.0 <= result.confidence <= 1.0


@pytest.fixture(scope="session")
def rendered(tmp_path_factory) -> Path:
    """A document rendered from text, so what it says is known exactly."""
    Image = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
    ImageDraw = pytest.importorskip("PIL.ImageDraw")

    path = tmp_path_factory.mktemp("ocr") / "generated.png"
    image = Image.new("RGB", (900, 300), "white")
    draw = ImageDraw.Draw(image)

    # Drawn large: PIL's default bitmap font is tiny, and text below the
    # detector's minimum height is a property of this fixture rather than of
    # the engine under test.
    for y, line in ((60, "NATIONAL IDENTITY CARD"),
                    (130, "Name: Muhammad Ali"),
                    (200, "Identity Number: 35202-1234567-1")):
        draw.text((40, y), line, fill="black", font_size=34)

    image.save(path)

    return path


@pytest.fixture(scope="session")
def real_engine():
    pytest.importorskip("paddleocr", reason="PaddleOCR is not installed")

    return PaddleOcrEngine(OcrConfig(use_angle_classifier=False))


@pytest.mark.slow
class TestAgainstTheRealEngine:
    """The only tests here that prove the adapter reads PaddleOCR's real output.

    Everything above uses result shapes written from the documentation. These
    run the actual library — and they earned their place immediately: they are
    what caught PaddlePaddle 3.3.1 failing outright with oneDNN enabled, which
    no fake-based test could ever have surfaced.

    Slow (~40s, loading models). Deselect with `-m "not slow"` while iterating;
    never deselect them in CI.
    """

    def test_it_reads_a_generated_document(self, real_engine, rendered):
        result = real_engine.read(rendered)

        assert not result.is_empty
        assert result.engine == "paddleocr"

    def test_it_finds_the_identity_number(self, real_engine, rendered):
        text = real_engine.read(rendered).text

        # The whole point of the pipeline: this string is what the extraction
        # patterns look for, and it has to survive detection, recognition and
        # the reading-order pass intact.
        assert "35202-1234567-1" in text.replace(" ", "")

    def test_it_reports_a_usable_confidence(self, real_engine, rendered):
        result = real_engine.read(rendered)

        # Risk scoring treats anything under 0.60 as high risk. Clean rendered
        # text scoring below that would mean the thresholds are wrong.
        assert result.confidence > 0.60


class TestTheRecognitionModelIsChosenOnce:
    """Which model reads a document, and where that decision actually lives.

    `ur` selects PaddleOCR's Arabic-script recogniser, which reads Urdu *and*
    Latin. It is not an Urdu-only setting, and it is the default because it
    reads these documents better — including their English.

    Measured on a real CNIC: the English model rendered the card's English
    headline as `A at ar` / `LAN`, while the Arabic-script model read
    `PAKISTAN National Identity Card` and `ISLAMIC REPUBLIC OF PAKISTAN`
    correctly. Surrounding Urdu was making the English model misread English —
    and "national identity card" is the classifier's strongest marker.

    On an English-only document the two are indistinguishable: 271 characters
    each, 0.997 against 0.996 confidence, same classification.
    """

    def test_the_default_reads_arabic_script(self):
        from app.ocr.config import OcrConfig

        assert OcrConfig().language == "ur"

    def test_a_deployment_reading_its_configuration_from_the_environment_gets_it_too(self):
        """The seam where this change could have silently not happened.

        from_env() repeated the default as a literal. Changing the field alone
        would have looked like a change and behaved exactly as before, because
        every deployment builds its configuration from the environment.
        """
        from app.ocr.config import OcrConfig

        assert OcrConfig.from_env({}).language == "ur"

    def test_a_deployment_can_still_ask_for_the_english_model(self):
        # For documents that really are English-only, and to get back to the
        # previous behaviour without editing code.
        from app.ocr.config import OcrConfig

        assert OcrConfig.from_env({"TAXPILOT_OCR_LANGUAGE": "en"}).language == "en"

    def test_the_default_is_named_once(self):
        from app.ocr.config import DEFAULT_LANGUAGE, OcrConfig

        assert OcrConfig().language == DEFAULT_LANGUAGE
        assert OcrConfig.from_env({}).language == DEFAULT_LANGUAGE


class TestTheDetectorIsPinnedWithoutLosingTheAlphabet:
    """Pinning a detector must never change which script gets read.

    Naming a detector alone makes PaddleOCR forget the recogniser the language
    chose — it loads its own default instead. That happened silently: the Urdu
    simply stopped being read while every log line still reported `ur`, and it
    nearly passed as a measurement showing the mobile detector was better. It
    was reading a different alphabet.
    """

    def _kwargs(self, config):
        engine = PaddleOcrEngine(config)
        captured = {}

        class FakePaddleOCR:
            def __init__(self, **kw):
                captured.update(kw)

        # Only the class is swapped, not the module. Replacing the whole module
        # also hides paddleocr._pipelines.ocr from the recogniser lookup, which
        # sends it down its fallback — so the test would pass or fail on
        # something other than what it claims to check.
        paddleocr = pytest.importorskip("paddleocr")
        original = paddleocr.PaddleOCR
        paddleocr.PaddleOCR = FakePaddleOCR
        try:
            engine._build_reader()  # noqa: SLF001
        finally:
            paddleocr.PaddleOCR = original

        return captured

    def test_both_model_names_are_given_together(self):
        from app.ocr.config import OcrConfig

        kwargs = self._kwargs(OcrConfig())

        assert kwargs["text_detection_model_name"] == "PP-OCRv5_mobile_det"
        # The Arabic recogniser, because the language is `ur` — not PaddleOCR's
        # default, which is what naming only the detector would have produced.
        assert kwargs["text_recognition_model_name"] == "arabic_PP-OCRv5_mobile_rec"

    def test_an_english_deployment_pins_its_own_recogniser(self):
        from app.ocr.config import OcrConfig

        kwargs = self._kwargs(OcrConfig(language="en"))

        assert kwargs["text_recognition_model_name"] == "PP-OCRv6_medium_rec"

    def test_neither_name_is_pinned_when_the_detector_is_unset(self):
        # Back to letting PaddleOCR choose both from the language.
        from app.ocr.config import OcrConfig

        kwargs = self._kwargs(OcrConfig(detection_model=""))

        assert "text_detection_model_name" not in kwargs
        assert "text_recognition_model_name" not in kwargs

    def test_the_detector_default_survives_the_environment(self):
        from app.ocr.config import DEFAULT_DETECTION_MODEL, OcrConfig

        assert OcrConfig.from_env({}).detection_model == DEFAULT_DETECTION_MODEL

    def test_a_deployment_can_hand_the_choice_back_to_paddleocr(self):
        from app.ocr.config import OcrConfig

        assert OcrConfig.from_env({"TAXPILOT_OCR_DETECTION_MODEL": ""}).detection_model == ""


class TestTheReaderGivesUpEventually:
    """A read that never returns used to block all document intake.

    The inbox job takes delivery of everything the webhook queued, so one stuck
    document stopped every other one until somebody restarted the agent. The
    daemon's watchdog noticed at fifteen minutes and only reported it.

    The deadline is 300s — roughly four times the slowest of twelve real
    documents measured (69s), so it interrupts none of them.
    """

    def _engine(self, seconds, work):
        from app.ocr.config import OcrConfig
        from app.ocr.paddle import PaddleOcrEngine

        engine = PaddleOcrEngine(OcrConfig(timeout_seconds=seconds))
        engine._recognise = work  # type: ignore[method-assign]

        return engine

    def test_a_read_that_hangs_is_abandoned_rather_than_waited_out(self, tmp_path):
        import threading

        started = threading.Event()

        def never_returns(path):
            started.set()
            threading.Event().wait(30)  # would outlast the deadline many times over

        doc = tmp_path / "stuck.jpg"
        doc.write_bytes(b"\xff\xd8\xff\xe0")

        result = self._engine(0.3, never_returns).read(doc)

        assert started.is_set()
        # Empty, and distinguishable from a blank page — the reason travels so a
        # reviewer is told to resend rather than to hunt for a client.
        assert result.blocks == []
        assert result.failed
        assert "within" in result.failure

    def test_a_read_inside_the_deadline_is_unaffected(self, tmp_path):
        doc = tmp_path / "fine.jpg"
        doc.write_bytes(b"\xff\xd8\xff\xe0")

        engine = self._engine(5.0, lambda path: [])
        result = engine.read(doc)

        assert result.failed is False
        assert result.failure is None

    def test_a_deadline_of_zero_means_no_deadline(self, tmp_path):
        # For a deployment that would rather wait than lose a slow document.
        doc = tmp_path / "fine.jpg"
        doc.write_bytes(b"\xff\xd8\xff\xe0")

        assert self._engine(0, lambda path: []).read(doc).failed is False

    def test_the_default_is_the_measured_number(self):
        from app.ocr.config import OcrConfig

        # Chosen from Part 3: the slowest of twelve real documents took 69s.
        assert OcrConfig().timeout_seconds == 300.0

    def test_a_deployment_can_raise_it(self):
        from app.ocr.config import OcrConfig

        cfg = OcrConfig.from_env({"TAXPILOT_OCR_TIMEOUT_SECONDS": "900"})

        assert cfg.timeout_seconds == 900.0
