"""Unit tests for the tiered PDF routing in ProcessorRegistry.

Covers: default fast-tier routing, the pymupdf rollback toggle, classification
recording derived from the extraction, and OCR escalation (on/off).
"""

from unittest.mock import MagicMock, call

import pytest

from nextcloud_mcp_server.document_processors import registry as reg_mod
from nextcloud_mcp_server.document_processors.base import (
    DocumentProcessor,
    ProcessingResult,
)
from nextcloud_mcp_server.document_processors.escalation import EscalationDecision
from nextcloud_mcp_server.document_processors.registry import ProcessorRegistry
from tests.fixtures.glyph_corruption import GLYPH_CORRUPT_TEXT

pytestmark = pytest.mark.unit


class _Fake(DocumentProcessor):
    def __init__(
        self,
        name: str,
        tier: str,
        # >= MIN_PAGE_CHARS of clean, whitespace-separated prose so the default
        # classifies "fast" (a shorter string trips the near-empty OCR signal).
        text: str = "this is clean readable prose text",
        success=True,
        pages: int = 1,
    ):
        self._name = name
        self._tier = tier
        self._text = text
        self._success = success
        self._pages = pages

    @property
    def name(self) -> str:
        return self._name

    @property
    def tier(self) -> str:
        return self._tier

    @property
    def supported_mime_types(self) -> set[str]:
        return {"application/pdf"}

    async def process(
        self, content, content_type, filename=None, options=None, progress_callback=None
    ):
        boundaries = (
            [{"page": 1, "start_offset": 0, "end_offset": len(self._text)}]
            if self._pages
            else []
        )
        return ProcessingResult(
            text=self._text,
            metadata={"page_count": self._pages, "page_boundaries": boundaries},
            processor=self._name,
            success=self._success,
        )

    async def health_check(self) -> bool:
        return True


class _Settings:
    def __init__(
        self,
        engine="pypdfium2",
        classify=True,
        ocr=False,
        min_text_quality=0.5,
        page_fraction=0.5,
        min_page_chars=16,
        detect_scanned=False,
        glyph_corruption_ratio=0.02,
        # Guard off by default so existing tiering tests are unaffected; tests
        # that exercise the size guard pass an explicit cap.
        max_pdf_size_mb=0.0,
    ):
        self.document_tier1_engine = engine
        self.document_classify_enabled = classify
        self.document_ocr_enabled = ocr
        self.document_ocr_min_text_quality = min_text_quality
        self.document_ocr_page_fraction = page_fraction
        self.document_ocr_min_page_chars = min_page_chars
        self.document_ocr_detect_scanned = detect_scanned
        self.document_glyph_corruption_ratio = glyph_corruption_ratio
        self.document_max_pdf_size_mb = max_pdf_size_mb


def _registry(*procs: tuple[DocumentProcessor, int]) -> ProcessorRegistry:
    r = ProcessorRegistry()
    for proc, prio in procs:
        r.register(proc, priority=prio)
    return r


async def test_pdf_routes_to_fast_tier(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings())
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("structured", "structured"), 10))
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "fast"


async def test_oversize_pdf_fails_fast_without_parsing(monkeypatch):
    """A PDF over the size cap must fail fast as 'oversize' before any tier runs."""
    monkeypatch.setattr(
        reg_mod, "get_settings", lambda: _Settings(max_pdf_size_mb=0.001)
    )
    fast = _Fake("fast", "fast")
    ran = False
    orig = fast.process

    async def _tracking(*a, **k):
        nonlocal ran
        ran = True
        return await orig(*a, **k)

    fast.process = _tracking  # type: ignore[method-assign]
    r = _registry((fast, 20))

    # ~2 KB > 0.001 MB (~1 KB) cap.
    res = await r.process(b"%PDF-1.7" + b"0" * 2048, "application/pdf", "big.pdf")

    assert res.success is False
    assert res.metadata["parse_failed_reason"] == "oversize"
    assert res.processor == "size_guard"
    assert ran is False, "size guard must short-circuit before the fast tier runs"


async def test_under_cap_pdf_still_parses(monkeypatch):
    """A PDF under the cap is unaffected by the guard."""
    monkeypatch.setattr(
        reg_mod, "get_settings", lambda: _Settings(max_pdf_size_mb=10.0)
    )
    r = _registry((_Fake("fast", "fast"), 20))
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.success is True
    assert res.processor == "fast"


async def test_engine_rollback_uses_structured(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(engine="pymupdf"))
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("structured", "structured"), 10))
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "structured"


async def test_engine_rollback_warns_when_no_structured(monkeypatch, caplog):
    # pymupdf rollback with no structured processor registered: it falls back to
    # the fast processor but must warn (it silently used what the user opted out
    # of otherwise).
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(engine="pymupdf"))
    r = _registry((_Fake("fast", "fast"), 20))
    with caplog.at_level(
        "WARNING", logger="nextcloud_mcp_server.document_processors.registry"
    ):
        res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "fast"
    assert any("no 'structured' processor" in rec.message for rec in caplog.records)


async def test_records_classification(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings())
    rec = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_classification", rec)
    r = _registry((_Fake("fast", "fast"), 20))
    await r.process(b"%PDF-1.7", "application/pdf")
    rec.assert_called_once()
    # recommended_tier, flags, mean_text_quality, ocr_page_fraction all threaded
    # through (the last two feed the per-tenant tuning histograms).
    args = rec.call_args.args
    assert len(args) == 4
    assert isinstance(args[0], str) and isinstance(args[3], float)


async def test_classify_disabled_skips_recording(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(classify=False))
    rec = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_classification", rec)
    r = _registry((_Fake("fast", "fast"), 20))
    await r.process(b"%PDF-1.7", "application/pdf")
    rec.assert_not_called()


async def test_ocr_escalation_on_empty_text(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text=""), 20),
        (_Fake("ocr", "ocr", text="ocr text"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "ocr"
    esc.assert_called_once()


async def test_zero_page_pdf_does_not_escalate(monkeypatch):
    # An empty/corrupt PDF (no pages) classifies "ocr" but must NOT escalate --
    # OCR can't help and it would be wasteful.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text="", pages=0), 20),
        (_Fake("ocr", "ocr"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "fast"
    esc.assert_not_called()


async def test_pipeline_tier_stamped_on_metadata(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings())
    r = _registry((_Fake("fast", "fast"), 20))
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.metadata["pipeline_tier"] == "fast"


async def test_ocr_failure_falls_back_to_fast(monkeypatch):
    # OCR enabled but the backend can't run (no creds / API down) -> keep the
    # tier-1 result instead of failing the document.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    monkeypatch.setattr(reg_mod, "record_document_escalation", MagicMock())
    r = _registry(
        (_Fake("fast", "fast", text=""), 20),
        (_Fake("ocr", "ocr", text="", success=False), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "fast"
    assert res.success is True


async def test_no_ocr_escalation_when_disabled(monkeypatch):
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=False))
    r = _registry(
        (_Fake("fast", "fast", text=""), 20),
        (_Fake("ocr", "ocr"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    # Fast tier is terminal when OCR is disabled.
    assert res.processor == "fast"


# --- glyph-corruption escalation + full-ladder parity ------------------------

# A fast-tier text layer that looks like words (HIGH text_quality) but leaks C0
# control chars -- the broken-/ToUnicode signature the control-char ratio catches.
# Shared with the classifier tests so the two can't diverge.
_GLYPH = GLYPH_CORRUPT_TEXT


async def test_glyph_corrupt_escalates_fast_to_structured(monkeypatch):
    # Not gated on OCR: structured is free + in-cluster, so a glyph-corrupt layer
    # escalates fast->structured even with OCR disabled.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=False))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text=_GLYPH), 20),
        (_Fake("structured", "structured", text="clean recovered prose text"), 10),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "structured"
    esc.assert_called_once_with("fast", "structured", "corrupt_glyphs")


async def test_glyph_corrupt_no_structured_stays_fast(monkeypatch):
    # No structured processor registered AND OCR off -> nothing to escalate to;
    # keep fast (the inline counterpart of the external "suppressed" outcome).
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=False))
    r = _registry((_Fake("fast", "fast", text=_GLYPH), 20))
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "fast"


async def test_glyph_corrupt_no_structured_falls_through_to_ocr(monkeypatch):
    # Parity with the external path: structured unregistered but OCR enabled ->
    # the glyph-corrupt doc falls through to OCR (not silently kept at fast).
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text=_GLYPH), 20),
        (_Fake("ocr", "ocr", text="ocr recovered text"), 5),
    )  # no structured registered
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "ocr"
    esc.assert_called_once_with("fast", "ocr", "corrupt_glyphs")


async def test_inline_lowconf_tries_structured_before_ocr(monkeypatch):
    # Full-ladder parity with the external path: a junk-but-non-empty fast layer
    # tries structured (fast->structured) BEFORE any OCR, even with OCR enabled.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text="x" * 40), 20),  # one long token -> quality ~0
        (_Fake("structured", "structured", text="clean recovered prose text here"), 10),
        (_Fake("ocr", "ocr", text="ocr text"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "structured"
    esc.assert_called_once_with("fast", "structured", "low_confidence")


async def test_inline_empty_skips_structured_straight_to_ocr(monkeypatch):
    # The one intended shortcut: a scanned/no-text-layer doc (total_chars == 0)
    # skips structured (it cannot extract text from a raster) and goes to OCR.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text=""), 20),
        (_Fake("structured", "structured", text="should not run"), 10),
        (_Fake("ocr", "ocr", text="ocr text"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "ocr"
    esc.assert_called_once_with("fast", "ocr", "empty_text")


async def test_inline_fast_structured_ocr_cascade(monkeypatch):
    # Full cascade: a junk-but-non-empty fast layer hops to structured, the
    # structured re-extract is empty (a doc that was ALSO scanned), so it then
    # hops to OCR. The second hop must be attributed from_tier="structured",
    # NOT a second "fast" escalation.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text="x" * 40), 20),  # quality ~0, non-empty
        (_Fake("structured", "structured", text=""), 10),  # re-extract empty
        (_Fake("ocr", "ocr", text="ocr recovered text"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "ocr"
    assert esc.call_args_list == [
        call("fast", "structured", "low_confidence"),
        call("structured", "ocr", "empty_text"),
    ]


async def test_inline_structured_still_corrupt_escalates_to_ocr(monkeypatch):
    # Edge: the structured re-extract is ALSO glyph-corrupt (pymupdf also failed to
    # decode). Re-classification stays "structured", so the OCR gate fires --
    # attributed from_tier="structured" with reason corrupt_glyphs.
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings(ocr=True))
    esc = MagicMock()
    monkeypatch.setattr(reg_mod, "record_document_escalation", esc)
    r = _registry(
        (_Fake("fast", "fast", text=_GLYPH), 20),
        (_Fake("structured", "structured", text=_GLYPH), 10),  # still corrupt
        (_Fake("ocr", "ocr", text="ocr recovered text"), 5),
    )
    res = await r.process(b"%PDF-1.7", "application/pdf")
    assert res.processor == "ocr"
    assert esc.call_args_list == [
        call("fast", "structured", "corrupt_glyphs"),
        call("structured", "ocr", "corrupt_glyphs"),
    ]


def test_evaluate_escalation_glyph_corrupt_goes_structured(monkeypatch):
    # External path mirrors the inline path: glyph-corrupt -> structured, never OCR.
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text=_GLYPH,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(_GLYPH)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True))
    assert decision == EscalationDecision("hop", "structured", "corrupt_glyphs")


def test_evaluate_escalation_glyph_corrupt_no_structured_falls_through_to_ocr(
    monkeypatch,
):
    # External path with structured unregistered: next_available_tier skips the
    # missing rung and lands on OCR, keeping the corrupt_glyphs reason.
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("ocr", "ocr"), 5),
    )  # no structured registered
    res = ProcessingResult(
        text=_GLYPH,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(_GLYPH)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True))
    assert decision == EscalationDecision("hop", "ocr", "corrupt_glyphs")


def test_evaluate_escalation_glyph_corrupt_no_structured_ocr_disabled_suppressed(
    monkeypatch,
):
    # Structured unregistered AND OCR registered-but-disabled: the would-be OCR
    # fallthrough is suppressed, and it carries the corrupt_glyphs reason (so the
    # "what-if OCR" counter can show latent glyph-corruption demand).
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("ocr", "ocr"), 5),
    )  # structured not registered; ocr registered but disabled below
    res = ProcessingResult(
        text=_GLYPH,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(_GLYPH)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False))
    assert decision == EscalationDecision("suppressed", "ocr", "corrupt_glyphs")


# --- Per-tier external path (Deck #323) -------------------------------------


async def test_process_tier_runs_named_tier(monkeypatch):
    """process_tier runs exactly the requested tier's processor, not priority."""
    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    res = await r.process_tier(b"%PDF-1.7", "application/pdf", "f.pdf", "structured")
    assert res.processor == "structured"


async def test_process_tier_unknown_tier_raises(monkeypatch):
    from nextcloud_mcp_server.document_processors.base import ProcessorError

    monkeypatch.setattr(reg_mod, "get_settings", lambda: _Settings())
    r = _registry((_Fake("fast", "fast"), 20))
    with pytest.raises(ProcessorError, match="structured"):
        await r.process_tier(b"%PDF-1.7", "application/pdf", "f.pdf", "structured")


async def test_process_tier_oversize_fails_fast(monkeypatch):
    """The size guard applies on the per-tier path too (before any parse)."""
    monkeypatch.setattr(
        reg_mod, "get_settings", lambda: _Settings(max_pdf_size_mb=0.001)
    )
    r = _registry((_Fake("ocr", "ocr"), 5))
    res = await r.process_tier(b"x" * 4096, "application/pdf", "big.pdf", "ocr")
    assert res.success is False
    assert res.metadata["parse_failed_reason"] == "oversize"


def test_next_available_tier_walks_ladder():
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    # ocr disabled -> structured is the only target above fast.
    s = _Settings(ocr=False)
    assert r.next_available_tier("fast", s) == "structured"
    assert r.next_available_tier("structured", s) is None  # ocr gated off
    # ocr enabled -> reachable; minimum skips the structured rung.
    s_ocr = _Settings(ocr=True)
    assert r.next_available_tier("structured", s_ocr) == "ocr"
    assert r.next_available_tier("fast", s_ocr, minimum="ocr") == "ocr"


def test_next_available_tier_skips_unregistered():
    # No structured processor -> fast escalates straight to ocr.
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    assert r.next_available_tier("fast", _Settings(ocr=True)) == "ocr"


def test_evaluate_escalation_good_text_indexes(monkeypatch):
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast", text="This is clean readable prose text."), 20),
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text="This is clean readable prose text.",
        metadata={
            "page_count": 1,
            "page_boundaries": [{"page": 1, "start_offset": 0, "end_offset": 34}],
        },
        processor="fast",
    )
    assert r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True)) is None


def test_evaluate_escalation_empty_jumps_to_ocr(monkeypatch):
    """A scanned (no-text-layer) result targets ocr directly, skipping structured."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text="",
        metadata={
            "page_count": 1,
            "page_boundaries": [{"page": 1, "start_offset": 0, "end_offset": 0}],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True))
    assert decision == EscalationDecision("hop", "ocr", "empty_text")


def test_evaluate_escalation_lowconf_goes_to_structured(monkeypatch):
    """A junk-but-non-empty layer escalates to the next rung (structured)."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    junk = "x" * 40  # one long token, no whitespace -> quality ~0
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text=junk,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(junk)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True))
    assert decision == EscalationDecision("hop", "structured", "low_confidence")


def test_evaluate_escalation_failure_not_escalated(monkeypatch):
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    res = ProcessingResult(
        text="",
        metadata={"parse_failed_reason": "error"},
        processor="fast",
        success=False,
    )
    assert r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True)) is None


def test_evaluate_escalation_terminal_when_no_higher_tier(monkeypatch):
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    # Only fast registered -> nowhere to escalate even on junk text.
    r = _registry((_Fake("fast", "fast"), 20))
    junk = "y" * 40
    res = ProcessingResult(
        text=junk,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(junk)}
            ],
        },
        processor="fast",
    )
    assert r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True)) is None


def test_evaluate_escalation_zero_page_does_not_escalate(monkeypatch):
    """A zero-page (empty/corrupt) PDF never escalates on the external path."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    res = ProcessingResult(
        text="",
        metadata={"page_count": 0, "page_boundaries": []},
        processor="fast",
    )
    assert r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True)) is None


def test_evaluate_escalation_lowconf_to_ocr_when_no_structured(monkeypatch):
    """fast+ocr only: a low-confidence parse routes straight to ocr (skips the
    unregistered structured rung), not to None."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    junk = "z" * 40  # non-empty but junk -> recommended ocr, total_chars > 0
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    res = ProcessingResult(
        text=junk,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(junk)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=True))
    assert decision == EscalationDecision("hop", "ocr", "low_confidence")


def test_evaluate_escalation_suppressed_when_ocr_disabled(monkeypatch):
    """OCR off: a scanned doc does NOT hop to ocr; it returns a 'suppressed'
    decision (the what-if-OCR signal) so the caller indexes at the current tier."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    res = ProcessingResult(
        text="",
        metadata={
            "page_count": 1,
            "page_boundaries": [{"page": 1, "start_offset": 0, "end_offset": 0}],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False))
    assert decision == EscalationDecision("suppressed", "ocr", "empty_text")


def test_evaluate_escalation_lowconf_suppressed_when_only_ocr_disabled(monkeypatch):
    """fast+ocr only, OCR off, junk text: the next rung is the disabled ocr, so
    the would-be hop is suppressed (not a structured hop, which isn't registered)."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    junk = "q" * 40
    r = _registry((_Fake("fast", "fast"), 20), (_Fake("ocr", "ocr"), 5))
    res = ProcessingResult(
        text=junk,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(junk)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False))
    assert decision == EscalationDecision("suppressed", "ocr", "low_confidence")


def test_evaluate_escalation_structured_hop_not_suppressed_when_ocr_off(monkeypatch):
    """OCR off but structured available + junk text → real hop to structured
    (not suppressed): the in-cluster rung can still run."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    junk = "w" * 40
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text=junk,
        metadata={
            "page_count": 1,
            "page_boundaries": [
                {"page": 1, "start_offset": 0, "end_offset": len(junk)}
            ],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False))
    assert decision == EscalationDecision("hop", "structured", "low_confidence")


def test_evaluate_escalation_terminal_when_ocr_unregistered_and_off(monkeypatch):
    """No OCR processor registered at all (not merely disabled) → genuinely
    terminal: returns None, NOT a suppressed decision. 'Absent' != 'disabled'."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry((_Fake("fast", "fast"), 20))  # only fast; no ocr processor
    res = ProcessingResult(
        text="",
        metadata={
            "page_count": 1,
            "page_boundaries": [{"page": 1, "start_offset": 0, "end_offset": 0}],
        },
        processor="fast",
    )
    assert r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False)) is None


def test_evaluate_escalation_empty_suppressed_even_when_structured_registered(
    monkeypatch,
):
    """empty_text uses minimum='ocr', so it skips structured even when structured
    IS registered: with OCR off it suppresses to ocr, never hops to structured
    (a text extractor can't conjure text from a raster scan)."""
    monkeypatch.setattr(reg_mod, "record_document_classification", MagicMock())
    r = _registry(
        (_Fake("fast", "fast"), 20),
        (_Fake("structured", "structured"), 10),  # registered but skipped for empty
        (_Fake("ocr", "ocr"), 5),
    )
    res = ProcessingResult(
        text="",
        metadata={
            "page_count": 1,
            "page_boundaries": [{"page": 1, "start_offset": 0, "end_offset": 0}],
        },
        processor="fast",
    )
    decision = r.evaluate_escalation(res, b"%PDF", "fast", _Settings(ocr=False))
    assert decision == EscalationDecision("suppressed", "ocr", "empty_text")
