from __future__ import annotations

import shutil
from dataclasses import dataclass

from evals import run_eval
from evals.patchers.base import ContextRequestResult, PatchResult


@dataclass(frozen=True)
class _NoDiffPatcher:
    name: str = "no_diff"

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


@dataclass(frozen=True)
class _BadDiffPatcher:
    name: str = "bad_diff"

    def propose_patch(self, *, prompt: str, context_dir, cwd) -> PatchResult:
        diff = (
            "--- a/buggy.py\n"
            "+++ b/buggy.py\n"
            "@@ -99,1 +99,1 @@\n"
            "-missing_line = 1\n"
            "+missing_line = 2\n"
        )
        return PatchResult(diff=diff, meta={"reason": "bad_diff"})


@dataclass(frozen=True)
class _MalformedStageAPatcher:
    name: str = "malformed_stage_a"

    def request_context(self, *, prompt: str, context_dir, cwd) -> ContextRequestResult:
        del prompt
        del context_dir
        del cwd
        return ContextRequestResult(
            request_json=None,
            raw_response="not-json",
            meta={"error": "bad_json"},
        )

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


@dataclass(frozen=True)
class _TestFileDiffPatcher:
    name: str = "test_file_diff"

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


def _make_case(tmp_path) -> run_eval.CaseSpec:
    case_dir = tmp_path / "case_src"
    case_dir.mkdir(parents=True, exist_ok=True)
    (case_dir / "buggy.py").write_text("def f() -> 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="force_patch_case",
        path=case_dir,
        category="traceback_controls",
        difficulty="easy",
        snapshot_dependency="none",
        expected_exception="AssertionError",
        tags=[],
        python_args=["-m", "pytest", "-q"],
        timeout_sec=30,
        description="force-patch policy test",
    )


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

    def _fake_run_pytest(cwd, python_args, condition, timeout_sec, output_format):
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", lambda cwd, diff: (True, ""))

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_NoDiffPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run",
        include_files_context=False,
        force_patch_attempt=True,
    )

    assert len(records) == 1
    record = records[0]
    assert record["note"] != "no_patch_proposed"
    assert record["patch_meta"]["runner_forced_patch_attempt"] is True
    assert record["patch_meta"]["runner_forced_patch_target"] == "buggy.py"
    assert record["patch_meta"]["patch_touches_tests"] is False
    assert record["patch_meta"]["patch_test_file_count"] == 0

    patch_text = (context_dir / "attempt_01" / "patch.diff").read_text(encoding="utf-8")
    assert "llmdebug-force-attempt-1" in patch_text


def test_runner_no_force_patch_attempt_preserves_no_patch_proposed(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_no_force"
    context_dir = tmp_path / "context_no_force"
    shutil.copytree(case.path, workdir)

    def _fake_run_pytest(cwd, python_args, condition, timeout_sec, output_format):
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    apply_called = {"count": 0}

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

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", _fake_apply_diff)

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_NoDiffPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    record = records[0]
    assert record["note"] == "no_patch_proposed"
    assert apply_called["count"] == 0


def test_runner_force_patch_recovers_after_apply_failure(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_recovery"
    context_dir = tmp_path / "context_recovery"
    shutil.copytree(case.path, workdir)

    def _fake_run_pytest(cwd, python_args, condition, timeout_sec, output_format):
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    apply_attempts = {"count": 0}

    def _fake_apply_diff(cwd, diff):
        apply_attempts["count"] += 1
        if "llmdebug-force-attempt-1" in diff:
            return True, ""
        return False, "simulated hunk failure"

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", _fake_apply_diff)

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_BadDiffPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run",
        include_files_context=False,
        force_patch_attempt=True,
    )

    assert len(records) == 1
    record = records[0]
    assert record["note"] == "tests_still_failing"
    assert record["patch_meta"]["runner_forced_patch_recovery"] is True
    assert record["patch_meta"]["runner_forced_patch_recovery_target"] == "buggy.py"
    assert apply_attempts["count"] == 2

    recovery_diff = (context_dir / "attempt_01" / "patch_recovery.diff").read_text(encoding="utf-8")
    assert "llmdebug-force-attempt-1" in recovery_diff


def test_runner_no_force_patch_keeps_apply_failure(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_recovery_off"
    context_dir = tmp_path / "context_recovery_off"
    shutil.copytree(case.path, workdir)

    def _fake_run_pytest(cwd, python_args, condition, timeout_sec, output_format):
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", lambda cwd, diff: (False, "simulated hunk failure"))

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_BadDiffPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    record = records[0]
    assert record["note"] == "patch_apply_failed"
    assert "runner_forced_patch_recovery" not in record["patch_meta"]


def test_staged_context_malformed_stage_a_uses_fallback_bundle(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_stage_a_fallback"
    context_dir = tmp_path / "context_stage_a_fallback"
    shutil.copytree(case.path, workdir)

    def _fake_run_pytest(cwd, python_args, condition, timeout_sec, output_format):
        return run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        )

    monkeypatch.setattr(run_eval, "run_pytest", _fake_run_pytest)
    monkeypatch.setattr(run_eval, "apply_diff", lambda cwd, diff: (True, ""))

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_MalformedStageAPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run-stage-a-fallback",
        include_files_context=False,
        force_patch_attempt=False,
        context_protocol="staged",
    )

    assert len(records) == 1
    record = records[0]
    assert record["attempt_metadata"]["stage_a_request_success"] is False
    assert record["attempt_metadata"]["stage_a_requests_count"] >= 1
    assert record["patch_meta"]["stage_a_used_fallback"] is True


def test_patch_metrics_detect_test_file_edits(tmp_path, monkeypatch) -> None:
    case = _make_case(tmp_path)
    workdir = tmp_path / "workdir_test_metric"
    context_dir = tmp_path / "context_test_metric"
    shutil.copytree(case.path, workdir)

    monkeypatch.setattr(
        run_eval,
        "run_pytest",
        lambda *args, **kwargs: run_eval.RunResult(
            returncode=1,
            duration_sec=0.01,
            stdout="E   AssertionError: expected 2 got 1\n",
            stderr="",
        ),
    )
    monkeypatch.setattr(run_eval, "apply_diff", lambda cwd, diff: (True, ""))

    records = run_eval.run_one_condition(
        case=case,
        condition=run_eval.Condition.TRACEBACK_ONLY,
        workdir=workdir,
        context_dir_base=context_dir,
        patcher=_TestFileDiffPatcher(),
        k=1,
        output_format="json_compact",
        run_id="run-test-metric",
        include_files_context=False,
        force_patch_attempt=False,
    )

    assert len(records) == 1
    record = records[0]
    patch_meta = record["patch_meta"]
    assert patch_meta["patch_touches_tests"] is True
    assert int(patch_meta["patch_test_file_count"]) == 1
    assert int(patch_meta["patch_file_count"]) >= 1
    assert "test_buggy.py" in patch_meta["patch_test_file_paths"]
