"""TDD tests for axm_bib.extract — PDF extraction via PyMuPDF.

Uses lazy imports of pymupdf inside fixtures/tests to avoid segfault
when loading pymupdf's C extension via pytest's import hooks.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from axm_bib.exceptions import ExtractionError

# ── Helpers ─────────────────────────────────────────────────────────────────


def _make_pdf(
    path: Path,
    *,
    pages: int = 2,
    text: str | None = "Hello World",
    images: list[tuple[int, int]] | None = None,
    encrypt: bool = False,
) -> None:
    """Create a minimal real PDF for testing."""
    import pymupdf

    doc = pymupdf.open()
    for i in range(pages):
        page = doc.new_page(width=612, height=792)
        if text:
            page.insert_text((72, 100 + i * 20), text, fontsize=12)

    if images:
        page0 = doc[0]
        y_offset = 200
        for w, h in images:
            pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, w, h), 1)
            pix.clear_with(200)  # fill with gray value
            img_bytes = pix.tobytes("png")
            rect = pymupdf.Rect(72, y_offset, 72 + w, y_offset + h)
            page0.insert_image(rect, stream=img_bytes)
            y_offset += h + 10

    if encrypt:
        doc.save(
            str(path),
            encryption=pymupdf.PDF_ENCRYPT_AES_256,
            owner_pw="owner",
            user_pw="user",
        )
    else:
        doc.save(str(path))
    doc.close()


def _make_non_pdf(path: Path) -> None:
    """Create a file that is not a valid PDF."""
    path.write_text("This is definitely not a PDF file.")


# ── TestExtractMarkdown ─────────────────────────────────────────────────────


class TestExtractMarkdown:
    """Tests for extract_markdown()."""

    def test_converts_pdf_to_markdown(self, tmp_path: Path) -> None:
        """Happy path: returns a non-empty markdown string."""
        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, text="Some important text")

        from axm_bib.extract import extract_markdown

        result = extract_markdown(pdf)
        assert isinstance(result, str)
        assert len(result) > 0

    def test_file_not_found(self) -> None:
        """Raises FileNotFoundError for missing PDF."""
        from axm_bib.extract import extract_markdown

        with pytest.raises(FileNotFoundError):
            extract_markdown(Path("/nonexistent/paper.pdf"))

    def test_invalid_pdf(self, tmp_path: Path) -> None:
        """Raises ValueError for non-PDF file."""
        txt = tmp_path / "notes.txt"
        _make_non_pdf(txt)

        from axm_bib.extract import extract_markdown

        with pytest.raises(ExtractionError, match="Not a valid PDF"):
            extract_markdown(txt)

    def test_encrypted_pdf(self, tmp_path: Path) -> None:
        """Raises ValueError for encrypted PDF."""
        pdf = tmp_path / "secret.pdf"
        _make_pdf(pdf, encrypt=True)

        from axm_bib.extract import extract_markdown

        with pytest.raises(ExtractionError, match="encrypted"):
            extract_markdown(pdf)

    def test_pdf_without_text(self, tmp_path: Path) -> None:
        """Returns a string (possibly whitespace-only) for a PDF with no text."""
        pdf = tmp_path / "empty_text.pdf"
        _make_pdf(pdf, text=None)

        from axm_bib.extract import extract_markdown

        result = extract_markdown(pdf)
        assert isinstance(result, str)


# ── TestExtractFigures ──────────────────────────────────────────────────────


class TestExtractFigures:
    """Tests for extract_figures()."""

    def test_extracts_images_as_png(self, tmp_path: Path) -> None:
        """Extracts images and saves as PNG files."""
        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=[(200, 150)])
        out = tmp_path / "output"

        from axm_bib.extract import extract_figures

        figures, _ = extract_figures(pdf, out)
        assert len(figures) >= 1
        for fig in figures:
            assert (out / fig.filename).exists()

    def test_sequential_numbering(self, tmp_path: Path) -> None:
        """Figures numbered sequentially: fig_001, fig_002, ..."""
        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=[(200, 150), (300, 200)])
        out = tmp_path / "output"

        from axm_bib.extract import extract_figures

        figures, _ = extract_figures(pdf, out)
        assert len(figures) >= 2
        assert figures[0].filename == "fig_001.png"
        assert figures[0].index == 1
        assert figures[1].filename == "fig_002.png"
        assert figures[1].index == 2

    def test_no_images_returns_empty_list(self, tmp_path: Path) -> None:
        """PDF with no images returns empty list."""
        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=None)
        out = tmp_path / "output"

        from axm_bib.extract import extract_figures

        figures, page_count = extract_figures(pdf, out)
        assert figures == []
        assert page_count == 2  # _make_pdf creates 2 pages by default

    def test_filters_small_images(self, tmp_path: Path) -> None:
        """Images smaller than 50x50 are filtered out."""
        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=[(10, 10), (200, 150)])
        out = tmp_path / "output"

        from axm_bib.extract import extract_figures

        figures, _ = extract_figures(pdf, out)
        assert len(figures) == 1
        assert figures[0].width == 200
        assert figures[0].height == 150

    def test_figure_info_metadata(self, tmp_path: Path) -> None:
        """FigureInfo contains correct metadata."""
        from axm_bib.extract import extract_figures
        from axm_bib.models import FigureInfo

        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=[(640, 480)])
        out = tmp_path / "output"

        figures, page_count = extract_figures(pdf, out)
        assert len(figures) >= 1
        assert page_count == 2  # _make_pdf creates 2 pages by default
        fig = figures[0]
        assert isinstance(fig, FigureInfo)
        assert fig.index == 1
        assert fig.page == 0
        assert fig.width == 640
        assert fig.height == 480
        assert fig.xref > 0
        assert fig.filename == "fig_001.png"

    def test_file_not_found(self, tmp_path: Path) -> None:
        """Raises FileNotFoundError for missing PDF."""
        from axm_bib.extract import extract_figures

        with pytest.raises(FileNotFoundError):
            extract_figures(Path("/nonexistent.pdf"), tmp_path)

    def test_deduplicates_images(self, tmp_path: Path) -> None:
        """Same image inserted on multiple pages is deduplicated by xref."""
        import pymupdf

        pdf_path = tmp_path / "dedup.pdf"
        doc = pymupdf.open()
        pix = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 100, 80), 1)
        pix.clear_with(100)  # fill with gray value
        img_bytes = pix.tobytes("png")

        for _ in range(2):
            page = doc.new_page()
            page.insert_image(pymupdf.Rect(50, 50, 150, 130), stream=img_bytes)

        doc.save(str(pdf_path))
        doc.close()

        from axm_bib.extract import extract_figures

        out = tmp_path / "output"
        figures, _ = extract_figures(pdf_path, out)
        assert len(figures) == 1

    def test_cmyk_image_converted_to_rgb(self, tmp_path: Path) -> None:
        """CMYK images are converted to sRGB and saved as PNG."""
        import pymupdf

        pdf_path = tmp_path / "cmyk.pdf"
        doc = pymupdf.open()
        page = doc.new_page()

        # Create a CMYK pixmap (4 color channels, no alpha)
        pix = pymupdf.Pixmap(pymupdf.csCMYK, pymupdf.IRect(0, 0, 200, 150), 0)
        pix.clear_with(128)
        # Convert to JPEG bytes (JPEG supports CMYK)
        img_bytes = pix.tobytes("jpeg")
        page.insert_image(pymupdf.Rect(50, 50, 250, 200), stream=img_bytes)

        doc.save(str(pdf_path))
        doc.close()

        from axm_bib.extract import extract_figures

        out = tmp_path / "output"
        figures, _ = extract_figures(pdf_path, out)
        assert len(figures) >= 1
        # Verify the saved file is a valid PNG
        saved = out / figures[0].filename
        assert saved.exists()
        assert saved.stat().st_size > 0

    def test_corrupt_image_skipped_gracefully(self, tmp_path: Path) -> None:
        """A corrupt/unsaveable image is skipped without crashing extraction."""
        import pymupdf

        pdf_path = tmp_path / "mixed.pdf"
        doc = pymupdf.open()
        page = doc.new_page()

        # Insert a valid image
        pix_ok = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 200, 150), 1)
        pix_ok.clear_with(100)
        page.insert_image(pymupdf.Rect(50, 50, 250, 200), stream=pix_ok.tobytes("png"))

        doc.save(str(pdf_path))
        doc.close()

        from axm_bib.extract import extract_figures

        # At minimum, the valid image should be extracted without crashing
        out = tmp_path / "output"
        figures, _ = extract_figures(pdf_path, out)
        assert isinstance(figures, list)

    def test_extract_figures_closes_doc_on_error(self, tmp_path: Path) -> None:
        """Document handle is closed even when extract_image raises."""
        from unittest.mock import MagicMock, patch

        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, images=[(200, 150)])
        out = tmp_path / "output"

        mock_doc = MagicMock()
        mock_doc.page_count = 1
        mock_page = MagicMock()
        mock_page.get_images.return_value = [(42,)]  # one xref
        mock_doc.load_page.return_value = mock_page
        mock_doc.extract_image.side_effect = RuntimeError("corrupt xref")

        with patch("pymupdf.open", return_value=mock_doc):
            from axm_bib.extract import extract_figures

            figures, _ = extract_figures(pdf, out)

        mock_doc.close.assert_called_once()
        assert figures == []

    def test_extract_figures_returns_page_count(self, tmp_path: Path) -> None:
        """extract_figures returns (figures, page_count) tuple."""
        from axm_bib.extract import extract_figures

        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf, pages=5, images=[(200, 150)])
        out = tmp_path / "output"

        figures, page_count = extract_figures(pdf, out)
        assert page_count == 5
        assert len(figures) >= 1

    def test_corrupt_xref_still_closes_doc(self, tmp_path: Path) -> None:
        """Simulates a fatal error during page iteration — doc still closed."""
        from unittest.mock import MagicMock, patch

        pdf = tmp_path / "test.pdf"
        _make_pdf(pdf)
        out = tmp_path / "output"

        mock_doc = MagicMock()
        mock_doc.page_count = 2
        mock_doc.load_page.side_effect = RuntimeError("corrupted page")

        with (
            patch("pymupdf.open", return_value=mock_doc),
            pytest.raises(RuntimeError, match="corrupted page"),
        ):
            from axm_bib.extract import extract_figures

            extract_figures(pdf, out)

        mock_doc.close.assert_called_once()


# ── TestExtractPdf ──────────────────────────────────────────────────────────


class TestExtractPdf:
    """Tests for extract_pdf() — the orchestrator."""

    def test_extract_pdf_no_redundant_open(self, tmp_path: Path) -> None:
        """extract_pdf itself does NOT open the PDF (delegated to sub-fns).

        Before the fix, extract_pdf opened the PDF a 3rd time just to
        read page_count.  Now that extract_figures returns page_count,
        the body of extract_pdf should contain zero pymupdf.open calls.
        We verify by inspecting the source code AST.
        """
        import ast
        import inspect
        import textwrap

        from axm_bib.extract import extract_pdf

        source = textwrap.dedent(inspect.getsource(extract_pdf))
        tree = ast.parse(source)

        # Collect all Name/Attribute nodes that reference 'pymupdf' or 'open'
        pymupdf_opens = [
            node
            for node in ast.walk(tree)
            if isinstance(node, ast.Attribute)
            and isinstance(node.value, ast.Name)
            and node.value.id == "pymupdf"
            and node.attr == "open"
        ]
        assert pymupdf_opens == [], "extract_pdf should not call pymupdf.open directly"

        # Also verify: no `import pymupdf` inside the function body
        imports = [
            node
            for node in ast.walk(tree)
            if isinstance(node, ast.Import)
            and any(alias.name == "pymupdf" for alias in node.names)
        ]
        assert imports == [], "extract_pdf should not import pymupdf (no longer needed)"

    def test_creates_output_directory(self, tmp_path: Path) -> None:
        """Output directory is created automatically."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf)
        out = tmp_path / "out"

        extract_pdf(pdf, out)
        assert out.is_dir()

    def test_default_output_dir(self, tmp_path: Path) -> None:
        """Default output_dir is {stem}/ next to PDF."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf)

        result = extract_pdf(pdf)
        expected = tmp_path / "paper"
        assert result.output_dir == str(expected)

    def test_creates_content_md(self, tmp_path: Path) -> None:
        """content.md is created with markdown content."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf, text="Research findings here")
        out = tmp_path / "out"

        result = extract_pdf(pdf, out)
        md_path = Path(result.markdown_path)
        assert md_path.exists()
        assert len(md_path.read_text()) > 0

    def test_creates_figures_subdir(self, tmp_path: Path) -> None:
        """figures/ subdirectory is created."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf)
        out = tmp_path / "out"

        extract_pdf(pdf, out)
        assert (out / "figures").is_dir()

    def test_result_metadata(self, tmp_path: Path) -> None:
        """ExtractionResult has correct metadata."""
        from axm_bib.extract import extract_pdf
        from axm_bib.models import ExtractionResult

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf, pages=3, images=[(200, 150)])
        out = tmp_path / "out"

        result = extract_pdf(pdf, out)
        assert isinstance(result, ExtractionResult)
        assert result.source_pdf == str(pdf)
        assert result.page_count == 3
        assert result.char_count > 0
        assert len(result.figures) >= 1

    def test_overwrites_existing(self, tmp_path: Path) -> None:
        """No error if output_dir already exists with old content."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf, text="new content")
        out = tmp_path / "out"
        out.mkdir()
        (out / "content.md").write_text("old content")

        result = extract_pdf(pdf, out)
        new_content = Path(result.markdown_path).read_text()
        assert "old content" not in new_content

    def test_word_count_present(self, tmp_path: Path) -> None:
        """ExtractionResult has a word_count field."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf, pages=1, text="Hello World")
        out = tmp_path / "out"

        result = extract_pdf(pdf, out)
        assert result.word_count > 0

    def test_char_count_excludes_formatting(self, tmp_path: Path) -> None:
        """char_count is computed from stripped text, not raw markdown."""
        from axm_bib.extract import extract_pdf

        pdf = tmp_path / "paper.pdf"
        _make_pdf(pdf, pages=1, text="Hello")
        out = tmp_path / "out"

        result = extract_pdf(pdf, out)
        # Read raw markdown for comparison
        raw_md = Path(result.markdown_path).read_text()
        # char_count should be <= raw markdown length (formatting stripped)
        assert result.char_count <= len(raw_md)


# ── TestStripMarkdown ───────────────────────────────────────────────────────


class TestStripMarkdown:
    """Tests for strip_markdown() helper."""

    def test_removes_headers(self) -> None:
        """Strips ATX-style headers."""
        from axm_bib.extract import strip_markdown

        assert strip_markdown("# Title\ntext") == "Title\ntext"
        assert strip_markdown("## Subtitle\ntext") == "Subtitle\ntext"
        assert strip_markdown("### H3\n#### H4") == "H3\nH4"

    def test_removes_bold_italic(self) -> None:
        """Strips bold and italic markers."""
        from axm_bib.extract import strip_markdown

        assert strip_markdown("**bold** and *italic*") == "bold and italic"
        assert strip_markdown("***both***") == "both"

    def test_removes_links(self) -> None:
        """Strips markdown links, keeps text."""
        from axm_bib.extract import strip_markdown

        assert strip_markdown("[text](https://example.com)") == "text"

    def test_removes_horizontal_rules(self) -> None:
        """Strips horizontal rules."""
        from axm_bib.extract import strip_markdown

        result = strip_markdown("above\n---\nbelow")
        assert "---" not in result
        assert "above" in result
        assert "below" in result

    def test_removes_table_pipes(self) -> None:
        """Strips table pipe characters."""
        from axm_bib.extract import strip_markdown

        result = strip_markdown("| col1 | col2 |")
        assert "|" not in result
        assert "col1" in result
        assert "col2" in result
