from __future__ import annotations

import argparse
import difflib
import json
import os
import shutil
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass
from enum import Enum
from pathlib import Path, PurePosixPath
from typing import Any


_REPO_ROOT = Path(__file__).resolve().parents[1]
_SRC_ROOT = _REPO_ROOT / "src"
if str(_SRC_ROOT) not in sys.path:
    sys.path.insert(0, str(_SRC_ROOT))


class Condition(str, Enum):
    TRACEBACK_ONLY = "traceback_only"
    WITH_SNAPSHOT = "with_snapshot"


VALID_CATEGORIES = {
    "hidden_state",
    "mutability_aliasing",
    "async_temporal",
    "cross_file_contract",
    "data_shape_runtime",
    "parse_value",
    "path_import_env",
    "traceback_controls",
    # Extended categories for imported datasets.
    "syntax_error",
    "reference_error",
    "logic_error",
    "type_coercion",
}
VALID_DIFFICULTIES = {"easy", "medium", "hard"}
VALID_SNAPSHOT_DEPENDENCIES = {"required", "helpful", "none"}
PATCH_APPLY_TIMEOUT_SEC = 10
PYTEST_TIMEOUT_RETURNCODE = 124


@dataclass(frozen=True)
class CaseSpec:
    case_id: str
    path: Path
    category: str
    difficulty: str
    snapshot_dependency: str
    expected_exception: str
    tags: list[str]
    python_args: list[str]
    timeout_sec: int
    description: str | None = None
    # Extended fields for imported cases (all optional, backward-compatible).
    bug_source: str | None = None
    contamination_risk: str | None = None
    expected_fix_lines: int | None = None
    source_id: str | None = None
    difficulty_rationale: str | None = None


@dataclass(frozen=True)
class RunResult:
    returncode: int
    duration_sec: float
    stdout: str
    stderr: str
    timed_out: bool = False
    timeout_sec: int | None = None


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Run llmdebug A/B eval harness.")
    parser.add_argument("--cases-dir", default="evals/cases", help="Cases directory.")
    parser.add_argument("--case", action="append", default=[], help="Case id(s) to run.")
    parser.add_argument("--category", action="append", default=[], help="Category filter (repeatable).")
    parser.add_argument("--difficulty", action="append", default=[], help="Difficulty filter (repeatable).")
    parser.add_argument(
        "--snapshot-dependency",
        action="append",
        default=[],
        help="Snapshot dependency filter (repeatable): required|helpful|none.",
    )
    parser.add_argument("--bug-source", action="append", default=[], help="Bug source filter (repeatable).")
    parser.add_argument("--max-cases", type=int, default=0, help="Max number of cases to run (0 = all).")
    parser.add_argument(
        "--conditions",
        default="traceback_only,with_snapshot",
        help="Comma-separated list: traceback_only,with_snapshot",
    )
    parser.add_argument("--k", type=int, default=3, help="Max patch attempts per condition.")
    parser.add_argument(
        "--patcher",
        default="command",
        choices=["command", "heuristic", "null", "openai"],
        help="Patch strategy.",
    )
    parser.add_argument(
        "--patch-cmd",
        default="",
        help="Command to run for command patcher (overrides LLMDEBUG_EVAL_PATCH_CMD).",
    )
    parser.add_argument("--output-format", default="json_compact", choices=["json", "json_compact", "toon"])
    parser.add_argument("--keep-workdirs", action="store_true", help="Keep copied case workdirs.")
    parser.add_argument("--openai-base-url", default=os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:1234"))
    parser.add_argument("--openai-model", default=os.getenv("OPENAI_MODEL", ""))
    parser.add_argument("--openai-api-key", default=os.getenv("OPENAI_API_KEY", ""))
    parser.add_argument("--openai-timeout-sec", type=float, default=120.0)
    parser.add_argument("--openai-max-tokens", type=int, default=2048)
    parser.add_argument("--openai-temperature", type=float, default=0.0)
    parser.add_argument("--openai-response-mode", choices=["edits", "diff"], default="edits")
    parser.add_argument(
        "--openai-force-patch-attempt",
        action="store_true",
        help="Force a minimal patch attempt when model output cannot be parsed into a diff.",
    )
    parser.add_argument(
        "--force-patch-attempt",
        dest="force_patch_attempt",
        action="store_true",
        default=True,
        help="Force a deterministic runner-side patch attempt when patcher returns no diff (default: enabled).",
    )
    parser.add_argument(
        "--no-force-patch-attempt",
        dest="force_patch_attempt",
        action="store_false",
        help="Disable runner-side forced patch attempts when no diff is proposed.",
    )
    parser.add_argument("--no-files-context", action="store_true", help="Do not include full file text in prompt.")
    args = parser.parse_args(argv)

    cases_dir = _REPO_ROOT / args.cases_dir
    try:
        cases = load_cases(cases_dir)
    except ValueError as e:
        print(f"Invalid case metadata: {e}")
        return 2

    if args.case:
        wanted = set(args.case)
        cases = [c for c in cases if c.case_id in wanted]
    if args.category:
        wanted_categories = {x.strip() for x in args.category if x.strip()}
        cases = [c for c in cases if c.category in wanted_categories]
    if args.difficulty:
        wanted_difficulties = {x.strip() for x in args.difficulty if x.strip()}
        cases = [c for c in cases if c.difficulty in wanted_difficulties]
    if args.snapshot_dependency:
        wanted_snapshot_dependency = {x.strip() for x in args.snapshot_dependency if x.strip()}
        cases = [c for c in cases if c.snapshot_dependency in wanted_snapshot_dependency]
    if args.bug_source:
        wanted_bug_sources = {x.strip() for x in args.bug_source if x.strip()}
        cases = [c for c in cases if (c.bug_source or "hand_crafted") in wanted_bug_sources]
    if args.max_cases > 0:
        cases = cases[: args.max_cases]

    if not cases:
        print("No cases found.")
        return 2

    patcher = make_patcher(args)

    conditions = [Condition(c.strip()) for c in args.conditions.split(",") if c.strip()]
    run_id = time.strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:8]

    artifacts_root = _REPO_ROOT / "evals" / "artifacts" / run_id
    results_root = _REPO_ROOT / "evals" / "results"
    results_root.mkdir(parents=True, exist_ok=True)
    results_path = results_root / f"{run_id}.jsonl"

    print(f"Run: {run_id}")
    print(f"Patcher: {patcher.name}")
    print(f"Runner force patch attempt: {'on' if args.force_patch_attempt else 'off'}")
    print(f"Results: {results_path}")

    for case in cases:
        for condition in conditions:
            workdir = artifacts_root / "workdirs" / case.case_id / condition.value
            context_dir_base = artifacts_root / "contexts" / case.case_id / condition.value
            workdir.parent.mkdir(parents=True, exist_ok=True)
            context_dir_base.mkdir(parents=True, exist_ok=True)

            if workdir.exists():
                shutil.rmtree(workdir)
            shutil.copytree(
                case.path,
                workdir,
                dirs_exist_ok=True,
                ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"),
            )

            case_records = run_one_condition(
                case=case,
                condition=condition,
                workdir=workdir,
                context_dir_base=context_dir_base,
                patcher=patcher,
                k=args.k,
                output_format=args.output_format,
                run_id=run_id,
                include_files_context=not args.no_files_context,
                force_patch_attempt=args.force_patch_attempt,
            )

            with results_path.open("a", encoding="utf-8") as f:
                for rec in case_records:
                    f.write(json.dumps(rec, ensure_ascii=False) + "\n")

            if not args.keep_workdirs:
                shutil.rmtree(workdir, ignore_errors=True)

    return 0


def load_cases(cases_dir: Path) -> list[CaseSpec]:
    if not cases_dir.exists():
        return []

    out: list[CaseSpec] = []
    for child in sorted(cases_dir.iterdir()):
        if not child.is_dir():
            continue
        case_id = child.name
        meta_path = child / "case.json"
        meta: dict[str, Any] = {}
        if meta_path.exists():
            try:
                meta = json.loads(meta_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError as e:
                raise ValueError(f"{case_id}: invalid JSON in case.json: {e}") from e
            except OSError as e:
                raise ValueError(f"{case_id}: cannot read case.json: {e}") from e

        category = meta.get("category")
        if not isinstance(category, str) or category not in VALID_CATEGORIES:
            raise ValueError(f"{case_id}: category must be one of {sorted(VALID_CATEGORIES)}")

        difficulty = meta.get("difficulty")
        if not isinstance(difficulty, str) or difficulty not in VALID_DIFFICULTIES:
            raise ValueError(f"{case_id}: difficulty must be one of {sorted(VALID_DIFFICULTIES)}")

        snapshot_dependency = meta.get("snapshot_dependency")
        if (
            not isinstance(snapshot_dependency, str)
            or snapshot_dependency not in VALID_SNAPSHOT_DEPENDENCIES
        ):
            raise ValueError(
                f"{case_id}: snapshot_dependency must be one of {sorted(VALID_SNAPSHOT_DEPENDENCIES)}"
            )

        expected_exception = meta.get("expected_exception")
        if not isinstance(expected_exception, str) or not expected_exception:
            raise ValueError(f"{case_id}: expected_exception must be a non-empty string")

        tags_raw = meta.get("tags")
        if tags_raw is None:
            tags = []
        elif isinstance(tags_raw, list) and all(isinstance(x, str) and x for x in tags_raw):
            tags = list(tags_raw)
        else:
            raise ValueError(f"{case_id}: tags must be an array of non-empty strings")

        python_args_raw = meta.get("python_args")
        if (
            not isinstance(python_args_raw, list)
            or not python_args_raw
            or not all(isinstance(x, str) and x for x in python_args_raw)
        ):
            python_args = ["-m", "pytest", "-q"]
        else:
            python_args = list(python_args_raw)

        timeout_sec_raw = meta.get("timeout_sec")
        if not isinstance(timeout_sec_raw, int) or timeout_sec_raw <= 0:
            timeout_sec = 120
        else:
            timeout_sec = timeout_sec_raw

        description = meta.get("description") if isinstance(meta.get("description"), str) else None

        # Extended optional fields for imported cases.
        bug_source = meta.get("bug_source") if isinstance(meta.get("bug_source"), str) else None
        contamination_risk = (
            meta.get("contamination_risk") if isinstance(meta.get("contamination_risk"), str) else None
        )
        expected_fix_lines_raw = meta.get("expected_fix_lines")
        expected_fix_lines = (
            expected_fix_lines_raw
            if isinstance(expected_fix_lines_raw, int) and expected_fix_lines_raw > 0
            else None
        )
        source_id = meta.get("source_id") if isinstance(meta.get("source_id"), str) else None
        difficulty_rationale = (
            meta.get("difficulty_rationale") if isinstance(meta.get("difficulty_rationale"), str) else None
        )

        out.append(
            CaseSpec(
                case_id=case_id,
                path=child,
                category=category,
                difficulty=difficulty,
                snapshot_dependency=snapshot_dependency,
                expected_exception=expected_exception,
                tags=tags,
                python_args=python_args,
                timeout_sec=timeout_sec,
                description=description,
                bug_source=bug_source,
                contamination_risk=contamination_risk,
                expected_fix_lines=expected_fix_lines,
                source_id=source_id,
                difficulty_rationale=difficulty_rationale,
            )
        )
    return out


def make_patcher(args: argparse.Namespace):
    from evals.patchers import CommandPatcher, HeuristicSnapshotPatcher, NullPatcher
    from evals.patchers.command_patcher import patcher_from_env

    if args.patcher == "openai":
        from evals.patchers import OpenAICompatPatcher

        model = (args.openai_model or "").strip()
        if not model:
            raise SystemExit("--openai-model is required for --patcher openai (or set OPENAI_MODEL).")
        api_key = (args.openai_api_key or "").strip() or None
        return OpenAICompatPatcher(
            base_url=str(args.openai_base_url),
            model=model,
            api_key=api_key,
            timeout_sec=float(args.openai_timeout_sec),
            max_tokens=int(args.openai_max_tokens),
            temperature=float(args.openai_temperature),
            response_mode=str(args.openai_response_mode),
            force_patch_attempt=bool(args.openai_force_patch_attempt),
        )

    if args.patcher == "heuristic":
        return HeuristicSnapshotPatcher()
    if args.patcher == "null":
        return NullPatcher()

    if args.patch_cmd:
        return CommandPatcher(cmd=args.patch_cmd)
    env_patcher = patcher_from_env()
    if env_patcher is None:
        raise SystemExit(
            "Command patcher selected but no command configured. "
            "Set --patch-cmd or LLMDEBUG_EVAL_PATCH_CMD."
        )
    return env_patcher


def run_one_condition(
    *,
    case: CaseSpec,
    condition: Condition,
    workdir: Path,
    context_dir_base: Path,
    patcher,
    k: int,
    output_format: str,
    run_id: str,
    include_files_context: bool,
    force_patch_attempt: bool,
) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []

    llmdebug_dir = workdir / ".llmdebug"

    # Initial run (attempt 0, no patch yet).
    shutil.rmtree(llmdebug_dir, ignore_errors=True)
    current = run_pytest(workdir, case.python_args, condition, case.timeout_sec, output_format)
    cumulative_runtime_sec = current.duration_sec
    if current.returncode == 0:
        attempt_dir = context_dir_base / "attempt_00"
        attempt_dir.mkdir(parents=True, exist_ok=True)
        _write_run_outputs(attempt_dir, current, prefix="pytest_before")
        records.append(
            {
                "run_id": run_id,
                "case_id": case.case_id,
                "category": case.category,
                "difficulty": case.difficulty,
                "snapshot_dependency": case.snapshot_dependency,
                "expected_exception": case.expected_exception,
                "tags": case.tags,
                "bug_source": case.bug_source,
                "contamination_risk": case.contamination_risk,
                "condition": condition.value,
                "attempt": 0,
                "success": True,
                "before_returncode": current.returncode,
                "before_duration_sec": current.duration_sec,
                "cumulative_runtime_sec": cumulative_runtime_sec,
                "patcher": patcher.name,
                "note": "no_patch_needed",
                **_timeout_fields(current),
            }
        )
        return records

    for attempt in range(1, k + 1):
        attempt_start = time.perf_counter()
        attempt_dir = context_dir_base / f"attempt_{attempt:02d}"
        attempt_dir.mkdir(parents=True, exist_ok=True)

        _write_run_outputs(attempt_dir, current, prefix="pytest_before")

        snapshot = None
        snapshot_error = None
        if condition == Condition.WITH_SNAPSHOT:
            snapshot, snapshot_error = read_latest_snapshot(llmdebug_dir)
            if snapshot is not None:
                (attempt_dir / "snapshot.json").write_text(
                    json.dumps(snapshot, indent=2, ensure_ascii=False),
                    encoding="utf-8",
                )

        file_index = collect_file_index(workdir)
        evidence = {
            "case_id": case.case_id,
            "condition": condition.value,
            "attempt": attempt,
            "repro": [sys.executable, *case.python_args],
            "stdout": current.stdout,
            "stderr": current.stderr,
            "file_index": file_index,
            "files": collect_text_context(workdir) if include_files_context else "",
            "snapshot_available": snapshot is not None,
            "snapshot_error": snapshot_error,
        }
        (attempt_dir / "evidence.json").write_text(
            json.dumps(evidence, indent=2, ensure_ascii=False),
            encoding="utf-8",
        )

        prompt = render_prompt(evidence=evidence, snapshot=snapshot)
        (attempt_dir / "prompt.txt").write_text(prompt, encoding="utf-8")

        patch_start = time.perf_counter()
        patch = patcher.propose_patch(prompt=prompt, context_dir=attempt_dir, cwd=workdir)
        patch_duration_sec = time.perf_counter() - patch_start
        patch_meta: dict[str, Any] = {}
        if isinstance(patch.meta, dict):
            patch_meta.update(patch.meta)
        elif patch.meta is not None:
            patch_meta["raw_meta"] = patch.meta
        patch_meta["propose_duration_sec"] = patch_duration_sec
        diff_to_apply = patch.diff
        if diff_to_apply is None and force_patch_attempt:
            force_start = time.perf_counter()
            forced = _build_runner_forced_attempt_diff(cwd=workdir, file_index=file_index, attempt=attempt)
            patch_meta["runner_forced_patch_build_sec"] = time.perf_counter() - force_start
            if forced is not None:
                forced_target, diff_to_apply = forced
                patch_meta["runner_forced_patch_attempt"] = True
                patch_meta["runner_forced_patch_target"] = forced_target
                patch_meta["runner_forced_patch_reason"] = "no_patch_proposed"
        if diff_to_apply is None:
            cumulative_runtime_sec += time.perf_counter() - attempt_start
            records.append(
                {
                    "run_id": run_id,
                    "case_id": case.case_id,
                    "category": case.category,
                    "difficulty": case.difficulty,
                    "snapshot_dependency": case.snapshot_dependency,
                    "expected_exception": case.expected_exception,
                    "tags": case.tags,
                    "bug_source": case.bug_source,
                    "contamination_risk": case.contamination_risk,
                    "condition": condition.value,
                    "attempt": attempt,
                    "success": False,
                    "before_returncode": current.returncode,
                    "before_duration_sec": current.duration_sec,
                    "cumulative_runtime_sec": cumulative_runtime_sec,
                    "patcher": patcher.name,
                    "patch_meta": patch_meta,
                    "note": "no_patch_proposed",
                    **_timeout_fields(current),
                }
            )
            continue

        (attempt_dir / "patch.diff").write_text(diff_to_apply, encoding="utf-8")

        apply_start = time.perf_counter()
        apply_ok, apply_msg = apply_diff(workdir, diff_to_apply)
        patch_meta["apply_duration_sec"] = time.perf_counter() - apply_start
        if not apply_ok and force_patch_attempt:
            patch_meta["initial_apply_error"] = apply_msg
            recovery_build_start = time.perf_counter()
            recovery_patch = _build_runner_forced_attempt_diff(
                cwd=workdir,
                file_index=file_index,
                attempt=attempt,
            )
            patch_meta["runner_forced_patch_recovery_build_sec"] = time.perf_counter() - recovery_build_start
            if recovery_patch is not None:
                recovery_target, recovery_diff = recovery_patch
                (attempt_dir / "patch_recovery.diff").write_text(recovery_diff, encoding="utf-8")
                recovery_apply_start = time.perf_counter()
                recovery_ok, recovery_msg = apply_diff(workdir, recovery_diff)
                patch_meta["runner_forced_patch_recovery_apply_sec"] = time.perf_counter() - recovery_apply_start
                patch_meta["runner_forced_patch_recovery"] = True
                patch_meta["runner_forced_patch_recovery_target"] = recovery_target
                patch_meta["runner_forced_patch_recovery_reason"] = "patch_apply_failed"
                if recovery_ok:
                    apply_ok = True
                    apply_msg = recovery_msg
                else:
                    patch_meta["runner_forced_patch_recovery_error"] = recovery_msg
        if not apply_ok:
            (attempt_dir / "apply_error.txt").write_text(apply_msg, encoding="utf-8")
            cumulative_runtime_sec += time.perf_counter() - attempt_start
            records.append(
                {
                    "run_id": run_id,
                    "case_id": case.case_id,
                    "category": case.category,
                    "difficulty": case.difficulty,
                    "snapshot_dependency": case.snapshot_dependency,
                    "expected_exception": case.expected_exception,
                    "tags": case.tags,
                    "bug_source": case.bug_source,
                    "contamination_risk": case.contamination_risk,
                    "condition": condition.value,
                    "attempt": attempt,
                    "success": False,
                    "before_returncode": current.returncode,
                    "before_duration_sec": current.duration_sec,
                    "cumulative_runtime_sec": cumulative_runtime_sec,
                    "patcher": patcher.name,
                    "patch_meta": patch_meta,
                    "note": "patch_apply_failed",
                    **_timeout_fields(current),
                }
            )
            continue

        # Re-run after patch.
        shutil.rmtree(llmdebug_dir, ignore_errors=True)
        after = run_pytest(workdir, case.python_args, condition, case.timeout_sec, output_format)
        _write_run_outputs(attempt_dir, after, prefix="pytest_after")
        cumulative_runtime_sec += time.perf_counter() - attempt_start

        success = after.returncode == 0
        records.append(
            {
                "run_id": run_id,
                "case_id": case.case_id,
                "category": case.category,
                "difficulty": case.difficulty,
                "snapshot_dependency": case.snapshot_dependency,
                "expected_exception": case.expected_exception,
                "tags": case.tags,
                "bug_source": case.bug_source,
                "contamination_risk": case.contamination_risk,
                "condition": condition.value,
                "attempt": attempt,
                "success": success,
                "before_returncode": current.returncode,
                "before_duration_sec": current.duration_sec,
                "after_returncode": after.returncode,
                "after_duration_sec": after.duration_sec,
                "cumulative_runtime_sec": cumulative_runtime_sec,
                "patcher": patcher.name,
                "patch_meta": patch_meta,
                "note": "success" if success else "tests_still_failing",
                **_timeout_fields(after),
            }
        )

        if success:
            break

        # Continue: next attempt uses this failure as evidence.
        current = after

    return records


def run_pytest(
    cwd: Path, python_args: list[str], condition: Condition, timeout_sec: int, output_format: str
) -> RunResult:
    env = os.environ.copy()
    env["PYTHONPATH"] = os.pathsep.join([str(_SRC_ROOT), env.get("PYTHONPATH", "")]).strip(os.pathsep)
    env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"
    env["PYTHONDONTWRITEBYTECODE"] = "1"
    env["LLMDEBUG_OUTPUT_FORMAT"] = output_format

    args = [sys.executable, *python_args]
    if condition == Condition.WITH_SNAPSHOT:
        # Explicitly enable llmdebug plugin.
        if "-m" in python_args and "pytest" in python_args:
            # Insert after `-m pytest` for readability: `python -m pytest -p ...`
            idx = python_args.index("pytest")
            args = [sys.executable, *python_args[: idx + 1], "-p", "llmdebug.pytest_plugin", *python_args[idx + 1 :]]
        else:
            args = [sys.executable, *python_args, "-p", "llmdebug.pytest_plugin"]

        # Make capture deterministic-ish and less noisy.
        env.setdefault("LLMDEBUG_DEBUG", "0")
        env.setdefault("LLMDEBUG_INCLUDE_GIT", "false")

    start = time.perf_counter()
    try:
        proc = subprocess.run(
            args,
            cwd=cwd,
            env=env,
            capture_output=True,
            text=True,
            timeout=timeout_sec,
        )
    except subprocess.TimeoutExpired as exc:
        end = time.perf_counter()
        partial_stdout = _coerce_subprocess_output(exc.stdout)
        partial_stderr = _coerce_subprocess_output(exc.stderr)
        timeout_message = f"pytest timed out after {timeout_sec}s"
        stderr = timeout_message if not partial_stderr else f"{partial_stderr}\n{timeout_message}"
        return RunResult(
            returncode=PYTEST_TIMEOUT_RETURNCODE,
            duration_sec=float(end - start),
            stdout=partial_stdout,
            stderr=stderr,
            timed_out=True,
            timeout_sec=timeout_sec,
        )
    end = time.perf_counter()
    return RunResult(
        returncode=int(proc.returncode),
        duration_sec=float(end - start),
        stdout=proc.stdout,
        stderr=proc.stderr,
        timed_out=False,
        timeout_sec=None,
    )


def _write_run_outputs(out_dir: Path, run: RunResult, *, prefix: str) -> None:
    (out_dir / f"{prefix}_stdout.txt").write_text(run.stdout or "", encoding="utf-8")
    (out_dir / f"{prefix}_stderr.txt").write_text(run.stderr or "", encoding="utf-8")
    combined = run.stdout + ("\n" if run.stdout and run.stderr else "") + run.stderr
    (out_dir / f"{prefix}_combined.txt").write_text(combined, encoding="utf-8")


def _coerce_subprocess_output(value: str | bytes | None) -> str:
    if value is None:
        return ""
    if isinstance(value, bytes):
        return value.decode("utf-8", errors="replace")
    return value


def _timeout_fields(run: RunResult) -> dict[str, Any]:
    if run.timed_out:
        return {
            "timed_out": True,
            "timeout_sec": run.timeout_sec,
        }
    return {}


def read_latest_snapshot(out_dir: Path) -> tuple[dict[str, Any] | None, str | None]:
    try:
        from llmdebug.output import get_latest_snapshot

        snap = get_latest_snapshot(str(out_dir))
        return snap, None
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"


def render_prompt(*, evidence: dict[str, Any], snapshot: dict[str, Any] | None) -> str:
    repro = evidence.get("repro")
    stdout = evidence.get("stdout", "")
    stderr = evidence.get("stderr", "")
    file_index = evidence.get("file_index", "")
    files = evidence.get("files") or ""

    parts: list[str] = []
    parts.append(
        "You are a coding assistant. Fix the failing tests.\n"
        "Return ONLY a single unified diff (git apply compatible) and nothing else.\n"
        "The diff MUST start with a line like: diff --git a/path b/path\n"
        "Do NOT wrap the diff in Markdown fences. Do NOT rename files.\n"
        "Make the smallest possible change (avoid extra refactors or added features)."
    )
    parts.append(f"Repro command:\n{repro}\n")
    if file_index:
        parts.append("Available files (use exact relative paths):\n" + str(file_index).rstrip() + "\n")
    if files:
        parts.append("Project files:\n" + str(files).rstrip() + "\n")
    parts.append("Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n")
    if stderr:
        parts.append("Pytest output (stderr):\n" + stderr.rstrip() + "\n")

    if snapshot is not None:
        exc = snapshot.get("exception", {})
        frames = snapshot.get("frames", [])
        parts.append("llmdebug snapshot summary:")
        parts.append(json.dumps({"exception": exc, "frames": frames[:1]}, indent=2, ensure_ascii=False))

    return "\n".join(parts).rstrip() + "\n"


def apply_diff(cwd: Path, diff: str) -> tuple[bool, str]:
    path_ok, path_error = _validate_diff_paths_within_cwd(cwd, diff)
    if not path_ok:
        return False, path_error

    # Prefer POSIX patch so paths are resolved relative to `cwd` (not git toplevel).
    patch = shutil.which("patch")
    if patch:
        try:
            proc = subprocess.run(
                [patch, "-p1", "--batch", "--forward"],
                input=diff,
                text=True,
                cwd=cwd,
                capture_output=True,
                timeout=PATCH_APPLY_TIMEOUT_SEC,
            )
        except subprocess.TimeoutExpired:
            return False, f"patch timed out after {PATCH_APPLY_TIMEOUT_SEC}s"
        if proc.returncode == 0:
            return True, ""
        msg = (proc.stderr or proc.stdout or "").strip()
        relaxed_ok, relaxed_msg = _apply_relaxed_replacements(cwd, diff)
        if relaxed_ok:
            return True, "applied via relaxed replacement fallback"
        return False, f"patch failed: {msg}; relaxed fallback failed: {relaxed_msg}"

    # Fallback to git apply when patch utility is unavailable.
    git = shutil.which("git")
    if git:
        try:
            proc = subprocess.run(
                [git, "apply", "--whitespace=nowarn", "-"],
                input=diff,
                text=True,
                cwd=cwd,
                capture_output=True,
                timeout=PATCH_APPLY_TIMEOUT_SEC,
            )
        except subprocess.TimeoutExpired:
            return False, f"git apply timed out after {PATCH_APPLY_TIMEOUT_SEC}s"
        if proc.returncode == 0:
            return True, ""
        msg = (proc.stderr or proc.stdout or "").strip()
        return False, f"git apply failed: {msg}"

    return False, "No patch tool found (git or patch required)."


def _extract_diff_paths(diff: str) -> list[str]:
    paths: list[str] = []
    for raw_line in diff.splitlines():
        line = raw_line.strip()
        if line.startswith("diff --git "):
            tokens = line.split()
            if len(tokens) >= 4:
                for token in (tokens[2], tokens[3]):
                    normalized = _normalize_diff_path_token(token)
                    if normalized is not None:
                        paths.append(normalized)
            continue
        if line.startswith("--- ") or line.startswith("+++ "):
            token = line[4:].strip().split()[0] if line[4:].strip() else ""
            normalized = _normalize_diff_path_token(token)
            if normalized is not None:
                paths.append(normalized)
    seen: set[str] = set()
    unique: list[str] = []
    for path in paths:
        if path in seen:
            continue
        seen.add(path)
        unique.append(path)
    return unique


def _normalize_diff_path_token(token: str) -> str | None:
    if not token:
        return None
    if token in {"a/", "b/"}:
        return None
    if token.startswith("a/"):
        token = token[2:]
    elif token.startswith("b/"):
        token = token[2:]
    return token


def _is_safe_relative_path(path: str) -> bool:
    if not path or path == "/dev/null":
        return False
    normalized = path.replace("\\", "/")
    pure = PurePosixPath(normalized)
    if pure.is_absolute():
        return False
    parts = pure.parts
    if not parts:
        return False
    if any(part in {"", ".", ".."} for part in parts):
        return False
    return True


def _validate_diff_paths_within_cwd(cwd: Path, diff: str) -> tuple[bool, str]:
    paths = _extract_diff_paths(diff)
    if not paths:
        return False, "patch rejected: no file paths found in diff"
    root = cwd.resolve()
    for raw_path in paths:
        if raw_path == "/dev/null":
            return False, "patch rejected: /dev/null paths are not supported by eval harness"
        if not _is_safe_relative_path(raw_path):
            return False, f"patch rejected: unsafe path {raw_path!r}"
        resolved = (root / raw_path).resolve()
        try:
            resolved.relative_to(root)
        except Exception:
            return False, f"patch rejected: path escapes workdir {raw_path!r}"
    return True, ""


def _apply_relaxed_replacements(cwd: Path, diff: str) -> tuple[bool, str]:
    """Best-effort fallback for near-correct model diffs with bad hunk context.

    Supports only line-for-line replacements extracted from unified hunks.
    """
    replacements: dict[str, list[tuple[str, str, list[str], list[str]]]] = {}
    current_path: str | None = None
    lines = diff.splitlines()
    i = 0
    while i < len(lines):
        raw_line = lines[i]
        if raw_line.startswith("+++ b/"):
            current_path = raw_line[6:].strip()
            i += 1
            continue
        if raw_line.startswith("@@"):
            if current_path is None:
                i += 1
                continue
            hunk_lines: list[str] = []
            i += 1
            while i < len(lines):
                nxt = lines[i]
                if nxt.startswith("@@") or nxt.startswith("diff --git ") or nxt.startswith("--- a/") or nxt.startswith("+++ b/"):
                    break
                hunk_lines.append(nxt)
                i += 1
            replacements.setdefault(current_path, []).extend(_extract_relaxed_pairs_from_hunk(hunk_lines))
            continue
        i += 1

    if not replacements:
        return False, "no replaceable line pairs found"

    root = cwd.resolve()
    pending_updates: list[tuple[Path, str]] = []
    for rel_path, pairs in replacements.items():
        file_path = (cwd / rel_path).resolve()
        try:
            file_path.relative_to(root)
        except Exception:
            return False, f"path outside working directory: {rel_path}"
        if not file_path.exists():
            return False, f"file not found: {rel_path}"
        text = file_path.read_text(encoding="utf-8")
        had_newline = text.endswith("\n")
        line_values = text.splitlines()

        changed = False
        for old_line, new_line, before_ctx, after_ctx in pairs:
            matching_indices: list[int] = []
            for idx, existing_line in enumerate(line_values):
                if existing_line != old_line:
                    continue
                if before_ctx:
                    start = idx - len(before_ctx)
                    if start < 0 or line_values[start:idx] != before_ctx:
                        continue
                if after_ctx:
                    end = idx + 1 + len(after_ctx)
                    if end > len(line_values) or line_values[idx + 1 : end] != after_ctx:
                        continue
                matching_indices.append(idx)

            if len(matching_indices) == 0:
                return False, f"could not find context-anchored target in {rel_path!r}: {old_line!r}"
            if len(matching_indices) > 1:
                return False, f"ambiguous relaxed replacement target in {rel_path!r}: {old_line!r}"
            line_values[matching_indices[0]] = new_line
            changed = True

        if changed:
            updated = "\n".join(line_values)
            if had_newline:
                updated += "\n"
            pending_updates.append((file_path, updated))

    for file_path, updated in pending_updates:
        file_path.write_text(updated, encoding="utf-8")

    return True, ""


def _extract_relaxed_pairs_from_hunk(hunk_lines: list[str]) -> list[tuple[str, str, list[str], list[str]]]:
    """Extract replaceable `-`/`+` pairs with nearby context anchors."""
    out: list[tuple[str, str, list[str], list[str]]] = []
    recent_context: list[str] = []
    i = 0
    while i < len(hunk_lines):
        line = hunk_lines[i]
        if line.startswith(" "):
            recent_context.append(line[1:])
            if len(recent_context) > 3:
                recent_context.pop(0)
            i += 1
            continue
        if line.startswith("-") and i + 1 < len(hunk_lines) and hunk_lines[i + 1].startswith("+"):
            old_line = line[1:]
            new_line = hunk_lines[i + 1][1:]
            after_ctx: list[str] = []
            j = i + 2
            while j < len(hunk_lines) and hunk_lines[j].startswith(" ") and len(after_ctx) < 2:
                after_ctx.append(hunk_lines[j][1:])
                j += 1
            before_ctx = recent_context[-2:] if recent_context else []
            out.append((old_line, new_line, before_ctx, after_ctx))
            i += 2
            continue
        i += 1
    return out


def _build_runner_forced_attempt_diff(*, cwd: Path, file_index: str, attempt: int) -> tuple[str, str] | None:
    """Build a deterministic minimal patch when a patcher returns no diff."""
    candidates = [line.strip() for line in file_index.splitlines() if line.strip()]
    if not candidates:
        return None

    target_rel = _select_forced_patch_target(candidates)
    if target_rel is None:
        return None

    target_path = (cwd / target_rel).resolve()
    try:
        target_path.relative_to(cwd.resolve())
    except Exception:
        return None
    if not target_path.exists():
        return None

    before = target_path.read_text(encoding="utf-8")
    marker_base = f"# llmdebug-force-attempt-{attempt}"
    marker = marker_base
    suffix = 2
    while marker in before:
        marker = f"{marker_base}-{suffix}"
        suffix += 1
    sep = "" if before.endswith("\n") else "\n"
    after = before + f"{sep}{marker}\n"
    if after == before:
        return None

    diff = _build_diff_from_changes([(target_rel, before, after)])
    if not diff.strip():
        return None
    return target_rel, diff


def _select_forced_patch_target(candidates: list[str]) -> str | None:
    target_rel: str | None = None
    for rel in candidates:
        if not rel.endswith(".py"):
            continue
        lower = rel.lower()
        name = Path(rel).name.lower()
        if name.startswith("test_") or "/test" in lower or "\\test" in lower:
            continue
        target_rel = rel
        break
    if target_rel is not None:
        return target_rel
    for rel in candidates:
        if rel.endswith(".py"):
            return rel
    return None


def _build_diff_from_changes(changes: list[tuple[str, str, str]]) -> str:
    parts: list[str] = []
    for path, before, after in changes:
        udiff = difflib.unified_diff(
            before.splitlines(),
            after.splitlines(),
            fromfile=f"a/{path}",
            tofile=f"b/{path}",
            lineterm="",
        )
        file_lines = list(udiff)
        if not file_lines:
            continue
        parts.append(f"diff --git a/{path} b/{path}")
        parts.extend(file_lines)
        if parts and parts[-1] != "":
            parts.append("")
    return "\n".join(parts).rstrip("\n") + "\n"


def collect_text_context(root: Path) -> str:
    """Collect small text files from a case directory for LLM context."""
    max_total_bytes = 200_000
    max_file_bytes = 50_000
    include_suffixes = {".py", ".txt", ".json", ".md"}
    exclude_dirs = {".llmdebug", "__pycache__", ".venv", ".git"}

    parts: list[str] = []
    total = 0

    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        if path.suffix not in include_suffixes:
            continue
        if any(p in exclude_dirs for p in path.parts):
            continue
        try:
            rel = path.relative_to(root)
        except Exception:
            rel = path
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue

        if len(text) > max_file_bytes:
            text = text[:max_file_bytes] + "\n...[TRUNC]\n"

        block = f"--- {rel} ---\n{text.rstrip()}\n"
        if total + len(block) > max_total_bytes:
            break
        parts.append(block)
        total += len(block)

    return "\n".join(parts).rstrip() + ("\n" if parts else "")


def collect_file_index(root: Path) -> str:
    include_suffixes = {".py", ".txt", ".json", ".md"}
    exclude_dirs = {".llmdebug", "__pycache__", ".venv", ".git"}
    paths: list[str] = []
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.suffix not in include_suffixes:
            continue
        if any(p in exclude_dirs for p in path.parts):
            continue
        try:
            rel = str(path.relative_to(root))
        except Exception:
            rel = str(path)
        paths.append(rel)
    return "\n".join(paths)


if __name__ == "__main__":
    raise SystemExit(main())
