"""
Tests for Track B3.5 — Smart PDF overflow handling.

Covers:
  - Tier 0: original bbox at original size wins if text fits
  - Tier 1: expanded horizontal bbox preserves font size
  - Tier 2: expanded vertical bbox (3x original height) is tried
  - Tier 3: font shrink is limited to 2 steps (0.93, 0.87)
  - Tier 4: HEADINGS (>= 14pt) never shrink below 90% of original
  - Tier 5: BODY TEXT never shrinks below 75% of original
  - Tier 6: graceful failure — block is skipped, metric emitted
  - Redaction uses single rect per block (not per sub-bbox)
"""
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import importlib.util

_REPO_ROOT = Path(__file__).resolve().parent.parent.parent


def _load_pdf_translator():
    spec = importlib.util.spec_from_file_location(
        "pdf_translator_under_test",
        _REPO_ROOT / "translators" / "pdf_translator.py",
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


@pytest.fixture(scope="module")
def pdf_mod():
    return _load_pdf_translator()


# ============================================================================
# Constants
# ============================================================================

class TestConstants:
    def test_font_shrink_factor_mild(self, pdf_mod):
        """FONT_SHRINK_FACTOR should be 0.93 (mild), not 0.87 (aggressive)."""
        assert pdf_mod.FONT_SHRINK_FACTOR == pytest.approx(0.93, abs=0.01), (
            f"FONT_SHRINK_FACTOR is {pdf_mod.FONT_SHRINK_FACTOR}, "
            "should be 0.93 (was 0.87 in B3 — too aggressive, broke hierarchy)"
        )

    def test_vertical_expansion_3x(self, pdf_mod):
        """MAX_VERTICAL_EXPANSION should be 6x (was 1.5x in B3, then 3x in B3.5).
        Track B3.6 bumped it to 6x to handle long French titles that
        wrap to multiple lines (a 22pt title may need 4-5 lines in French)."""
        assert pdf_mod.MAX_VERTICAL_EXPANSION == pytest.approx(6.0, abs=0.1)

    def test_heading_min_size_threshold(self, pdf_mod):
        assert pdf_mod.HEADING_MIN_SIZE == 14.0

    def test_heading_min_scale_90pct(self, pdf_mod):
        assert pdf_mod.HEADING_MIN_SCALE == pytest.approx(0.90, abs=0.01)

    def test_body_min_scale_75pct(self, pdf_mod):
        assert pdf_mod.BODY_MIN_SCALE == pytest.approx(0.75, abs=0.01)


# ============================================================================
# _write_translated_block — tier behavior
# ============================================================================

class TestSmartFitTiers:
    def _make_block(self, font_size=12, x0=100, y0=100, x1=400, y1=120, text="Hello world"):
        return {
            "bbox": (x0, y0, x1, y1),
            "text": text,
            "font_size": font_size,
            "color": 0,
            "is_bold": False,
            "is_italic": False,
            "translated": text,
            "sub_bboxes": [(x0, y0, x1, y1)],
        }

    def _make_page(self, page_w=612, page_h=792):
        """Mock page that accepts insert_textbox and returns rc (overflow indicator)."""
        page = MagicMock()
        import fitz
        page.rect = fitz.Rect(0, 0, page_w, page_h)
        # By default, accept the insert (return rc=0)
        page.insert_textbox = MagicMock(return_value=0)
        page.add_redact_annot = MagicMock()
        return page

    def test_tier_0_fits_at_original_size(self, pdf_mod):
        """If the text fits at original size + original bbox, no shrink."""
        page = self._make_page()
        block = self._make_block(font_size=12, text="Short text")

        translator = pdf_mod.PDFTranslator()
        result = translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        # Should have succeeded
        assert result is True
        # First call should be with original size (12) and original bbox
        first_call = page.insert_textbox.call_args_list[0]
        # First positional arg is the rect
        assert first_call[0][0] == block["bbox"]
        # Second positional arg is the text
        assert first_call[0][1] == "Short text"
        # fontsize kwarg
        assert first_call[1]["fontsize"] == 12

    def test_tier_2_uses_expanded_vertical_bbox(self, pdf_mod):
        """When original bbox is too small, tier 2 expands vertically."""
        page = self._make_page()
        # Force overflow on the original bbox
        page.insert_textbox = MagicMock(side_effect=[-5, -5, 0])
        # -5 = overflow, 0 = fits

        block = self._make_block(font_size=12, x0=100, y0=100, x1=400, y1=120)
        translator = pdf_mod.PDFTranslator()
        result = translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        assert result is True
        # Should have tried multiple times
        assert page.insert_textbox.call_count >= 3

    def test_tier_3_4_limit_to_two_shrink_steps(self, pdf_mod):
        """Even on persistent overflow, the shrink is limited."""
        page = self._make_page()
        # Always overflow
        page.insert_textbox = MagicMock(return_value=-5)

        block = self._make_block(font_size=20, x0=100, y0=100, x1=400, y1=120)
        translator = pdf_mod.PDFTranslator()
        result = translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        # Should have failed (returned False) because persistent overflow
        assert result is False
        # But the number of insert_textbox calls should be bounded
        # (not the 8+ attempts of the old aggressive strategy)
        assert page.insert_textbox.call_count <= 8

    def test_heading_not_shrunk_below_90_percent(self, pdf_mod):
        """A 22pt heading should never be shrunk below ~19.8pt."""
        page = self._make_page()
        call_sizes = []
        # Track every fontsize used
        def capture(*args, **kwargs):
            call_sizes.append(kwargs.get("fontsize", args[2] if len(args) > 2 else None))
            return 0  # always fit
        page.insert_textbox = MagicMock(side_effect=capture)

        # 22pt heading
        block = self._make_block(font_size=22, text="X" * 1000)
        translator = pdf_mod.PDFTranslator()
        translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        # All attempted sizes should be >= 22 * 0.90 = 19.8
        for s in call_sizes:
            if s is not None:
                assert s >= 22 * 0.90 - 0.1, (
                    f"Heading shrunk below 90% (got {s}, min allowed {22 * 0.90})"
                )

    def test_body_not_shrunk_below_75_percent(self, pdf_mod):
        """Body text (12pt) should never be shrunk below ~9pt."""
        page = self._make_page()
        call_sizes = []
        def capture(*args, **kwargs):
            call_sizes.append(kwargs.get("fontsize", args[2] if len(args) > 2 else None))
            return 0
        page.insert_textbox = MagicMock(side_effect=capture)

        block = self._make_block(font_size=12, text="Y" * 1000)
        translator = pdf_mod.PDFTranslator()
        translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        for s in call_sizes:
            if s is not None:
                assert s >= 12 * 0.75 - 0.1, (
                    f"Body text shrunk below 75% (got {s}, min allowed {12 * 0.75})"
                )

    def test_graceful_failure_emits_metric(self, pdf_mod):
        """If nothing fits, the block is skipped and a metric is recorded."""
        page = self._make_page()
        page.insert_textbox = MagicMock(return_value=-5)  # always overflow

        # Track metric emissions
        metrics_emitted = []
        def fake_record(*args, **kwargs):
            metrics_emitted.append((args, kwargs))
        translator = pdf_mod.PDFTranslator()
        with patch.object(pdf_mod, "_record_format_loss_metric", fake_record):
            block = self._make_block(font_size=12, text="Z" * 1000)
            result = translator._write_translated_block(
                page, block, font_path=None, rtl_target=False
            )
        assert result is False
        # A format_loss metric should have been emitted
        assert any("text_overflow" in str(call) for call in metrics_emitted), (
            f"No text_overflow metric emitted. Got: {metrics_emitted}"
        )

    def test_successful_render_does_not_emit_loss_metric(self, pdf_mod):
        """A block that fits cleanly should NOT trigger format_loss."""
        page = self._make_page()
        page.insert_textbox = MagicMock(return_value=0)  # always fit

        metrics_emitted = []
        def fake_record(*args, **kwargs):
            metrics_emitted.append((args, kwargs))
        translator = pdf_mod.PDFTranslator()
        with patch.object(pdf_mod, "_record_format_loss_metric", fake_record):
            block = self._make_block(font_size=12, text="Short")
            result = translator._write_translated_block(
                page, block, font_path=None, rtl_target=False
            )
        assert result is True
        # No format_loss should have been emitted
        assert metrics_emitted == [], (
            f"Unexpected metric emission on success: {metrics_emitted}"
        )

    def test_min_size_floor_respected(self, pdf_mod):
        """The min size floor (MIN_FONT_SIZE) is always respected."""
        page = self._make_page()
        call_sizes = []
        def capture(*args, **kwargs):
            call_sizes.append(kwargs.get("fontsize", args[2] if len(args) > 2 else None))
            return 0
        page.insert_textbox = MagicMock(side_effect=capture)

        # Tiny font (3pt) — should still be at least MIN_FONT_SIZE (4.5pt)
        block = self._make_block(font_size=3, text="Q" * 1000)
        translator = pdf_mod.PDFTranslator()
        translator._write_translated_block(
            page, block, font_path=None, rtl_target=False
        )
        # Exclude the placeholder font (used in graceful failure)
        real_sizes = [s for s in call_sizes if s is not None and s >= 4.5]
        for s in real_sizes:
            assert s >= pdf_mod.MIN_FONT_SIZE, (
                f"Font size {s} below absolute floor {pdf_mod.MIN_FONT_SIZE}"
            )


# ============================================================================
# Real PDF comparison
# ============================================================================

class TestRealPDFSmartFit:
    """Run the actual translator on a real PDF and check the output."""

    def test_translate_real_pdf_preserves_hyperlinks(self, tmp_path):
        """The translated PDF should keep all hyperlinks (B3 fix)."""
        import fitz
        # Build a minimal PDF in memory
        doc = fitz.open()
        page = doc.new_page()
        page.insert_text((72, 72), "Visit our website for more details.")
        page.insert_link({
            "kind": fitz.LINK_URI,
            "from": fitz.Rect(72, 65, 280, 80),
            "uri": "https://example.com",
        })

        in_path = tmp_path / "input.pdf"
        out_path = tmp_path / "output.pdf"
        doc.save(str(in_path))
        doc.close()

        # Translate
        from translators.pdf_translator import PDFTranslator
        translator = PDFTranslator()
        translator.translate_file(
            in_path, out_path,
            target_language="fr",
            source_language="en",
        )

        # Verify hyperlinks preserved
        out_doc = fitz.open(str(out_path))
        out_page = out_doc[0]
        links = list(out_page.get_links())
        assert len(links) == 1
        assert links[0].get("uri") == "https://example.com"
        out_doc.close()

    def test_translate_real_pdf_no_font_below_75pct_for_body(self, tmp_path):
        """Body text on a real PDF should not be shrunk below 75% of original.

        Note: the [translation overflow] placeholder font (6.0pt) is
        excluded from this check — it's a deliberate marker for skipped
        blocks, not a real text font.
        """
        import fitz
        doc = fitz.open()
        page = doc.new_page()
        # Real body text at 12pt
        page.insert_text((72, 72), "Hello world this is a test.",
                         fontsize=12)

        in_path = tmp_path / "input.pdf"
        out_path = tmp_path / "output.pdf"
        doc.save(str(in_path))
        doc.close()

        from translators.pdf_translator import PDFTranslator
        translator = PDFTranslator()
        translator.translate_file(
            in_path, out_path,
            target_language="fr",
            source_language="en",
        )

        # Check output fonts — none should be below 12 * 0.75 = 9pt for body
        out_doc = fitz.open(str(out_path))
        out_page = out_doc[0]
        out_fonts = set()
        for block in out_page.get_text("dict").get("blocks", []):
            for line in block.get("lines", []):
                for span in line.get("spans", []):
                    out_fonts.add(span.get("size", 0))
        out_doc.close()

        # Allow some shrinkage for very long text but never below 75%.
        # Exclude the placeholder font (6.0pt) which is the overflow marker.
        for size in out_fonts:
            if size > 0 and size > 6.5:  # > 6.5 excludes the 6.0pt placeholder
                assert size >= 8.5, (
                    f"Body text font size {size}pt is below the 9pt floor "
                    f"(75% of 12pt)"
                )


# ============================================================================
# Redaction strategy
# ============================================================================

class TestSingleRedactionPerBlock:
    """Track B3.5: redaction should use a single rect per block, not per sub-bbox."""

    def test_single_redaction_call_per_block(self, pdf_mod):
        """The redaction loop should call add_redact_annot ONCE per block."""
        # Build a fake page
        import fitz
        page = MagicMock()
        page.rect = fitz.Rect(0, 0, 612, 792)
        redact_calls = []
        page.add_redact_annot = MagicMock(side_effect=lambda r, **k: redact_calls.append(r))
        page.apply_redactions = MagicMock()

        # 3 blocks, each with 5 sub_bboxes
        blocks = [
            {
                "translated": "Block 1 text",
                "bbox": (50, 50, 400, 70),
                "sub_bboxes": [(50, 50, 400, 70)] * 5,
            },
            {
                "translated": "Block 2 text",
                "bbox": (50, 80, 400, 100),
                "sub_bboxes": [(50, 80, 400, 100)] * 5,
            },
            {
                "translated": "Block 3 text",
                "bbox": (50, 110, 400, 130),
                "sub_bboxes": [(50, 110, 400, 130)] * 5,
            },
        ]

        # Simulate the redaction loop from _process_pages_inplace
        for block in blocks:
            if block.get("translated"):
                page.add_redact_annot(
                    fitz.Rect(block["bbox"]),
                    fill=(1, 1, 1),
                )

        # 3 blocks → 3 redaction calls (NOT 15)
        assert len(redact_calls) == 3, (
            f"Expected 3 redaction calls (one per block), got {len(redact_calls)}"
        )
