"""
Tests for Track B3 — PDF format preservation fixes.

Covers:
  B3.1 — Hyperlink preservation: links inside redacted text areas are
          re-inserted after redaction so the page remains navigable.
  B3.2 — Safe redaction: PDF_REDACT_IMAGE_NONE is used so images
          intersecting the redaction area are NOT rasterized away.
  B3.3 — Block merge: paragraph-spanning blocks are merged into one
          before translation (better context, less font shrinking).
  B3.4 — LibreOffice absence is logged with hint, not just a warning.
  B3.5 — _record_format_loss_metric and _libreoffice_available helpers
          never raise.
"""
import os
import sys
import time
import tempfile
import importlib.util
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# Load pdf_translator directly to avoid pulling the heavy app imports
# ---------------------------------------------------------------------------

_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
def pdf_mod():
    return _load_pdf_translator()


# ============================================================================
# B3.1 — Hyperlink preservation
# ============================================================================

class TestPdfHyperlinkPreservation:
    """A PDF page with a hyperlink should keep the link after the
    in-place text replacement."""

    def test_hyperlink_captured_before_redaction(self, pdf_mod):
        """The pipeline must read links BEFORE applying redactions."""
        # Build a fake page
        fake_page = MagicMock()
        fake_page.get_links.return_value = [
            {"uri": "https://example.com", "from": MagicMock()}
        ]
        # The from_rect needs to be a real fitz.Rect for the page_rect.intersects() check
        import fitz
        fake_page.get_links.return_value = [
            {"uri": "https://example.com", "from": fitz.Rect(0, 0, 100, 20)}
        ]
        fake_page.rect = fitz.Rect(0, 0, 612, 792)

        links = list(fake_page.get_links())
        assert len(links) == 1
        assert links[0]["uri"] == "https://example.com"

    def test_internal_goto_link_recognized(self, pdf_mod):
        """An internal goto link (page number) should be re-insertable."""
        import fitz
        link = {
            "kind": fitz.LINK_GOTO,
            "from": fitz.Rect(10, 10, 80, 30),
            "page": 5,
            "to": fitz.Point(0, 0),
        }
        # The branching logic in the PDF translator should recognize
        # `link.get("page") is not None` as a goto link
        assert link.get("page") is not None
        assert "uri" not in link

    def test_external_uri_link_recognized(self, pdf_mod):
        """An external URI link should be re-insertable."""
        import fitz
        link = {
            "kind": fitz.LINK_URI,
            "from": fitz.Rect(10, 10, 80, 30),
            "uri": "https://example.com",
        }
        assert link.get("uri") is not None

    def test_link_without_uri_or_page_is_lost(self, pdf_mod):
        """A link with no URI and no target page can't be re-inserted —
        it should be counted as lost, not crash the pipeline."""
        link = {"from": MagicMock()}
        # The branching should fall through to the `lost += 1` branch
        assert "uri" not in link
        assert link.get("page") is None

    def test_page_insert_link_uri(self, tmp_path):
        """Smoke test: insert_link accepts the URI shape we build."""
        import fitz
        # Create an empty PDF in memory
        doc = fitz.open()
        page = doc.new_page()
        rect = fitz.Rect(50, 50, 200, 70)
        # The actual insertion
        page.insert_link({
            "kind": fitz.LINK_URI,
            "from": rect,
            "uri": "https://example.com",
        })
        # Save and re-open to verify persistence (PyMuPDF only shows
        # links after save in some cases).
        out = tmp_path / "test_uri.pdf"
        doc.save(str(out))
        doc.close()
        # Re-open and check
        doc2 = fitz.open(str(out))
        links = list(doc2[0].get_links())
        doc2.close()
        assert len(links) == 1
        assert links[0].get("uri") == "https://example.com"

    def test_page_insert_link_goto(self, tmp_path):
        """Smoke test: insert_link accepts the goto shape we build."""
        import fitz
        doc = fitz.open()
        # Create at least 2 pages first
        doc.new_page()
        page2 = doc.new_page()
        # Now insert the link on page 2 (last created page is the
        # only valid reference; the first page reference is invalid
        # because PyMuPDF invalidates earlier page refs when new
        # pages are added)
        page2.insert_link({
            "kind": fitz.LINK_GOTO,
            "from": fitz.Rect(50, 50, 200, 70),
            "page": 0,
            "to": fitz.Point(0, 0),
        })
        out = tmp_path / "test_goto.pdf"
        doc.save(str(out))
        doc.close()
        doc2 = fitz.open(str(out))
        links = list(doc2[1].get_links())  # page 2 (index 1)
        doc2.close()
        assert len(links) == 1
        assert links[0].get("page") == 0


# ============================================================================
# B3.2 — Safe redaction (PDF_REDACT_IMAGE_NONE)
# ============================================================================

class TestSafeRedaction:
    def test_redact_image_none_used(self):
        """The code MUST use PDF_REDACT_IMAGE_NONE so images intersecting
        the redaction area are not rasterized away."""
        import fitz
        # The flag exists in PyMuPDF
        assert hasattr(fitz, "PDF_REDACT_IMAGE_NONE")
        # PDF_REDACT_IMAGE_NONE = 0 means "do not touch images in the
        # redaction area". This is the SAFE choice — images, vector
        # graphics, etc. stay intact.
        # PDF_REDACT_IMAGE_OTHER (1) or PDF_REDACT_IMAGE_ALL (2) would
        # rasterize the affected area and remove the image.
        assert fitz.PDF_REDACT_IMAGE_NONE == 0
        # The PDF translator must use the NONE flag.
        import inspect
        src = inspect.getsource(_load_pdf_translator())
        assert "PDF_REDACT_IMAGE_NONE" in src, (
            "pdf_translator.py should use PDF_REDACT_IMAGE_NONE for safe "
            "redaction of image content."
        )

    def test_redact_with_no_image_param(self):
        """The pipeline calls apply_redactions with images=PDF_REDACT_IMAGE_NONE.
        We can verify by mocking and inspecting the call."""
        import fitz
        doc = fitz.open()
        page = doc.new_page()
        # Insert some text and add a redaction
        page.insert_text((50, 50), "Hello world")
        page.add_redact_annot(fitz.Rect(0, 0, 200, 100), fill=(1, 1, 1))

        # Spy on apply_redactions
        original = page.apply_redactions
        called_with = {}

        def spy(images=None, **kwargs):
            called_with["images"] = images
            called_with["kwargs"] = kwargs
            return original(images=images, **kwargs)

        with patch.object(page, "apply_redactions", side_effect=spy):
            page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE)

        assert called_with["images"] == fitz.PDF_REDACT_IMAGE_NONE
        doc.close()


# ============================================================================
# B3.3 — Block merge
# ============================================================================

class TestBlockMerge:
    def test_should_merge_same_paragraph(self, pdf_mod):
        """Two blocks with the same font, vertically close, and same x
        should be merged."""
        a = {
            "bbox": (50, 100, 200, 120),
            "font_size": 12,
            "line_count": 1,
        }
        b = {
            "bbox": (50, 125, 200, 145),
            "font_size": 12,
            "line_count": 1,
        }
        assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is True

    def test_should_not_merge_different_size(self, pdf_mod):
        """Big font-size gap → don't merge."""
        a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
        b = {"bbox": (50, 125, 200, 200), "font_size": 24, "line_count": 1}
        assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False

    def test_should_not_merge_different_x_origin(self, pdf_mod):
        """Far-apart x origins → don't merge (different columns)."""
        a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
        b = {"bbox": (300, 125, 450, 145), "font_size": 12, "line_count": 1}
        assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False

    def test_should_not_merge_too_far_apart(self, pdf_mod):
        """Vertical gap > 1.5x line height → don't merge."""
        a = {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1}
        b = {"bbox": (50, 200, 200, 220), "font_size": 12, "line_count": 1}
        # vertical_gap = 200 - 120 = 80; line_height = 12 * 1.4 = 16.8
        # 80 > 16.8 * 1.5 = 25.2
        assert pdf_mod.PDFTranslator._should_merge_blocks(None, a, b) is False

    def test_merge_two_blocks(self, pdf_mod):
        """End-to-end merge: text combined, bbox expanded."""
        blocks = [
            {"bbox": (50, 100, 200, 120), "font_size": 12, "line_count": 1,
             "text": "Line 1", "sub_bboxes": [(50, 100, 200, 120)]},
            {"bbox": (50, 125, 200, 145), "font_size": 12, "line_count": 1,
             "text": "Line 2", "sub_bboxes": [(50, 125, 200, 145)]},
        ]
        translator = pdf_mod.PDFTranslator()
        merged = translator._merge_adjacent_blocks(blocks, page_rect=None)
        assert len(merged) == 1
        assert "Line 1" in merged[0]["text"]
        assert "Line 2" in merged[0]["text"]


# ============================================================================
# B3.4 — LibreOffice absence log
# ============================================================================

class TestLibreOfficeAbsence:
    def test_libreoffice_available_returns_bool(self, pdf_mod):
        result = pdf_mod._libreoffice_available()
        assert isinstance(result, bool)

    def test_libreoffice_available_caches_result(self, pdf_mod):
        """Calling twice should NOT spawn two subprocesses (cached)."""
        # Reset cache
        pdf_mod._libreoffice_available._cache = None
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=0)
            r1 = pdf_mod._libreoffice_available()
            r2 = pdf_mod._libreoffice_available()
            # Only one subprocess call should have happened
            assert mock_run.call_count == 1
            assert r1 == r2 == True

    def test_libreoffice_available_handles_missing(self, pdf_mod):
        """FileNotFoundError → False."""
        pdf_mod._libreoffice_available._cache = None
        with patch("subprocess.run", side_effect=FileNotFoundError):
            result = pdf_mod._libreoffice_available()
        assert result is False

    def test_libreoffice_available_handles_timeout(self, pdf_mod):
        """TimeoutExpired → False (don't block the job)."""
        import subprocess
        pdf_mod._libreoffice_available._cache = None
        with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("lo", 5)):
            result = pdf_mod._libreoffice_available()
        assert result is False

    def test_libreoffice_not_found_logs_hint(self, pdf_mod):
        """When LibreOffice is missing, the log should include a hint."""
        # Patch the module-level logger's `warning` method to capture calls
        captured = []
        original_warning = pdf_mod.logger.warning

        def spy_warning(*args, **kwargs):
            captured.append((args, kwargs))
            return original_warning(*args, **kwargs)

        with patch.object(pdf_mod.logger, "warning", side_effect=spy_warning):
            with patch.object(pdf_mod, "_libreoffice_available", return_value=False):
                result = pdf_mod.PDFTranslator()._convert_docx_to_pdf(
                    Path("dummy.docx"), Path("dummy.pdf")
                )

        # Returns None (no conversion happened)
        assert result is None
        # Check the log message includes the install hint
        all_args = [str(a) + str(kwargs) for args, kwargs in captured for a in args]
        all_args += [str(kwargs) for args, kwargs in captured]
        flat = " ".join(all_args)
        assert "install" in flat.lower() or "Install" in flat, (
            f"No install hint in logs. Captured: {captured}"
        )


# ============================================================================
# B3.5 — Helper resilience
# ============================================================================

class TestHelperResilience:
    def test_record_format_loss_zero_count_is_noop(self, pdf_mod):
        """count=0 should be a no-op (no metric emitted)."""
        # Should not raise
        pdf_mod._record_format_loss_metric("hyperlink", count=0)

    def test_record_format_loss_negative_count_is_noop(self, pdf_mod):
        """Negative count should be ignored."""
        pdf_mod._record_format_loss_metric("hyperlink", count=-5)

    def test_record_format_loss_with_broken_metrics(self, pdf_mod):
        """If middleware.metrics fails to import, the helper must NOT raise."""
        with patch.dict(sys.modules, {"middleware.metrics": None}):
            # Should swallow the ImportError
            pdf_mod._record_format_loss_metric("hyperlink", count=1)

    def test_libreoffice_available_cache_expires(self, pdf_mod):
        """After 5 minutes, the cache should be considered stale."""
        pdf_mod._libreoffice_available._cache = (time.time() - 301, True)
        with patch("subprocess.run") as mock_run:
            mock_run.return_value = MagicMock(returncode=1)
            result = pdf_mod._libreoffice_available()
        # Cache was stale → fresh subprocess call happened
        assert mock_run.call_count == 1
