"""``vibeqc.output.citations`` — DB loader, assembly, writers.

Pins the contract documented in
``docs/design_output_module.md § Citation database``:

  1. The bundled ``database.toml`` loads cleanly via
     :func:`load_default_database` — no missing entries referenced
     from any route.
  2. ``schema_version`` matches ``CitationDatabase.SCHEMA_VERSION``.
  3. Every functional / basis / dispersion model exercised in the test
     suite resolves through ``assemble(plan, ...)`` to at least one
     citation (no silent gaps in routing for the v0.8.0 surface).
  4. The vibe-qc software citation is always the first entry.
  5. ``write_bibtex`` and ``write_references`` produce non-empty files
     containing the assembled bibtex_keys / titles.
  6. Template substitution: ``{{VIBEQC_VERSION}}`` resolves to the
     running package version in the software citation.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from vibeqc.output import OutputPlan
from vibeqc.output.citations import (
    Citation,
    CitationDatabase,
    DatabaseError,
    assemble,
    citation_manifest_rows,
    format_references,
    format_references_block,
    load_database,
    load_default_database,
    write_bibtex,
    write_references,
)

# ---------------------------------------------------------------------------
# Database load + structural integrity
# ---------------------------------------------------------------------------


def test_default_database_loads_without_errors() -> None:
    db = load_default_database()
    assert isinstance(db, CitationDatabase)
    # The bundled database has at least the v0.8.0-on-main coverage.
    entries = db.entries()
    assert "vibeqc_software" in entries
    assert "libint_valeev" in entries
    assert "libxc_2018" in entries
    assert "peintinger_pob_tzvp_2013" in entries
    assert "pbe_1996" in entries
    assert "becke_1993" in entries
    assert "pulay_diis_1980" in entries


def test_database_schema_version_matches_loader() -> None:
    db = load_default_database()
    # If schema_version drifts in the TOML, this test fails fast — the
    # loader's strict schema-version check enforces the contract.
    assert db.SCHEMA_VERSION == "1"


def test_load_database_rejects_dangling_route_reference(
    tmp_path: Path,
) -> None:
    bad = tmp_path / "broken.toml"
    bad.write_text(
        'schema_version = "1"\n'
        "[entries.foo]\n"
        'kind = "article"\n'
        'bibtex_key = "foo_2020"\n'
        'authors = ["A, B"]\n'
        'title = "T"\n'
        "\n"
        "[routes.basis_sets]\n"
        '"bar" = ["nonexistent_entry"]\n',
        encoding="utf-8",
    )
    with pytest.raises(DatabaseError, match="missing entry"):
        load_database(bad)


# ---------------------------------------------------------------------------
# Assembly: software always first, expected entries fire
# ---------------------------------------------------------------------------


def _plan(
    tmp_path: Path, *, basis: str, functional: str | None = None, method: str = "rks"
) -> OutputPlan:
    return OutputPlan.from_run_job_kwargs(
        output=tmp_path / "job",
        method=method,
        basis=basis,
        functional=functional,
    )


def test_software_citation_is_first(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g"))
    assert len(result) > 0
    assert result.citations[0].key == "vibeqc_software"


def test_ccsd_t_route_pins_canonical_cc_papers(tmp_path: Path) -> None:
    """method='ccsd(t)' fires the canonical CC + (T) + DF papers, DOIs pinned.

    The route is keyed on the method name, so closed-shell RCCSD(T)
    (cpp/src/ccsd.cpp) and open-shell UCCSD(T) (cpp/src/uccsd.cpp) share it.
    DOIs pinned mechanically per CLAUDE.md section 8 (the pob-* audit lesson).
    """
    result = assemble(_plan(tmp_path, basis="cc-pvdz", method="ccsd(t)"))
    by_key = {c.key: c for c in result.citations}
    expected = {
        "purvis_bartlett_ccsd_1982": "10.1063/1.443164",
        "stanton_gauss_watts_bartlett_ccsd_1991": "10.1063/1.460620",
        "raghavachari_ccsdt_1989": "10.1016/S0009-2614(89)87395-6",
        "deprince_sherrill_df_ccsd_2013": "10.1021/ct400250u",
    }
    for key, doi in expected.items():
        assert key in by_key, f"{key} did not fire for ccsd(t)"
        assert by_key[key].doi == doi
        assert by_key[key].print is True  # reaches .out / .bibtex


def test_accsdt_route_pins_lambda_triples_papers(tmp_path: Path) -> None:
    """triples='A-CCSD(T)' routes the Lambda/asymmetric triples papers."""
    result = assemble(_plan(tmp_path, basis="cc-pvdz", method="a-ccsd(t)"))
    by_key = {c.key: c for c in result.citations}
    expected = {
        "purvis_bartlett_ccsd_1982": "10.1063/1.443164",
        "stanton_gauss_watts_bartlett_ccsd_1991": "10.1063/1.460620",
        "kucharski_bartlett_accsdt_1998": "10.1063/1.475961",
        "crawford_stanton_accsdt_1998": (
            "10.1002/(SICI)1097-461X(1998)70:4/5<601::AID-QUA6>3.0.CO;2-Z"
        ),
        "deprince_sherrill_df_ccsd_2013": "10.1021/ct400250u",
    }
    for key, doi in expected.items():
        assert key in by_key, f"{key} did not fire for a-ccsd(t)"
        assert by_key[key].doi == doi
        assert by_key[key].print is True
    assert "raghavachari_ccsdt_1989" not in by_key


def test_plain_ccsd_route_omits_triples_paper(tmp_path: Path) -> None:
    """Plain CCSD must not cite the Raghavachari (T) paper."""
    keys = {
        c.key
        for c in assemble(_plan(tmp_path, basis="cc-pvdz", method="ccsd")).citations
    }
    assert "purvis_bartlett_ccsd_1982" in keys
    assert "raghavachari_ccsdt_1989" not in keys


def test_canonical_non_df_ccsd_omits_df_assembly_paper(tmp_path: Path) -> None:
    """cc_density_fit=False drops DePrince-Sherrill, keeps the CC papers.

    A canonical (CCSDOptions(density_fit=False)) run assembles exact
    four-index integrals, so citing the DF-CCSD assembly paper would claim
    an algorithm the run did not use (CLAUDE.md section 8: attribute,
    never claim). The defining CC + (T) papers still fire.
    """
    keys = {
        c.key
        for c in assemble(
            _plan(tmp_path, basis="cc-pvdz", method="ccsd(t)"),
            cc_density_fit=False,
        ).citations
    }
    assert "deprince_sherrill_df_ccsd_2013" not in keys
    assert "purvis_bartlett_ccsd_1982" in keys
    assert "stanton_gauss_watts_bartlett_ccsd_1991" in keys
    assert "raghavachari_ccsdt_1989" in keys


def test_libint_always_fires_after_software(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g"))
    keys = [c.key for c in result.citations]
    assert "libint_valeev" in keys
    assert keys.index("libint_valeev") == 1


def test_pbe_run_pulls_libxc_and_pbe(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g", functional="PBE"))
    keys = {c.key for c in result.citations}
    assert "libxc_2018" in keys
    assert "pbe_1996" in keys
    assert result.warnings == ()


def test_pob_tzvp_rev2_pulls_both_basis_papers(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="pob-tzvp-rev2"))
    keys = {c.key for c in result.citations}
    assert "peintinger_pob_tzvp_2013" in keys
    assert "vilela_oliveira_pob_rev2_2019" in keys


def test_b3lyp_pulls_becke_lyp_and_stephens(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="6-31g*", functional="B3LYP"))
    keys = {c.key for c in result.citations}
    assert "becke_1993" in keys
    assert "lee_yang_parr_1988" in keys
    assert "stephens_b3lyp_1994" in keys
    assert "libxc_2018" in keys
    # The flavor-disambiguation paper (which VWN parametrisation fills
    # the B3 correlation slot) rides along since the v0.12.0 flavor
    # audit (bare b3lyp = VWN5 / ORCA definition, unchanged).
    assert "hertwig_koch_1997" in keys


@pytest.mark.parametrize("spelling", ["b3lyp5", "b3lypg", "b3lyp/g"])
def test_b3lyp_flavor_aliases_cite_the_same_papers(
    tmp_path: Path, spelling: str
) -> None:
    """b3lyp5 (VWN5 / ORCA-TURBOMOLE-CRYSTAL flavor) and the b3lypg /
    b3lyp/g spellings of the default cite identically to bare b3lyp."""
    base = assemble(_plan(tmp_path, basis="6-31g*", functional="B3LYP"))
    alias = assemble(_plan(tmp_path, basis="6-31g*", functional=spelling))
    assert {c.key for c in alias.citations} == {c.key for c in base.citations}


def test_diis_default_fires(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g"))
    keys = {c.key for c in result.citations}
    assert "pulay_diis_1980" in keys
    assert "pulay_diis_1982" in keys


def test_unknown_basis_emits_warning_but_does_not_raise(
    tmp_path: Path,
) -> None:
    result = assemble(_plan(tmp_path, basis="totally-made-up-basis"))
    assert any("totally-made-up-basis" in w for w in result.warnings)
    # The software + libint + DIIS routes still fire.
    keys = {c.key for c in result.citations}
    assert "vibeqc_software" in keys


def test_unknown_functional_emits_warning_but_does_not_raise(
    tmp_path: Path,
) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g", functional="MADE-UP-XC"))
    assert any("MADE-UP-XC" in w for w in result.warnings)


def test_dispersion_d3bj_pulls_both_grimme_papers(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="6-31g*", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, dispersion="d3bj")
    keys = {c.key for c in result.citations}
    assert "grimme_d3_2010" in keys
    assert "grimme_d3bj_2011" in keys


def test_dispersion_d4_pulls_caldeweyher(tmp_path: Path) -> None:
    """d4 fires the full three-paper set upstream dftd4 asks users to
    cite — 2017 precursor + 2019 method + 2020 periodic extension
    (maintainer decision 2026-06-11; see the routes.methods comment)."""
    plan = _plan(tmp_path, basis="6-31g*", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, dispersion="d4")
    keys = {c.key for c in result.citations}
    assert "caldeweyher_d4_2017" in keys
    assert "caldeweyher_d4_2019" in keys
    assert "caldeweyher_d4_2020" in keys


def test_dispersion_d4_param_fit_paper_fires(tmp_path: Path) -> None:
    """A parametrization fit outside the 2019 D4 method paper pulls its
    fit paper via routes.dispersion_params, alongside the method paper."""
    plan = _plan(tmp_path, basis="6-31g*", functional="r2scan")
    db = load_default_database()
    result = db.assemble_from_plan(plan, dispersion="d4", dispersion_params="r2scan")
    keys = {c.key for c in result.citations}
    assert "caldeweyher_d4_2019" in keys
    assert "ehlert_r2scan_d4_2021" in keys


def test_dispersion_d4_method_paper_params_add_nothing(tmp_path: Path) -> None:
    """A parametrization from the 2019 method paper itself (pbe) has no
    dispersion_params row — only the method paper fires, silently."""
    plan = _plan(tmp_path, basis="6-31g*", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, dispersion="d4", dispersion_params="pbe")
    keys = {c.key for c in result.citations}
    assert "caldeweyher_d4_2019" in keys
    fit_keys = {
        entry
        for row in db.routes().get("dispersion_params", {}).values()
        for entry in row
    }
    assert not (keys & fit_keys), (
        "method-paper parametrization must not pull a separate fit paper"
    )
    assert not result.warnings or all(
        "dispersion_params" not in w for w in result.warnings
    )


def test_dispersion_params_ignored_without_dispersion(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="6-31g*", functional="r2scan")
    db = load_default_database()
    result = db.assemble_from_plan(plan, dispersion_params="r2scan")
    keys = {c.key for c in result.citations}
    assert "ehlert_r2scan_d4_2021" not in keys


class TestD4ParameterDoiCoverage:
    """Mechanical pin of the inline ``doi=`` provenance in
    ``vibeqc.dispersion_d4_parameters`` against the citation database
    (CLAUDE.md § 8): every DOI a D4 parameter row claims must exist as
    a database entry, and every parametrization fit OUTSIDE the 2019
    D4 method paper must have a ``routes.dispersion_params`` row that
    fires that DOI's entry. Guards against the pob-* failure mode —
    provenance strings that survive review with no mechanical test
    pinning them to the user-facing citation surface."""

    # The 2019 D4 method paper — parametrizations fit there are covered
    # by the methods.d4 route and need no dispersion_params row.
    METHOD_PAPER_DOI = "10.1063/1.5090222"

    @staticmethod
    def _param_rows() -> dict[str, str]:
        """name → doi for every D4 parameter row that records one."""
        from vibeqc.dispersion_d4_parameters import (
            get_d4_params,
            list_d4_functionals,
        )

        return {
            name: get_d4_params(name).doi
            for name in list_d4_functionals()
            if get_d4_params(name).doi
        }

    def test_every_d4_param_doi_has_a_database_entry(self) -> None:
        db = load_default_database()
        known_dois = {e.doi for e in db.entries().values() if e.doi}
        missing = {
            name: doi
            for name, doi in self._param_rows().items()
            if doi not in known_dois
        }
        assert not missing, (
            f"D4 parameter rows cite DOIs with no [entries.*] block in "
            f"database.toml: {missing} — add the entry (and a "
            f"routes.dispersion_params row) in the same merge, per "
            f"CLAUDE.md § 8."
        )

    def test_every_external_fit_doi_is_routed(self) -> None:
        from vibeqc.dispersion_d4_parameters import normalize_d4_key

        db = load_default_database()
        table = db.routes().get("dispersion_params", {})
        entries = db.entries()
        for name, doi in self._param_rows().items():
            if doi == self.METHOD_PAPER_DOI:
                continue
            route_key = f"d4:{normalize_d4_key(name)}"
            row = table.get(route_key)
            assert row, (
                f"D4 parametrization {name!r} was fit in {doi} (not the "
                f"2019 method paper) but routes.dispersion_params has no "
                f"{route_key!r} row — the fit paper would never reach a "
                f"user's references block."
            )
            routed_dois = {entries[k].doi for k in row}
            assert doi in routed_dois, (
                f"routes.dispersion_params[{route_key!r}] fires "
                f"{sorted(row)} (DOIs {sorted(d for d in routed_dois if d)}) "
                f"but the parameter row claims doi={doi}."
            )

    def test_no_stale_dispersion_params_routes(self) -> None:
        """Reverse direction: every route row maps back to a live D4
        parameter-table key, so renames/removals can't leave dead routes."""
        from vibeqc.dispersion_d4_parameters import list_d4_functionals

        db = load_default_database()
        live = set(list_d4_functionals())
        for route_key in db.routes().get("dispersion_params", {}):
            disp, _, param_key = route_key.partition(":")
            assert disp == "d4" and param_key, (
                f"routes.dispersion_params key {route_key!r} is not of the "
                f"documented '<disp>:<param>' form"
            )
            assert param_key in live, (
                f"routes.dispersion_params[{route_key!r}] references a "
                f"parametrization missing from dispersion_d4_parameters "
                f"— stale route?"
            )


def test_periodic_run_pulls_spglib(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="pob-tzvp", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, periodic=True)
    keys = {c.key for c in result.citations}
    assert "togo_shinohara_tanaka_2024" in keys


def test_smearing_run_pulls_mermin(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="pob-tzvp", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, periodic=True, uses_smearing=True)
    keys = {c.key for c in result.citations}
    assert "mermin_finite_temperature_dft_1965" in keys


def test_level_shift_pulls_saunders_hillier(tmp_path: Path) -> None:
    """A job run with a Saunders-Hillier level shift cites the defining
    1973 paper; a plain run does not."""
    plan = _plan(tmp_path, basis="6-31g*")
    db = load_default_database()

    result = db.assemble_from_plan(plan, uses_level_shift=True)
    keys = {c.key for c in result.citations}
    assert "saunders_hillier_levelshift_1973" in keys

    baseline = db.assemble_from_plan(plan)
    assert "saunders_hillier_levelshift_1973" not in {
        c.key for c in baseline.citations
    }


def test_uses_ecp_pulls_libecpint(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="def2-tzvp", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, uses_ecp=True)
    keys = {c.key for c in result.citations}
    assert "shaw_gilbert_libecpint" in keys


def test_uses_ase_pulls_ase_paper(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="6-31g*")
    db = load_default_database()
    result = db.assemble_from_plan(plan, uses_ase=True)
    keys = {c.key for c in result.citations}
    bibtex_keys = {c.bibtex_key for c in result.citations}
    assert "ase_2017" in keys
    assert "larsen_ase_2017" in bibtex_keys


def test_direct_scf_pulls_almlof_and_haser(tmp_path: Path) -> None:
    """When direct_scf=True, both the Almlöf 1982 and Häser-Ahlrichs
    1989 references fire."""
    plan = _plan(tmp_path, basis="def2-svp")
    db = load_default_database()
    result = db.assemble_from_plan(plan, direct_scf=True)
    keys = {c.key for c in result.citations}
    assert "almlof_direct_scf_1982" in keys
    assert "haser_ahlrichs_schwarz_1989" in keys


def test_direct_scf_false_does_not_pull_direct_refs(tmp_path: Path) -> None:
    """Without direct_scf=True the direct-SCF references are not cited."""
    plan = _plan(tmp_path, basis="def2-svp")
    db = load_default_database()
    result = db.assemble_from_plan(plan)  # direct_scf defaults to False
    keys = {c.key for c in result.citations}
    assert "almlof_direct_scf_1982" not in keys
    assert "haser_ahlrichs_schwarz_1989" not in keys


# ---------------------------------------------------------------------------
# Deduplication: each entry appears once
# ---------------------------------------------------------------------------


def test_assembled_list_has_no_duplicates(tmp_path: Path) -> None:
    # b3lyp pulls VWN 1980 via routes.functionals; pulling the LDA route
    # explicitly (if we ever do — we don't here) shouldn't duplicate.
    result = assemble(_plan(tmp_path, basis="6-31g*", functional="B3LYP"))
    keys = [c.key for c in result.citations]
    assert len(keys) == len(set(keys))


# ---------------------------------------------------------------------------
# Template substitution
# ---------------------------------------------------------------------------


def test_software_citation_resolves_version_template(
    tmp_path: Path,
) -> None:
    from vibeqc.banner import VIBEQC_VERSION

    result = assemble(_plan(tmp_path, basis="sto-3g"))
    software = result.citations[0]
    assert software.version == VIBEQC_VERSION
    # Year is the current year (best-effort).
    assert software.year and str(software.year).isdigit()


# ---------------------------------------------------------------------------
# Coverage gate: every functional / basis used in tests is routed.
# This is the CI gate that enforces the dev-chat discipline — adding a
# functional without updating database.toml fails here.
# ---------------------------------------------------------------------------

# Hand-picked from the v0.8.0 test surface. Update this list when a
# new feature lands; that fail is the explicit reminder to add the
# matching route.
_REQUIRED_FUNCTIONALS = (
    "LDA",
    "PBE",
    "PBE0",
    "B3LYP",
    "PW91",
    "B2PLYP",
    # meta-GGA + range-separated hybrids (v0.9.0 XC-library expansion).
    "TPSS",
    "r2scan",
    "wb97x",
    "wb97x-v",
    "wb97m-v",
    "vv10",
    # functionals wired in xc.cpp that were missing routes (§8 backfill).
    "blyp",
    "tpssh",
    "m06-l",
    "m06-2x",
    "r2scan0",
    "r2scanh",
    "wb97x-d",
    "cam-b3lyp",
    "camb3lyp",
    "lc-wpbe",
    "lcwpbe",
    "mn15",
    # revDSD-PBEP86-D4 double-hybrid SCF part (also a [routes.methods] key).
    "revdsd-pbep86",
    # B3LYP flavor spellings (v0.12.0 flavor audit): all three must
    # cite the same papers as bare b3lyp.
    "b3lyp5",
    "b3lypg",
    "b3lyp/g",
)
# Every method `run_job` accepts that carries a method-specific
# citation route. Mean-field methods (rhf / uhf / rks / uks) are
# deliberately absent — their citations come from the integral
# library + the functional, not a method-specific paper. Post-SCF
# (CCSD / FCI) and composite-3c methods each have a defining paper.
_REQUIRED_METHODS = (
    "direct_scf",
    "rohf",
    "roks",
    "ccsd",
    "ccsd(t)",
    "a-ccsd(t)",
    "fci",
    "hf-3c",
    "pbeh-3c",
    "b97-3c",
    "b3lyp-3c",
    "r2scan-3c",
    "wb97x-3c",
    "hse-3c",
    # Active-space wavefunction methods (method= in run_job).
    "selected_ci",
    "dmrg",
    "v2rdm",
    "transcorrelated_ci",
    "casci",
    "mrci",
    "casscf",
    "nevpt2",
    "caspt2",
    # Fixed-space variational CISD (vibeqc.solvers.cisd; surfaced by the
    # semiempirical msindo_cisd path via assemble extra_entries).
    "cisd",
    # MP2 lineage (standalone runners + run_double_hybrid SCF prep).
    "mp2",
    "ri-mp2",
    "scs-mp2",
    "sos-mp2",
    # Semicanonical ROHF-MP2 (vibeqc.cc.run_rohf_mp2; Knowles 1991).
    "rohf-mp2",
    # OVGF / GF2 Green's-function quasiparticle IPs (method="ovgf";
    # vibeqc.propagator). The ab-initio route cites Cederbaum 1975 + the
    # von Niessen-Schirmer-Cederbaum 1984 computational review.
    "ovgf",
    # Double-hybrid dispatchers.
    "b2plyp",
    "dsd-pbep86",
    "revdsd-pbep86",
    "pwpb95",
    # ωB97X-D (run_wb97x_d).
    "wb97x-d",
    # Non-default SCF accelerators (Pulay base + accelerator paper).
    "adiis",
    "kdiis",
    "ediis_diis",
    # GPW / GAPW (Lippert-Hutter / Quickstep). Fires when
    # ``jk_method='gpw'`` is used in ``run_periodic_job`` or when the
    # standalone ``run_periodic_rhf_gpw`` / ``run_periodic_rks_gpw_multi_k``
    # entries are called.
    "gpw",
    "gapw",
    # BIPOLE periodic Coulomb route. The runner fires this through the
    # explicit uses_bipole flag when ``jk_method='bipole'`` is selected.
    "bipole",
    # GFN2-xTB. Experimental + gated (python/vibeqc/semiempirical/methods/
    # gfn2.py), but its citation must still fire whenever a job actually uses
    # it — CLAUDE.md § 8.
    "gfn2_xtb",
    # NDDO semiempirical methods exposed by run_job. Even while PM6 / OMx
    # remain validation-gated for production science, their method papers must
    # fire whenever the executable routes are used.
    "pm6",
    "upm6",
    "om1",
    "om2",
    "om3",
    # MSINDO (Bredow/Geudtner/Jug INDO; closed-shell s/p, H–F). Its method
    # papers must fire whenever a job uses method="msindo" — CLAUDE.md § 8.
    "msindo",
    # MSINDO semiempirical Cyclic Cluster Model (preferred method="seccm",
    # normalized internally to legacy method="ccm",
    # vibeqc.semiempirical.methods.msindo_ccm). Peintinger & Bredow 2014 must
    # fire whenever a periodic CCM calculation is run — CLAUDE.md § 8.
    "ccm",
    "seccm",
    # χ-CCM, the finite-character (Γ-centred character-mesh) ab-initio CCM route. The
    # periodic runner fires this key for jk_method="aiccm2026dev-b" while GDF
    # and Ewald infrastructure citations are added by their feature flags.
    "aiccm2026dev-b",
    "aiccm2026dev-b-ri-mp2",
    "aiccm2026dev-b-dlpno-mp2",
    "aiccm2026dev-b-dlpno-ccsd",
    "aiccm2026dev-b-dlpno-ccsd(t)",
    "aiccm2026dev-b-ccsd",
    "aiccm2026dev-b-ccsd(t)",
    "aiccm2026dev-b-ri-ump2",
    "aiccm2026dev-b-dlpno-ump2",
    "aiccm2026dev-b-dlpno-uccsd",
    "aiccm2026dev-b-dlpno-uccsd(t)",
    # MACE MLIP (method="mace", vibeqc.mlip.mace). External pre-trained
    # model; its citation route must fire whenever a job uses it.
    "mace",
)
_REQUIRED_BASIS_SETS = (
    "STO-3G",
    "STO-6G",
    "3-21G",
    "6-31G*",
    "6-31G**",
    "6-311G**",
    "6-311+G(3df,2p)",
    "def2-svp",
    "def2-tzvp",
    "def2-tzvpp",
    "def2-qzvp",
    "def2-qzvpp",
    "def2-svpd",
    "def2-tzvpd",
    "def2-svp-jk",
    "def2-svp-jkfit",
    "def2-tzvp-jk",
    "def2-universal-jkfit",
    "def2-universal-jfit",
    "def2-svp-rifit",
    "def2-tzvp-rifit",
    "cc-pvdz",
    "cc-pvtz",
    "cc-pvqz",
    "cc-pv5z",
    "cc-pvdz-ri",
    "pob-tzvp",
    "pob-dzvp-rev2",
    "pob-tzvp-rev2",
)


@pytest.mark.parametrize("functional", _REQUIRED_FUNCTIONALS)
def test_required_functional_has_a_route(
    tmp_path: Path,
    functional: str,
) -> None:
    result = assemble(_plan(tmp_path, basis="sto-3g", functional=functional))
    # No warning that *this* functional was unrouted.
    bad = [w for w in result.warnings if functional in w]
    assert not bad, f"functional {functional!r} has no citation route"


@pytest.mark.parametrize("basis", _REQUIRED_BASIS_SETS)
def test_required_basis_has_a_route(tmp_path: Path, basis: str) -> None:
    result = assemble(_plan(tmp_path, basis=basis))
    bad = [w for w in result.warnings if basis.lower() in w.lower()]
    assert not bad, f"basis {basis!r} has no citation route"


def test_default_filtered_basis_name_uses_source_citation_route() -> None:
    db = load_default_database()
    source = db.assemble(method="rhf", basis="sto-3g")
    filtered = db.assemble(method="rhf", basis="_vibeqc_filtered_sto-3g_deadbeef")
    assert not [w for w in filtered.warnings if "basis set" in w]
    assert {c.key for c in filtered.citations} == {c.key for c in source.citations}


@pytest.mark.parametrize("method_key", _REQUIRED_METHODS)
def test_required_method_route_is_present(method_key: str) -> None:
    """Every key in _REQUIRED_METHODS must exist in the database's
    methods routes table and resolve without dangling references."""
    db = load_default_database()
    routes = db._routes.get("methods", {})
    assert method_key in routes, (
        f"method route {method_key!r} missing from database.toml"
    )
    # Validate entries resolve.
    for entry_key in routes[method_key]:
        assert entry_key in db._entries, (
            f"route methods.{method_key!r} references unknown entry {entry_key!r}"
        )


@pytest.mark.parametrize("method_key", _REQUIRED_METHODS)
def test_required_method_route_actually_fires(
    tmp_path: Path,
    method_key: str,
) -> None:
    """Regression for the method= routing bug: ``assemble()`` must
    actually walk ``routes.methods[method]`` and emit those entries
    — not merely have the route present in the database. Before this
    was fixed, the CCSD / direct-SCF routes existed but no job's
    ``.bibtex`` ever contained them."""
    db = load_default_database()
    expected = set(db._routes["methods"][method_key])
    result = db.assemble(method=method_key, basis="sto-3g")
    got = {c.key for c in result.citations}
    missing = expected - got
    assert not missing, (
        f"method {method_key!r} declares routes {sorted(expected)} "
        f"but assemble() did not emit {sorted(missing)}"
    )


def test_caspt2_route_includes_exact_ipea_references() -> None:
    db = load_default_database()
    result = db.assemble(method="caspt2", basis="sto-3g")
    emitted = {citation.key for citation in result.citations}
    assert {
        "ghigo_roos_malmqvist_ipea_2004",
        "nishimoto_ipea_derivatives_2023",
    } <= emitted


# ---------------------------------------------------------------------------
# Geometry-optimization optimizers + coordinate systems
# (vibeqc.geomopt, run_job(geom_opt=..., geom_coords=...)). Each
# keyword-selected optimizer / coordinate system that traces to a defining
# paper must carry a route that fires. sd / cg / (L-)BFGS / cartesian are
# textbook (or covered by the ASE BFGS + Pulay-forces routes) and carry no
# row on purpose.
# ---------------------------------------------------------------------------

_REQUIRED_OPTIMIZER_ROUTES = ("trust", "rfo", "ef", "prfo", "gdiis", "fire")
_REQUIRED_COORDINATE_ROUTES = ("dlc", "internal", "delocalized")


@pytest.mark.parametrize("opt_key", _REQUIRED_OPTIMIZER_ROUTES)
def test_required_optimizer_route_is_present(opt_key: str) -> None:
    """Every geom_opt optimizer with a defining paper has a
    ``routes.optimizers`` row that resolves without dangling references."""
    db = load_default_database()
    routes = db._routes.get("optimizers", {})
    assert opt_key in routes, (
        f"optimizer route {opt_key!r} missing from database.toml"
    )
    for entry_key in routes[opt_key]:
        assert entry_key in db._entries, (
            f"route optimizers.{opt_key!r} references unknown entry {entry_key!r}"
        )


@pytest.mark.parametrize("opt_key", _REQUIRED_OPTIMIZER_ROUTES)
def test_required_optimizer_route_actually_fires(opt_key: str) -> None:
    """``assemble(geom_optimizer=...)`` must actually walk
    ``routes.optimizers`` and emit those entries — not merely have the
    route present (CLAUDE.md § 8 step 5)."""
    db = load_default_database()
    expected = set(db._routes["optimizers"][opt_key])
    result = db.assemble(method="rhf", basis="sto-3g", geom_optimizer=opt_key)
    got = {c.key for c in result.citations}
    missing = expected - got
    assert not missing, (
        f"optimizer {opt_key!r} declares routes {sorted(expected)} "
        f"but assemble() did not emit {sorted(missing)}"
    )


@pytest.mark.parametrize("coord_key", _REQUIRED_COORDINATE_ROUTES)
def test_required_coordinate_route_actually_fires(coord_key: str) -> None:
    """``assemble(geom_coords=...)`` must emit the DLC + redundant-internals
    coordinate-system citations for every DLC alias."""
    db = load_default_database()
    expected = set(db._routes["coordinates"][coord_key])
    result = db.assemble(method="rhf", basis="sto-3g", geom_coords=coord_key)
    got = {c.key for c in result.citations}
    missing = expected - got
    assert not missing, (
        f"coordinate {coord_key!r} declares routes {sorted(expected)} "
        f"but assemble() did not emit {sorted(missing)}"
    )


def test_geomopt_rfo_dlc_run_cites_optimizer_and_coords() -> None:
    """An RFO/DLC geometry optimization surfaces Banerjee 1985 (RFO) plus
    the two delocalized-internal-coordinate papers, all user-visible."""
    db = load_default_database()
    result = db.assemble(
        method="rks",
        basis="sto-3g",
        functional="pbe",
        geom_optimizer="rfo",
        geom_coords="dlc",
        uses_gradient=True,
    )
    printable = {c.key for c in result.printable}
    assert {
        "banerjee_rfo_1985",
        "baker_kessi_delley_dlc_1996",
        "peng_ayala_schlegel_frisch_redundant_1996",
    } <= printable
    assert not list(result.warnings), result.warnings


def test_ef_prfo_routes_add_baker_on_top_of_banerjee() -> None:
    """Eigenvector-following / P-RFO cite Baker 1986 in addition to the
    Banerjee 1985 RFO foundation."""
    db = load_default_database()
    for opt in ("ef", "prfo"):
        keys = {
            c.key
            for c in db.assemble(
                method="rhf", basis="sto-3g", geom_optimizer=opt
            ).citations
        }
        assert {"banerjee_rfo_1985", "baker_ts_1986"} <= keys


def test_geomopt_optimizer_citations_silent_when_unset() -> None:
    """A plain SCF (no geom_opt / geom_coords) pulls none of the
    geometry-optimizer or coordinate-system papers."""
    db = load_default_database()
    keys = {c.key for c in db.assemble(method="rhf", basis="sto-3g").citations}
    for k in (
        "banerjee_rfo_1985",
        "baker_ts_1986",
        "csaszar_pulay_gdiis_1984",
        "bitzek_fire_2006",
        "more_sorensen_trust_1983",
        "baker_kessi_delley_dlc_1996",
        "peng_ayala_schlegel_frisch_redundant_1996",
    ):
        assert k not in keys


def test_cartesian_and_textbook_optimizers_carry_no_citation() -> None:
    """Cartesian coordinates and the textbook BFGS optimizer have no
    defining-paper route; selecting them must not warn or emit a geomopt
    citation."""
    db = load_default_database()
    result = db.assemble(
        method="rhf", basis="sto-3g", geom_optimizer="bfgs", geom_coords="cartesian"
    )
    keys = {c.key for c in result.citations}
    assert "baker_kessi_delley_dlc_1996" not in keys
    assert "banerjee_rfo_1985" not in keys
    assert not list(result.warnings), result.warnings


def test_mlip_suppresses_libint_and_diis() -> None:
    """An MLIP engine (method="mace") evaluates no Gaussian integrals and
    runs no SCF: ``assemble(uses_integrals=False, uses_scf=False)`` must
    drop the always-on libint + Pulay-DIIS routes while still firing the
    MACE method paper and the vibe-qc software cite — the references must
    reflect what actually ran (CLAUDE.md § 8)."""
    db = load_default_database()
    result = db.assemble(method="mace", uses_integrals=False, uses_scf=False)
    keys = {c.key for c in result.citations}
    # vibe-qc + the MACE *method* paper (static route) fire.
    assert "vibeqc_software" in keys
    assert "batatia_mace_2022" in keys
    # The wrong, always-on routes are suppressed for an MLIP.
    assert "libint_valeev" not in keys
    assert "pulay_diis_1980" not in keys
    assert "pulay_diis_1982" not in keys
    # The foundation-model paper is per-model (extra_entries), so it is NOT
    # in the static route output.
    assert "batatia_mace_mp_2024" not in keys


@pytest.mark.parametrize("method_key", ["msindo", "ccm", "seccm"])
def test_indo_suppresses_libint_keeps_diis(method_key: str) -> None:
    """The INDO-family engines (MSINDO molecular + periodic CCM) evaluate
    analytic Slater (STO) integrals, not libint Gaussians, but DO run a
    Pulay-DIIS-accelerated SCF. So ``assemble(uses_integrals=False,
    uses_scf=True)`` — how runner.py calls it for these methods — must drop
    the always-on libint citation while keeping Pulay DIIS, and still fire
    the method's own route entries + the vibe-qc software cite (CLAUDE.md
    §8). This pins the ``_is_indo`` flag in run_job's citation assembly."""
    db = load_default_database()
    result = db.assemble(
        method=method_key,
        uses_integrals=False,
        uses_scf=True,
    )
    keys = {c.key for c in result.citations}
    # libint is suppressed (STO integrals, not Gaussian).
    assert "libint_valeev" not in keys
    # Pulay DIIS stays — these methods accelerate the SCF with it.
    assert "pulay_diis_1980" in keys
    # The vibe-qc software cite + the method's own route entries fire.
    assert "vibeqc_software" in keys
    for entry_key in db._routes["methods"][method_key]:
        assert entry_key in keys, (
            f"method {method_key!r} route entry {entry_key!r} did not fire"
        )


def test_mlip_per_model_foundation_citation_via_extra_entries() -> None:
    """The foundation-model paper is model-dependent and added via
    ``extra_entries``: MPA-0 -> Batatia 2024, OFF23 -> Kovács 2023. The
    MACE method paper always fires; the wrong foundation paper never does.
    Empty / unknown model -> method paper only, no crash (M2 § 8)."""
    db = load_default_database()
    mp = {
        c.key
        for c in db.assemble(
            method="mace",
            uses_integrals=False,
            uses_scf=False,
            extra_entries=["batatia_mace_mp_2024"],
        ).citations
    }
    assert "batatia_mace_2022" in mp
    assert "batatia_mace_mp_2024" in mp
    assert "kovacs_mace_off_2023" not in mp

    off = {
        c.key
        for c in db.assemble(
            method="mace",
            uses_integrals=False,
            uses_scf=False,
            extra_entries=["kovacs_mace_off_2023"],
        ).citations
    }
    assert "batatia_mace_2022" in off
    assert "kovacs_mace_off_2023" in off
    assert "batatia_mace_mp_2024" not in off

    unknown = {
        c.key
        for c in db.assemble(
            method="mace", uses_integrals=False, uses_scf=False, extra_entries=[""]
        ).citations
    }
    assert "batatia_mace_2022" in unknown
    assert "batatia_mace_mp_2024" not in unknown


def test_default_run_still_cites_libint_and_diis(tmp_path: Path) -> None:
    """Guard the gating defaults: a normal SCF job (uses_integrals/uses_scf
    default True) must still pull libint + DIIS — the MLIP suppression must
    not leak into the ab-initio path."""
    result = assemble(_plan(tmp_path, basis="sto-3g"))
    keys = {c.key for c in result.citations}
    assert "libint_valeev" in keys
    assert "pulay_diis_1980" in keys


def test_dft_plus_u_route_fires_when_enabled() -> None:
    """``assemble(dft_plus_u=True)`` must surface both Dudarev 1998
    (rotationally-invariant formalism) and Cococcioni-Gironcoli 2005
    (linear-response U) — the two canonical DFT+U citations per
    CLAUDE.md § 8 and docs/user_guide/dft_plus_u.md."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g", dft_plus_u=True)
    keys = {c.key for c in result.citations}
    assert "dudarev_dft_plus_u_1998" in keys
    assert "cococcioni_gironcoli_2005" in keys


def test_ewald_ao_ft_route_fires_when_enabled() -> None:
    """The native EWALD_3D analytical Hartree J path uses the AO-pair
    FT machinery even when the user did not select GDF."""
    db = load_default_database()
    result = db.assemble(
        method="rhf",
        basis="sto-3g",
        periodic=True,
        uses_ewald_ao_ft=True,
    )
    keys = {c.key for c in result.citations}
    assert "sun_berkelbach_gdf_2017" in keys
    assert "mcmurchie_davidson_1978" in keys
    assert "helgaker_jorgensen_olsen_2000" in keys


def test_bipole_sr_range_screening_route() -> None:
    """The M4b QQR-style separation-aware SR screening
    (LatticeSumOptions.sr_range_screening) fires the Maurer-Ochsenfeld
    QQR citation only when the opt-in flag ran."""
    db = load_default_database()
    on = db.assemble(
        method="rhf",
        basis="sto-3g",
        periodic=True,
        uses_bipole=True,
        uses_bipole_sr_range=True,
    )
    assert "maurer_ochsenfeld_qqr_2012" in {c.key for c in on.citations}
    off = db.assemble(
        method="rhf",
        basis="sto-3g",
        periodic=True,
        uses_bipole=True,
    )
    assert "maurer_ochsenfeld_qqr_2012" not in {c.key for c in off.citations}


def test_bipole_route_fires_when_enabled() -> None:
    """BIPOLE is a periodic Coulomb route, not a mean-field method name,
    so ``run_periodic_job(..., jk_method="bipole")`` fires it through
    a dedicated assembly flag."""
    db = load_default_database()
    result = db.assemble(
        method="rhf",
        basis="sto-3g",
        periodic=True,
        uses_bipole=True,
    )
    keys = {c.key for c in result.citations}
    assert "saunders_bipole_1992" in keys
    assert "dovesi_crystal14_2014" in keys


def test_dft_plus_u_route_silent_when_disabled() -> None:
    """The Dudarev / Cococcioni entries must NOT fire on a plain RHF
    job — only when the +U surface is explicitly enabled."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "dudarev_dft_plus_u_1998" not in keys
    assert "cococcioni_gironcoli_2005" not in keys


def test_neb_driver_route_fires_when_enabled() -> None:
    """``assemble(uses_neb=True)`` must surface Henkelman+Jónsson 2000
    (improved-tangent NEB) plus Smidstrup 2014 (IDPP — the default
    initial-path constructor in vibeqc.run_neb). CLAUDE.md § 8 — the
    implementing chat that lands a citable algorithm wires the
    route + verifies it fires."""
    db = load_default_database()
    result = db.assemble(method="uhf", basis="sto-3g", uses_neb=True)
    keys = {c.key for c in result.citations}
    assert "henkelman_jonsson_neb_2000" in keys
    assert "smidstrup_idpp_2014" in keys
    assert not list(result.warnings), result.warnings


def test_neb_driver_route_silent_when_disabled() -> None:
    """A plain RHF job without NEB must NOT pull in the NEB papers."""
    db = load_default_database()
    result = db.assemble(method="uhf", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "henkelman_jonsson_neb_2000" not in keys
    assert "smidstrup_idpp_2014" not in keys


def test_ci_neb_route_adds_climbing_paper_on_top_of_neb() -> None:
    """``assemble(uses_neb=True, uses_ci_neb=True)`` must surface
    the climbing-image NEB paper *in addition* to the base
    improved-tangent + IDPP bundle. CI-NEB fires only on top of
    plain NEB — never on its own."""
    db = load_default_database()
    result = db.assemble(method="uhf", basis="sto-3g", uses_neb=True, uses_ci_neb=True)
    keys = {c.key for c in result.citations}
    assert "henkelman_jonsson_neb_2000" in keys
    assert "smidstrup_idpp_2014" in keys
    assert "henkelman_uberuaga_jonsson_ci_neb_2000" in keys
    assert not list(result.warnings), result.warnings


def test_ci_neb_route_silent_when_disabled() -> None:
    """A plain NEB run (no climbing) must NOT include the CI-NEB
    paper — only the base improved-tangent + IDPP bundle."""
    db = load_default_database()
    result = db.assemble(method="uhf", basis="sto-3g", uses_neb=True)
    keys = {c.key for c in result.citations}
    assert "henkelman_uberuaga_jonsson_ci_neb_2000" not in keys


def test_soscf_route_fires_when_enabled() -> None:
    """``assemble(uses_soscf=True)`` surfaces Bacskay 1981 (quadratic
    SCF) + Neese 2000 (approximate SOSCF) — the second-order SCF path in
    cpp/src/soscf.hpp (RHFOptions.soscf_threshold). CLAUDE.md § 8."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g", uses_soscf=True)
    keys = {c.key for c in result.citations}
    assert "bacskay_qcscf_1981" in keys
    assert "neese_soscf_2000" in keys


def test_trah_route_fires_when_enabled() -> None:
    """``assemble(uses_trah=True)`` surfaces Helmich-Paris 2021 — the
    trust-region augmented-Hessian path in cpp/src/trah.hpp
    (RHFOptions.trah_threshold)."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g", uses_trah=True)
    keys = {c.key for c in result.citations}
    assert "helmich_paris_trah_2021" in keys


def test_soscf_trah_silent_when_disabled() -> None:
    """A plain SCF must not pull the second-order-SCF papers."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "bacskay_qcscf_1981" not in keys
    assert "helmich_paris_trah_2021" not in keys


def test_tddft_route_fires_runge_gross_and_casida() -> None:
    """``assemble(uses_tddft=True)`` surfaces Runge-Gross 1984 + Casida
    1995 — the linear-response TDDFT formalism in python/vibeqc/tddft.py
    (run_tddft_casida). CLAUDE.md § 8."""
    db = load_default_database()
    result = db.assemble(
        method="rks", basis="def2-svp", functional="pbe", uses_tddft=True
    )
    keys = {c.key for c in result.citations}
    assert "runge_gross_1984" in keys
    assert "casida_tddft_1995" in keys
    # Without the TDA variant the Hirata-Head-Gordon paper does NOT fire.
    assert "hirata_headgordon_tda_1999" not in keys
    assert not list(result.warnings), result.warnings


def test_tddft_tda_variant_adds_hirata() -> None:
    """The Tamm-Dancoff variant (run_tddft_tda) fires Hirata-Head-Gordon
    1999 *in addition* to the Runge-Gross + Casida base bundle."""
    db = load_default_database()
    result = db.assemble(
        method="rks",
        basis="def2-svp",
        functional="pbe",
        uses_tddft=True,
        tddft_variant="tda",
    )
    keys = {c.key for c in result.citations}
    assert "runge_gross_1984" in keys
    assert "casida_tddft_1995" in keys
    assert "hirata_headgordon_tda_1999" in keys


def test_tddft_silent_when_disabled() -> None:
    """A plain ground-state run must not pull the TDDFT papers."""
    db = load_default_database()
    result = db.assemble(method="rks", basis="def2-svp", functional="pbe")
    keys = {c.key for c in result.citations}
    assert "runge_gross_1984" not in keys
    assert "casida_tddft_1995" not in keys


def test_cis_route_fires_foresman() -> None:
    """``assemble(uses_cis=True)`` surfaces Foresman-Head-Gordon-Pople-Frisch
    1992 — the wavefunction CIS excited states (vibeqc.excited / msindo_cis) and
    CIS excited-state gradients (vibeqc.excited_gradient). CLAUDE.md § 8."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g", uses_cis=True)
    keys = {c.key for c in result.citations}
    assert "foresman_cis_1992" in keys
    assert not list(result.warnings), result.warnings


def test_cis_silent_when_disabled() -> None:
    """A plain ground-state run must not pull the CIS paper."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "foresman_cis_1992" not in keys


def test_msindo_cis_gradient_emits_citations(tmp_path: Path) -> None:
    """msindo_cis_gradient_fd(output=...) writes the .bibtex / .references
    siblings citing MSINDO + CIS, with libint suppressed (INDO uses no Gaussian
    integrals). CLAUDE.md § 8.5 — verify the citation reaches its destination."""
    from vibeqc.semiempirical.methods.msindo import msindo_cis_gradient_fd

    stem = tmp_path / "h2o_cis_grad"
    msindo_cis_gradient_fd(
        [8, 1, 1],
        [[0.0, 0.0, 0.117], [0.0, 0.757, -0.467], [0.0, -0.757, -0.467]],
        1,
        spin="singlet",
        step=2e-3,
        output=stem,
    )
    bib = (tmp_path / "h2o_cis_grad.bibtex").read_text(encoding="utf-8")
    assert "foresman_cis_1992" in bib  # the CIS method paper
    assert "ahlswede_jug_msindo_1_1999" in bib  # MSINDO itself
    assert "valeev_libint" not in bib  # INDO: no Gaussian integrals


def test_conical_intersection_route_fires_levine() -> None:
    """``assemble(uses_conical_intersection=True)`` surfaces Levine-Coe-Martínez
    2008 — the penalty-function MECI optimizer (vibeqc.conical). CLAUDE.md § 8."""
    db = load_default_database()
    result = db.assemble(
        method="rhf", basis="sto-3g", uses_cis=True, uses_conical_intersection=True
    )
    keys = {c.key for c in result.citations}
    assert "levine_meci_2008" in keys
    assert "foresman_cis_1992" in keys  # the CIS states it couples
    assert not list(result.warnings), result.warnings


def test_conical_intersection_silent_when_disabled() -> None:
    """A plain CIS run must not pull the conical-intersection paper."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g", uses_cis=True)
    keys = {c.key for c in result.citations}
    assert "levine_meci_2008" not in keys


def test_msindo_meci_emits_citations(tmp_path: Path) -> None:
    """msindo_meci(output=...) writes siblings citing MSINDO + CIS + the
    conical-intersection penalty method. CLAUDE.md § 8.5."""
    from vibeqc.semiempirical.methods.msindo import msindo_meci

    stem = tmp_path / "h2o_meci"
    msindo_meci(
        [8, 1, 1],
        [[0.0, 0.0, 0.117], [0.0, 0.757, -0.467], [0.0, -0.757, -0.467]],
        lower_state=0,
        upper_state=1,
        spin="singlet",
        step=2e-3,
        gap_tol=1e-3,
        max_macro=2,
        max_micro=8,
        output=stem,
    )
    bib = (tmp_path / "h2o_meci.bibtex").read_text(encoding="utf-8")
    assert "levine_meci_2008" in bib  # the MECI optimizer
    assert "foresman_cis_1992" in bib  # the CIS states
    assert "ahlswede_jug_msindo_1_1999" in bib  # MSINDO itself


def test_mean_field_methods_have_no_method_route_warning(
    tmp_path: Path,
) -> None:
    """RHF / UHF / RKS / UKS must NOT produce a routing warning for
    the absence of a method-specific route — they are covered by the
    integral library + functional, by design."""
    db = load_default_database()
    for m in ("rhf", "uhf", "rks", "uks"):
        result = db.assemble(method=m, basis="sto-3g")
        bad = [w for w in result.warnings if "method" in w.lower()]
        assert not bad, (
            f"mean-field method {m!r} should not warn about a "
            f"missing method route; got {bad}"
        )


# ---------------------------------------------------------------------------
# Writers
# ---------------------------------------------------------------------------


def test_write_bibtex_emits_one_entry_per_citation(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="6-31g*", functional="PBE"))
    target = write_bibtex(tmp_path / "job", result)
    assert target == (tmp_path / "job").with_suffix(".bibtex")
    body = target.read_text(encoding="utf-8")
    # One @<type>{... block per *printable* citation. write_bibtex emits
    # result.printable, not result.citations: entries flagged print = false
    # (e.g. eigen, link-time infrastructure) are confined to the .system
    # manifest and must not appear here.
    n_entries = body.count("@")
    assert n_entries >= len(result.printable)
    assert all(c.bibtex_key not in body for c in result.citations if not c.print)
    # bibtex_key of the software citation must appear.
    assert "peintinger_vibeqc" in body
    # libxc + PBE keys must appear in a PBE run.
    assert "lehtola_libxc_2018" in body
    assert "perdew_burke_ernzerhof_1996" in body


def test_write_references_emits_numbered_list(tmp_path: Path) -> None:
    result = assemble(_plan(tmp_path, basis="6-31g*", functional="PBE"))
    target = write_references(tmp_path / "job", result)
    assert target == (tmp_path / "job").with_suffix(".references")
    body = target.read_text(encoding="utf-8")
    assert "[1]" in body
    assert "[2]" in body
    assert "vibe-qc" in body.lower() or "vibeqc" in body.lower()
    # libxc + PBE citations appear in the plain text too.
    assert "Lehtola" in body
    assert "Perdew" in body


def test_format_references_block_starts_with_section_header() -> None:
    citations = (
        Citation(
            key="x",
            kind="article",
            bibtex_key="x_2020",
            authors=("Doe, Jane",),
            title="A study",
            year=2020,
            journal="J. Test",
            volume=1,
            pages="1--2",
        ),
    )
    block = format_references_block(citations)
    assert block.startswith("## References")
    assert "Doe, Jane" in block


def test_format_references_empty_list_is_graceful() -> None:
    block = format_references_block(())
    assert "## References" in block
    assert "no citations assembled" in block.lower()


def test_format_references_includes_warnings_in_plain_file() -> None:
    from vibeqc.output.citations.registry import AssembledCitations

    ac = AssembledCitations(citations=(), warnings=("synthetic warning",))
    body = format_references(ac)
    assert "synthetic warning" in body


# ---------------------------------------------------------------------------
# `print` flag — user-visibility gate on individual citations
# ---------------------------------------------------------------------------
#
# Per CLAUDE.md § 8 ("When you implement something citable") the optional
# `print` field on `[entries.<key>]` controls whether a citation lands in
# the user-facing references block or stays in the .system manifest only.
# These tests pin the contract end-to-end.


def test_citation_print_defaults_to_true():
    """Bare-bones Citation must default to print=True (backward compat)."""
    c = Citation(
        key="x",
        kind="article",
        bibtex_key="x_2020",
        authors=("Doe, J.",),
        title="T",
    )
    assert c.print is True


def test_to_jsonable_includes_print_flag():
    """Manifest serialisation must surface the flag for downstream consumers."""
    c = Citation(
        key="x",
        kind="article",
        bibtex_key="x_2020",
        authors=("Doe, J.",),
        title="T",
        print=False,
    )
    jb = c.to_jsonable()
    assert jb["print"] is False


def test_database_omits_print_field_means_true(tmp_path: Path):
    """An entry without `print` parses as printable — keeps the entire
    existing database backwards-compatible."""
    f = tmp_path / "db.toml"
    f.write_text(
        'schema_version = "1"\n'
        "[entries.foo]\n"
        'kind = "article"\n'
        'bibtex_key = "foo"\n'
        'authors = ["A, B"]\n'
        'title = "T"\n',
        encoding="utf-8",
    )
    db = load_database(f)
    assert db.entries()["foo"].print is True


def test_database_reads_print_false(tmp_path: Path):
    f = tmp_path / "db.toml"
    f.write_text(
        'schema_version = "1"\n'
        "[entries.foo]\n"
        'kind = "article"\n'
        'bibtex_key = "foo"\n'
        'authors = ["A, B"]\n'
        'title = "T"\n'
        "print = false\n",
        encoding="utf-8",
    )
    db = load_database(f)
    assert db.entries()["foo"].print is False


def test_database_rejects_non_boolean_print(tmp_path: Path):
    f = tmp_path / "db.toml"
    f.write_text(
        'schema_version = "1"\n'
        "[entries.foo]\n"
        'kind = "article"\n'
        'bibtex_key = "foo"\n'
        'authors = ["A, B"]\n'
        'title = "T"\n'
        'print = "no"\n',
        encoding="utf-8",
    )
    with pytest.raises(DatabaseError, match="'print' must be a boolean"):
        load_database(f)


def _two_citation_set(*, second_print: bool):
    """Build an AssembledCitations with two entries; second's print flag
    is the parameter under test."""
    from vibeqc.output.citations.registry import AssembledCitations

    visible = Citation(
        key="v",
        kind="article",
        bibtex_key="v_2020",
        authors=("Visible, Author",),
        title="Paper-relevant",
        year=2020,
        journal="J. Visible",
        volume=1,
        pages="1",
    )
    other = Citation(
        key="i",
        kind="software",
        bibtex_key="i_2024",
        authors=("Infra, Author",),
        title="Infrastructure",
        version="1.0",
        print=second_print,
    )
    return AssembledCitations(citations=(visible, other))


def test_printable_property_filters_to_print_true():
    ac = _two_citation_set(second_print=False)
    assert len(ac.citations) == 2
    assert len(ac.printable) == 1
    assert ac.printable[0].key == "v"


def test_printable_property_passes_through_when_all_true():
    ac = _two_citation_set(second_print=True)
    assert ac.printable == ac.citations


def test_eigen_is_internal_provenance_not_a_printed_reference():
    """Eigen is linked into the C++ core for every job, so it fires
    unconditionally from routes.libraries -- but it is link-time infrastructure,
    not a scientific dependency. It belongs in the .system manifest and must
    stay out of the references block a paper's Methods section copies from
    (CLAUDE.md § 8 step 3). This is the first real database entry exercising
    print = false; the tests above use synthetic entries."""
    db = load_default_database()
    result = db.assemble(method="rhf", basis="sto-3g")
    assert "eigen" in {c.key for c in result.citations}
    assert "eigen" not in {c.key for c in result.printable}
    assert not result.warnings
    rows = {r["key"]: r for r in citation_manifest_rows(result)}
    assert rows["eigen"]["print"] is False


def test_eigen_fires_for_a_semiempirical_job_too():
    """The core is linked regardless of method, so the unconditional route must
    not be accidentally gated on a Gaussian-integral job."""
    db = load_default_database()
    result = db.assemble(method="msindo", uses_integrals=False)
    assert "eigen" in {c.key for c in result.citations}
    assert "valeev_libint" not in {c.key for c in result.citations}


def test_write_references_omits_print_false(tmp_path: Path):
    ac = _two_citation_set(second_print=False)
    path = write_references(tmp_path / "job", ac)
    body = path.read_text(encoding="utf-8")
    assert "Visible, Author" in body
    assert "Infra, Author" not in body, body


def test_write_bibtex_omits_print_false(tmp_path: Path):
    ac = _two_citation_set(second_print=False)
    path = write_bibtex(tmp_path / "job", ac)
    body = path.read_text(encoding="utf-8")
    assert "v_2020" in body
    assert "i_2024" not in body, body


def test_format_references_block_omits_print_false():
    ac = _two_citation_set(second_print=False)
    block = format_references_block(ac)
    assert "Visible, Author" in block
    assert "Infra, Author" not in block, block


def test_raw_iterable_path_renders_all(tmp_path: Path):
    """When the writer gets a raw iterable (not an AssembledCitations),
    the caller has already chosen the subset — every entry renders, regardless
    of the print flag on individual items. This is the .system manifest path."""
    ac = _two_citation_set(second_print=False)
    # Pass the .citations tuple directly (raw iterable, bypasses .printable).
    path = write_references(tmp_path / "manifest", ac.citations)
    body = path.read_text(encoding="utf-8")
    assert "Visible, Author" in body
    assert "Infra, Author" in body


# ---------------------------------------------------------------------------
# CPCM / solvation routing — fires when uses_cpcm=True.
# ---------------------------------------------------------------------------


def test_cpcm_pulls_klamt_cossi_scalmani(tmp_path: Path) -> None:
    """run_job(..., solvent=...) sets uses_cpcm=True; the assembler
    must then surface the three CPCM-formulation papers."""
    plan = _plan(tmp_path, basis="def2-svp", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan, uses_cpcm=True)
    keys = {c.key for c in result.citations}
    assert "klamt_schuurmann_cosmo_1993" in keys
    assert "cossi_cpcm_2003" in keys
    assert "scalmani_frisch_csc_2010" in keys
    assert result.warnings == ()


def test_cpcm_off_does_not_pull_solvation_refs(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="def2-svp", functional="PBE")
    db = load_default_database()
    result = db.assemble_from_plan(plan)  # uses_cpcm defaults to False
    keys = {c.key for c in result.citations}
    assert "klamt_schuurmann_cosmo_1993" not in keys


def test_cpcm_unknown_variant_warns(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="sto-3g")
    db = load_default_database()
    result = db.assemble_from_plan(plan, uses_cpcm=True, solvent_variant="made-up-pcm")
    assert any("made-up-pcm" in w for w in result.warnings)


# ---------------------------------------------------------------------------
# SCF accelerator routing — non-DIIS accelerators must cite their paper.
# ---------------------------------------------------------------------------


def test_adiis_route_pulls_hu_yang(tmp_path: Path) -> None:
    plan = _plan(tmp_path, basis="sto-3g")
    db = load_default_database()
    result = db.assemble_from_plan(plan, scf_accelerator="adiis")
    bibtex_keys = {c.bibtex_key for c in result.citations}
    assert "hu_yang_adiis_2010" in bibtex_keys


def test_kdiis_route_pulls_kollmar(tmp_path: Path) -> None:
    """KDIIS must cite Kollmar 1997, the originating publication for the
    orbital-rotation-gradient DIIS variant.

    Garza & Scuseria 2012 ("Comparison of self-consistent field
    convergence acceleration techniques") is a comparative study of
    ADIIS / LIST / EDIIS+DIIS whose text never mentions Kollmar or
    KDIIS. It was routed here in error until 2026-07-10; keep it off
    this route.
    """
    plan = _plan(tmp_path, basis="sto-3g")
    db = load_default_database()
    result = db.assemble_from_plan(plan, scf_accelerator="kdiis")
    bibtex_keys = {c.bibtex_key for c in result.citations}
    assert "kollmar_kdiis_1997" in bibtex_keys
    assert "garza_scuseria_2012" not in bibtex_keys


def test_ediis_diis_route_pulls_garza_scuseria(tmp_path: Path) -> None:
    """The EDIIS+DIIS hybrid is what Garza & Scuseria 2012 actually
    recommends, so that is the route their entry belongs on."""
    plan = _plan(tmp_path, basis="sto-3g")
    db = load_default_database()
    result = db.assemble_from_plan(plan, scf_accelerator="ediis_diis")
    bibtex_keys = {c.bibtex_key for c in result.citations}
    assert "garza_scuseria_2012" in bibtex_keys
    assert "kudin_ediis_2002" in bibtex_keys


# ---------------------------------------------------------------------------
# Active-space + post-SCF method routes.
# ---------------------------------------------------------------------------


def test_selected_ci_pulls_cipsi() -> None:
    db = load_default_database()
    result = db.assemble(method="selected_ci", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "huron_malrieu_cipsi_1973" in keys
    assert "holmes_tubman_umrigar_shci_2016" in keys


def test_dmrg_pulls_white_and_chan() -> None:
    db = load_default_database()
    result = db.assemble(method="dmrg", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "white_dmrg_1992" in keys
    assert "chan_headgordon_dmrg_2002" in keys


def test_v2rdm_pulls_mazziotti() -> None:
    db = load_default_database()
    result = db.assemble(method="v2rdm", basis="sto-3g")
    keys = {c.key for c in result.citations}
    assert "mazziotti_v2rdm_2011" in keys


def test_mp2_route_fires_moller_plesset() -> None:
    db = load_default_database()
    result = db.assemble(method="mp2", basis="def2-svp")
    keys = {c.key for c in result.citations}
    assert "moller_plesset_1934" in keys


def test_scs_mp2_route_adds_grimme() -> None:
    db = load_default_database()
    result = db.assemble(method="scs-mp2", basis="def2-svp")
    keys = {c.key for c in result.citations}
    assert "moller_plesset_1934" in keys
    assert "grimme_scs_mp2_2003" in keys


def test_pwpb95_route_carries_dh_bundle() -> None:
    db = load_default_database()
    result = db.assemble(method="pwpb95", basis="def2-tzvp")
    keys = {c.key for c in result.citations}
    assert "goerigk_grimme_pwpb95_2011" in keys
    assert "moller_plesset_1934" in keys


def test_wb97x_d_route_pulls_chg_dispersion() -> None:
    db = load_default_database()
    result = db.assemble(method="wb97x-d", basis="def2-svp")
    keys = {c.key for c in result.citations}
    assert "chai_headgordon_wb97xd_2008" in keys
    assert "grimme_dftd2_2006" in keys


# ---------------------------------------------------------------------------
# emit_citations helper — used by standalone runners (run_b2plyp etc.)
# ---------------------------------------------------------------------------


def test_emit_citations_writes_bibtex_and_references(tmp_path: Path) -> None:
    from vibeqc.output.citations import emit_citations

    stem = tmp_path / "wb97xd_job"
    citations, bib_path, ref_path, block = emit_citations(
        stem,
        method="wb97x-d",
        basis="def2-svp",
        functional="wb97x-d",
    )
    assert bib_path == stem.with_suffix(".bibtex")
    assert ref_path == stem.with_suffix(".references")
    assert bib_path.is_file()
    assert ref_path.is_file()
    assert "## References" in block
    body = bib_path.read_text(encoding="utf-8")
    assert "chai_headgordon_wb97xd_2008" in body
    assert "grimme_dftd2_2006" in body
    assert "peintinger_vibeqc" in body


# ---------------------------------------------------------------------------
# Forward-looking routes (roadmap features pre-staged in the 2026-06 citation
# expansion). These fire through the standard method= / functional= / basis=
# triggers and the new scf_guess / properties / acceleration / numerics /
# gradient / hessian / smearing_method hooks. They must stay silent by default.
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "method_key,expected",
    [
        ("cc2", {"christiansen_cc2_1995"}),
        ("cc3", {"koch_cc3_1997"}),
        (
            "dlpno-ccsd(t)",
            {"riplinger_dlpno_2013", "riplinger_sparsemaps_2016", "guo_dlpno_t1_2018"},
        ),
        ("eom-ip-ccsd", {"stanton_bartlett_eomcc_1993", "stanton_gauss_eomip_1994"}),
        ("ccsdt", {"noga_bartlett_ccsdt_1987"}),
        ("mrci+q", {"werner_knowles_mrci_1988", "langhoff_davidson_1974"}),
    ],
)
def test_forward_method_ladder_routes(method_key, expected) -> None:
    db = load_default_database()
    keys = {c.key for c in db.assemble(method=method_key, basis="cc-pvtz").citations}
    assert expected <= keys, f"{method_key}: missing {expected - keys}"


@pytest.mark.parametrize(
    "accel,expected",
    [
        ("anderson", "anderson_mixing_1965"),
        ("broyden", "broyden_1965"),
        ("ot", "vandevondele_ot_2003"),
        ("oda", "cances_lebris_oda_2000"),
        ("r_cdiis", "chupin_adaptive_diis_2021"),
        ("ad_cdiis", "chupin_adaptive_diis_2021"),
        ("adiis_diis", "hu_yang_adiis_2010"),
    ],
)
def test_forward_scf_accelerator_routes(accel, expected) -> None:
    db = load_default_database()
    keys = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", scf_accelerator=accel
        ).citations
    }
    assert expected in keys


def test_dft_integration_grid_fires_for_any_functional() -> None:
    """Becke 1988 multicenter integration + Treutler-Ahlrichs 1995 fire for
    any KS-DFT run (the XC grid), but not for pure Hartree-Fock."""
    db = load_default_database()
    dft = {
        c.key
        for c in db.assemble(method="rks", basis="def2-svp", functional="pbe").citations
    }
    assert {"becke_grid_1988", "treutler_ahlrichs_1995"} <= dft
    hf = {c.key for c in db.assemble(method="rhf", basis="sto-3g").citations}
    assert "becke_grid_1988" not in hf


def test_scf_guess_routes() -> None:
    db = load_default_database()
    sad = {
        c.key
        for c in db.assemble(method="rhf", basis="sto-3g", scf_guess="sad").citations
    }
    assert "vanlenthe_sad_2006" in sad
    sap = {
        c.key
        for c in db.assemble(method="rhf", basis="sto-3g", scf_guess="sap").citations
    }
    assert {"lehtola_sap_2019", "lehtola_visscher_engel_sap_2020"} <= sap
    # silent by default
    plain = {c.key for c in db.assemble(method="rhf", basis="sto-3g").citations}
    assert "vanlenthe_sad_2006" not in plain


def test_properties_routes() -> None:
    db = load_default_database()
    r = db.assemble(
        method="rks",
        basis="def2-svp",
        functional="pbe",
        properties=["nmr_shielding", "nci", "hirshfeld"],
    )
    keys = {c.key for c in r.citations}
    assert {"ditchfield_giao_1974", "wolinski_hinton_pulay_giao_1990"} <= keys
    assert "johnson_nci_2010" in keys
    assert "hirshfeld_1977" in keys
    # silent by default
    plain = {
        c.key
        for c in db.assemble(method="rks", basis="def2-svp", functional="pbe").citations
    }
    assert "johnson_nci_2010" not in plain

    # COOP / COHP routes
    coop_keys = {
        c.key
        for c in db.assemble(
            method="rks",
            basis="def2-svp",
            functional="pbe",
            properties=["coop"],
        ).citations
    }
    assert "hughbanks_hoffmann_coop_1983" in coop_keys
    assert "dronskowski_cohp_1993" in coop_keys
    assert "deringer_cohp_2011" in coop_keys
    assert "maintz_lobster3_2016" in coop_keys

    cohp_keys = {
        c.key
        for c in db.assemble(
            method="rks",
            basis="def2-svp",
            functional="pbe",
            properties=["cohp"],
        ).citations
    }
    assert "hughbanks_hoffmann_coop_1983" not in cohp_keys  # COOP original only on coop
    assert "dronskowski_cohp_1993" in cohp_keys
    assert "deringer_cohp_2011" in cohp_keys
    assert "maintz_lobster3_2016" in cohp_keys
    # silent by default
    plain = {
        c.key
        for c in db.assemble(method="rks", basis="def2-svp", functional="pbe").citations
    }
    assert "dronskowski_cohp_1993" not in plain


def test_bond_analysis_routes_pin_dois() -> None:
    """v0.22.0 BOND property routes fire their defining papers, DOIs pinned.

    DOIs pinned mechanically per CLAUDE.md section 8 (the pob-* audit
    lesson) -- every DOI below was verified against Crossref / the arXiv
    abstract page on 2026-07-11 when the original hand-entered metadata
    for four of these entries turned out to be wrong or hallucinated.
    """
    db = load_default_database()
    expected_by_route = {
        "wiberg": {
            "wiberg_bond_index_1968": "10.1016/0040-4020(68)88057-3",
        },
        "delocalization_index": {
            "matito_esi_2007": "10.1039/B605086G",
            "outeiral_di_2018": "10.1039/C8SC01338A",
        },
        "morokuma_eda": {
            "morokuma_eda_1971": "10.1063/1.1676210",
        },
        "entanglement": {
            "legeza_entanglement_2003": "10.1103/PhysRevB.68.195116",
        },
        "multiorbital_correlation": {
            "szalay_multiorbital_2017": "10.1038/s41598-017-02447-z",
        },
        "entanglement_bonds": {
            "entanglement_bonds_2025": "10.48550/arXiv.2501.15699",
        },
        "nbo": {
            "reed_weinhold_npa_1985": "10.1063/1.449486",
            "foster_weinhold_nho_1980": "10.1021/ja00544a007",
            "reed_curtiss_weinhold_nbo_1988": "10.1021/cr00088a005",
        },
    }
    for route, expected in expected_by_route.items():
        by_key = {
            c.key: c
            for c in db.assemble(
                method="rhf",
                basis="sto-3g",
                properties=[route],
            ).citations
        }
        for key, doi in expected.items():
            assert key in by_key, f"{key} did not fire for properties=[{route!r}]"
            assert by_key[key].doi == doi
            assert by_key[key].print is True  # reaches .out / .bibtex
    # silent by default -- no bond-analysis paper on a plain SCF
    plain_scf = {
        c.key for c in db.assemble(method="rhf", basis="sto-3g").citations
    }
    for expected in expected_by_route.values():
        for key in expected:
            assert key not in plain_scf


def test_acceleration_and_numerics_routes() -> None:
    db = load_default_database()
    acc = {
        c.key
        for c in db.assemble(
            method="rks",
            basis="def2-svp",
            functional="pbe",
            acceleration=["rij", "rijcosx"],
        ).citations
    }
    assert {"whitten_1973", "eichkorn_rij_1995", "neese_rijcosx_2009"} <= acc
    num = {
        c.key
        for c in db.assemble(
            method="rhf", basis="cc-pvqz", numerics=["pivoted_cholesky"]
        ).citations
    }
    assert "lehtola_pivoted_cholesky_2019" in num
    gr = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", numerics=["generalized_regular_kgrid"]
        ).citations
    }
    assert {"wisesa_grids_2016", "wang_grids_2021"} <= gr
    kdb = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", numerics=["kpoint_database"]
        ).citations
    }
    assert {"monkhorst_pack_1976", "mehl_kpoint_database_1990"} <= kdb
    lob = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", numerics=["lobpcg"]
        ).citations
    }
    assert "knyazev_lobpcg_2001" in lob
    # The k-point rows that KPoints.citation_numerics emits. The producer side
    # is pinned by tests/test_kpoints.py; this pins that the routes answer.
    kppra = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", periodic=True, numerics=["kppra"]
        ).citations
    }
    assert "curtarolo_aflow_2012" in kppra
    hpkot = {
        c.key
        for c in db.assemble(
            method="rhf", basis="sto-3g", periodic=True, numerics=["hpkot_band_path"]
        ).citations
    }
    assert "hinuma_seekpath_2017" in hpkot


def test_gradient_and_hessian_driver_routes() -> None:
    db = load_default_database()
    g = {
        c.key
        for c in db.assemble(method="rhf", basis="sto-3g", uses_gradient=True).citations
    }
    assert {"pulay_forces_1969", "feynman_forces_1939"} <= g
    h = {
        c.key
        for c in db.assemble(method="rhf", basis="sto-3g", uses_hessian=True).citations
    }
    assert "wilson_decius_cross_1955" in h
    # silent by default
    plain = {c.key for c in db.assemble(method="rhf", basis="sto-3g").citations}
    assert "pulay_forces_1969" not in plain


def test_smearing_method_variant_route() -> None:
    """uses_smearing fires Mermin; a non-Fermi-Dirac broadening additionally
    cites its defining paper via smearing_method."""
    db = load_default_database()
    mp = {
        c.key
        for c in db.assemble(
            method="rks",
            basis="def2-svp",
            functional="pbe",
            periodic=True,
            uses_smearing=True,
            smearing_method="methfessel_paxton",
        ).citations
    }
    assert "mermin_finite_temperature_dft_1965" in mp
    assert "methfessel_paxton_1989" in mp


def test_forward_basis_family_routes() -> None:
    db = load_default_database()
    for basis, expect in [
        ("pcseg-2", "jensen_pcseg_2014"),
        ("pcs-2", "jensen_pcs_2008"),
        ("lanl2dz", "hay_wadt_lanl_1985"),
        ("ano-r2", "zobel_ano_r_2020"),
        ("cc-pv(t+d)z", "dunning_tightd_2001"),
        ("x2c-tzvpall", "pollak_weigend_x2c_2017"),
    ]:
        keys = {c.key for c in db.assemble(method="rhf", basis=basis).citations}
        assert expect in keys, f"basis {basis}: missing {expect}"
        bad = [
            w
            for w in db.assemble(method="rhf", basis=basis).warnings
            if basis.lower() in w.lower()
        ]
        assert not bad, f"basis {basis} warned: {bad}"
