"""Unit tests for common/arxiv.py — the ARXIV typed-source foundation.

Pure unit: recognition, id normalization, and Atom-feed card parsing are exercised
WITHOUT any network. conftest.py already puts common/ + the infra root on sys.path
and stubs boto3/botocore, so `import arxiv` is import-light (the heavy boto3 path is
only reached at submit/stash time, which these tests never trigger).
"""
from __future__ import annotations

import arxiv


# ── recognition: normalize_id ──────────────────────────────────────────────────

def test_normalize_new_id_with_version():
    assert arxiv.normalize_id("2501.12948v3") == ("2501.12948", "v3")


def test_normalize_new_id_no_version():
    assert arxiv.normalize_id("2501.12948") == ("2501.12948", None)


def test_normalize_old_id():
    assert arxiv.normalize_id("cond-mat/0703012") == ("cond-mat/0703012", None)


def test_normalize_old_id_subclass_and_version():
    assert arxiv.normalize_id("math.AG/0309136v2") == ("math.ag/0309136", "v2")


# ── recognition: recognize (URLs, DOI, bare ids) ───────────────────────────────

def test_recognize_abs_url():
    assert arxiv.recognize("https://arxiv.org/abs/2501.12948") == "2501.12948"


def test_recognize_pdf_url_with_suffix_and_version():
    assert arxiv.recognize("https://arxiv.org/pdf/2501.12948v2.pdf") == "2501.12948v2"


def test_recognize_format_url():
    assert arxiv.recognize("https://arxiv.org/format/0704.0001") == "0704.0001"


def test_recognize_export_host():
    assert arxiv.recognize("http://export.arxiv.org/abs/1706.03762") == "1706.03762"


def test_recognize_doi_form():
    assert arxiv.recognize("https://doi.org/10.48550/arXiv.2501.12948") == "2501.12948"


def test_recognize_bare_new_id():
    assert arxiv.recognize("see paper 2501.12948 for details") == "2501.12948"


def test_recognize_bare_old_id():
    assert arxiv.recognize("the cond-mat/0703012 result") == "cond-mat/0703012"


def test_recognize_arxiv_prefixed_bare():
    assert arxiv.recognize("arXiv:1706.03762") == "1706.03762"


def test_recognize_none_for_non_arxiv():
    assert arxiv.recognize("https://example.com/paper.pdf") is None
    assert arxiv.recognize("just some prose, no ids here") is None


def test_recognize_does_not_false_match_version_like_numbers():
    # a plain decimal that isn't a YYMM.NNNNN shape must not match
    assert arxiv.recognize("the price was 12.50 dollars") is None


# ── recognition: recognize_all (curated lists / blog posts) ────────────────────

def test_recognize_all_multiple_distinct():
    text = (
        "Great reads: https://arxiv.org/abs/2501.12948 (DeepSeek-R1) and the "
        "Transformer paper arXiv:1706.03762, plus the old cond-mat/0703012."
    )
    assert arxiv.recognize_all(text) == ["2501.12948", "1706.03762", "cond-mat/0703012"]


def test_recognize_all_dedups_same_paper():
    text = "abs https://arxiv.org/abs/2501.12948 and pdf https://arxiv.org/pdf/2501.12948"
    assert arxiv.recognize_all(text) == ["2501.12948"]


def test_is_arxiv():
    assert arxiv.is_arxiv("https://arxiv.org/abs/2501.12948") is True
    assert arxiv.is_arxiv("https://example.com") is False


# ── URL builders ───────────────────────────────────────────────────────────────

def test_url_builders():
    assert arxiv.abs_url("2501.12948").endswith("/abs/2501.12948")
    assert arxiv.pdf_url_for("2501.12948").endswith("/pdf/2501.12948")
    assert arxiv.eprint_url("2501.12948").endswith("/e-print/2501.12948")


# ── card parsing from a synthetic Atom entry (no network) ──────────────────────

_ATOM = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
      xmlns:arxiv="http://arxiv.org/schemas/atom">
  <entry>
    <id>http://arxiv.org/abs/2501.12948v1</id>
    <updated>2025-01-23T10:00:00Z</updated>
    <published>2025-01-22T09:00:00Z</published>
    <title>DeepSeek-R1: Incentivizing Reasoning Capability in LLMs</title>
    <summary>  We present DeepSeek-R1, a reasoning model trained via RL.  </summary>
    <author><name>Daya Guo</name></author>
    <author>
      <name>Dejian Yang</name>
      <arxiv:affiliation>DeepSeek-AI</arxiv:affiliation>
    </author>
    <arxiv:comment>22 pages, 10 figures</arxiv:comment>
    <arxiv:journal_ref>Nature 2025</arxiv:journal_ref>
    <arxiv:doi>10.1000/example.doi</arxiv:doi>
    <link href="http://arxiv.org/abs/2501.12948v1" rel="alternate" type="text/html"/>
    <link title="pdf" href="http://arxiv.org/pdf/2501.12948v1"
          rel="related" type="application/pdf"/>
    <link rel="license" href="http://creativecommons.org/licenses/by/4.0/"/>
    <arxiv:primary_category term="cs.CL"/>
    <category term="cs.CL"/>
    <category term="cs.AI"/>
    <category term="cs.LG"/>
  </entry>
</feed>"""


def _card():
    from xml.etree import ElementTree as ET
    root = ET.fromstring(_ATOM)
    entry = root.find("atom:entry", arxiv._NS)
    return arxiv._parse_entry(entry)


def test_card_core_fields():
    c = _card()
    assert c.arxiv_id == "2501.12948"
    assert c.version == "v1"
    assert c.title == "DeepSeek-R1: Incentivizing Reasoning Capability in LLMs"
    assert c.abstract == "We present DeepSeek-R1, a reasoning model trained via RL."
    assert c.primary_category == "cs.CL"
    assert c.categories == ["cs.CL", "cs.AI", "cs.LG"]
    assert c.comments == "22 pages, 10 figures"
    assert c.journal_ref == "Nature 2025"
    assert c.doi == "10.1000/example.doi"
    assert c.license == "http://creativecommons.org/licenses/by/4.0/"
    assert c.provider == "arxiv"


def test_card_authors_and_affiliation():
    c = _card()
    assert [a.name for a in c.authors] == ["Daya Guo", "Dejian Yang"]
    assert c.authors[0].affiliation is None
    assert c.authors[1].affiliation == "DeepSeek-AI"


def test_card_urls():
    c = _card()
    assert c.abs_url.endswith("/abs/2501.12948v1")
    assert c.pdf_url.endswith("/pdf/2501.12948v1")
    assert c.eprint_url.endswith("/e-print/2501.12948v1")


def test_card_to_metadata_shape():
    c = _card()
    meta = c.to_metadata()
    assert meta["source_type"] == "arxiv"
    assert meta["provider"] == "arxiv"
    assert meta["title"] == c.title
    assert meta["arxiv"]["arxiv_id"] == "2501.12948"
    assert meta["arxiv"]["categories"] == ["cs.CL", "cs.AI", "cs.LG"]


def test_best_body_foundation():
    c = _card()
    body = arxiv.best_body(c)
    assert c.title in body
    assert "reasoning model trained via RL" in body
    assert "22 pages, 10 figures" in body


# ── PDF deep extraction is still a Phase-2 seam (extract_pdf) ───────────────────

def test_extract_pdf_is_phase2_seam():
    c = _card()
    try:
        arxiv.extract_pdf(b"", c)
    except NotImplementedError:
        pass
    else:
        raise AssertionError("extract_pdf should still be an unimplemented Phase-2 seam")


# ════════════════════════════════════════════════════════════════════════════════
#  Phase 1 — extract_eprint(): structured TYPED TeX extraction (OFFLINE fixtures)
# ════════════════════════════════════════════════════════════════════════════════
#
# All fixtures are synthetic in-memory {filename: bytes} maps — NO network, NO arxiv.
# They exercise: main-.tex discovery, \input resolution (extension-omitted), nested
# envs, inline+display+named math, tabular row parsing, figure \includegraphics, and
# references from both .bbl and .bib.

import pytest

# pylatexenc is the real parser these tests assert against. If it is genuinely absent
# (it is in requirements-test.txt, so CI has it), the structured-fidelity assertions
# are skipped — the fallback path is exercised by test_extract_fallback_when_no_pylatexenc.
_HAS_PYLATEXENC = True
try:
    import pylatexenc  # noqa: F401
except Exception:       # noqa: BLE001
    _HAS_PYLATEXENC = False

requires_pylatexenc = pytest.mark.skipif(
    not _HAS_PYLATEXENC, reason="pylatexenc not installed")


def _eprint_files() -> dict:
    """A small but representative synthetic e-print: a main .tex that \\inputs a
    methods file (extension omitted), inline + display + equation + align math, a
    table with parsed rows, a figure with \\includegraphics, and a .bib bibliography."""
    main = (
        r"\documentclass{article}" "\n"
        r"\usepackage{graphicx}" "\n"
        r"\newcommand{\foo}{bar}" "\n"
        r"\begin{document}" "\n"
        r"\title{A Test Paper}\maketitle" "\n"
        r"\section{Introduction}" "\n"
        r"We study $E=mc^2$ here % \input{should_not_expand} (a comment)" "\n"
        r"and the comment text must vanish." "\n"
        r"\input{sec_methods}" "\n"
        r"\begin{equation}\label{eq:main}" "\n"
        r"\sum_{i=1}^n x_i = X" "\n"
        r"\end{equation}" "\n"
        r"\section{Results}" "\n"
        r"\begin{table}[h]" "\n"
        r"\caption{Accuracy by model}\label{tab:acc}" "\n"
        r"\begin{tabular}{l|c}" "\n"
        r"\hline" "\n"
        r"Model & Acc \\" "\n"
        r"\hline" "\n"
        r"A & 0.91 \\" "\n"
        r"B & 0.88 \\" "\n"
        r"\hline" "\n"
        r"\end{tabular}" "\n"
        r"\end{table}" "\n"
        r"\begin{figure}" "\n"
        r"\includegraphics[width=0.5\textwidth]{plot.pdf}" "\n"
        r"\caption{The plot}\label{fig:plot}" "\n"
        r"\end{figure}" "\n"
        r"\bibliography{refs}" "\n"
        r"\end{document}" "\n"
    )
    methods = (
        r"\section{Methods}" "\n"
        r"We align:" "\n"
        r"\begin{align}" "\n"
        r"a &= b + c \\" "\n"
        r"d &= e" "\n"
        r"\end{align}" "\n"
        r"and inline \(\alpha + \beta\) too." "\n"
    )
    bib = (
        r"@article{smith2020," "\n"
        r"  author = {Smith, John and Doe, Jane}," "\n"
        r"  title = {On Things}," "\n"
        r"  journal = {Journal of Things}," "\n"
        r"  year = {2020}," "\n"
        r"  doi = {10.1000/xyz123}," "\n"
        r"}" "\n"
        r"@misc{attn2017," "\n"
        r"  title = {Attention is All You Need}," "\n"
        r"  year = {2017}," "\n"
        r"  note = {arXiv:1706.03762}," "\n"
        r"}" "\n"
    )
    return {
        "main.tex":        main.encode(),
        "sec_methods.tex": methods.encode(),
        "refs.bib":        bib.encode(),
    }


def test_extract_eprint_output_contract_shape():
    r = arxiv.extract_eprint(_eprint_files())
    for k in ("body", "formulae", "tables", "figures", "references",
              "source", "main_tex", "warnings"):
        assert k in r, f"missing contract key {k!r}"
    assert r["source"] == "tex"
    assert r["main_tex"] == "main.tex"
    assert isinstance(r["formulae"], list)


def test_extract_eprint_finds_main_tex():
    # the included methods file has no \documentclass/\begin{document}, so main.tex wins
    r = arxiv.extract_eprint(_eprint_files())
    assert r["main_tex"] == "main.tex"


@requires_pylatexenc
def test_extract_eprint_formulae_typed_and_verbatim():
    r = arxiv.extract_eprint(_eprint_files())
    fs = r["formulae"]
    # inline ($..$) + display equation + align (from \input) + inline \(..\)
    envs = [f["env"] for f in fs]
    assert "inline" in envs
    assert "equation" in envs
    assert "align" in envs
    # named equation keeps its label and VERBATIM latex (not flattened to text)
    eq = next(f for f in fs if f["env"] == "equation")
    assert eq["label"] == "eq:main"
    assert r"\sum_{i=1}^n x_i = X" in eq["latex"]
    assert eq["latex"].startswith(r"\begin{equation}")
    assert eq["mathml"] is None                      # Phase 2
    # the inline E=mc^2 is captured verbatim with delimiters
    inl = next(f for f in fs if f["env"] == "inline" and "mc^2" in f["latex"])
    assert inl["latex"] == "$E=mc^2$"


@requires_pylatexenc
def test_extract_eprint_input_resolution_pulls_methods_math():
    # the align env lives ONLY in sec_methods.tex — proves \input{sec_methods} (no .tex)
    # was resolved and flattened in.
    r = arxiv.extract_eprint(_eprint_files())
    aligns = [f for f in r["formulae"] if f["env"] == "align"]
    assert len(aligns) == 1
    assert "a &= b + c" in aligns[0]["latex"]
    assert aligns[0]["section_context"] == "Methods"


@requires_pylatexenc
def test_extract_eprint_table_rows_parsed():
    r = arxiv.extract_eprint(_eprint_files())
    assert len(r["tables"]) == 1
    t = r["tables"][0]
    assert t["caption"] == "Accuracy by model"
    assert t["label"] == "tab:acc"
    assert t["section_context"] == "Results"
    # \hline rows dropped; cells split on & and rows on \\
    assert t["rows"] == [["Model", "Acc"], ["A", "0.91"], ["B", "0.88"]]
    # the verbatim latex is carried (the whole table env)
    assert r"\begin{tabular}" in t["latex"]


@requires_pylatexenc
def test_extract_eprint_figure_graphics_and_caption():
    r = arxiv.extract_eprint(_eprint_files())
    assert len(r["figures"]) == 1
    fig = r["figures"][0]
    assert fig["caption"] == "The plot"
    assert fig["label"] == "fig:plot"
    assert fig["graphics"] == ["plot.pdf"]
    assert r"\includegraphics" in fig["latex"]


@requires_pylatexenc
def test_extract_eprint_references_from_bib():
    r = arxiv.extract_eprint(_eprint_files())
    refs = {ref["key"]: ref for ref in r["references"]}
    assert "smith2020" in refs and "attn2017" in refs
    s = refs["smith2020"]
    assert s["title"] == "On Things"
    assert s["year"] == "2020"
    assert s["venue"] == "Journal of Things"
    assert s["doi"] == "10.1000/xyz123"
    assert "Smith" in (s["authors"] or "")
    # the arxiv id in the @misc note is harvested
    assert refs["attn2017"]["arxiv_id"] == "1706.03762"


@requires_pylatexenc
def test_extract_eprint_typed_content_not_flattened_into_body():
    # the WHOLE point: math/table/figure content is TYPED, not dumped into the prose
    # body. The body keeps section structure + prose; the equation/table latex is NOT
    # spilled into it.
    r = arxiv.extract_eprint(_eprint_files())
    body = r["body"]
    assert "Introduction" in body
    assert "Methods" in body
    assert "Results" in body
    # commented-out \input target never expanded into the body
    assert "should_not_expand" not in body
    # the table's raw latex must not be flattened into the prose body
    assert r"\begin{tabular}" not in body
    assert "0.91" not in body                         # table cell stays in the table atom


@requires_pylatexenc
def test_extract_eprint_comments_stripped():
    files = {"main.tex": (
        r"\documentclass{article}\begin{document}" "\n"
        r"real text % this whole tail is a comment with $fake math$" "\n"
        r"more real text \% literal-percent-not-a-comment" "\n"
        r"\end{document}").encode()}
    r = arxiv.extract_eprint(files)
    # the $fake math$ lives only inside a comment → must NOT be captured as a formula
    assert all("fake" not in f["latex"] for f in r["formulae"])
    assert "real text" in r["body"]


@requires_pylatexenc
def test_extract_eprint_nested_envs():
    # a figure nested with a tabular caption + math inside the caption: the parser must
    # not get confused by the nesting.
    files = {"main.tex": (
        r"\documentclass{article}\begin{document}" "\n"
        r"\begin{figure}" "\n"
        r"\begin{center}" "\n"
        r"\includegraphics{a.png}" "\n"
        r"\end{center}" "\n"
        r"\caption{Figure with $x^2$ in caption}\label{fig:n}" "\n"
        r"\end{figure}" "\n"
        r"\end{document}").encode()}
    r = arxiv.extract_eprint(files)
    assert len(r["figures"]) == 1
    assert r["figures"][0]["graphics"] == ["a.png"]
    assert r["figures"][0]["label"] == "fig:n"
    assert "x^2" in r["figures"][0]["caption"]


def test_extract_eprint_bbl_preferred_over_bib():
    # when BOTH a compiled .bbl and a .bib exist, the .bbl wins (rendered bibliography)
    files = _eprint_files()
    files["refs.bbl"] = (
        r"\begin{thebibliography}{9}" "\n"
        r"\bibitem{guo2025} D. Guo et al., DeepSeek-R1, arXiv:2501.12948, 2025." "\n"
        r"\bibitem{vaswani} A. Vaswani et al., Attention, doi:10.5555/abc, 2017." "\n"
        r"\end{thebibliography}").encode()
    r = arxiv.extract_eprint(files)
    keys = {ref["key"] for ref in r["references"]}
    assert keys == {"guo2025", "vaswani"}             # from the .bbl, not the .bib
    g = next(ref for ref in r["references"] if ref["key"] == "guo2025")
    assert g["arxiv_id"] == "2501.12948"
    assert g["year"] == "2025"


def test_extract_eprint_unresolved_input_warns_not_raises():
    files = {"main.tex": (
        r"\documentclass{article}\begin{document}" "\n"
        r"\input{nonexistent_section}" "\n"
        r"body text" "\n"
        r"\end{document}").encode()}
    r = arxiv.extract_eprint(files)             # must NOT raise
    assert any("nonexistent_section" in w for w in r["warnings"])
    assert "body text" in r["body"]


def test_extract_eprint_empty_and_no_tex():
    assert arxiv.extract_eprint({})["main_tex"] is None
    r = arxiv.extract_eprint({"readme.txt": b"not tex"})
    assert r["main_tex"] is None
    assert any("no .tex" in w.lower() for w in r["warnings"])


def test_extract_eprint_accepts_directory(tmp_path):
    # the dir form (an already-unpacked e-print) is accepted too
    (tmp_path / "main.tex").write_text(
        r"\documentclass{article}\begin{document}"
        r"text $a+b$ \end{document}")
    r = arxiv.extract_eprint(str(tmp_path))
    assert r["main_tex"] == "main.tex"
    assert any("a+b" in f["latex"] for f in r["formulae"])


def test_extract_fallback_when_no_pylatexenc(monkeypatch):
    # force the pragmatic-regex fallback by making `import pylatexenc` fail, and assert
    # it still pulls typed formulae/tables/figures (weaker, but env-aware) + a warning.
    import builtins
    real_import = builtins.__import__

    def _fail(name, *a, **k):
        if name == "pylatexenc" or name.startswith("pylatexenc."):
            raise ImportError("forced-absent for test")
        return real_import(name, *a, **k)

    monkeypatch.setattr(builtins, "__import__", _fail)
    r = arxiv.extract_eprint(_eprint_files())
    assert any("pylatexenc unavailable" in w for w in r["warnings"])
    # the fallback is still env-aware: it finds the equation + table + figure
    assert any(f["env"] == "equation" for f in r["formulae"])
    assert len(r["tables"]) == 1
    assert r["tables"][0]["rows"][0] == ["Model", "Acc"]
    assert len(r["figures"]) == 1
    assert r["figures"][0]["graphics"] == ["plot.pdf"]
