from __future__ import annotations

import shutil
from dataclasses import dataclass, field
from pathlib import Path

from evals import run_eval
from evals.adaptive_online_policy import (
    ACTION_INVOKE_HELPER,
    ACTION_SKIP_HELPER,
    STATE_SCHEMA_VERSION,
    AdaptiveOnlinePolicyController,
    OnlinePolicyConfig,
)
from evals.patchers.base import ContextRequestResult, PatchResult
from evals.patchers.openai_compat_patcher import OpenAICompatPatcher
from llmdebug import _json as json


def _make_case(tmp_path: Path) -> run_eval.CaseSpec:
    case_dir = tmp_path / "case_src"
    case_dir.mkdir(parents=True, exist_ok=True)
    (case_dir / "buggy.py").write_text(
        "import helper\n\n\ndef f() -> int:\n    return helper.value()\n",
        encoding="utf-8",
    )
    (case_dir / "helper.py").write_text("def value() -> int:\n    return 1\n", encoding="utf-8")
    (case_dir / "test_buggy.py").write_text(
        "from buggy import f\n\n\ndef test_f() -> None:\n    assert f() == 2\n",
        encoding="utf-8",
    )
    return run_eval.CaseSpec(
        case_id="adaptive_case",
        path=case_dir,
        category="traceback_controls",
        difficulty="easy",
        snapshot_dependency="helpful",
        expected_exception="AssertionError",
        tags=[],
        python_args=["-m", "pytest", "-q"],
        timeout_sec=30,
    )


def _snapshot_payload() -> dict:
    return {
        "exception": {"type": "AssertionError", "message": "expected 2 got 1"},
        "frames": [
            {
                "file_rel": "buggy.py",
                "line": 4,
                "function": "f",
                "code": "return helper.value()",
                "locals": {"x": 1},
            }
        ],
        "pytest": {"nodeid": "test_buggy.py::test_f", "repro": ["python", "-m", "pytest", "-q"]},
    }


def _skip_biased_online_controller(
    tmp_path: Path, *, mode: str
) -> tuple[AdaptiveOnlinePolicyController, Path]:
    state_path = tmp_path / f"policy_state_{mode}.json"
    events_path = tmp_path / f"policy_events_{mode}.jsonl"
    state_path.write_text(
        json.dumps(
            {
                "schema_version": STATE_SCHEMA_VERSION,
                "mode": mode,
                "algorithm": "linucb",
                "alpha": 0.0,
                "epsilon": 0.0,
                "l2": 1.0,
                "warmup_decisions": 0,
                "save_every": 1,
                "feature_names": ["bias"],
                "decisions": 0,
                "updates": 0,
                "action_counts": {
                    ACTION_SKIP_HELPER: 0,
                    ACTION_INVOKE_HELPER: 0,
                },
                "A": {
                    ACTION_SKIP_HELPER: [[1.0]],
                    ACTION_INVOKE_HELPER: [[1.0]],
                },
                "b": {
                    ACTION_SKIP_HELPER: [2.0],
                    ACTION_INVOKE_HELPER: [0.0],
                },
            },
            ensure_ascii=False,
        ),
        encoding="utf-8",
    )
    config = OnlinePolicyConfig.normalize(
        mode=mode,
        algorithm="linucb",
        alpha=0.0,
        epsilon=0.0,
        l2=1.0,
        warmup_decisions=0,
        save_every=1,
        state_path=state_path,
        events_path=events_path,
        random_seed=0,
    )
    return AdaptiveOnlinePolicyController(config), events_path


@dataclass
class _AdaptiveGatedCapturePatcher:
    name: str = "adaptive_gated_capture"
    request_calls: int = 0
    prompts: list[str] = field(default_factory=list)

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": True,
                "requests": [{"type": "file", "path": "buggy.py"}],
                "sufficiency_rationale": "need code context",
            },
            raw_response='{"need_more_context":true}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del context_dir, cwd
        self.prompts.append(prompt)
        return PatchResult(diff=None, meta={"reason": "no_diff"})


@dataclass
class _AdaptiveDiscretionDeclinePatcher:
    name: str = "adaptive_discretion_decline"
    request_calls: int = 0

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": False,
                "requests": [],
                "sufficiency_rationale": "enough evidence",
            },
            raw_response='{"need_more_context":false,"requests":[]}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        return PatchResult(diff=None, meta={"reason": "no_diff"})


@dataclass
class _AdaptiveDiscretionInvalidPatcher:
    name: str = "adaptive_discretion_invalid"
    request_calls: int = 0
    prompts: list[str] = field(default_factory=list)

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(request_json=None, raw_response="{invalid-json", meta={})

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del context_dir, cwd
        self.prompts.append(prompt)
        return PatchResult(diff=None, meta={"reason": "no_diff"})


@dataclass
class _AdaptiveDiscretionSnapshotGenericPatcher:
    name: str = "adaptive_discretion_snapshot_generic"
    request_calls: int = 0
    prompts: list[str] = field(default_factory=list)

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": False,
                "need_snapshot": True,
                "snapshot_justification": "Need more context.",
                "requests": [],
                "sufficiency_rationale": "need state",
            },
            raw_response='{"need_more_context":false,"need_snapshot":true}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del context_dir, cwd
        self.prompts.append(prompt)
        return PatchResult(diff=None, meta={"reason": "no_diff"})


@dataclass
class _AdaptiveToggleDiffPatcher:
    name: str = "adaptive_toggle_diff"
    request_calls: int = 0
    patch_calls: int = 0

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": True,
                "requests": [{"type": "file", "path": "buggy.py"}],
                "sufficiency_rationale": "need source",
            },
            raw_response='{"need_more_context":true}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        self.patch_calls += 1
        if self.patch_calls % 2 == 1:
            diff = (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return 2\n"
            )
        else:
            diff = (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 2\n"
                "+    return 1\n"
            )
        return PatchResult(diff=diff, meta={"reason": "toggle_diff"})


@dataclass
class _AdaptiveTestEditPatcher:
    name: str = "adaptive_test_edit"
    request_calls: int = 0

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": True,
                "requests": [{"type": "file", "path": "buggy.py"}],
                "sufficiency_rationale": "need source",
            },
            raw_response='{"need_more_context":true}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        diff = (
            "diff --git a/test_buggy.py b/test_buggy.py\n"
            "--- a/test_buggy.py\n"
            "+++ b/test_buggy.py\n"
            "@@ -3,4 +3,4 @@\n"
            " \n"
            " \n"
            " def test_f() -> None:\n"
            "-    assert f() == 2\n"
            "+    assert f() == 1\n"
        )
        return PatchResult(diff=diff, meta={"reason": "test_edit"})


@dataclass
class _AdaptiveNeighborEditPatcher:
    name: str = "adaptive_neighbor_edit"
    request_calls: int = 0

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt, context_dir, cwd
        self.request_calls += 1
        return ContextRequestResult(
            request_json={
                "need_more_context": True,
                "requests": [{"type": "file", "path": "buggy.py"}],
                "sufficiency_rationale": "need source",
            },
            raw_response='{"need_more_context":true}',
            meta={},
        )

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        diff = (
            "diff --git a/helper.py b/helper.py\n"
            "--- a/helper.py\n"
            "+++ b/helper.py\n"
            "@@ -1,2 +1,2 @@\n"
            " def value() -> int:\n"
            "-    return 1\n"
            "+    return 2\n"
        )
        return PatchResult(diff=diff, meta={"reason": "neighbor_edit"})


@dataclass
class _SyntaxCyclingPatcher:
    name: str = "syntax_cycling"
    diffs: list[str] = field(default_factory=list)
    metas: list[dict[str, object]] = field(default_factory=list)
    patch_calls: int = 0

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        self.patch_calls += 1
        if not self.diffs:
            return PatchResult(diff=None, meta={"reason": "no_diff"})
        idx = min(self.patch_calls - 1, len(self.diffs) - 1)
        meta: dict[str, object] = {"reason": "cycle_diff", "llm_call_count": 1}
        if self.metas:
            meta.update(self.metas[min(idx, len(self.metas) - 1)])
        return PatchResult(diff=self.diffs[idx], meta=meta)


def test_adaptive_gated_invokes_helper_after_repeated_signature(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_gated"
    context_dir = tmp_path / "context_adaptive_gated"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher()

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_GATED,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=2,
        output_format="json_compact",
        run_id="run-adaptive-gated",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_gate_policy="balanced",
        adaptive_stagnation_window=1,
    )

    assert len(records) == 2
    assert patcher.request_calls == 1
    first_attempt_meta = records[0]["attempt_metadata"]
    second_attempt_meta = records[1]["attempt_metadata"]
    assert first_attempt_meta["adaptive_helper_invoked"] is False
    assert second_attempt_meta["adaptive_helper_invoked"] is True
    assert first_attempt_meta["context_protocol"] == "monolithic"
    assert second_attempt_meta["context_protocol"] == "staged"
    assert records[1]["patch_meta"]["adaptive_helper_decision_reason"] in {
        "gate_balanced_repeated_signature",
        "gate_requested_helper",
    }
    assert len(patcher.prompts) == 2
    assert "Pytest output (stdout):" in patcher.prompts[1]
    assert "Stage-A evidence index:" not in patcher.prompts[1]
    assert second_attempt_meta["retry_delta_prompt"] is False


def test_adaptive_llm_discretion_decline_keeps_monolithic_attempt(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_discretion_decline"
    context_dir = tmp_path / "context_adaptive_discretion_decline"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveDiscretionDeclinePatcher()

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-discretion-decline",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    assert patcher.request_calls == 1
    attempt_meta = records[0]["attempt_metadata"]
    assert attempt_meta["adaptive_helper_invoked"] is False
    assert attempt_meta["adaptive_helper_decision_reason"] == "llm_declined_helper"
    assert attempt_meta["context_protocol"] == "monolithic"
    assert records[0]["patch_meta"]["stage_a_request_success"] is True


def test_adaptive_openai_helper_response_mode_override(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_openai_override"
    context_dir = tmp_path / "context_adaptive_openai_override"
    shutil.copytree(case.path, workdir)

    captured_response_modes: list[str] = []

    def _fake_request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del self, prompt, context_dir, cwd
        return ContextRequestResult(
            request_json={
                "need_more_context": True,
                "requests": [{"type": "file", "path": "buggy.py"}],
                "sufficiency_rationale": "need source",
            },
            raw_response='{"need_more_context":true}',
            meta={},
        )

    def _fake_propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        captured_response_modes.append(str(self.response_mode))
        return PatchResult(
            diff=None, meta={"reason": "no_diff", "response_mode": self.response_mode}
        )

    monkeypatch.setattr(OpenAICompatPatcher, "request_context", _fake_request_context)
    monkeypatch.setattr(OpenAICompatPatcher, "propose_patch", _fake_propose_patch)
    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    patcher = OpenAICompatPatcher(
        base_url="http://127.0.0.1:1234",
        model="dummy",
        response_mode="edits",
        force_patch_attempt=False,
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-openai-override",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_openai_helper_response_mode="diff",
    )

    assert len(records) == 1
    assert captured_response_modes == ["diff"]
    assert records[0]["patch_meta"]["adaptive_openai_response_mode_override"] == "diff"
    assert records[0]["model_metadata"]["response_mode"] == "diff"


def test_adaptive_llm_discretion_invalid_response_does_not_count_as_invocation(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_discretion_invalid"
    context_dir = tmp_path / "context_adaptive_discretion_invalid"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveDiscretionInvalidPatcher()

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-discretion-invalid",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    assert patcher.request_calls == 1
    assert len(patcher.prompts) == 1
    assert "Stage-A requested context:" not in patcher.prompts[0]
    attempt_meta = records[0]["attempt_metadata"]
    assert attempt_meta["adaptive_helper_invoked"] is False
    assert attempt_meta["adaptive_helper_decision_reason"] == "llm_invalid_response_no_helper"
    assert attempt_meta["context_protocol"] == "monolithic"
    assert records[0]["patch_meta"]["stage_a_request_success"] is False


def test_adaptive_llm_discretion_attempt1_snapshot_denial_syncs_helper_reason(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_discretion_snapshot_denial"
    context_dir = tmp_path / "context_adaptive_discretion_snapshot_denial"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveDiscretionSnapshotGenericPatcher()

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-discretion-snapshot-denial",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    assert patcher.request_calls == 1
    attempt_meta = records[0]["attempt_metadata"]
    patch_meta = records[0]["patch_meta"]
    assert attempt_meta["adaptive_snapshot_attempt1_denied"] is True
    assert attempt_meta["adaptive_snapshot_included"] is False
    assert attempt_meta["adaptive_helper_invoked"] is False
    assert attempt_meta["adaptive_helper_decision_reason"] == "llm_snapshot_request_denied_attempt1"
    assert patch_meta["adaptive_helper_decision_reason"] == "llm_snapshot_request_denied_attempt1"
    assert attempt_meta["context_protocol"] == "monolithic"


def test_adaptive_llm_discretion_caps_helper_calls_and_stops_on_no_progress(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_discretion_cap"
    context_dir = tmp_path / "context_adaptive_discretion_cap"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_discretion_cap")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=5,
        output_format="json_compact",
        run_id="run-adaptive-discretion-cap",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_max_helper_calls=1,
        adaptive_budget_topup_max_calls=0,
    )

    assert len(records) == 3
    assert patcher.request_calls == 1
    assert len(patcher.prompts) == 3
    assert records[1]["attempt_metadata"]["adaptive_helper_budget_exhausted"] is True
    assert records[2]["attempt_metadata"]["adaptive_progress_stop_triggered"] is True
    assert records[2]["patch_meta"]["adaptive_progress_stop_reason"] == "helper_budget_exhausted"
    assert records[2]["note"] == "no_patch_proposed"


def test_adaptive_llm_discretion_grants_one_budget_topup(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_discretion_topup"
    context_dir = tmp_path / "context_adaptive_discretion_topup"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_discretion_topup")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=4,
        output_format="json_compact",
        run_id="run-adaptive-discretion-topup",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_max_helper_calls=1,
        adaptive_budget_topup_max_calls=1,
    )

    assert len(records) == 4
    assert patcher.request_calls == 2
    topup_attempts = [
        record
        for record in records
        if bool(record["attempt_metadata"]["adaptive_budget_topup_granted"])
    ]
    assert len(topup_attempts) == 1
    assert (
        topup_attempts[0]["attempt_metadata"]["adaptive_budget_topup_reason"]
        == "need_more_context_recovery_once"
    )
    assert records[-1]["attempt_metadata"]["adaptive_helper_budget_exhausted"] is True
    assert records[-1]["attempt_metadata"]["adaptive_progress_stop_triggered"] is True


def test_snapshot_justification_generic_detector_handles_variants() -> None:
    assert run_eval._is_generic_snapshot_justification("Need more context.")
    assert run_eval._is_generic_snapshot_justification("Need the snapshot, please!")
    assert not run_eval._is_generic_snapshot_justification(
        "Need snapshot to inspect locals in frame 0 for None-path divergence."
    )


def test_adaptive_reintroduces_stage_a_index_when_context_is_truncated(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_index_fallback"
    context_dir = tmp_path / "context_adaptive_index_fallback"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_index_fallback")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-index-fallback",
        include_files_context=False,
        force_patch_attempt=False,
        prompt_budget_total_chars=900,
        prompt_budget_evidence_chars=80,
    )

    assert len(records) == 1
    assert patcher.request_calls == 1
    assert len(patcher.prompts) == 1
    assert records[0]["attempt_metadata"]["stage_a_index_included"] is True
    assert records[0]["attempt_metadata"]["stage_a_index_reason"] == "context_truncated_fallback"


def test_adaptive_postpatch_recompute_avoids_premature_stop_on_signature_change(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_postpatch_recompute"
    context_dir = tmp_path / "context_adaptive_postpatch_recompute"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveToggleDiffPatcher()

    run_outputs = [
        "sig_a",  # initial run
        "sig_a",  # after attempt 1
        "sig_a",  # after attempt 2
        "sig_b",  # after attempt 3 (changed)
        "sig_b",  # after attempt 4
    ]
    call_counter = {"idx": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        idx = call_counter["idx"]
        call_counter["idx"] += 1
        signature = run_outputs[min(idx, len(run_outputs) - 1)]
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout=f"{signature}\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )
    monkeypatch.setattr(
        run_eval,
        "_build_failure_signature_for_attempt",
        lambda *, run, snapshot: (run.stdout or "").strip(),
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=4,
        output_format="json_compact",
        run_id="run-adaptive-postpatch-recompute",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_max_helper_calls=1,
    )

    assert len(records) == 4
    third_attempt = records[2]
    assert third_attempt["attempt"] == 3
    assert third_attempt["patch_meta"]["adaptive_progress_recomputed_after_patch"] is True
    assert third_attempt["attempt_metadata"]["adaptive_progress_stop_triggered"] is False


def test_adaptive_patch_verifier_rejects_test_only_patch_before_apply(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_verifier_reject"
    context_dir = tmp_path / "context_adaptive_verifier_reject"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveTestEditPatcher()

    run_calls = {"count": 0}
    apply_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    def _fake_apply_diff(cwd, diff):
        del cwd, diff
        apply_calls["count"] += 1
        return True, ""

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", _fake_apply_diff)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-verifier-reject",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    assert run_calls["count"] == 1
    assert apply_calls["count"] == 0
    assert records[0]["note"] == "patch_verifier_rejected"
    assert records[0]["patch_meta"]["patch_verifier_enabled"] is True
    assert records[0]["patch_meta"]["patch_verifier_scope"] == "adaptive_only"
    assert records[0]["patch_meta"]["patch_verifier_passed"] is False
    assert records[0]["attempt_metadata"]["patch_verifier_passed"] is False


def test_adaptive_patch_verifier_allows_neighbor_patch_and_reaches_apply(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_verifier_pass"
    context_dir = tmp_path / "context_adaptive_verifier_pass"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveNeighborEditPatcher()

    run_calls = {"count": 0}
    apply_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    def _fake_apply_diff(cwd, diff):
        del cwd, diff
        apply_calls["count"] += 1
        return False, "simulated apply failure"

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", _fake_apply_diff)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-adaptive-verifier-pass",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    assert run_calls["count"] == 1
    assert apply_calls["count"] == 1
    assert records[0]["note"] == "patch_apply_failed"
    assert records[0]["patch_meta"]["patch_verifier_enabled"] is True
    assert records[0]["patch_meta"]["patch_verifier_passed"] is True


def test_adaptive_patch_verifier_infers_source_scope_from_failing_test_file(tmp_path) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_verifier_scope_infer"
    shutil.copytree(case.path, workdir)

    diff = (
        "diff --git a/buggy.py b/buggy.py\n"
        "--- a/buggy.py\n"
        "+++ b/buggy.py\n"
        "@@ -2,4 +2,4 @@\n"
        " \n"
        " \n"
        " def f() -> int:\n"
        "-    return helper.value()\n"
        "+    return helper.value() + 0\n"
    )
    snapshot = {
        "exception": {"type": "AssertionError", "message": "expected 2 got 1"},
        "frames": [
            {
                "file_rel": "tests/test_buggy.py",
                "line": 4,
                "function": "test_f",
                "code": "assert f() == 2",
                "locals": {},
            }
        ],
    }
    stdout = "test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n"

    passed_without_infer, meta_without_infer = run_eval._score_patch_relevance(
        diff=diff,
        cwd=workdir,
        snapshot=snapshot,
        stdout=stdout,
        stderr="",
        infer_test_source_scope=False,
    )
    passed_with_infer, meta_with_infer = run_eval._score_patch_relevance(
        diff=diff,
        cwd=workdir,
        snapshot=snapshot,
        stdout=stdout,
        stderr="",
        infer_test_source_scope=True,
    )

    assert passed_without_infer is False
    assert meta_without_infer["patch_verifier_source_scope_matched"] is False
    assert passed_with_infer is True
    assert meta_with_infer["patch_verifier_source_scope_matched"] is True
    assert "buggy.py" in meta_with_infer["patch_verifier_inferred_source_scope"]


def test_adaptive_patch_verifier_infers_scope_from_subprocess_target(tmp_path) -> None:
    workdir = tmp_path / "workdir_adaptive_verifier_subprocess_scope"
    (workdir / "tests").mkdir(parents=True, exist_ok=True)
    (workdir / "buggy.py").write_text("def f() -> int:\n    return 1\n", encoding="utf-8")
    (workdir / "tests" / "test_cli.py").write_text(
        (
            "import subprocess\n\n"
            "def test_cli() -> None:\n"
            "    subprocess.run(['python', 'buggy.py'])\n"
            "    assert True\n"
        ),
        encoding="utf-8",
    )

    diff = (
        "diff --git a/buggy.py b/buggy.py\n"
        "--- a/buggy.py\n"
        "+++ b/buggy.py\n"
        "@@ -1,2 +1,2 @@\n"
        " def f() -> int:\n"
        "-    return 1\n"
        "+    return 2\n"
    )
    snapshot = {
        "exception": {"type": "AssertionError", "message": "cli mismatch"},
        "frames": [
            {
                "file_rel": "tests/test_cli.py",
                "line": 4,
                "function": "test_cli",
                "code": "assert True",
                "locals": {},
            }
        ],
    }
    stdout = "tests/test_cli.py::test_cli\nE   AssertionError: cli mismatch\n"

    passed_without_infer, _meta_without_infer = run_eval._score_patch_relevance(
        diff=diff,
        cwd=workdir,
        snapshot=snapshot,
        stdout=stdout,
        stderr="",
        infer_test_source_scope=False,
    )
    passed_with_infer, meta_with_infer = run_eval._score_patch_relevance(
        diff=diff,
        cwd=workdir,
        snapshot=snapshot,
        stdout=stdout,
        stderr="",
        infer_test_source_scope=True,
    )

    assert passed_without_infer is False
    assert passed_with_infer is True
    assert meta_with_infer["patch_verifier_source_scope_matched"] is True
    assert "buggy.py" in meta_with_infer["patch_verifier_inferred_source_scope"]


def test_patch_verifier_scope_match_avoids_ambiguous_basename_false_positive() -> None:
    ambiguous_scope = {"pkg/a/utils.py", "pkg/b/utils.py"}
    assert (
        run_eval._patch_mentions_scope_files(
            patch_files=["other/utils.py"],
            scope_files=ambiguous_scope,
        )
        is False
    )
    assert (
        run_eval._patch_mentions_scope_files(
            patch_files=["utils.py"],
            scope_files=ambiguous_scope,
        )
        is False
    )

    unambiguous_scope = {"pkg/a/buggy.py"}
    assert (
        run_eval._patch_mentions_scope_files(
            patch_files=["buggy.py"],
            scope_files=unambiguous_scope,
        )
        is True
    )
    assert (
        run_eval._patch_mentions_scope_files(
            patch_files=["other/buggy.py"],
            scope_files=unambiguous_scope,
        )
        is False
    )


def test_syntax_preflight_retries_within_same_attempt(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_syntax_preflight_retry"
    context_dir = tmp_path / "context_syntax_preflight_retry"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="syntax_preflight_retry",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return (\n"
            ),
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return 2\n"
            ),
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        helper_text = (workdir / "helper.py").read_text(encoding="utf-8")
        if "return 2" in helper_text and "return (" not in helper_text:
            return run_eval.RunResult(
                returncode=0,
                duration_sec=0.01,
                stdout="1 passed\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   SyntaxError: '(' was never closed\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-syntax-preflight-retry",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
    )

    assert len(records) == 1
    assert run_calls["count"] == 2
    assert patcher.patch_calls == 2
    assert records[0]["success"] is True
    assert records[0]["note"] == "success"
    assert records[0]["attempt"] == 1
    assert records[0]["attempt_metadata"]["syntax_preflight_cycles_used"] == 2
    assert records[0]["attempt_metadata"]["syntax_preflight_failures"] == 1
    events = records[0]["patch_meta"]["syntax_preflight_events"]
    assert len(events) == 2
    assert events[0]["passed"] is False
    assert events[1]["passed"] is True


def test_syntax_preflight_exhaustion_marks_attempt_failed(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_syntax_preflight_failed"
    context_dir = tmp_path / "context_syntax_preflight_failed"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="syntax_preflight_failed",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return (\n"
            )
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-syntax-preflight-failed",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
    )

    assert len(records) == 1
    assert run_calls["count"] == 1
    assert patcher.patch_calls == 2
    assert records[0]["success"] is False
    assert records[0]["note"] == "syntax_preflight_failed"
    assert records[0]["attempt_metadata"]["syntax_preflight_cycles_used"] == 2
    assert records[0]["attempt_metadata"]["syntax_preflight_failures"] == 2
    assert records[0]["attempt_metadata"]["syntax_preflight_last_error_type"] == "PyCompileError"
    events = records[0]["patch_meta"]["syntax_preflight_events"]
    assert len(events) == 2
    assert all(event["passed"] is False for event in events)


def test_syntax_preflight_clears_transient_patch_meta_between_cycles(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_syntax_preflight_meta_cleanup"
    context_dir = tmp_path / "context_syntax_preflight_meta_cleanup"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="syntax_preflight_meta_cleanup",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return (\n"
            ),
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return 2\n"
            ),
        ],
        metas=[
            {"runner_forced_patch_attempt": True},
            {},
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=0,
            duration_sec=0.01,
            stdout="1 passed\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-syntax-preflight-meta-cleanup",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
    )

    assert len(records) == 1
    assert records[0]["success"] is True
    assert patcher.patch_calls == 2
    assert "runner_forced_patch_attempt" not in records[0]["patch_meta"]


def test_patch_sanitize_reject_then_retry_success(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_patch_sanitize_retry"
    context_dir = tmp_path / "context_patch_sanitize_retry"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="patch_sanitize_retry",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,3 @@\n"
                " def value() -> int:\n"
                "+    # keep \\n marker\n"
                "     return 1\n"
            ),
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,2 @@\n"
                " def value() -> int:\n"
                "-    return 1\n"
                "+    return 2\n"
            ),
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=0,
            duration_sec=0.01,
            stdout="1 passed\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-patch-sanitize-retry",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
        patch_sanitize_enabled=True,
    )

    assert len(records) == 1
    assert records[0]["success"] is True
    assert run_calls["count"] == 2
    assert patcher.patch_calls == 2
    assert records[0]["attempt_metadata"]["syntax_preflight_failures"] == 1
    events = records[0]["patch_meta"]["syntax_preflight_events"]
    assert len(events) == 2
    assert events[0]["passed"] is False
    assert events[0]["syntax_preflight_error_type"] == "PatchSanitizationError"
    assert events[1]["passed"] is True


def test_patch_sanitize_exhaustion_marks_attempt_failed(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_patch_sanitize_failed"
    context_dir = tmp_path / "context_patch_sanitize_failed"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="patch_sanitize_failed",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,3 @@\n"
                " def value() -> int:\n"
                "+    # keep \\n marker\n"
                "     return 1\n"
            )
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-patch-sanitize-failed",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
        patch_sanitize_enabled=True,
    )

    assert len(records) == 1
    assert records[0]["success"] is False
    assert records[0]["note"] == "syntax_preflight_failed"
    assert run_calls["count"] == 1
    assert patcher.patch_calls == 2
    assert records[0]["attempt_metadata"]["syntax_preflight_failures"] == 2
    assert (
        records[0]["attempt_metadata"]["syntax_preflight_last_error_type"]
        == "PatchSanitizationError"
    )
    assert records[0]["patch_meta"]["patch_sanitize_rejected"] is True
    assert records[0]["patch_meta"]["patch_sanitize_reasons"]


def test_patch_sanitize_disabled_allows_patch_to_apply(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_patch_sanitize_disabled"
    context_dir = tmp_path / "context_patch_sanitize_disabled"
    shutil.copytree(case.path, workdir)
    patcher = _SyntaxCyclingPatcher(
        name="patch_sanitize_disabled",
        diffs=[
            (
                "diff --git a/helper.py b/helper.py\n"
                "--- a/helper.py\n"
                "+++ b/helper.py\n"
                "@@ -1,2 +1,3 @@\n"
                " def value() -> int:\n"
                "+    # keep \\n marker\n"
                "     return 1\n"
            )
        ],
    )

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=0,
            duration_sec=0.01,
            stdout="1 passed\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-patch-sanitize-disabled",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=False,
        patch_sanitize_enabled=False,
    )

    assert len(records) == 1
    assert records[0]["success"] is True
    assert run_calls["count"] == 2
    assert patcher.patch_calls == 1
    assert records[0]["patch_meta"]["patch_sanitize_enabled"] is False
    assert records[0]["patch_meta"]["patch_sanitize_rejected"] is False


def test_openai_response_mode_autoswitch_on_patch_sanitize_retry(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_openai_autoswitch_sanitize"
    context_dir = tmp_path / "context_openai_autoswitch_sanitize"
    shutil.copytree(case.path, workdir)

    captured_response_modes: list[str] = []
    diffs = [
        (
            "diff --git a/helper.py b/helper.py\n"
            "--- a/helper.py\n"
            "+++ b/helper.py\n"
            "@@ -1,2 +1,3 @@\n"
            " def value() -> int:\n"
            "+    # keep \\n marker\n"
            "     return 1\n"
        ),
        (
            "diff --git a/helper.py b/helper.py\n"
            "--- a/helper.py\n"
            "+++ b/helper.py\n"
            "@@ -1,2 +1,2 @@\n"
            " def value() -> int:\n"
            "-    return 1\n"
            "+    return 2\n"
        ),
    ]

    def _fake_propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        captured_response_modes.append(str(self.response_mode))
        idx = min(len(captured_response_modes) - 1, len(diffs) - 1)
        return PatchResult(diff=diffs[idx], meta={"response_mode": self.response_mode})

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=0,
            duration_sec=0.01,
            stdout="1 passed\n",
            stderr="",
        )

    monkeypatch.setattr(OpenAICompatPatcher, "propose_patch", _fake_propose_patch)
    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    patcher = OpenAICompatPatcher(
        base_url="http://127.0.0.1:1234",
        model="dummy",
        response_mode="edits",
        force_patch_attempt=False,
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-openai-autoswitch-sanitize",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
        patch_sanitize_enabled=True,
        openai_response_mode_autoswitch="retry_to_diff",
    )

    assert len(records) == 1
    assert records[0]["success"] is True
    assert run_calls["count"] == 2
    assert captured_response_modes == ["edits", "diff"]
    assert records[0]["patch_meta"]["openai_response_mode_autoswitched"] is True
    assert records[0]["patch_meta"]["openai_response_mode_autoswitch_reason"] == (
        "patch_sanitize_rejected"
    )
    assert records[0]["model_metadata"]["response_mode"] == "diff"


def test_openai_response_mode_autoswitch_on_syntax_preflight_retry(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_openai_autoswitch_syntax"
    context_dir = tmp_path / "context_openai_autoswitch_syntax"
    shutil.copytree(case.path, workdir)

    captured_response_modes: list[str] = []
    diffs = [
        (
            "diff --git a/helper.py b/helper.py\n"
            "--- a/helper.py\n"
            "+++ b/helper.py\n"
            "@@ -1,2 +1,2 @@\n"
            " def value() -> int:\n"
            "-    return 1\n"
            "+    return (\n"
        ),
        (
            "diff --git a/helper.py b/helper.py\n"
            "--- a/helper.py\n"
            "+++ b/helper.py\n"
            "@@ -1,2 +1,2 @@\n"
            " def value() -> int:\n"
            "-    return 1\n"
            "+    return 2\n"
        ),
    ]

    def _fake_propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        del prompt, context_dir, cwd
        captured_response_modes.append(str(self.response_mode))
        idx = min(len(captured_response_modes) - 1, len(diffs) - 1)
        return PatchResult(diff=diffs[idx], meta={"response_mode": self.response_mode})

    run_calls = {"count": 0}

    def _fake_run_pytest(*args, **kwargs) -> run_eval.RunResult:
        del args, kwargs
        run_calls["count"] += 1
        if run_calls["count"] == 1:
            return run_eval.RunResult(
                returncode=1,
                duration_sec=0.01,
                stdout="test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
                stderr="",
            )
        helper_text = (workdir / "helper.py").read_text(encoding="utf-8")
        if "return 2" in helper_text and "return (" not in helper_text:
            return run_eval.RunResult(
                returncode=0,
                duration_sec=0.01,
                stdout="1 passed\n",
                stderr="",
            )
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   SyntaxError: '(' was never closed\n",
            stderr="",
        )

    monkeypatch.setattr(OpenAICompatPatcher, "propose_patch", _fake_propose_patch)
    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    patcher = OpenAICompatPatcher(
        base_url="http://127.0.0.1:1234",
        model="dummy",
        response_mode="edits",
        force_patch_attempt=False,
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.WITH_SNAPSHOT,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=1,
        output_format="json_compact",
        run_id="run-openai-autoswitch-syntax",
        include_files_context=False,
        force_patch_attempt=False,
        syntax_preflight_enabled=True,
        syntax_preflight_max_cycles=2,
        patch_sanitize_enabled=False,
        openai_response_mode_autoswitch="retry_to_diff",
    )

    assert len(records) == 1
    assert records[0]["success"] is True
    assert run_calls["count"] == 2
    assert captured_response_modes == ["edits", "diff"]
    assert records[0]["patch_meta"]["openai_response_mode_autoswitched"] is True
    assert records[0]["patch_meta"]["openai_response_mode_autoswitch_reason"] == (
        "syntax_preflight_failed"
    )
    assert records[0]["model_metadata"]["response_mode"] == "diff"


def test_adaptive_online_policy_shadow_keeps_baseline_action(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_online_shadow"
    context_dir = tmp_path / "context_adaptive_online_shadow"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_online_shadow")
    controller, events_path = _skip_biased_online_controller(tmp_path, mode="shadow")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    try:
        records = run_eval.run_one_condition(
            case=case,
            condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
            workdir=workdir,
            context_dir_base=context_dir,
            patcher=patcher,
            k=1,
            output_format="json_compact",
            run_id="run-adaptive-online-shadow",
            include_files_context=False,
            force_patch_attempt=False,
            adaptive_online_policy_controller=controller,
        )
    finally:
        controller.close()

    assert len(records) == 1
    assert patcher.request_calls == 1
    attempt_meta = records[0]["attempt_metadata"]
    assert attempt_meta["adaptive_online_policy_mode"] == "shadow"
    assert attempt_meta["adaptive_online_action_selected"] == ACTION_SKIP_HELPER
    assert attempt_meta["adaptive_online_action_applied"] == ACTION_INVOKE_HELPER
    assert attempt_meta["adaptive_online_override_reason"] == "shadow_mode_baseline"
    assert attempt_meta["adaptive_online_updated"] is False
    assert attempt_meta["adaptive_online_update_reason"] == "shadow_no_update"
    assert attempt_meta["adaptive_online_event_logged"] is True
    events = [line for line in events_path.read_text(encoding="utf-8").splitlines() if line.strip()]
    assert len(events) == 1


def test_adaptive_online_policy_on_can_skip_helper(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_online_on"
    context_dir = tmp_path / "context_adaptive_online_on"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_online_on")
    controller, events_path = _skip_biased_online_controller(tmp_path, mode="on")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    try:
        records = run_eval.run_one_condition(
            case=case,
            condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
            workdir=workdir,
            context_dir_base=context_dir,
            patcher=patcher,
            k=1,
            output_format="json_compact",
            run_id="run-adaptive-online-on",
            include_files_context=False,
            force_patch_attempt=False,
            adaptive_online_policy_controller=controller,
        )
    finally:
        controller.close()

    assert len(records) == 1
    assert patcher.request_calls == 0
    attempt_meta = records[0]["attempt_metadata"]
    assert attempt_meta["adaptive_helper_decision_reason"] == "online_policy_skip_helper"
    assert attempt_meta["adaptive_online_policy_mode"] == "on"
    assert attempt_meta["adaptive_online_action_selected"] == ACTION_SKIP_HELPER
    assert attempt_meta["adaptive_online_action_applied"] == ACTION_SKIP_HELPER
    assert attempt_meta["adaptive_online_updated"] is True
    assert attempt_meta["adaptive_online_update_reason"] == "on_policy_update"
    assert attempt_meta["adaptive_online_event_logged"] is True
    events = [line for line in events_path.read_text(encoding="utf-8").splitlines() if line.strip()]
    assert len(events) == 1


def test_adaptive_stagnation_override_forces_helper_after_policy_skip(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_stagnation_override"
    context_dir = tmp_path / "context_adaptive_stagnation_override"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_stagnation_override")
    controller, _events_path = _skip_biased_online_controller(tmp_path, mode="on")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    try:
        records = run_eval.run_one_condition(
            case=case,
            condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
            workdir=workdir,
            context_dir_base=context_dir,
            patcher=patcher,
            k=2,
            output_format="json_compact",
            run_id="run-adaptive-stagnation-override",
            include_files_context=False,
            force_patch_attempt=False,
            adaptive_online_policy_controller=controller,
        )
    finally:
        controller.close()

    assert len(records) == 2
    assert patcher.request_calls == 1
    assert records[0]["attempt_metadata"]["adaptive_stagnation_override_applied"] is False
    assert records[1]["attempt_metadata"]["adaptive_stagnation_override_applied"] is True
    assert (
        records[1]["attempt_metadata"]["adaptive_stagnation_override_reason"]
        == "repeated_no_progress_after_stagnation_note"
    )
    assert records[1]["attempt_metadata"]["adaptive_helper_invoked"] is True


def test_adaptive_online_feature_vector_includes_extended_and_lagged_signals(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_online_features"
    context_dir = tmp_path / "context_adaptive_online_features"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveDiscretionInvalidPatcher(name="adaptive_online_feature_keys")

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=patcher,
        k=2,
        output_format="json_compact",
        run_id="run-adaptive-online-feature-keys",
        include_files_context=False,
        force_patch_attempt=False,
        adaptive_informativeness_pregate=True,
    )

    assert len(records) == 2
    feature_vector_1 = records[0]["attempt_metadata"]["adaptive_online_feature_vector"]
    feature_vector_2 = records[1]["attempt_metadata"]["adaptive_online_feature_vector"]
    expected_keys = {
        "attempts_remaining_frac",
        "helper_budget_remaining_frac",
        "helper_topup_used_frac",
        "helper_topup_remaining_frac",
        "context_compaction_enabled",
        "supports_context_compaction",
        "context_compaction_calls_used_frac",
        "context_compaction_calls_remaining_frac",
        "last_context_compaction_requested",
        "last_context_compaction_invoked",
        "last_context_compaction_success",
        "last_context_compaction_ratio",
        "last_helper_need_more_context",
        "last_helper_invalid_response",
        "last_helper_declined",
        "case_difficulty_ord",
        "case_snapshot_dependency_ord",
        "case_bug_source_imported",
        "pregate_exception_is_assertion",
        "pregate_exception_is_timeout",
        "pregate_exception_is_syntax",
    }
    assert expected_keys.issubset(feature_vector_1.keys())
    assert expected_keys.issubset(feature_vector_2.keys())
    assert feature_vector_1["attempts_remaining_frac"] == 0.5
    assert feature_vector_2["attempts_remaining_frac"] == 0.0
    assert feature_vector_1["case_difficulty_ord"] == 0.0
    assert feature_vector_1["case_snapshot_dependency_ord"] == 0.5
    assert feature_vector_1["case_bug_source_imported"] == 0.0
    assert feature_vector_1["pregate_exception_is_assertion"] == 1.0
    assert feature_vector_1["last_helper_invalid_response"] == 0.0
    assert feature_vector_2["last_helper_invalid_response"] == 1.0


def test_adaptive_online_policy_logging_failure_does_not_abort_attempt(
    tmp_path, monkeypatch
) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_adaptive_online_logging_failure"
    context_dir = tmp_path / "context_adaptive_online_logging_failure"
    shutil.copytree(case.path, workdir)
    patcher = _AdaptiveGatedCapturePatcher(name="adaptive_online_logging_failure")
    controller, _events_path = _skip_biased_online_controller(tmp_path, mode="on")

    def _raise_record_outcome(**kwargs):
        del kwargs
        raise OSError("simulated filesystem failure")

    monkeypatch.setattr(controller, "record_outcome", _raise_record_outcome)
    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="tests/test_buggy.py::test_f\nE   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(
        run_eval, "read_latest_snapshot", lambda *_args, **_kwargs: (_snapshot_payload(), None)
    )

    try:
        records = run_eval.run_one_condition(
            case=case,
            condition=run_eval.Condition.ADAPTIVE_LLM_DISCRETION,
            workdir=workdir,
            context_dir_base=context_dir,
            patcher=patcher,
            k=1,
            output_format="json_compact",
            run_id="run-adaptive-online-logging-failure",
            include_files_context=False,
            force_patch_attempt=False,
            adaptive_online_policy_controller=controller,
        )
    finally:
        controller.close()

    assert len(records) == 1
    attempt_meta = records[0]["attempt_metadata"]
    assert attempt_meta["adaptive_online_updated"] is False
    assert attempt_meta["adaptive_online_event_logged"] is False
    assert attempt_meta["adaptive_online_update_reason"] == "event_logging_error:OSError"
