"""v0.53.7 — Data Forge + Pipeline live wave 1.

Tests for:
- #88  markdown heading-aware ingest split
- #112 decontaminate --benchmark-file
- #87  prompt_strategy live runtime
- #86  soup data preprocess live tokenize
- #111 soup data forge --judge-provider
- #75  QA log entry (file-presence assertion only)
- #106 utils/recipe_run.run_recipe live
- #105 utils/trainer_plugins.instantiate_trainer_plugins live
- #103 /v1/tools/{python, bash, web_search} live
- #102 /v1/messages on vLLM backend + streaming SSE
"""

from __future__ import annotations

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

import pytest

# ----- #88 markdown heading split ----------------------------------------


class TestMarkdownHeadingSplit:
    def test_empty_string_returns_empty_list(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        assert split_markdown_by_headings("") == []

    def test_preamble_only_returns_single_row_with_none_section(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        rows = split_markdown_by_headings("first paragraph\nsecond line")
        assert len(rows) == 1
        assert rows[0]["section"] is None
        assert rows[0]["level"] is None
        assert "first paragraph" in rows[0]["text"]

    def test_three_headings_yield_three_sections(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        md = (
            "# Intro\nIntro body\n## Sub\nSub body\n### Deep\nDeep body\n"
        )
        rows = split_markdown_by_headings(md)
        assert len(rows) == 3
        assert rows[0]["section"] == "Intro"
        assert rows[0]["level"] == 1
        assert rows[1]["section"] == "Sub"
        assert rows[1]["level"] == 2
        assert rows[2]["section"] == "Deep"
        assert rows[2]["level"] == 3

    def test_preamble_plus_headings_yield_preamble_row(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        md = "preamble text\n# Heading\nbody"
        rows = split_markdown_by_headings(md)
        assert len(rows) == 2
        assert rows[0]["section"] is None
        assert rows[0]["level"] is None
        assert "preamble text" in rows[0]["text"]
        assert rows[1]["section"] == "Heading"
        assert rows[1]["level"] == 1

    def test_atx_levels_1_through_6_accepted(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        md = "\n".join(f"{'#' * n} L{n}\nbody{n}" for n in range(1, 7))
        rows = split_markdown_by_headings(md)
        assert [r["level"] for r in rows] == [1, 2, 3, 4, 5, 6]

    def test_seven_hashes_not_a_heading(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        rows = split_markdown_by_headings("####### not a heading\nbody")
        assert len(rows) == 1
        assert rows[0]["section"] is None

    def test_hash_without_space_not_a_heading(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        rows = split_markdown_by_headings("#NoSpace\nbody")
        assert len(rows) == 1
        assert rows[0]["section"] is None

    def test_non_string_input_raises_typeerror(self):
        from soup_cli.utils.data_pipeline import split_markdown_by_headings
        with pytest.raises(TypeError):
            split_markdown_by_headings(123)
        with pytest.raises(TypeError):
            split_markdown_by_headings(None)

    def test_ingest_cli_emits_one_row_per_heading(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        md_path = tmp_path / "doc.md"
        md_path.write_text(
            "# H1\nfirst\n## H2\nsecond\n## H3\nthird\n",
            encoding="utf-8",
        )
        out = tmp_path / "out.jsonl"
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["data", "ingest", str(md_path), "-o", str(out)],
        )
        assert result.exit_code == 0, result.output
        lines = out.read_text(encoding="utf-8").strip().splitlines()
        assert len(lines) == 3
        first = json.loads(lines[0])
        assert first["section"] == "H1"
        assert first["level"] == 1

    def test_ingest_cli_preamble_creates_extra_row(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        md_path = tmp_path / "doc.md"
        md_path.write_text(
            "preamble\n# Heading\nbody\n",
            encoding="utf-8",
        )
        out = tmp_path / "out.jsonl"
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["data", "ingest", str(md_path), "-o", str(out)],
        )
        assert result.exit_code == 0, result.output
        rows = [json.loads(line) for line in out.read_text(
            encoding="utf-8"
        ).strip().splitlines()]
        assert len(rows) == 2
        assert rows[0]["section"] is None
        assert rows[1]["section"] == "Heading"


# ----- #112 decontaminate --benchmark-file -------------------------------


class TestDecontaminateBenchmarkFile:
    def test_loads_operator_corpus_and_filters(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        # The decontaminate row text overlaps the benchmark text by n-grams.
        bench_path = tmp_path / "bench.jsonl"
        bench_path.write_text(
            json.dumps({"text": (
                "the quick brown fox jumps over the lazy dog "
                "many times in the morning sun every day"
            )}) + "\n",
            encoding="utf-8",
        )
        rows_path = tmp_path / "input.jsonl"
        rows_path.write_text(
            json.dumps({"text": (
                "the quick brown fox jumps over the lazy dog "
                "many times in the morning sun every day"
            )}) + "\n"
            + json.dumps({"text": "completely unrelated content here"}) + "\n",
            encoding="utf-8",
        )
        out_path = tmp_path / "clean.jsonl"
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "data", "decontaminate",
                "-i", str(rows_path),
                "--benchmark-file", str(bench_path),
                "-o", str(out_path),
                "--n", "4",
                "--threshold", "0.3",
            ],
        )
        assert result.exit_code == 0, result.output
        kept = [
            json.loads(line)
            for line in out_path.read_text(encoding="utf-8").strip().splitlines()
        ]
        # Overlapping row removed, unrelated kept.
        assert len(kept) == 1
        assert "unrelated" in kept[0]["text"]

    def test_missing_benchmarks_and_file_exits(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        rows_path = tmp_path / "input.jsonl"
        rows_path.write_text(json.dumps({"text": "hi"}) + "\n", encoding="utf-8")
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["data", "decontaminate", "-i", str(rows_path), "-o", "out.jsonl"],
        )
        assert result.exit_code != 0

    def test_bad_benchmark_file_path_rejected(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        rows_path = tmp_path / "input.jsonl"
        rows_path.write_text(json.dumps({"text": "hi"}) + "\n", encoding="utf-8")
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "data", "decontaminate",
                "-i", str(rows_path),
                "--benchmark-file", "/etc/does-not-exist.jsonl",
                "-o", "out.jsonl",
            ],
        )
        assert result.exit_code != 0

    def test_benchmarks_flag_still_accepted_with_label(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        rows_path = tmp_path / "input.jsonl"
        rows_path.write_text(json.dumps({"text": "any"}) + "\n", encoding="utf-8")
        out_path = tmp_path / "out.jsonl"
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "data", "decontaminate",
                "-i", str(rows_path),
                "-b", "mmlu",
                "-o", str(out_path),
            ],
        )
        assert result.exit_code == 0, result.output


# ----- #87 prompt_strategy live runtime ----------------------------------


class TestPromptStrategyRuntime:
    def test_resolve_finds_callable(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        # json.dumps takes a positional arg
        spec = "json:dumps"
        fn = resolve_prompt_strategy(spec)
        assert callable(fn)

    def test_resolve_missing_module_raises(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        with pytest.raises(ValueError, match="could not be imported"):
            resolve_prompt_strategy("definitely_not_a_module_xyz:fn")

    def test_resolve_missing_attribute_raises(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        with pytest.raises(ValueError, match="no attribute"):
            resolve_prompt_strategy("json:not_a_real_attr_zzz")

    def test_resolve_non_callable_raises(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        # sys.maxsize is an int, not callable
        with pytest.raises(ValueError, match="not callable"):
            resolve_prompt_strategy("sys:maxsize")

    def test_resolve_bad_shape_raises(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        with pytest.raises(ValueError):
            resolve_prompt_strategy("no_colon")
        with pytest.raises(ValueError):
            resolve_prompt_strategy("")

    def test_resolve_non_string_raises(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        with pytest.raises(ValueError):
            resolve_prompt_strategy(123)  # type: ignore[arg-type]

    def test_apply_with_none_returns_row(self):
        from soup_cli.utils.data_pipeline import apply_prompt_strategy
        row = {"a": 1}
        assert apply_prompt_strategy(None, row) is row

    def test_apply_callable_transforms_row(self, tmp_path, monkeypatch):
        # Create a tiny module with an upper-casing transform
        mod_dir = tmp_path / "tfm_mod"
        mod_dir.mkdir()
        (mod_dir / "__init__.py").write_text("", encoding="utf-8")
        (mod_dir / "t.py").write_text(
            "def upper(row):\n"
            "    return {k: v.upper() if isinstance(v, str) else v for k, v in row.items()}\n",
            encoding="utf-8",
        )
        monkeypatch.syspath_prepend(str(tmp_path))
        # Clear lru_cache so spec resolves with new module
        from soup_cli.utils.data_pipeline import (
            apply_prompt_strategy,
            resolve_prompt_strategy,
        )
        resolve_prompt_strategy.cache_clear()
        spec = "tfm_mod.t:upper"
        out = apply_prompt_strategy(spec, {"text": "hi"})
        assert out["text"] == "HI"

    def test_apply_callable_exception_falls_through(self, tmp_path, monkeypatch):
        mod_dir = tmp_path / "tfm_mod2"
        mod_dir.mkdir()
        (mod_dir / "__init__.py").write_text("", encoding="utf-8")
        (mod_dir / "t.py").write_text(
            "def boom(row):\n    raise RuntimeError('nope')\n",
            encoding="utf-8",
        )
        monkeypatch.syspath_prepend(str(tmp_path))
        from soup_cli.utils.data_pipeline import (
            apply_prompt_strategy,
            resolve_prompt_strategy,
        )
        resolve_prompt_strategy.cache_clear()
        spec = "tfm_mod2.t:boom"
        row = {"text": "x"}
        out = apply_prompt_strategy(spec, row)
        assert out == row  # silent-degrade per CrossDocCollator policy

    def test_apply_non_mapping_return_falls_through(self, tmp_path, monkeypatch):
        mod_dir = tmp_path / "tfm_mod3"
        mod_dir.mkdir()
        (mod_dir / "__init__.py").write_text("", encoding="utf-8")
        (mod_dir / "t.py").write_text(
            "def to_str(row):\n    return 'not a mapping'\n",
            encoding="utf-8",
        )
        monkeypatch.syspath_prepend(str(tmp_path))
        from soup_cli.utils.data_pipeline import (
            apply_prompt_strategy,
            resolve_prompt_strategy,
        )
        resolve_prompt_strategy.cache_clear()
        row = {"text": "x"}
        out = apply_prompt_strategy("tfm_mod3.t:to_str", row)
        assert out == row

    def test_sft_format_threads_prompt_strategy(self, tmp_path, monkeypatch):
        """build_format_row routes through prompt_strategy when set."""
        mod_dir = tmp_path / "tfm_mod_sft"
        mod_dir.mkdir()
        (mod_dir / "__init__.py").write_text("", encoding="utf-8")
        (mod_dir / "t.py").write_text(
            "def attach(row):\n"
            "    return {**row, '_attached': True}\n",
            encoding="utf-8",
        )
        monkeypatch.syspath_prepend(str(tmp_path))
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        resolve_prompt_strategy.cache_clear()

        from soup_cli.config.schema import DataConfig
        from soup_cli.data.sft_format import build_format_row

        data_cfg = DataConfig(
            train="dummy.jsonl",
            prompt_strategy="tfm_mod_sft.t:attach",
            train_on_responses_only=False,
            chat_template="chatml",
        )
        tokenizer = MagicMock()
        tokenizer.chat_template = "{% for msg in messages %}{{msg['content']}}{% endfor %}"
        tokenizer.apply_chat_template = MagicMock(return_value="rendered")
        fn = build_format_row(tokenizer, data_cfg)
        # When invoked, the inner row should pass through the attach transform
        result = fn({"messages": [{"role": "user", "content": "hi"}]})
        # Verify the legacy text formatter ran with attached row.
        assert tokenizer.apply_chat_template.called
        assert "rendered" in result.get("text", "")


# ----- #86 soup data preprocess live tokenize ----------------------------


class TestPreprocessTokenize:
    def test_help_advertises_live_loop(self):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        runner = CliRunner()
        result = runner.invoke(app, ["data", "preprocess", "--help"])
        assert result.exit_code == 0

    def test_load_pretokenized_dataset_rejects_outside_cwd(self, tmp_path, monkeypatch):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset

        monkeypatch.chdir(tmp_path)
        # An absolute outside-cwd path
        with pytest.raises(ValueError, match="under cwd"):
            load_pretokenized_dataset("/etc/some_dir")

    def test_load_pretokenized_dataset_rejects_null_byte(self, tmp_path, monkeypatch):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset

        monkeypatch.chdir(tmp_path)
        with pytest.raises(ValueError, match="null byte"):
            load_pretokenized_dataset("foo\x00bar")

    def test_load_pretokenized_dataset_rejects_empty(self):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset
        with pytest.raises(ValueError, match="non-empty"):
            load_pretokenized_dataset("")

    def test_load_pretokenized_dataset_rejects_non_string(self):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset
        with pytest.raises(TypeError, match="string"):
            load_pretokenized_dataset(123)  # type: ignore[arg-type]

    def test_load_pretokenized_dataset_rejects_missing_path(self, tmp_path, monkeypatch):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset

        monkeypatch.chdir(tmp_path)
        with pytest.raises(ValueError, match="not found"):
            load_pretokenized_dataset("nonexistent_dir")

    @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink rejection")
    def test_load_pretokenized_dataset_rejects_symlink(self, tmp_path, monkeypatch):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset

        monkeypatch.chdir(tmp_path)
        real_dir = tmp_path / "real"
        real_dir.mkdir()
        link = tmp_path / "link"
        link.symlink_to(real_dir)
        with pytest.raises(ValueError, match="symlink"):
            load_pretokenized_dataset(str(link))

    def test_load_pretokenized_dataset_rejects_cache_key_mismatch(
        self, tmp_path, monkeypatch
    ):
        from soup_cli.utils.data_pipeline import load_pretokenized_dataset

        pytest.importorskip("datasets")
        monkeypatch.chdir(tmp_path)
        target = tmp_path / "tokenized"
        target.mkdir()
        (target / "metadata.json").write_text(
            json.dumps({"cache_key": "DIFFERENT"}), encoding="utf-8"
        )
        with pytest.raises(ValueError, match="cache_key mismatch"):
            load_pretokenized_dataset(
                str(target), expected_cache_key="EXPECTED"
            )


# ----- #111 forge --judge-provider ---------------------------------------


class TestForgeJudgeProvider:
    def test_make_judge_provider_fn_unknown_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError, match="unknown judge provider"):
            make_judge_provider_fn("openai")

    def test_make_judge_provider_fn_non_string_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(TypeError):
            make_judge_provider_fn(123)  # type: ignore[arg-type]

    def test_make_judge_provider_fn_bad_model_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError, match="model"):
            make_judge_provider_fn("ollama", model="")
        with pytest.raises(ValueError, match="NUL-free"):
            make_judge_provider_fn("ollama", model="x\x00y")

    def test_make_judge_provider_fn_bool_timeout_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(TypeError):
            make_judge_provider_fn("ollama", timeout_seconds=True)

    def test_make_judge_provider_fn_bad_timeout_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError):
            make_judge_provider_fn("ollama", timeout_seconds=0)
        with pytest.raises(ValueError):
            make_judge_provider_fn("ollama", timeout_seconds=1000)

    def test_anthropic_requires_env_var(self, monkeypatch):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
        with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"):
            make_judge_provider_fn("anthropic")

    def test_ollama_rejects_remote_url(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError):
            make_judge_provider_fn(
                "ollama", base_url="http://10.0.0.1:11434"
            )

    def test_ollama_judge_returns_text_on_success(self, monkeypatch):
        # Monkeypatch httpx.post in the module
        import httpx

        from soup_cli.utils.data_forge import make_judge_provider_fn

        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            "choices": [{"message": {"content": "hello world"}}]
        }
        monkeypatch.setattr(httpx, "post", lambda *a, **k: mock_response)
        judge = make_judge_provider_fn("ollama")
        reply = judge("prompt")
        assert reply["text"] == "hello world"

    def test_ollama_judge_swallows_http_error(self, monkeypatch):
        import httpx

        from soup_cli.utils.data_forge import make_judge_provider_fn

        def _boom(*a, **k):
            raise httpx.ConnectError("nope")

        monkeypatch.setattr(httpx, "post", _boom)
        judge = make_judge_provider_fn("ollama")
        reply = judge("prompt")
        assert reply["text"] == ""

    def test_vllm_judge_validates_url(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError):
            make_judge_provider_fn(
                "vllm", base_url="ftp://localhost:8000"
            )

    def test_forge_provider_constants(self):
        from soup_cli.utils.data_forge import JUDGE_PROVIDERS
        assert JUDGE_PROVIDERS == frozenset({"ollama", "anthropic", "vllm"})

    def test_forge_cli_rejects_unknown_provider(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        docs = tmp_path / "docs"
        docs.mkdir()
        (docs / "a.txt").write_text("hello world", encoding="utf-8")
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "data", "forge",
                "--docs", str(docs),
                "--task", "sft",
                "--target-rows", "1",
                "--judge-provider", "openai",
                "-o", "out.jsonl",
                "-p", "prov.json",
            ],
        )
        assert result.exit_code == 2


# ----- #75 QA log entry --------------------------------------------------


class TestQALogEntry:
    def test_qa_log_mentions_v0537(self):
        qa_log = (
            Path(__file__).parent / "qa" / "v053_qa.md"
        )
        assert qa_log.is_file()
        text = qa_log.read_text(encoding="utf-8")
        assert "v0.53.7" in text
        assert "#75" in text


# ----- #106 run_recipe live ----------------------------------------------


class TestRunRecipeLive:
    def test_stub_gone(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": "doesnotexist.jsonl"}},
                ],
                "edges": [],
            }
        )
        # Should NOT raise NotImplementedError now (was the v0.53.6 stub).
        # Instead should fail on missing seed path.
        with pytest.raises(ValueError):
            run_recipe(dag, output_dir=str(tmp_path / "out"))

    def test_seed_node_loads_jsonl(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(
            json.dumps({"text": "a"}) + "\n" + json.dumps({"text": "b"}) + "\n",
            encoding="utf-8",
        )
        out_dir = tmp_path / "out"
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                    {"name": "sink", "kind": "sampler", "config": {}},
                ],
                "edges": [["seed1", "sink"]],
            }
        )
        result = run_recipe(dag, output_dir=str(out_dir))
        assert result["status"] == "completed"
        assert "seed1" in result["completed_nodes"]
        assert "sink" in result["completed_nodes"]
        # sampler wrote the rows
        sink_jsonl = out_dir / "sink.jsonl"
        assert sink_jsonl.is_file()
        lines = sink_jsonl.read_text(encoding="utf-8").strip().splitlines()
        assert len(lines) == 2

    def test_validator_node_regex_filter(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(
            json.dumps({"text": "hello world"}) + "\n"
            + json.dumps({"text": "skip me"}) + "\n",
            encoding="utf-8",
        )
        out_dir = tmp_path / "out"
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                    {"name": "vchk", "kind": "validator", "config": {"regex": "hello"}},
                    {"name": "sink", "kind": "sampler", "config": {}},
                ],
                "edges": [["seed1", "vchk"], ["vchk", "sink"]],
            }
        )
        result = run_recipe(dag, output_dir=str(out_dir))
        assert result["node_row_counts"]["vchk"] == 1
        assert result["node_row_counts"]["sink"] == 1

    def test_llm_text_offline_stub(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
        out_dir = tmp_path / "out"
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                    {
                        "name": "llm",
                        "kind": "llm_text",
                        "config": {"prompt": "tell me about {text}"},
                    },
                    {"name": "sink", "kind": "sampler", "config": {}},
                ],
                "edges": [["seed1", "llm"], ["llm", "sink"]],
            }
        )
        result = run_recipe(dag, output_dir=str(out_dir))
        assert result["status"] == "completed"
        # Offline stub injects llm column
        sink = (out_dir / "sink.jsonl").read_text(encoding="utf-8").strip().splitlines()
        first = json.loads(sink[0])
        assert "llm" in first

    def test_judge_node_offline_default_keeps_all(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(
            json.dumps({"text": "a"}) + "\n" + json.dumps({"text": "b"}) + "\n",
            encoding="utf-8",
        )
        out_dir = tmp_path / "out"
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                    {"name": "j", "kind": "judge", "config": {}},
                    {"name": "sink", "kind": "sampler", "config": {}},
                ],
                "edges": [["seed1", "j"], ["j", "sink"]],
            }
        )
        result = run_recipe(dag, output_dir=str(out_dir))
        assert result["node_row_counts"]["j"] == 2

    def test_checkpoint_written_on_completion(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
        out_dir = tmp_path / "out"
        dag = parse_recipe(
            {
                "nodes": [
                    {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                ],
                "edges": [],
            }
        )
        run_recipe(dag, output_dir=str(out_dir))
        cp = out_dir / ".checkpoint.json"
        assert cp.is_file()
        state = json.loads(cp.read_text(encoding="utf-8"))
        assert state["status"] == "completed"
        assert "seed1" in state["completed_nodes"]

    def test_rejects_non_recipedag(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_run import run_recipe
        monkeypatch.chdir(tmp_path)
        with pytest.raises(TypeError):
            run_recipe("not a dag", output_dir="x")  # type: ignore[arg-type]

    def test_output_dir_outside_cwd_rejected(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        dag = parse_recipe({
            "nodes": [{"name": "n1", "kind": "sampler", "config": {}}],
            "edges": [],
        })
        with pytest.raises(ValueError, match="under cwd"):
            run_recipe(dag, output_dir="/etc/foo")

    def test_validator_requires_regex_or_schema(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
        out_dir = tmp_path / "out"
        dag = parse_recipe({
            "nodes": [
                {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                {"name": "v", "kind": "validator", "config": {}},
            ],
            "edges": [["seed1", "v"]],
        })
        with pytest.raises(ValueError, match="regex.*schema"):
            run_recipe(dag, output_dir=str(out_dir))

    def test_code_node_requires_code_field(self, tmp_path, monkeypatch):
        from soup_cli.utils.recipe_dag import parse_recipe
        from soup_cli.utils.recipe_run import run_recipe

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
        out_dir = tmp_path / "out"
        dag = parse_recipe({
            "nodes": [
                {"name": "seed1", "kind": "seed", "config": {"path": str(seed_path)}},
                {"name": "c", "kind": "code", "config": {}},
            ],
            "edges": [["seed1", "c"]],
        })
        with pytest.raises(ValueError, match="code"):
            run_recipe(dag, output_dir=str(out_dir))

    def test_recipe_cli_executes_dag(self, tmp_path, monkeypatch):
        from typer.testing import CliRunner

        from soup_cli.cli import app

        monkeypatch.chdir(tmp_path)
        seed_path = tmp_path / "seed.jsonl"
        seed_path.write_text(json.dumps({"text": "x"}) + "\n", encoding="utf-8")
        recipe_path = tmp_path / "recipe.yaml"
        recipe_path.write_text(
            "nodes:\n"
            f"  - {{name: seed1, kind: seed, config: {{path: {seed_path.name}}}}}\n"
            "  - {name: sink, kind: sampler, config: {}}\n"
            "edges:\n"
            "  - [seed1, sink]\n",
            encoding="utf-8",
        )
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "data", "recipe",
                str(recipe_path),
                "--execute",
                "--output", "out",
            ],
        )
        assert result.exit_code == 0, result.output
        # v0.53.7 marker should NOT appear in success path
        assert "v0.53.7" not in result.output or "Re-run" not in result.output


# ----- #105 instantiate_trainer_plugins live ----------------------------


class TestInstantiateTrainerPlugins:
    def test_cce_plugin_returns_advisory_callback(self):
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins

        out = instantiate_trainer_plugins(["cce_plugin"])
        assert len(out) == 1
        cb = out[0]
        assert hasattr(cb, "plugin_name")
        assert cb.plugin_name == "cce_plugin"

    def test_unknown_name_rejected_before_instantiation(self):
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins
        with pytest.raises(ValueError):
            instantiate_trainer_plugins(["definitely_unknown_plugin"])

    def test_non_sequence_rejected(self):
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins
        with pytest.raises(TypeError):
            instantiate_trainer_plugins("grokfast")  # bare string

    def test_empty_list_returns_empty_tuple(self):
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins
        out = instantiate_trainer_plugins([])
        assert out == ()

    def test_no_longer_raises_notimplementederror_for_cce(self):
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins
        # cce_plugin has no required upstream dep; should NOT raise.
        out = instantiate_trainer_plugins(["cce_plugin"])
        assert out  # non-empty

    def test_missing_upstream_pkg_raises_importerror(self):
        """grokfast / spectrum etc. may not be installed in CI."""
        from soup_cli.utils.trainer_plugins import instantiate_trainer_plugins
        # If the package is installed, this test asserts no ImportError.
        # If absent (the common case), ImportError fires with friendly hint.
        try:
            out = instantiate_trainer_plugins(["grokfast"])
            # If installed, just verify we got something callable / module-like
            assert out
        except ImportError as exc:
            assert "grokfast" in str(exc)
            assert "pip install" in str(exc)


# ----- #103 /v1/tools/{python,bash,web_search} live ---------------------


def _create_test_app():
    """Build a test FastAPI app via _create_app (lazy fastapi import)."""
    try:
        import fastapi  # noqa: F401
    except ImportError:
        pytest.skip("FastAPI not installed")

    from soup_cli.commands.serve import _create_app

    return _create_app(
        model_obj=MagicMock(),
        tokenizer=MagicMock(),
        device="cpu",
        model_name="test-model",
        max_tokens_default=128,
    )


class TestToolEndpointsLive:
    @pytest.fixture(autouse=True)
    def _require_fastapi(self):
        pytest.importorskip("fastapi")
        pytest.importorskip("httpx")

    def test_python_tool_runs_simple_code(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/python",
            json={"code": "print('hello')"},
        )
        # The sandbox may print warnings on first run; should still return 200.
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert "stdout" in data
        assert "exit_code" in data
        assert "timed_out" in data

    def test_python_tool_rejects_missing_code(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post("/v1/tools/python", json={})
        assert resp.status_code == 400

    def test_python_tool_rejects_oversize_code(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        oversize = "x" * (64 * 1024 + 1)
        resp = client.post("/v1/tools/python", json={"code": oversize})
        assert resp.status_code == 400

    def test_python_tool_non_dict_rejected(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        # FastAPI body parsing accepts list as dict-typed param? It coerces.
        # Use empty code which is rejected as ValueError-equivalent.
        resp = client.post("/v1/tools/python", json={"code": ""})
        assert resp.status_code == 400

    def test_bash_tool_returns_501_review_fix_c1(self):
        """v0.53.7 C1 review fix: bash reverted to 501.

        ``/bin/sh -c`` spawns a child outside the RLVR sandbox's OS-level
        isolation (``unshare(CLONE_NEWNET)`` / macOS ``sandbox-exec`` /
        socket patch); a caller could reach the cloud-metadata service
        from the child shell. Reverted until container/namespace work
        lands in v0.53.8.
        """
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/bash",
            json={"command": "echo hello"},
        )
        assert resp.status_code == 501
        assert "v0.53.9" in resp.text

    def test_bash_tool_returns_501_with_empty_body(self):
        """The 501 stub does not parse the body — it always returns 501."""
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post("/v1/tools/bash", json={})
        assert resp.status_code == 501

    def test_web_search_default_deny_all(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/web_search",
            json={"query": "anything"},
        )
        # Empty allowlist => 403
        assert resp.status_code == 403

    def test_web_search_rejects_oversize_query(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/web_search",
            json={"query": "x" * 2000},
        )
        assert resp.status_code == 400

    def test_web_search_rejects_bool_max_results(self):
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/web_search",
            json={"query": "q", "max_results": True},
        )
        assert resp.status_code == 400

    def test_web_search_with_allowlist_calls_backend(self):
        from fastapi.testclient import TestClient

        from soup_cli.commands.serve import _create_app
        from soup_cli.utils.server_tools import WebSearchConfig

        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")

        def fake_backend(query, max_results, allowlist):
            return [
                {"url": "https://example.com/page1", "snippet": "snippet1"},
                {"url": "https://other.com/blocked", "snippet": "blocked"},
            ]

        cfg = WebSearchConfig(domain_allowlist=("example.com",))
        app = _create_app(
            model_obj=MagicMock(),
            tokenizer=MagicMock(),
            device="cpu",
            model_name="test-model",
            max_tokens_default=128,
            web_search_config=cfg,
            web_search_backend=fake_backend,
        )
        client = TestClient(app)
        resp = client.post(
            "/v1/tools/web_search",
            json={"query": "hello", "max_results": 5},
        )
        assert resp.status_code == 200
        data = resp.json()
        # Only the allowlist-matched URL should survive.
        urls = [r["url"] for r in data["results"]]
        assert any("example.com" in u for u in urls)
        assert all("other.com" not in u for u in urls)

    def test_tools_no_longer_return_501(self):
        """v0.53.7 #103 regression: 501-stubs are gone."""
        from fastapi.testclient import TestClient
        app = _create_test_app()
        client = TestClient(app)
        # Python: should return 200 with sandbox response.
        resp = client.post("/v1/tools/python", json={"code": "print(1)"})
        assert resp.status_code != 501


# ----- #102 vLLM /v1/messages + streaming SSE -----------------------------


class TestAnthropicMessagesStreaming:
    @pytest.fixture(autouse=True)
    def _require_fastapi(self):
        pytest.importorskip("fastapi")
        pytest.importorskip("httpx")

    def test_stream_true_returns_sse(self):
        from fastapi.testclient import TestClient

        # Patch _generate_response so we get deterministic output without
        # actually invoking a model.
        with patch(
            "soup_cli.commands.serve._generate_response",
            return_value=("hello world", 3, 2),
        ):
            app = _create_test_app()
            client = TestClient(app)
            resp = client.post(
                "/v1/messages",
                json={
                    "model": "test-model",
                    "max_tokens": 16,
                    "stream": True,
                    "messages": [{"role": "user", "content": "hi"}],
                },
            )
            assert resp.status_code == 200, resp.text
            body = resp.text
            assert "event: message_start" in body
            assert "event: content_block_delta" in body
            assert "event: message_delta" in body
            assert "event: message_stop" in body
            assert "text_delta" in body

    def test_non_stream_returns_anthropic_envelope(self):
        from fastapi.testclient import TestClient

        with patch(
            "soup_cli.commands.serve._generate_response",
            return_value=("response", 1, 1),
        ):
            app = _create_test_app()
            client = TestClient(app)
            resp = client.post(
                "/v1/messages",
                json={
                    "model": "test-model",
                    "max_tokens": 16,
                    "messages": [{"role": "user", "content": "hi"}],
                },
            )
            assert resp.status_code == 200
            data = resp.json()
            assert data["type"] == "message"
            assert data["role"] == "assistant"
            assert data["stop_reason"] == "end_turn"
            assert data["content"][0]["type"] == "text"

    def test_stream_no_longer_returns_501(self):
        """Regression: v0.53.6 returned 501 on stream:true."""
        from fastapi.testclient import TestClient

        with patch(
            "soup_cli.commands.serve._generate_response",
            return_value=("x", 1, 1),
        ):
            app = _create_test_app()
            client = TestClient(app)
            resp = client.post(
                "/v1/messages",
                json={
                    "model": "test-model",
                    "max_tokens": 16,
                    "stream": True,
                    "messages": [{"role": "user", "content": "hi"}],
                },
            )
            assert resp.status_code != 501


class TestVllmAnthropicMessages:
    def test_vllm_app_has_v1_messages_route(self):
        """Source-level guard: vLLM create_vllm_app installs /v1/messages."""
        from pathlib import Path

        src = (
            Path(__file__).parent.parent
            / "soup_cli" / "utils" / "vllm.py"
        ).read_text(encoding="utf-8")
        # Verify the route + helper landed.
        assert "/v1/messages" in src
        assert "_stream_anthropic_messages_vllm" in src
        assert "validate_anthropic_payload" in src

    def test_vllm_stream_helper_emits_anthropic_events(self):
        from soup_cli.utils.vllm import _stream_anthropic_messages_vllm

        frames = list(
            _stream_anthropic_messages_vllm(
                msg_id="m1",
                model="test",
                text="hello world",
                input_tokens=2,
                output_tokens=2,
            )
        )
        joined = "".join(frames)
        assert "event: message_start" in joined
        assert "event: content_block_delta" in joined
        assert "event: message_delta" in joined
        assert "event: message_stop" in joined
        assert "text_delta" in joined

    def test_anthropic_streaming_helper_handles_empty_text(self):
        from soup_cli.utils.vllm import _stream_anthropic_messages_vllm

        frames = list(
            _stream_anthropic_messages_vllm(
                msg_id="m1",
                model="test",
                text="",
                input_tokens=0,
                output_tokens=0,
            )
        )
        joined = "".join(frames)
        # Even with empty text we still emit start / delta / stop events.
        assert "event: message_start" in joined
        assert "event: message_stop" in joined


class TestServeStreamHelper:
    def test_transformers_stream_helper_emits_anthropic_events(self):
        from soup_cli.commands.serve import _stream_anthropic_messages

        frames = list(
            _stream_anthropic_messages(
                msg_id="m1",
                model="test",
                text="hello world from soup",
                input_tokens=4,
                output_tokens=4,
            )
        )
        joined = "".join(frames)
        assert "event: message_start" in joined
        assert "event: content_block_delta" in joined
        assert "event: message_delta" in joined
        assert "event: message_stop" in joined
        # 4 words → at least 4 content_block_delta frames.
        assert joined.count("content_block_delta") >= 4


# ----- Cross-cutting / smoke ----------------------------------------------


class TestPublicSurface:
    def test_data_pipeline_exports(self):
        from soup_cli.utils import data_pipeline
        for name in (
            "split_markdown_by_headings",
            "resolve_prompt_strategy",
            "apply_prompt_strategy",
            "load_pretokenized_dataset",
            "validate_prompt_strategy",
        ):
            assert hasattr(data_pipeline, name), name

    def test_data_forge_exports(self):
        from soup_cli.utils import data_forge
        for name in ("make_judge_provider_fn", "JUDGE_PROVIDERS"):
            assert hasattr(data_forge, name), name

    def test_recipe_run_exports(self):
        from soup_cli.utils import recipe_run
        assert hasattr(recipe_run, "run_recipe")

    def test_trainer_plugins_exports(self):
        from soup_cli.utils import trainer_plugins
        assert hasattr(trainer_plugins, "instantiate_trainer_plugins")


# ============================================================================
# v0.53.7 review-fix tests
# ============================================================================


class TestReviewFixesRecipeRun:
    """Tests for review fixes H-B/H-C/H-D/M-C/M-E/M-F + H-J/H-K/M-O."""

    @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
    def test_seed_rejects_symlink_via_lstat_on_raw_path(self, tmp_path, monkeypatch):
        """v0.53.7 H-B: lstat the raw path BEFORE realpath."""
        import os as _os
        monkeypatch.chdir(tmp_path)
        target = tmp_path / "real.jsonl"
        target.write_text('{"x": 1}\n', encoding="utf-8")
        link = tmp_path / "link.jsonl"
        _os.symlink(str(target), str(link))
        from soup_cli.utils.recipe_dag import RecipeNode
        from soup_cli.utils.recipe_run import _node_seed
        node = RecipeNode(name="s", kind="seed", config={"path": "link.jsonl"})
        with pytest.raises(ValueError, match="symlink"):
            _node_seed(node, ())

    def test_redact_exc_message_strips_posix_paths(self):
        from soup_cli.utils.recipe_run import _redact_exc_message
        exc = FileNotFoundError("/etc/passwd: no such file")
        redacted = _redact_exc_message(exc)
        assert "/etc/passwd" not in redacted
        assert "FileNotFoundError" in redacted

    def test_redact_exc_message_truncates_long(self):
        from soup_cli.utils.recipe_run import _redact_exc_message
        exc = ValueError("x" * 1000)
        redacted = _redact_exc_message(exc, limit=64)
        assert len(redacted) <= 64

    def test_redact_exc_message_handles_windows_paths(self):
        from soup_cli.utils.recipe_run import _redact_exc_message
        exc = OSError(r"C:\Users\alice\secret.txt missing")
        redacted = _redact_exc_message(exc)
        assert "alice" not in redacted

    def test_node_code_row_injection_blocked_by_double_encode(self, tmp_path, monkeypatch):
        """v0.53.7 M-C: tricky row content cannot escape the Python literal."""
        from soup_cli.utils.recipe_dag import RecipeNode
        from soup_cli.utils.recipe_run import _node_code
        monkeypatch.chdir(tmp_path)
        evil = {"text": 'foo"); print("HIJACK"); ("'}
        node = RecipeNode(
            name="c",
            kind="code",
            config={"code": "import json\nprint(json.dumps({'L': len(_row['text'])}))"},
        )
        result = _node_code(node, ((evil,),))
        assert len(result) == 1
        out = result[0]["c"]
        # No HIJACK side effect; the literal survived intact.
        if isinstance(out, dict) and "L" in out:
            assert out["L"] == len(evil["text"])

    def test_run_recipe_resume_rehydrates_predecessor_outputs(self, tmp_path, monkeypatch):
        """v0.53.7 M-F: resume rehydrates from sidecar (was ``[]`` in v0.53.6)."""
        monkeypatch.chdir(tmp_path)
        from soup_cli.utils.recipe_dag import RecipeDAG, RecipeNode
        from soup_cli.utils.recipe_run import run_recipe

        seed = tmp_path / "seed.jsonl"
        seed.write_text('{"x": 1}\n{"x": 2}\n', encoding="utf-8")
        outdir = tmp_path / "out"

        nodes = (
            RecipeNode(name="s", kind="seed", config={"path": "seed.jsonl"}),
            RecipeNode(name="snk", kind="sampler", config={}),
        )
        dag = RecipeDAG(
            nodes=nodes, edges=(("s", "snk"),), topo_order=("s", "snk")
        )

        result1 = run_recipe(dag, output_dir=str(outdir))
        assert result1["status"] == "completed"
        assert result1["node_row_counts"]["snk"] == 2

        result2 = run_recipe(dag, output_dir=str(outdir), resume=True)
        assert result2["status"] == "completed"
        assert set(result2["completed_nodes"]) == {"s", "snk"}

    def test_run_recipe_failed_node_state_persisted(self, tmp_path, monkeypatch):
        """v0.53.7 M-O: mid-DAG failure persists status + failed_node."""
        monkeypatch.chdir(tmp_path)
        import json as _json

        from soup_cli.utils.recipe_dag import RecipeDAG, RecipeNode
        from soup_cli.utils.recipe_run import run_recipe

        outdir = tmp_path / "out"
        # seed node with a missing path raises ValueError BEFORE reaching
        # the sandbox-swallow path; this surfaces as a real failure.
        nodes = (
            RecipeNode(
                name="badseed",
                kind="seed",
                config={"path": "no_such_file_xyzzy.jsonl"},
            ),
        )
        dag = RecipeDAG(nodes=nodes, edges=(), topo_order=("badseed",))
        with pytest.raises(ValueError):
            run_recipe(dag, output_dir=str(outdir))
        ckpt = _json.loads((outdir / ".checkpoint.json").read_text(encoding="utf-8"))
        assert ckpt["status"] == "failed"
        assert ckpt["failed_node"] == "badseed"
        assert "ValueError" in ckpt["failed_reason"]

    def test_node_code_happy_path(self, tmp_path, monkeypatch):
        """v0.53.7 H-K: live code-node execution through sandbox."""
        monkeypatch.chdir(tmp_path)
        from soup_cli.utils.recipe_dag import RecipeDAG, RecipeNode
        from soup_cli.utils.recipe_run import run_recipe

        seed = tmp_path / "seed.jsonl"
        seed.write_text('{"x": 5}\n', encoding="utf-8")
        outdir = tmp_path / "out"
        nodes = (
            RecipeNode(name="s", kind="seed", config={"path": "seed.jsonl"}),
            RecipeNode(
                name="c",
                kind="code",
                config={"code": "import json\nprint(json.dumps({'doubled': _row['x']*2}))"},
            ),
            RecipeNode(name="snk", kind="sampler", config={}),
        )
        dag = RecipeDAG(
            nodes=nodes,
            edges=(("s", "c"), ("c", "snk")),
            topo_order=("s", "c", "snk"),
        )
        result = run_recipe(dag, output_dir=str(outdir))
        assert result["status"] == "completed"
        out_path = outdir / "snk.jsonl"
        if out_path.exists():
            content = out_path.read_text(encoding="utf-8")
            assert "doubled" in content or "10" in content

    def test_save_checkpoint_atomic_via_mkstemp(self, tmp_path, monkeypatch):
        """v0.53.7 H-C/M-E: mkstemp + os.replace pattern."""
        monkeypatch.chdir(tmp_path)
        from soup_cli.utils.recipe_run import _save_checkpoint
        outdir = tmp_path / "out"
        outdir.mkdir()
        _save_checkpoint(str(outdir), {"status": "running"})
        ckpt = outdir / ".checkpoint.json"
        assert ckpt.is_file()
        assert not ckpt.is_symlink()


class TestReviewFixesPromptStrategy:
    """v0.53.7 M-P + L-B + H-E coverage."""

    def setup_method(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        resolve_prompt_strategy.cache_clear()

    def test_resolve_null_byte_rejected(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        with pytest.raises(ValueError):
            resolve_prompt_strategy("mod\x00name:fn")

    def test_resolve_oversize_rejected(self):
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        oversize = "a" * 1000 + ":fn"
        with pytest.raises(ValueError):
            resolve_prompt_strategy(oversize)

    def test_h_e_bad_signature_propagates(self, tmp_path, monkeypatch):
        """H-E: signature-shape ValueError must NOT be swallowed."""
        monkeypatch.chdir(tmp_path)
        monkeypatch.syspath_prepend(str(tmp_path))
        (tmp_path / "tfm_bad_sig.py").write_text(
            "def fn(a, b, c):\n    return {'ok': True}\n",
            encoding="utf-8",
        )
        from soup_cli.utils.data_pipeline import resolve_prompt_strategy
        resolve_prompt_strategy.cache_clear()
        with pytest.raises(ValueError, match="positional"):
            resolve_prompt_strategy("tfm_bad_sig:fn")


class TestReviewFixesForge:
    """v0.53.7 M-M temperature bool + M-L typing."""

    def test_make_judge_provider_fn_bool_temperature_rejected(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(TypeError, match="temperature"):
            make_judge_provider_fn("ollama", temperature=True)

    def test_make_judge_provider_fn_temperature_out_of_range(self):
        from soup_cli.utils.data_forge import make_judge_provider_fn
        with pytest.raises(ValueError, match=r"\[0, 2\]"):
            make_judge_provider_fn("ollama", temperature=5.0)

    def test_judge_providers_typed_frozenset_of_str(self):
        from soup_cli.utils.data_forge import JUDGE_PROVIDERS
        assert isinstance(JUDGE_PROVIDERS, frozenset)
        assert all(isinstance(p, str) for p in JUDGE_PROVIDERS)


class TestReviewFixesVllmAnthropicLive:
    """v0.53.7 H-I + M-Q: live route tests with mocked AsyncLLMEngine."""

    @pytest.fixture(autouse=True)
    def _require_fastapi(self):
        pytest.importorskip("fastapi")
        pytest.importorskip("httpx")

    def _build_vllm_app(self):
        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")
        try:
            import vllm  # noqa: F401
        except ImportError:
            pytest.skip("vLLM not installed")
        from soup_cli.utils.vllm import create_vllm_app

        engine = MagicMock()
        app = create_vllm_app(
            engine=engine,
            engine_model_name="test-model",
            model_name="test-model",
            max_tokens_default=128,
        )
        return app

    def test_messages_route_registered(self):
        app = self._build_vllm_app()
        paths = [r.path for r in app.routes if hasattr(r, "path")]
        assert "/v1/messages" in paths

    def test_messages_malformed_returns_400(self):
        from fastapi.testclient import TestClient
        app = self._build_vllm_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/messages", json={"messages": [], "model": "x"}
        )
        assert resp.status_code == 400

    def test_messages_max_tokens_over_cap_returns_400(self):
        from fastapi.testclient import TestClient
        app = self._build_vllm_app()
        client = TestClient(app)
        resp = client.post(
            "/v1/messages",
            json={
                "messages": [{"role": "user", "content": "hi"}],
                "model": "x",
                "max_tokens": 999_999,
            },
        )
        assert resp.status_code == 400


class TestReviewFixesDataScore:
    """v0.53.7 H-H + M-J coverage."""

    @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlinks")
    def test_load_jsonl_rows_rejects_symlink(self, tmp_path, monkeypatch):
        import os as _os
        monkeypatch.chdir(tmp_path)
        target_outside = tmp_path.parent / "outside_bench.jsonl"
        try:
            target_outside.write_text('{"text": "x"}\n', encoding="utf-8")
            link = tmp_path / "link.jsonl"
            _os.symlink(str(target_outside), str(link))
            from soup_cli.utils.data_score import load_jsonl_rows
            with pytest.raises(ValueError, match="symlink|under cwd"):
                load_jsonl_rows("link.jsonl")
        finally:
            if target_outside.exists():
                target_outside.unlink()

    def test_load_jsonl_rows_outside_cwd_rejected(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        outside = tmp_path.parent / "evil_bench.jsonl"
        try:
            outside.write_text('{"x": 1}\n', encoding="utf-8")
            from soup_cli.utils.data_score import load_jsonl_rows
            with pytest.raises(ValueError, match="under cwd"):
                load_jsonl_rows(str(outside))
        finally:
            if outside.exists():
                outside.unlink()

    def test_extract_row_text_publicly_exposed(self):
        """v0.53.7 M-J: public alias is the documented import."""
        from soup_cli.utils import data_score
        from soup_cli.utils.data_score import extract_row_text
        assert callable(extract_row_text)
        assert "extract_row_text" in data_score.__all__


class TestReviewFixesTrainerPlugins:
    """v0.53.7 L-A + L-I dedup + L-E narrow catch."""

    def test_validate_trainer_plugin_list_rejects_duplicates(self):
        """L-I: duplicate names are explicitly rejected (not silently deduped)."""
        from soup_cli.utils.trainer_plugins import validate_trainer_plugin_list
        with pytest.raises(ValueError, match="duplicate"):
            validate_trainer_plugin_list(["grokfast", "grokfast"])

    def test_simple_plugin_returns_none_when_module_lacks_attrs(self, monkeypatch):
        """L-A: missing API surface returns None, not the bare module."""
        import types

        from soup_cli.utils import trainer_plugins

        dummy = types.ModuleType("grokfast")
        monkeypatch.setitem(sys.modules, "grokfast", dummy)
        result = trainer_plugins._instantiate_simple_plugin(
            "grokfast", "grokfast", ("GrokFastCallback", "Gradfilter")
        )
        assert result is None


class TestReviewFixesSSEHeaders:
    """v0.53.7 L-C + M-A: SSE no-store + header-injection sanitisation."""

    def test_sanitise_sse_field_strips_crlf_nul(self):
        from soup_cli.commands.serve import _sanitise_sse_field
        assert _sanitise_sse_field("hello\r\nworld", max_len=200) == "helloworld"
        assert _sanitise_sse_field("a\x00b", max_len=200) == "ab"
        assert _sanitise_sse_field("x" * 500, max_len=10) == "x" * 10

    def test_stream_anthropic_sanitises_msg_id(self):
        """Caller-controlled msg_id with CR/LF cannot create a new SSE event.

        After sanitisation the injected ``event:``/``data:`` text becomes a
        literal substring inside an existing JSON ``id`` value — no new
        ``\\n\\n`` boundary, so the SSE consumer treats the whole frame as
        one event with garbled-but-safe ``id`` content.
        """
        from soup_cli.commands.serve import _stream_anthropic_messages
        evil_id = "msg-1\nevent: hijack\ndata: {}\n"
        frames = list(_stream_anthropic_messages(
            msg_id=evil_id, model="m", text="hi",
            input_tokens=1, output_tokens=1,
        ))
        # Each frame is one SSE event terminated by exactly one "\n\n".
        # The CR/LFs from the evil id must have been stripped — count of
        # "\n\n" must equal the number of frames produced.
        joined = "".join(frames)
        assert joined.count("\n\n") == len(frames)
        # The injected literal "event: hijack" cannot start a new line; it
        # always appears as a substring inside an existing data line, never
        # at line-start, because no CR/LF separator survived.
        for line in joined.split("\n"):
            assert not line.startswith("event: hijack")
            assert not line.startswith("data: {}")


class TestReviewFixesC1BashStub:
    """v0.53.7 C1: bash endpoint reverted to 501."""

    def test_bash_returns_501_with_v0538_marker(self):
        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")
        from fastapi.testclient import TestClient

        from soup_cli.commands.serve import _create_app

        app = _create_app(
            model_obj=MagicMock(),
            tokenizer=MagicMock(),
            device="cpu",
            model_name="test-model",
            max_tokens_default=128,
        )
        client = TestClient(app)
        resp = client.post("/v1/tools/bash", json={"command": "ls"})
        assert resp.status_code == 501
        assert "v0.53.9" in resp.text


class TestReviewFixesAuthToken:
    """v0.53.7 H-A: optional Bearer-token gate on tool endpoints."""

    def test_python_tool_with_auth_token_requires_bearer(self):
        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")
        from fastapi.testclient import TestClient

        from soup_cli.commands.serve import _create_app

        app = _create_app(
            model_obj=MagicMock(),
            tokenizer=MagicMock(),
            device="cpu",
            model_name="test-model",
            max_tokens_default=128,
            auth_token="s3cret",
        )
        client = TestClient(app)
        # No header -> 401.
        resp = client.post("/v1/tools/python", json={"code": "print(1)"})
        assert resp.status_code == 401
        # Wrong token -> 401.
        resp = client.post(
            "/v1/tools/python",
            json={"code": "print(1)"},
            headers={"Authorization": "Bearer wrong"},
        )
        assert resp.status_code == 401
        # Right token -> 200.
        resp = client.post(
            "/v1/tools/python",
            json={"code": "print(1)"},
            headers={"Authorization": "Bearer s3cret"},
        )
        assert resp.status_code == 200

    def test_auth_token_none_means_no_gate(self):
        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")
        from fastapi.testclient import TestClient

        from soup_cli.commands.serve import _create_app

        app = _create_app(
            model_obj=MagicMock(),
            tokenizer=MagicMock(),
            device="cpu",
            model_name="test-model",
            max_tokens_default=128,
        )
        client = TestClient(app)
        resp = client.post("/v1/tools/python", json={"code": "print(1)"})
        assert resp.status_code == 200


class TestReviewFixesPreprocessAtomic:
    """v0.53.7 M-H + L-J source-grep guard."""

    def test_preprocess_save_uses_atomic_pattern(self):
        src = Path(__file__).parent.parent / "soup_cli" / "commands" / "data.py"
        body = src.read_text(encoding="utf-8")
        assert ".tmp_" in body
        assert "os.replace" in body


class TestReviewFixesVllmAnthropicCors:
    """v0.53.7 M-G: vLLM /v1/messages CORS restricted to loopback."""

    def test_vllm_app_cors_is_loopback_only(self):
        try:
            import fastapi  # noqa: F401
        except ImportError:
            pytest.skip("FastAPI not installed")
        try:
            import vllm  # noqa: F401
        except ImportError:
            pytest.skip("vLLM not installed")
        from soup_cli.utils.vllm import create_vllm_app

        engine = MagicMock()
        app = create_vllm_app(
            engine=engine,
            engine_model_name="test-model",
            model_name="test-model",
            max_tokens_default=128,
        )
        # Inspect installed middleware for the loopback regex.
        cors_options = []
        for m in app.user_middleware:
            cors_options.append(getattr(m, "options", {}))
        # Find the CORSMiddleware entry; its options should carry
        # ``allow_origin_regex`` not ``allow_origins=*``.
        found_loopback = False
        for opts in cors_options:
            regex = opts.get("allow_origin_regex", "")
            if regex and "localhost" in regex:
                found_loopback = True
        assert found_loopback, "vLLM /v1/messages CORS must be loopback-only"


# ----- #86 SFT + Pretrain pre_tokenized short-circuit --------------------
#
# These tests verify that SFTTrainerWrapper and PretrainTrainerWrapper
# actually call ``load_pretokenized_dataset`` when ``data.format =
# 'pre_tokenized'`` and ``data.tokenized_path`` is set, instead of running
# the normal tokenize pipeline. Schema-layer validators (e.g. the
# ``format='pre_tokenized'`` + missing-``tokenized_path`` cross-validator)
# are already covered by existing v0.42.0 tests; this class focuses on the
# wiring at the trainer setup() boundary.

class TestSftPretrainPreTokenizedShortCircuit:
    """v0.53.7 #86 — trainer-side short-circuit + cache-hash gate.

    Note: ``data.tokenized_path`` is cwd-containment-checked at schema load
    time, so every test ``monkeypatch.chdir(tmp_path)`` before loading the
    config and writes its Arrow fixture under ``tmp_path``.
    """

    def _write_arrow_dir(self, tmp_path: Path, cache_key: object) -> Path:
        """Save a 2-row pre-tokenized Arrow dataset under tmp_path/tokenized.

        ``cache_key`` is the value written into ``metadata.json``; pass
        ``...`` (Ellipsis) to skip writing the metadata file entirely.
        """
        pytest.importorskip("datasets")
        from datasets import Dataset

        target = tmp_path / "tokenized"
        rows = [
            {
                "input_ids": [1, 2, 3, 4],
                "labels": [1, 2, 3, 4],
                "attention_mask": [1, 1, 1, 1],
            },
            {
                "input_ids": [5, 6, 7, 8],
                "labels": [5, 6, 7, 8],
                "attention_mask": [1, 1, 1, 1],
            },
        ]
        ds = Dataset.from_list(rows)
        ds.save_to_disk(str(target))
        if cache_key is not Ellipsis:
            (target / "metadata.json").write_text(
                json.dumps({"cache_key": cache_key}), encoding="utf-8"
            )
        return target

    def _build_cfg_yaml(
        self,
        train_path: str,
        tokenized_path: str,
        *,
        max_length: int = 64,
        task: str = "sft",
    ) -> str:
        return (
            "base: TinyLlama/TinyLlama-1.1B-Chat-v1.0\n"
            f"task: {task}\n"
            "data:\n"
            f"  train: {train_path}\n"
            "  format: pre_tokenized\n"
            f"  tokenized_path: {tokenized_path}\n"
            f"  max_length: {max_length}\n"
            "output: ./out\n"
        )

    def test_sft_short_circuits_to_arrow_dataset(self, tmp_path, monkeypatch):
        """Happy path: SFT trainer loads pre-tokenized Arrow shards directly
        and skips ``Dataset.from_list(...).map(format_row)`` entirely."""
        from soup_cli.config.loader import load_config_from_string
        from soup_cli.utils.data_pipeline import make_preprocess_cache_key

        monkeypatch.chdir(tmp_path)
        # The cfg.data.train path doesn't need to exist for the short-circuit
        # path — we never load it.
        train_jsonl = "train.jsonl"
        target = self._write_arrow_dir(tmp_path, cache_key="placeholder")

        # Compute matching cache_key so the gate passes.
        cache_key = make_preprocess_cache_key(
            dataset_path=train_jsonl,
            tokenizer_name="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
            max_length=64,
            format_name="pre_tokenized",
        )
        (target / "metadata.json").write_text(
            json.dumps({"cache_key": cache_key}), encoding="utf-8"
        )

        yaml = self._build_cfg_yaml(train_jsonl, "tokenized")
        cfg = load_config_from_string(yaml)

        # Exercise the helper directly — bypasses the full ``setup()`` (which
        # would also load a 1B model). The helper is the unit under test.
        from soup_cli.trainer.sft import _maybe_load_pretokenized

        captured = MagicMock()
        result = _maybe_load_pretokenized(cfg.data, cfg.base, captured)
        assert result is not None
        train_ds, eval_ds = result
        assert len(train_ds) == 2
        assert eval_ds is None
        # Ensure the format_row tokenizer-call path was NOT taken — we do
        # this by counting console.print advisory lines (yellow advisory
        # for missing metadata.json was NOT printed since metadata exists).
        printed = "".join(str(c) for c in captured.print.call_args_list)
        assert "skipping tokenization" in printed
        assert "no metadata.json" not in printed

    def test_pretrain_short_circuits_to_arrow_dataset(self, tmp_path, monkeypatch):
        """Same as SFT but for the Pretrain wrapper — verifies pretrain.py
        also short-circuits, not just sft.py."""
        from soup_cli.config.loader import load_config_from_string
        from soup_cli.utils.data_pipeline import make_preprocess_cache_key

        monkeypatch.chdir(tmp_path)
        train_jsonl = "train.txt"
        target = self._write_arrow_dir(tmp_path, cache_key="placeholder")

        cache_key = make_preprocess_cache_key(
            dataset_path=train_jsonl,
            tokenizer_name="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
            max_length=64,
            format_name="pre_tokenized",
        )
        (target / "metadata.json").write_text(
            json.dumps({"cache_key": cache_key}), encoding="utf-8"
        )

        yaml = self._build_cfg_yaml(
            train_jsonl, "tokenized", task="pretrain",
        )
        cfg = load_config_from_string(yaml)

        # The pretrain wrapper imports the helper from sft.py — verify that
        # import resolves and the function behaves identically.
        from soup_cli.trainer.sft import _maybe_load_pretokenized as helper

        captured = MagicMock()
        result = helper(cfg.data, cfg.base, captured)
        assert result is not None
        train_ds, _ = result
        assert len(train_ds) == 2

    def test_cache_hash_mismatch_raises(self, tmp_path, monkeypatch):
        """metadata.json carrying a stale cache_key (e.g. user changed
        ``max_length`` without re-running ``soup data preprocess``) must
        raise with the keyword ``cache hash mismatch`` so users know what
        to do."""
        from soup_cli.config.loader import load_config_from_string

        monkeypatch.chdir(tmp_path)
        self._write_arrow_dir(tmp_path, cache_key="STALE_KEY_FROM_OLD_RUN")

        yaml = self._build_cfg_yaml("train.jsonl", "tokenized")
        cfg = load_config_from_string(yaml)

        from soup_cli.trainer.sft import _maybe_load_pretokenized

        with pytest.raises(ValueError, match="cache hash mismatch"):
            _maybe_load_pretokenized(cfg.data, cfg.base, MagicMock())

    def test_missing_metadata_proceeds_with_yellow_advisory(
        self, tmp_path, monkeypatch,
    ):
        """When metadata.json is absent, the trainer falls back to "trusted"
        mode with a yellow advisory and still loads the Arrow shards."""
        from soup_cli.config.loader import load_config_from_string

        monkeypatch.chdir(tmp_path)
        # Ellipsis sentinel → skip writing metadata.json entirely.
        self._write_arrow_dir(tmp_path, cache_key=Ellipsis)

        yaml = self._build_cfg_yaml("train.jsonl", "tokenized")
        cfg = load_config_from_string(yaml)

        from soup_cli.trainer.sft import _maybe_load_pretokenized

        captured = MagicMock()
        result = _maybe_load_pretokenized(cfg.data, cfg.base, captured)
        assert result is not None
        train_ds, _ = result
        assert len(train_ds) == 2

        # Yellow advisory must mention the user-actionable next step.
        printed = "".join(str(c) for c in captured.print.call_args_list)
        assert "no metadata.json" in printed
        assert "soup data preprocess" in printed

    # Schema layer already rejects ``format='pre_tokenized'`` without a
    # ``data.tokenized_path`` via ``DataConfig._validate_v042_pre_tokenized_path``
    # (see test_v0420.py). No re-test here — that's the schema gate's job.
