"""Regression tests: verify CLI output matches reference values.

Reference files were generated from:
- main branch (pre-refactor): generateajf, log2cpf, convertcpf
- pre-refactor commit (7cd75bf): udf2gro, gro2udf (udf+gro mode, from-top mode)
"""
import os
import re
import shutil
import subprocess
import sys

import pytest

TESTS_DIR = os.path.dirname(__file__)
REF_MAIN = os.path.join(TESTS_DIR, "regression", "reference", "main")
REF_PREREFACTOR = os.path.join(TESTS_DIR, "regression", "reference", "prerefactor")
SAMPLE_DIR = os.path.join(TESTS_DIR, os.pardir, "sample")

# External sample data (abmptools-sample repo, not bundled)
_ABMPTOOLS_SAMPLE = os.path.normpath(os.path.join(
    os.path.dirname(TESTS_DIR), os.pardir, os.pardir,
    "abmptools-sample", "sample",
))
_GETIFIE_SAMPLE = os.path.join(_ABMPTOOLS_SAMPLE, "getifiepieda")
_HAS_GETIFIE_DATA = os.path.isdir(
    os.path.join(_GETIFIE_SAMPLE, "6lu7-multi-fmolog")
)
_HAS_EXT_SAMPLE = os.path.isdir(_ABMPTOOLS_SAMPLE)


def _run(args, cwd):
    """Run a CLI command and return completed process."""
    return subprocess.run(
        [sys.executable, "-m"] + args,
        cwd=cwd,
        capture_output=True,
        text=True,
        timeout=120,
    )


def _normalize_line(line):
    """Normalize lines that contain timestamps or absolute paths."""
    # CPF header: "... Generated by ABMPTools [log2cpf] 2026-04-05 00:54:50.125453"
    line = re.sub(
        r"(Generated by ABMPTools\b.*?)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+",
        r"\1 <TIMESTAMP>",
        line,
    )
    # TOP header: "; input: /tmp/.../test.udf"
    line = re.sub(r"^(;\s*input:\s+).*$", r"\1<PATH>", line)
    return line


def _compare_files(generated, reference, ignore_blank_lines=False):
    """Assert two files have identical content (ignoring timestamps/paths)."""
    with open(generated) as f:
        gen_lines = f.readlines()
    with open(reference) as f:
        ref_lines = f.readlines()
    if ignore_blank_lines:
        gen_lines = [l for l in gen_lines if l.strip()]
        ref_lines = [l for l in ref_lines if l.strip()]
    gen_text = "".join(_normalize_line(l) for l in gen_lines)
    ref_text = "".join(_normalize_line(l) for l in ref_lines)
    assert gen_text == ref_text, (
        f"Output differs from reference.\n"
        f"  generated: {generated}\n"
        f"  reference: {reference}\n"
    )


# ---------------------------------------------------------------------------
# generateajf: gly5.pdb with all method/option combinations
# ---------------------------------------------------------------------------
GENERATEAJF_PARAMS = [
    ("--method", "MP2D", "--resp", "--nonbo"),
    ("--method", "MP2D", "--resp"),
    ("--method", "MP2D", "--nonbo"),
    ("--method", "MP2D"),
    ("--method", "HF", "--resp", "--nonbo"),
    ("--method", "HF", "--resp"),
    ("--method", "HF", "--nonbo"),
    ("--method", "HF"),
    ("--method", "MP2"),
    ("--method", "MP3"),
    ("--method", "HF+D"),
    ("--nopieda", "-pb", "-bsse", "--method", "HF"),
    ("--nopieda", "-pb", "-bsse", "--method", "MP2"),
    ("--nopieda", "-pb", "-bsse", "--method", "MP3"),
    ("--nopieda", "-pb", "-bsse", "--method", "HF+D"),
    ("--nopieda", "-pb", "--method", "HF"),
    ("--nopieda", "-pb", "--method", "MP2"),
    ("--nopieda", "-pb", "--method", "MP3"),
    ("--nopieda", "-pb", "--method", "HF+D"),
    ("--nopieda", "-bsse", "--method", "HF"),
    ("--nopieda", "-bsse", "--method", "MP2"),
    ("--nopieda", "-bsse", "--method", "MP3"),
    ("--nopieda", "-bsse", "--method", "HF+D"),
    ("--nopieda", "--method", "HF"),
    ("--nopieda", "--method", "MP2"),
    ("--nopieda", "--method", "MP3"),
    ("--nopieda", "--method", "HF+D"),
    ("-pb", "-bsse", "--method", "HF"),
    ("-pb", "-bsse", "--method", "MP2"),
    ("-pb", "-bsse", "--method", "MP3"),
    ("-pb", "-bsse", "--method", "HF+D"),
    ("-pb", "--method", "HF"),
    ("-pb", "--method", "MP2"),
    ("-pb", "--method", "MP3"),
    ("-pb", "--method", "HF+D"),
    ("-bsse", "--method", "HF"),
    ("-bsse", "--method", "MP2"),
    ("-bsse", "--method", "MP3"),
    ("-bsse", "--method", "HF+D"),
    ("--method", "HF"),
    ("--method", "MP2"),
    ("--method", "MP3"),
    ("--method", "HF+D"),
]


def _ajf_id(params):
    """Create a short test ID from generateajf params."""
    return " ".join(params)


@pytest.mark.parametrize("extra_args", GENERATEAJF_PARAMS, ids=_ajf_id)
def test_generateajf_gly5(tmp_path, extra_args):
    """generateajf output must match main-branch reference."""
    pdb_src = os.path.join(SAMPLE_DIR, "generateajf", "gly5.pdb")
    shutil.copy(pdb_src, tmp_path / "gly5.pdb")

    result = _run(
        ["abmptools.generateajf", "-i", "gly5.pdb"] + list(extra_args),
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"generateajf failed:\n{result.stderr}"

    # Find the generated .ajf file
    ajf_files = [f for f in os.listdir(tmp_path) if f.endswith(".ajf")]
    assert len(ajf_files) == 1, f"Expected 1 ajf, got {ajf_files}"
    ajf_name = ajf_files[0]

    ref_path = os.path.join(REF_MAIN, "generateajf_gly5", ajf_name)
    assert os.path.exists(ref_path), f"No reference file: {ref_path}"
    _compare_files(str(tmp_path / ajf_name), ref_path)


# ---------------------------------------------------------------------------
# log2cpf
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
    "log_file,cpf_file",
    [
        ("gly5-MP2-STO-3G-nbo.log", "gly5-MP2-STO-3G-nbo.cpf"),
        ("gly5-HF-STO-3G-resp.log", "gly5-HF-STO-3G-resp.cpf"),
    ],
)
def test_log2cpf(tmp_path, log_file, cpf_file):
    """log2cpf output must match main-branch reference."""
    src_dir = os.path.join(SAMPLE_DIR, "log2cpf")
    # Copy all needed files (log, ajf, pdb)
    for f in os.listdir(src_dir):
        src = os.path.join(src_dir, f)
        if os.path.isfile(src):
            shutil.copy(src, tmp_path / f)

    result = _run(
        ["abmptools.log2cpf", "-i", log_file, "-o", cpf_file],
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"log2cpf failed:\n{result.stderr}"
    assert (tmp_path / cpf_file).exists()

    ref_path = os.path.join(REF_MAIN, "log2cpf", cpf_file)
    _compare_files(str(tmp_path / cpf_file), ref_path)


# ---------------------------------------------------------------------------
# convertcpf
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
    "in_cpf,out_cpf",
    [
        ("gly5-10.cpf", "gly5-10to23.cpf"),
        ("gly5-23.cpf", "gly5-23to23.cpf"),
        ("gly5-4201.cpf", "gly5-4to23.cpf"),
    ],
)
def test_convertcpf_gly5(tmp_path, in_cpf, out_cpf):
    """convertcpf output must match main-branch reference."""
    src_dir = os.path.join(SAMPLE_DIR, "convertcpf", "gly5")
    shutil.copy(os.path.join(src_dir, in_cpf), tmp_path / in_cpf)

    result = _run(
        ["abmptools.convertcpf", "-i", in_cpf, "-o", out_cpf],
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"convertcpf failed:\n{result.stderr}"
    assert (tmp_path / out_cpf).exists()

    ref_path = os.path.join(REF_MAIN, "convertcpf_gly5", out_cpf)
    _compare_files(str(tmp_path / out_cpf), ref_path)


# ---------------------------------------------------------------------------
# udf2gro (added during refactor)
# ---------------------------------------------------------------------------
def test_udf2gro(tmp_path):
    """udf2gro output must match pre-refactor reference."""
    udf_src = os.path.join(SAMPLE_DIR, "udf2gro", "input", "test.udf")
    shutil.copy(udf_src, tmp_path / "test.udf")

    result = _run(
        ["abmptools.udf2gro", "test.udf", "test"],
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"udf2gro failed:\n{result.stderr}"

    for ext in ("gro", "top", "mdp"):
        gen = str(tmp_path / f"test.{ext}")
        ref = os.path.join(REF_PREREFACTOR, "udf2gro", f"test.{ext}")
        assert os.path.exists(gen), f"Missing output: test.{ext}"
        _compare_files(gen, ref)


# ---------------------------------------------------------------------------
# gro2udf: udf + gro mode (added during refactor)
# ---------------------------------------------------------------------------
def test_gro2udf_udf_gro_mode(tmp_path):
    """gro2udf udf+gro mode output must match pre-refactor reference."""
    src_dir = os.path.join(SAMPLE_DIR, "gro2udf", "udf_and_gro_mode", "input")
    for f in os.listdir(src_dir):
        shutil.copy(os.path.join(src_dir, f), tmp_path / f)

    result = _run(
        ["abmptools.gro2udf", "test.udf", "output.gro"],
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"gro2udf failed:\n{result.stderr}"

    gen = str(tmp_path / "test_groout.udf")
    ref = os.path.join(REF_PREREFACTOR, "gro2udf_udf_gro_mode", "test_groout.udf")
    assert os.path.exists(gen)
    _compare_files(gen, ref, ignore_blank_lines=True)


# ---------------------------------------------------------------------------
# gro2udf: --from-top mode (added during refactor)
# ---------------------------------------------------------------------------
def test_gro2udf_from_top_mode(tmp_path):
    """gro2udf --from-top mode output must match pre-refactor reference."""
    src_dir = os.path.join(SAMPLE_DIR, "gro2udf", "gro_top_mode", "input")
    for f in os.listdir(src_dir):
        shutil.copy(os.path.join(src_dir, f), tmp_path / f)

    result = _run(
        [
            "abmptools.gro2udf",
            "--from-top", "test.top", "output.gro",
            "--template", "template_empty.udf",
            "--out", "fromtop_out.udf",
        ],
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"gro2udf --from-top failed:\n{result.stderr}"

    gen = str(tmp_path / "fromtop_out.udf")
    ref = os.path.join(REF_PREREFACTOR, "gro2udf_from_top_mode", "fromtop_out.udf")
    assert os.path.exists(gen)
    _compare_files(gen, ref, ignore_blank_lines=True)


# ---------------------------------------------------------------------------
# getifiepieda: IFIE/PIEDA analysis (requires external sample data)
# ---------------------------------------------------------------------------
_skip_getifie = pytest.mark.skipif(
    not _HAS_GETIFIE_DATA,
    reason="getifiepieda sample data not available",
)

_L6 = os.path.join(
    _GETIFIE_SAMPLE,
    "6lu7-multi-fmolog",
    "6lu7orig_md040j8_163neu-100-hopt-ps-mod_forabmp_192n-2p-24t.log",
)
_LCD7 = os.path.join(
    _GETIFIE_SAMPLE,
    "cd7-fmolog",
    "sbecd7_50nsdynamics_namd1700-moved-sed-around-8.0-for_abmp.log",
)
_LPB = os.path.join(
    _GETIFIE_SAMPLE,
    "6m0j-pb-fmolog",
    "6m0j-170res_1.0_pb.log",
)
_ML6_JSON = (
    '["{pfx}", "-hopt-ps-mod_forabmp_192n-2p-24t.log"]'.format(
        pfx=os.path.join(
            _GETIFIE_SAMPLE,
            "6lu7-multi-fmolog",
            "6lu7orig_md040j8_163neu-",
        )
    )
)
_MCD7_JSON = (
    '["{pfx}", "-moved-sed-around-8.0-for_abmp.log"]'.format(
        pfx=os.path.join(
            _GETIFIE_SAMPLE,
            "cd7-fmolog",
            "sbecd7_50nsdynamics_namd",
        )
    )
)

GETIFIE_SINGLE_CASES = [
    ("frag_dist-6lu7", ["--frag", "310", "-d", "8.0", "-i", _L6, "--nof90"]),
    ("fragids-6lu7", ["--frag", "307-311", "1-306", "-i", _L6, "--nof90"]),
    ("fraginmol-6lu7", ["--fraginmol", "1", "2", "WAT", "1", "-i", _L6, "--nof90"]),
    ("fraginmol-cd7", ["--fraginmol", "1", "2", "WAT", "1", "-i", _LCD7, "--nof90"]),
    ("ffmatrix-6lu7", ["--ffmatrix", "1-100", "1-100", "-i", _L6, "--nof90"]),
    ("getpb-6m0j", ["--ffmatrix", "1-40", "41-80", "-i", _LPB, "--nof90"]),
    ("mol_selectmid-cd7", ["--mol", "1", "-d", "3.0", "-i", _LCD7, "--nof90"]),
    ("mol_selectmids-cd7", ["--mol", "1-4", "-i", _LCD7, "--nof90"]),
]

GETIFIE_MULTI_CASES = [
    ("multi_fragid_dist-6lu7", ["--multi", "310", "-d", "4.5", "-t", "100", "3100", "1000", "-i", _ML6_JSON, "--nof90"]),
    ("multi_fragid_fragid-6lu7", ["--multi", "310", "41", "-t", "100", "5100", "1000", "-i", _ML6_JSON, "--nof90"]),
    ("multi_fragid_molname-cd7", ["--multi", "1", "--molname", "WAT", "-t", "1700", "4200", "500", "-i", _MCD7_JSON, "--nof90"]),
    ("multi_fragid_molname-cd7-pt2", ["--multi", "2", "--molname", "WAT", "-d", "3.0", "-t", "1700", "2200", "500", "-i", _MCD7_JSON, "--nof90"]),
    ("multi_fragid_molname_6lu7", ["--multi", "20-21", "--molname", "WAT", "-d", "10.0", "-t", "100", "2100", "1000", "-i", _ML6_JSON, "--nof90"]),
    ("multi_fragids-tfmatrix-6lu7", ["--tfmatrix", "307-311", "1-306", "-t", "100", "1100", "1000", "-i", _ML6_JSON, "--exclude", "145", "-np", "2", "--nof90"]),
    ("multi_wat_tfmatrix-6lu7", ["--tfmatrix", "307-311", "1700-", "-t", "100", "5100", "1000", "-i", _ML6_JSON, "--exclude", "145", "-np", "1", "--nores", "--nof90"]),
    ("xyzfile", [
        "--multi", "1", "-dimeres", "-zp", "5", "-t", "1", "2", "1", "-nof90",
        "-i", '["{pfx}", "-pbe-d3-opt_pl_acclayer5-around_ar12.0_12n-2p-24t.log"]'.format(
            pfx=os.path.join(_GETIFIE_SAMPLE, "xyzfile", "XXXI_structure_")
        ),
        "--noresinfo", "-momene", "1", "-dimene", "3", "1", "--nof90",
    ]),
]


def _compare_csv_dir(csv_dir, ref_dir):
    """Compare all CSVs in csv_dir against reference directory."""
    ref_files = sorted(f for f in os.listdir(ref_dir) if f.endswith(".csv"))
    assert ref_files, f"No reference CSVs in {ref_dir}"
    for fname in ref_files:
        gen = os.path.join(csv_dir, fname)
        ref = os.path.join(ref_dir, fname)
        assert os.path.exists(gen), f"Missing output: {fname}"
        _compare_files(gen, ref)


@_skip_getifie
@pytest.mark.parametrize("case_name,cli_args", GETIFIE_SINGLE_CASES, ids=[c[0] for c in GETIFIE_SINGLE_CASES])
def test_getifiepieda_single(tmp_path, case_name, cli_args):
    """getifiepieda single-file mode output must match main-branch reference."""
    result = _run(
        ["abmptools.getifiepieda"] + cli_args,
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"getifiepieda failed:\n{result.stderr}"
    ref_dir = os.path.join(REF_MAIN, "getifiepieda", case_name)
    _compare_csv_dir(str(tmp_path / "csv"), ref_dir)


@_skip_getifie
@pytest.mark.parametrize("case_name,cli_args", GETIFIE_MULTI_CASES, ids=[c[0] for c in GETIFIE_MULTI_CASES])
def test_getifiepieda_multi(tmp_path, case_name, cli_args):
    """getifiepieda multi-file mode output must match main-branch reference."""
    result = _run(
        ["abmptools.getifiepieda"] + cli_args,
        cwd=str(tmp_path),
    )
    assert result.returncode == 0, f"getifiepieda failed:\n{result.stderr}"
    ref_dir = os.path.join(REF_MAIN, "getifiepieda", case_name)
    _compare_csv_dir(str(tmp_path / "csv"), ref_dir)


# ---------------------------------------------------------------------------
# Additional tools (requires external sample data): addsolvfrag, pdb2fmo,
# pdbmodify, setupserialpdb_ajf
# ---------------------------------------------------------------------------
_skip_ext = pytest.mark.skipif(
    not _HAS_EXT_SAMPLE, reason="abmptools-sample not available",
)


def _compare_output_dir(out_dir, ref_dir):
    """Compare all files in ref_dir against out_dir (exact match)."""
    ref_files = sorted(os.listdir(ref_dir))
    assert ref_files, f"No reference files in {ref_dir}"
    for fname in ref_files:
        gen = os.path.join(out_dir, fname)
        ref = os.path.join(ref_dir, fname)
        assert os.path.exists(gen), f"Missing output: {fname}"
        _compare_files(gen, ref)


@_skip_ext
def test_pdbmodify_CyD(tmp_path):
    """pdbmodify resnum/rename output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "pdbmodify", "CyD")
    shutil.copy(os.path.join(src, "cyc.pdb.1"), tmp_path / "cyc.pdb.1")
    shutil.copy(os.path.join(src, "run.sh"), tmp_path / "run.sh")
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=120,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_output_dir(str(tmp_path), os.path.join(REF_MAIN, "pdbmodify_CyD"))


@_skip_ext
def test_addsolvfrag_covneu(tmp_path):
    """addsolvfrag (covneu) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "addsolvfrag", "covneu")
    for fname in ("6lu7-covneu-nowat-hinagata0514.ajf",
                  "6lu7orig_md040j8_163neu-100-hopt-ps-mod.pdb",
                  "run.sh", "segment_data.dat"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=300,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_output_dir(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "addsolvfrag_covneu"),
    )


@_skip_ext
def test_addsolvfrag_6lu7_covhip(tmp_path):
    """addsolvfrag (6lu7-covhip, 3-PDB subset) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "addsolvfrag", "6lu7-covhip-100ns100str-0514")
    for fname in ("6lu7-covhip-nowat-hinagata0515.ajf",
                  "6lu7-covhip-nowat-hinagata0515.pdb",
                  "segment_data.dat"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    (tmp_path / "inpdbs").mkdir()
    for t in ("100", "1100", "2100"):
        f = f"6lu7orig_md0408_163hip-{t}-hopt-ps-mod.pdb"
        shutil.copy(os.path.join(src, "inpdbs", f), tmp_path / "inpdbs" / f)
    result = _run([
        "abmptools.addsolvfrag",
        "-temp", "6lu7-covhip-nowat-hinagata0515.ajf",
        "-solv", "HOH", "WAT", "NA",
        "-i",
        str(tmp_path / "inpdbs" / "6lu7orig_md0408_163hip-100-hopt-ps-mod.pdb"),
        str(tmp_path / "inpdbs" / "6lu7orig_md0408_163hip-1100-hopt-ps-mod.pdb"),
        str(tmp_path / "inpdbs" / "6lu7orig_md0408_163hip-2100-hopt-ps-mod.pdb"),
    ], cwd=str(tmp_path))
    assert result.returncode == 0, f"addsolvfrag failed:\n{result.stderr}"
    _compare_output_dir(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "addsolvfrag_6lu7-covhip"),
    )


@_skip_ext
def test_pdb2fmo_cd7(tmp_path):
    """pdb2fmo (cd7-sample, 4 cut modes) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "pdb2fmo", "cd7-sample")
    for fname in ("CD7.frag", "MRT.frag", "NA.frag", "run.sh",
                  "sbecd7+mzp_priz_50nsdynamics_namd40000-moved-sed.pdb"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=300,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_output_dir(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "pdb2fmo_cd7"),
    )


@_skip_ext
def test_pdb2fmo_cbz(tmp_path):
    """pdb2fmo (cbz, 4 cut modes) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "pdb2fmo", "cbz")
    for fname in ("CBZ.frag", "IN-.frag", "NG-.frag", "TIN.frag", "run.sh",
                  "50cbz-144ruting-1.pdb"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=300,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_output_dir(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "pdb2fmo_cbz"),
    )


@_skip_ext
def test_setupserialpdb_ajf(tmp_path):
    """setupserialpdb_ajf pipeline (3 time points) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "setupserialpdb_ajf")
    for t in ("100000", "101000", "102000"):
        fname = f"1eo8-ff14sb-{t}ps.pdb"
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    cwd = str(tmp_path)
    # Step 1: rename CYX -> CYS
    import glob
    pdbs = [os.path.basename(p) for p in glob.glob(str(tmp_path / "*ps.pdb"))]
    r1 = _run(["abmptools.pdbmodify", "-mode", "rename", "-str", "CYX", "CYS",
               "-i"] + pdbs, cwd=cwd)
    assert r1.returncode == 0, f"step1 failed:\n{r1.stderr}"
    # Step 2: pdbmodify main
    renamed = [os.path.basename(p) for p in glob.glob(str(tmp_path / "*d.pdb"))]
    r2 = _run(["abmptools.pdbmodify", "-i"] + renamed, cwd=cwd)
    assert r2.returncode == 0, f"step2 failed:\n{r2.stderr}"
    # Step 3: generateajf template
    r3 = _run([
        "abmptools.generateajf", "-i", "1eo8-ff14sb-xxxps-renamed-mod.pdb",
        "-ml", "-mll", "921", "-cmm", "-mem", "6000", "-np", "1",
        "-lc", "NA", "1", "-lc", "CL", "-1",
        "-rs", "Na", "0.0", "-rs", "Cl", "0.0",
        "-basis", "6-31G*", "--method", "MP2", "-nocpf",
    ], cwd=cwd)
    assert r3.returncode == 0, f"step3 failed:\n{r3.stderr}"
    # Step 4: ajfserial
    r4 = _run([
        "abmptools.ajfserial",
        "-i", "1eo8-ff14sb-xxxps-renamed-mod-MP2-6-31Gd-nocpf-nbo.ajf",
        "-t", "100000", "102000", "1000", "-str", "xxx",
    ], cwd=cwd)
    assert r4.returncode == 0, f"step4 failed:\n{r4.stderr}"
    # Copy template to 'template.ajf' for comparison
    shutil.copy(
        tmp_path / "1eo8-ff14sb-xxxps-renamed-mod-MP2-6-31Gd-nocpf-nbo.ajf",
        tmp_path / "template.ajf",
    )
    _compare_output_dir(cwd, os.path.join(REF_MAIN, "setupserialpdb_ajf_1eo8"))


# ---------------------------------------------------------------------------
# udf2fmo: requires obabel (e.g. fcewsenv) + external sample data
# ---------------------------------------------------------------------------
def _have_obabel():
    from shutil import which
    return which("obabel") is not None


_skip_udf2fmo = pytest.mark.skipif(
    not _HAS_EXT_SAMPLE or not _have_obabel(),
    reason="udf2fmo requires obabel and abmptools-sample",
)


def _compare_ajf_only(out_dir, ref_dir):
    """Compare only .ajf files in ref_dir against out_dir."""
    ajfs = sorted(f for f in os.listdir(ref_dir) if f.endswith(".ajf"))
    assert ajfs, f"No reference .ajf in {ref_dir}"
    for fname in ajfs:
        gen = os.path.join(out_dir, fname)
        ref = os.path.join(ref_dir, fname)
        assert os.path.exists(gen), f"Missing output: {fname}"
        _compare_files(gen, ref)


@_skip_udf2fmo
def test_udf2fmo_nafion(tmp_path):
    """udf2fmo (nafion) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "udf2fmo", "nafion")
    for fname in ("nafion_rmap.bdf", "run.sh", "segment_data.dat", "nafion.pdb"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=600,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_ajf_only(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "udf2fmo_nafion"),
    )


@_skip_udf2fmo
def test_udf2fmo_membrane(tmp_path):
    """udf2fmo (membrane) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "udf2fmo", "membrane")
    for fname in ("mem75rev.bdf", "run.sh", "segment_data.dat"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=600,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_ajf_only(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "udf2fmo_membrane"),
    )


@_skip_udf2fmo
def test_udf2fmo_membrane_cholesterol(tmp_path):
    """udf2fmo (membrane_cholesterol) output must match main-branch reference."""
    src = os.path.join(_ABMPTOOLS_SAMPLE, "udf2fmo", "membrane_cholesterol")
    for fname in ("dpdDOPC30-cho5_size7.1_revmap_out_last.udf",
                  "run.sh", "segment_data.dat"):
        shutil.copy(os.path.join(src, fname), tmp_path / fname)
    result = subprocess.run(
        ["bash", "run.sh"], cwd=str(tmp_path),
        capture_output=True, text=True, timeout=600,
    )
    assert result.returncode == 0, f"run.sh failed:\n{result.stderr}"
    _compare_ajf_only(
        str(tmp_path / "for_abmp"),
        os.path.join(REF_MAIN, "udf2fmo_membrane_cholesterol"),
    )
