"""Unit tests for axm-bib CLI — TDD RED phase."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from axm_bib.models import BibEntry, Paper, SearchResult

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

_PIPELINE = "axm_bib.cli.BibPipeline"


def _make_paper(**overrides: object) -> Paper:
    defaults: dict[str, object] = {
        "title": "Test Paper",
        "authors": ["Alice"],
        "year": 2024,
        "doi": "10.1234/test",
        "source": "crossref",
    }
    defaults.update(overrides)
    return Paper(**defaults)  # type: ignore[arg-type]


def _make_entry(**overrides: object) -> BibEntry:
    defaults: dict[str, object] = {
        "key": "alice2024test",
        "entry_type": "article",
        "title": "Test Paper",
        "raw": "@article{alice2024test, title={Test Paper}}",
        "doi": "10.1234/test",
    }
    defaults.update(overrides)
    return BibEntry(**defaults)  # type: ignore[arg-type]


# ═══════════════════════════════════════════════════════════════════════════
# search command
# ═══════════════════════════════════════════════════════════════════════════


class TestSearchCommand:
    """Tests for `axm-bib search`."""

    @patch(_PIPELINE)
    def test_search_prints_results(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Happy path: results printed to stdout."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[
                _make_paper(title="Paper A", doi="10.1/a"),
                _make_paper(title="Paper B", doi="10.1/b"),
            ],
            sources_queried=["S2", "CrossRef"],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("loop invariants")
        out = capsys.readouterr().out
        assert "Paper A" in out
        assert "Paper B" in out

    @patch(_PIPELINE)
    def test_search_empty_query(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Empty query prints error message."""
        from axm_bib.cli import search

        with pytest.raises(SystemExit):
            search("")

    @patch(_PIPELINE)
    def test_search_no_results(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """No results → informative message, no crash."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[],
            sources_queried=[],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("xyznonexistent")
        out = capsys.readouterr().out
        assert "no results" in out.lower() or "0" in out

    @patch(_PIPELINE)
    def test_search_api_error(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Network failure → graceful error exit."""
        mock_cls.return_value.search.side_effect = RuntimeError("Connection refused")
        from axm_bib.cli import search

        with pytest.raises(SystemExit):
            search("test query")

    @patch(_PIPELINE)
    def test_search_custom_limit(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """`--limit 3` is forwarded to pipeline."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[],
            sources_queried=[],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("test", limit=3)
        mock_cls.return_value.search.assert_called_once_with(
            "test",
            limit=3,
            venue=None,
            author=None,
            year=None,
        )

    @patch(_PIPELINE)
    def test_search_limit_zero_error(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """`--limit 0` → error exit."""
        from axm_bib.cli import search

        with pytest.raises(SystemExit):
            search("test", limit=0)
        err = capsys.readouterr().err
        assert "limit" in err.lower()

    @patch(_PIPELINE)
    def test_search_limit_negative_error(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """`--limit -1` → error exit."""
        from axm_bib.cli import search

        with pytest.raises(SystemExit):
            search("test", limit=-1)

    @patch(_PIPELINE)
    def test_search_limit_large_clamped(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """`--limit 200` is clamped to 100."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[],
            sources_queried=[],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("test", limit=200)
        mock_cls.return_value.search.assert_called_once_with(
            "test",
            limit=100,
            venue=None,
            author=None,
            year=None,
        )

    @patch(_PIPELINE)
    def test_search_shows_full_abstract(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Full abstract shown by default (no truncation)."""
        long_abstract = "We present a deep learning approach. " * 10
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[
                _make_paper(abstract=long_abstract),
            ],
            sources_queried=["S2"],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("test")
        out = capsys.readouterr().out
        assert long_abstract.strip() in out

    @patch(_PIPELINE)
    def test_search_abstract_len_truncates(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--abstract-len 50 truncates and adds '...'."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[
                _make_paper(abstract="A" * 100),
            ],
            sources_queried=["S2"],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("test", abstract_len=50)
        out = capsys.readouterr().out
        assert "A" * 50 in out
        assert "..." in out
        assert "A" * 100 not in out

    @patch(_PIPELINE)
    def test_search_no_abstract_flag(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """--no-abstract hides abstract from output."""
        mock_cls.return_value.search.return_value = SearchResult(
            papers=[
                _make_paper(abstract="We present a deep learning approach"),
            ],
            sources_queried=["S2"],
            sources_failed=[],
        )
        from axm_bib.cli import search

        search("test", abstract=False)
        out = capsys.readouterr().out
        assert "We present" not in out


class TestResolveCommand:
    """Tests for `axm-bib resolve`."""

    @patch(_PIPELINE)
    def test_resolve_single_id(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Single DOI → prints BibTeX."""
        entry = _make_entry(raw="@article{a, title={X}}")
        mock_cls.return_value.resolve.return_value = entry
        from axm_bib.cli import resolve

        resolve(("10.1234/test",))
        out = capsys.readouterr().out
        assert "@article" in out

    @patch("axm_bib.cli.check_coherence", return_value=True)
    @patch("axm_bib.cli.merge_entries")
    @patch(_PIPELINE)
    def test_resolve_n_args_merge(
        self,
        mock_cls: MagicMock,
        mock_merge: MagicMock,
        _mock_coherence: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Multiple IDs → resolve each, merge."""
        entry1 = _make_entry(raw="@article{a, title={X}}")
        entry2 = _make_entry(raw="@article{b, title={X}}")
        merged = _make_entry(raw="@article{merged, title={X}}")
        mock_cls.return_value.resolve.side_effect = [entry1, entry2]
        mock_merge.return_value = merged
        from axm_bib.cli import resolve

        resolve(("10.1234/a", "arXiv:2503.18813"))
        out = capsys.readouterr().out
        assert "@article{merged" in out

    @patch(_PIPELINE)
    def test_resolve_title_rejected(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Title string → pipeline raises ValueError → exit 1."""
        mock_cls.return_value.resolve.side_effect = ValueError(
            "'Defeating Prompt Injections' looks like a title"
        )
        from axm_bib.cli import resolve

        with pytest.raises(SystemExit):
            resolve(("Defeating Prompt Injections",))
        err = capsys.readouterr().err
        assert "looks like a title" in err

    @patch(_PIPELINE)
    def test_resolve_empty_tuple(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Empty tuple → error exit."""
        from axm_bib.cli import resolve

        with pytest.raises(SystemExit):
            resolve(())


# ═══════════════════════════════════════════════════════════════════════════
# extract command
# ═══════════════════════════════════════════════════════════════════════════


class TestExtractCommand:
    """Tests for `axm-bib extract`."""

    @patch("axm_bib.extract.extract_pdf")
    def test_extract_happy_path(
        self,
        mock_extract: MagicMock,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Extracts and prints summary."""
        from axm_bib.models import ExtractionResult, FigureInfo

        pdf_file = tmp_path / "test.pdf"
        pdf_file.write_bytes(b"%PDF-dummy")

        mock_extract.return_value = ExtractionResult(
            source_pdf=str(pdf_file),
            output_dir=str(tmp_path),
            markdown_path=str(tmp_path / "content.md"),
            figures=[
                FigureInfo(
                    index=1,
                    filename="fig_001.png",
                    page=0,
                    width=800,
                    height=600,
                    xref=10,
                ),
            ],
            page_count=5,
            word_count=1200,
            char_count=7000,
        )
        from axm_bib.cli import extract

        extract(pdf_file)
        out = capsys.readouterr().out
        assert "Extracted" in out
        assert "fig_001.png" in out
        assert "Pages: 5" in out

    def test_extract_file_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Missing file → error exit."""
        from axm_bib.cli import extract

        with pytest.raises(SystemExit):
            extract(Path("/nonexistent/paper.pdf"))
        err = capsys.readouterr().err
        assert "not found" in err.lower()

    @patch("axm_bib.extract.extract_pdf")
    def test_extract_error(
        self,
        mock_extract: MagicMock,
        tmp_path: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Extraction error → graceful exit."""
        pdf_file = tmp_path / "bad.pdf"
        pdf_file.write_bytes(b"not-a-pdf")
        mock_extract.side_effect = ValueError("Not a valid PDF")
        from axm_bib.cli import extract

        with pytest.raises(SystemExit):
            extract(pdf_file)
        err = capsys.readouterr().err
        assert "Not a valid PDF" in err


# ═══════════════════════════════════════════════════════════════════════════
# graph command
# ═══════════════════════════════════════════════════════════════════════════


class TestGraphCommand:
    """Tests for `axm-bib graph`."""

    @patch(_PIPELINE)
    def test_graph_prints_results(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Happy path: results printed to stdout."""
        mock_cls.return_value.graph.return_value = [
            _make_paper(title="Citing Paper A"),
            _make_paper(title="Citing Paper B"),
        ]
        from axm_bib.cli import graph

        graph("10.1234/test")
        out = capsys.readouterr().out
        assert "Citing Paper A" in out
        assert "Citing Paper B" in out
        assert "2 citations:" in out

    @patch(_PIPELINE)
    def test_graph_empty_paper_id(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Empty paper_id → error exit."""
        from axm_bib.cli import graph

        with pytest.raises(SystemExit):
            graph("")
        err = capsys.readouterr().err
        assert "paper_id" in err.lower()

    @patch(_PIPELINE)
    def test_graph_invalid_direction(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Invalid direction → error exit."""
        from axm_bib.cli import graph

        with pytest.raises(SystemExit):
            graph("10.1234/test", direction="backward")
        err = capsys.readouterr().err
        assert "direction" in err.lower()

    @patch(_PIPELINE)
    def test_graph_no_results(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """No results → informative message."""
        mock_cls.return_value.graph.return_value = []
        from axm_bib.cli import graph

        graph("10.1234/test")
        out = capsys.readouterr().out
        assert "no" in out.lower() or "0" in out

    @patch(_PIPELINE)
    def test_graph_api_error(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Network failure → graceful error exit."""
        mock_cls.return_value.graph.side_effect = RuntimeError("Connection refused")
        from axm_bib.cli import graph

        with pytest.raises(SystemExit):
            graph("10.1234/test")

    @patch(_PIPELINE)
    def test_graph_direction_references(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """direction='references' → forwarded to pipeline."""
        mock_cls.return_value.graph.return_value = []
        from axm_bib.cli import graph

        graph("10.1234/test", direction="references")
        mock_cls.return_value.graph.assert_called_once_with(
            "10.1234/test", direction="references", limit=20
        )

    @patch(_PIPELINE)
    def test_graph_custom_limit(
        self, mock_cls: MagicMock, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Custom limit is forwarded."""
        mock_cls.return_value.graph.return_value = []
        from axm_bib.cli import graph

        graph("10.1234/test", limit=50)
        mock_cls.return_value.graph.assert_called_once_with(
            "10.1234/test", direction="citations", limit=50
        )
