from __future__ import annotations

import argparse
import difflib
import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
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
EVAL_RECORD_SCHEMA_VERSION = 2
RECORD_MODES = {"full", "hash", "none"}


@dataclass
class _ActiveRunSettings:
    deterministic: bool = False
    seed: int | None = None


_ACTIVE_RUN_SETTINGS = _ActiveRunSettings()


@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:
    global _ACTIVE_RUN_SETTINGS
    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."
    )
    parser.add_argument(
        "--max-context-chars",
        type=int,
        default=200_000,
        help="Max characters for project file context in prompt (default: 200000).",
    )
    parser.add_argument(
        "--deterministic",
        action="store_true",
        help="Enable deterministic mode (requires --seed; fails fast if guarantees are missing).",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=None,
        help="Deterministic seed used for patch generation and subprocess hash randomization.",
    )
    parser.add_argument(
        "--record-prompt",
        choices=["full", "hash", "none"],
        default="hash",
        help="How to persist prompt material into attempt metadata.",
    )
    parser.add_argument(
        "--record-evidence",
        choices=["full", "hash", "none"],
        default="hash",
        help="How to persist evidence material into attempt metadata.",
    )
    parser.add_argument(
        "--record-env",
        action="append",
        default=[],
        help="Environment variable key to include in run_metadata (repeatable).",
    )
    parser.add_argument(
        "--record-deps",
        choices=["none", "minimal", "runtime"],
        default="minimal",
        help="Dependency version capture mode for run_metadata.",
    )
    args = parser.parse_args(argv)

    if args.deterministic and args.seed is None:
        print("Invalid arguments: --seed is required when --deterministic is enabled.")
        return 2
    if args.record_prompt not in RECORD_MODES:
        print(f"Invalid --record-prompt mode: {args.record_prompt}")
        return 2
    if args.record_evidence not in RECORD_MODES:
        print(f"Invalid --record-evidence mode: {args.record_evidence}")
        return 2
    if args.deterministic and args.patcher == "openai" and float(args.openai_temperature) != 0.0:
        print("Deterministic mode enabled: forcing --openai-temperature=0.0")
        args.openai_temperature = 0.0

    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]
    started_at_utc = (
        datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
    )

    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"
    run_metadata = _build_run_metadata(
        args=args,
        patcher=patcher,
        run_id=run_id,
        started_at_utc=started_at_utc,
    )

    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}")
    previous_run_settings = _ACTIVE_RUN_SETTINGS
    _ACTIVE_RUN_SETTINGS = _ActiveRunSettings(
        deterministic=bool(args.deterministic),
        seed=args.seed if args.deterministic else None,
    )
    try:
        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"),
                )

                try:
                    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,
                        max_context_chars=args.max_context_chars,
                        force_patch_attempt=args.force_patch_attempt,
                        run_metadata=run_metadata,
                        record_prompt=args.record_prompt,
                        record_evidence=args.record_evidence,
                        deterministic=bool(args.deterministic),
                        seed=args.seed if args.deterministic else None,
                    )
                except RuntimeError as exc:
                    print(f"Eval aborted: {exc}")
                    return 2

                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)
    finally:
        _ACTIVE_RUN_SETTINGS = previous_run_settings

    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),
            seed=args.seed if bool(args.deterministic) else None,
        )

    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 _build_run_metadata(
    *,
    args: argparse.Namespace,
    patcher: Any,
    run_id: str,
    started_at_utc: str,
) -> dict[str, Any]:
    patcher_config = _build_patcher_config(args=args, patcher=patcher)
    metadata: dict[str, Any] = {
        "run_id": run_id,
        "started_at_utc": started_at_utc,
        "deterministic": bool(args.deterministic),
        "seed": args.seed if bool(args.deterministic) else None,
        "python_version": platform.python_version(),
        "platform": platform.platform(),
        "llmdebug_version": _resolve_llmdebug_version(),
        "patcher_name": str(getattr(patcher, "name", "unknown")),
        "patcher_config_fingerprint": _sha256_jsonlike(patcher_config),
        "record_policy": {
            "prompt": str(args.record_prompt),
            "evidence": str(args.record_evidence),
        },
    }
    env_capture = _collect_allowed_env(args.record_env)
    if env_capture:
        metadata["env"] = env_capture
    dependency_versions = _collect_dependency_versions(mode=str(args.record_deps))
    if dependency_versions:
        metadata["dependency_versions"] = dependency_versions
    return metadata


def _build_patcher_config(*, args: argparse.Namespace, patcher: Any) -> dict[str, Any]:
    config: dict[str, Any] = {
        "patcher": str(args.patcher),
        "patcher_name": str(getattr(patcher, "name", "unknown")),
        "k": int(args.k),
        "output_format": str(args.output_format),
        "force_patch_attempt": bool(args.force_patch_attempt),
        "deterministic": bool(args.deterministic),
        "seed": args.seed if bool(args.deterministic) else None,
        "no_files_context": bool(args.no_files_context),
        "max_context_chars": int(args.max_context_chars),
    }
    if str(args.patcher) == "openai":
        config.update(
            {
                "openai_base_url": str(args.openai_base_url),
                "openai_model": str(args.openai_model),
                "openai_timeout_sec": float(args.openai_timeout_sec),
                "openai_max_tokens": int(args.openai_max_tokens),
                "openai_temperature": float(args.openai_temperature),
                "openai_response_mode": str(args.openai_response_mode),
                "openai_force_patch_attempt": bool(args.openai_force_patch_attempt),
            }
        )
    elif str(args.patcher) == "command":
        config["patch_cmd"] = str(args.patch_cmd or os.getenv("LLMDEBUG_EVAL_PATCH_CMD", ""))
    return config


def _resolve_llmdebug_version() -> str:
    try:
        import llmdebug  # type: ignore

        value = getattr(llmdebug, "__version__", None)
        if isinstance(value, str) and value:
            return value
    except Exception:
        pass
    return "unknown"


def _collect_allowed_env(keys: list[str]) -> dict[str, str]:
    out: dict[str, str] = {}
    for raw in keys:
        key = raw.strip()
        if not key:
            continue
        value = os.environ.get(key)
        if value is not None:
            out[key] = value
    return out


def _collect_dependency_versions(*, mode: str) -> dict[str, str]:
    if mode == "none":
        return {}

    try:
        from importlib import metadata as importlib_metadata
    except ImportError:
        return {}

    if mode == "runtime":
        out: dict[str, str] = {}
        for dist in importlib_metadata.distributions():
            try:
                name = dist.metadata.get("Name")
                version = dist.version
            except Exception:
                continue
            if not name or not version:
                continue
            out[str(name)] = str(version)
        return dict(sorted(out.items()))

    names = (
        "llmdebug",
        "pytest",
        "mcp",
        "openai",
        "numpy",
        "torch",
        "jax",
        "tensorflow",
    )
    out: dict[str, str] = {}
    for name in names:
        try:
            out[name] = importlib_metadata.version(name)
        except importlib_metadata.PackageNotFoundError:
            continue
        except Exception:
            continue
    return out


def _sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _sha256_jsonlike(data: Any) -> str:
    payload = json.dumps(
        data, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
    )
    return _sha256_text(payload)


def _normalize_openai_base_url(base_url: str) -> str:
    base = base_url.rstrip("/")
    if base.endswith("/v1"):
        return base
    return base + "/v1"


def _default_run_metadata(*, run_id: str, patcher_name: str) -> dict[str, Any]:
    return {
        "run_id": run_id,
        "started_at_utc": datetime.now(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z"),
        "deterministic": False,
        "seed": None,
        "python_version": platform.python_version(),
        "platform": platform.platform(),
        "llmdebug_version": _resolve_llmdebug_version(),
        "patcher_name": patcher_name,
        "patcher_config_fingerprint": _sha256_text("default"),
        "record_policy": {"prompt": "hash", "evidence": "hash"},
    }


def _build_base_record(
    *,
    case: CaseSpec,
    condition: Condition,
    run_id: str,
    attempt: int,
    patcher_name: str,
    run_metadata: dict[str, Any],
    attempt_metadata: dict[str, Any],
    model_metadata: dict[str, Any],
) -> dict[str, Any]:
    return {
        "schema_version": EVAL_RECORD_SCHEMA_VERSION,
        "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,
        "patcher": patcher_name,
        "run_metadata": dict(run_metadata),
        "attempt_metadata": dict(attempt_metadata),
        "model_metadata": dict(model_metadata),
    }


def _empty_attempt_metadata(*, attempt: int) -> dict[str, Any]:
    return {
        "attempt": attempt,
        "prompt_sha256": None,
        "prompt_path": None,
        "evidence_sha256": None,
        "evidence_path": None,
    }


def _materialize_attempt_metadata(
    *,
    attempt_dir: Path,
    attempt: int,
    evidence: dict[str, Any],
    prompt: str,
    record_prompt: str,
    record_evidence: str,
) -> dict[str, Any]:
    metadata = _empty_attempt_metadata(attempt=attempt)

    if record_evidence != "none":
        metadata["evidence_sha256"] = _sha256_jsonlike(evidence)
    if record_evidence == "full":
        evidence_path = attempt_dir / "evidence.json"
        evidence_path.write_text(
            json.dumps(evidence, indent=2, ensure_ascii=False), encoding="utf-8"
        )
        metadata["evidence_path"] = str(evidence_path)

    if record_prompt != "none":
        metadata["prompt_sha256"] = _sha256_text(prompt)
    if record_prompt == "full":
        prompt_path = attempt_dir / "prompt.txt"
        prompt_path.write_text(prompt, encoding="utf-8")
        metadata["prompt_path"] = str(prompt_path)

    return metadata


def _build_model_metadata(*, patcher: Any, patch_meta: dict[str, Any] | None) -> dict[str, Any]:
    patcher_name = str(getattr(patcher, "name", "unknown"))
    metadata: dict[str, Any] = {"backend": patcher_name, "name": patcher_name}
    if patcher_name != "openai":
        return metadata

    patch_meta_dict = patch_meta if isinstance(patch_meta, dict) else {}
    requested_seed = patch_meta_dict.get("api_compat_seed_requested")
    if requested_seed is None:
        requested_seed = getattr(patcher, "seed", None)
    acknowledged = patch_meta_dict.get("api_compat_seed_acknowledged")
    metadata.update(
        {
            "backend": "openai",
            "base_url": str(
                patch_meta_dict.get("base_url")
                or _normalize_openai_base_url(str(getattr(patcher, "base_url", "")))
            ),
            "model": str(patch_meta_dict.get("model") or getattr(patcher, "model", "")),
            "temperature": patch_meta_dict.get(
                "temperature", getattr(patcher, "temperature", None)
            ),
            "max_tokens": patch_meta_dict.get("max_tokens", getattr(patcher, "max_tokens", None)),
            "response_mode": str(
                patch_meta_dict.get("response_mode") or getattr(patcher, "response_mode", "")
            ),
            "api_compat_seed_requested": requested_seed,
            "api_compat_seed_acknowledged": bool(acknowledged)
            if acknowledged is not None
            else False,
            "api_compat_seed_response": patch_meta_dict.get("api_compat_seed_response"),
        }
    )
    return metadata


def _ensure_deterministic_seed_ack(
    *,
    deterministic: bool,
    seed: int | None,
    patcher_name: str,
    patch_meta: dict[str, Any],
    case_id: str,
    condition: str,
    attempt: int,
) -> None:
    if not deterministic or patcher_name != "openai":
        return

    requested = patch_meta.get("api_compat_seed_requested")
    acknowledged = patch_meta.get("api_compat_seed_acknowledged")
    if requested != seed:
        raise RuntimeError(
            "deterministic seed mismatch for "
            f"{case_id}/{condition}/attempt={attempt}: requested={requested!r}, expected={seed!r}"
        )
    if acknowledged is not True:
        error = patch_meta.get("error")
        body = patch_meta.get("body")
        exc = patch_meta.get("exc")
        details = error or body or exc or "backend did not acknowledge seed support"
        raise RuntimeError(
            f"deterministic mode failed for {case_id}/{condition}/attempt={attempt}: {details}"
        )


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,
    max_context_chars: int = 200_000,
    force_patch_attempt: bool,
    run_metadata: dict[str, Any] | None = None,
    record_prompt: str = "hash",
    record_evidence: str = "hash",
    deterministic: bool = False,
    seed: int | None = None,
) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    effective_run_metadata = (
        dict(run_metadata)
        if isinstance(run_metadata, dict)
        else _default_run_metadata(
            run_id=run_id,
            patcher_name=str(getattr(patcher, "name", "unknown")),
        )
    )

    llmdebug_dir = workdir / ".llmdebug"
    previous_failure_context: dict[str, Any] | None = None

    # 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")
        record = _build_base_record(
            case=case,
            condition=condition,
            run_id=run_id,
            attempt=0,
            patcher_name=str(getattr(patcher, "name", "unknown")),
            run_metadata=effective_run_metadata,
            attempt_metadata=_empty_attempt_metadata(attempt=0),
            model_metadata=_build_model_metadata(patcher=patcher, patch_meta=None),
        )
        record.update(
            {
                "success": True,
                "before_returncode": current.returncode,
                "before_duration_sec": current.duration_sec,
                "cumulative_runtime_sec": cumulative_runtime_sec,
                "note": "no_patch_needed",
            }
        )
        record.update(_timeout_fields(current))
        records.append(record)
        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",
                )

        failure_signature = _build_failure_signature_for_attempt(run=current, snapshot=snapshot)
        current_failure_context: dict[str, Any] = {
            "attempt": attempt,
            "failure_signature": failure_signature,
            "stdout": current.stdout,
            "stderr": current.stderr,
            "snapshot": snapshot,
        }
        previous_attempt_diff = _build_structured_previous_attempt_diff(
            previous_failure_context,
            current_failure_context=current_failure_context,
        )

        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,
            "failure_signature": failure_signature,
            "previous_failure_signature": (
                previous_failure_context.get("failure_signature")
                if isinstance(previous_failure_context, dict)
                else None
            ),
            "failure_signature_repeat": (
                bool(previous_failure_context)
                and previous_failure_context.get("failure_signature") == failure_signature
            ),
            "previous_attempt_diff": previous_attempt_diff,
            "file_index": file_index,
            "files": (
                collect_text_context(workdir, max_total_chars=max_context_chars)
                if include_files_context
                else ""
            ),
            "snapshot_available": snapshot is not None,
            "snapshot_error": snapshot_error,
        }
        prompt = render_prompt(evidence=evidence, snapshot=snapshot)
        attempt_metadata = _materialize_attempt_metadata(
            attempt_dir=attempt_dir,
            attempt=attempt,
            evidence=evidence,
            prompt=prompt,
            record_prompt=record_prompt,
            record_evidence=record_evidence,
        )

        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
        _ensure_deterministic_seed_ack(
            deterministic=deterministic,
            seed=seed,
            patcher_name=str(getattr(patcher, "name", "unknown")),
            patch_meta=patch_meta,
            case_id=case.case_id,
            condition=condition.value,
            attempt=attempt,
        )

        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
            record = _build_base_record(
                case=case,
                condition=condition,
                run_id=run_id,
                attempt=attempt,
                patcher_name=str(getattr(patcher, "name", "unknown")),
                run_metadata=effective_run_metadata,
                attempt_metadata=attempt_metadata,
                model_metadata=_build_model_metadata(patcher=patcher, patch_meta=patch_meta),
            )
            record.update(
                {
                    "success": False,
                    "before_returncode": current.returncode,
                    "before_duration_sec": current.duration_sec,
                    "cumulative_runtime_sec": cumulative_runtime_sec,
                    "patch_meta": patch_meta,
                    "note": "no_patch_proposed",
                }
            )
            record.update(_timeout_fields(current))
            records.append(record)
            previous_failure_context = current_failure_context
            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
            record = _build_base_record(
                case=case,
                condition=condition,
                run_id=run_id,
                attempt=attempt,
                patcher_name=str(getattr(patcher, "name", "unknown")),
                run_metadata=effective_run_metadata,
                attempt_metadata=attempt_metadata,
                model_metadata=_build_model_metadata(patcher=patcher, patch_meta=patch_meta),
            )
            record.update(
                {
                    "success": False,
                    "before_returncode": current.returncode,
                    "before_duration_sec": current.duration_sec,
                    "cumulative_runtime_sec": cumulative_runtime_sec,
                    "patch_meta": patch_meta,
                    "note": "patch_apply_failed",
                }
            )
            record.update(_timeout_fields(current))
            records.append(record)
            previous_failure_context = current_failure_context
            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
        record = _build_base_record(
            case=case,
            condition=condition,
            run_id=run_id,
            attempt=attempt,
            patcher_name=str(getattr(patcher, "name", "unknown")),
            run_metadata=effective_run_metadata,
            attempt_metadata=attempt_metadata,
            model_metadata=_build_model_metadata(patcher=patcher, patch_meta=patch_meta),
        )
        record.update(
            {
                "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,
                "patch_meta": patch_meta,
                "note": "success" if success else "tests_still_failing",
            }
        )
        record.update(_timeout_fields(after))
        records.append(record)

        if success:
            break

        # Continue: next attempt uses this failure as evidence.
        previous_failure_context = current_failure_context
        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
    if _ACTIVE_RUN_SETTINGS.deterministic and _ACTIVE_RUN_SETTINGS.seed is not None:
        env["PYTHONHASHSEED"] = str(_ACTIVE_RUN_SETTINGS.seed)
        env.setdefault("TZ", "UTC")

    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 _build_failure_signature_for_attempt(*, run: RunResult, snapshot: dict[str, Any] | None) -> str:
    try:
        from llmdebug.rca_state import build_failure_signature

        return build_failure_signature(snapshot, stdout=run.stdout, stderr=run.stderr)
    except Exception as e:
        sys.stderr.write(
            f"llmdebug eval: build_failure_signature failed, using text fallback: "
            f"{type(e).__name__}: {e}\n"
        )
        text = (run.stderr or "").strip() or (run.stdout or "").strip() or "unknown_failure"
        head = "\n".join([line for line in text.splitlines() if line.strip()][-3:])
        digest = hashlib.sha256(head.encode("utf-8")).hexdigest()[:16]
        return f"TextFailure:{digest}"


def _build_structured_previous_attempt_diff(
    previous_failure_context: dict[str, Any] | None,
    *,
    current_failure_context: dict[str, Any],
) -> dict[str, Any] | None:
    if not previous_failure_context:
        return None

    prev_attempt = int(previous_failure_context.get("attempt", 0))
    curr_attempt = int(current_failure_context.get("attempt", 0))
    prev_signature = str(previous_failure_context.get("failure_signature") or "")
    curr_signature = str(current_failure_context.get("failure_signature") or "")

    prev_snapshot = previous_failure_context.get("snapshot")
    curr_snapshot = current_failure_context.get("snapshot")
    if isinstance(prev_snapshot, dict) and isinstance(curr_snapshot, dict):
        try:
            from llmdebug.snapshot_diff import diff_snapshots, structured_diff_summary

            diff = diff_snapshots(prev_snapshot, curr_snapshot)
            structured = structured_diff_summary(diff)
            structured["attempt_prev"] = f"attempt_{prev_attempt:02d}"
            structured["attempt_curr"] = f"attempt_{curr_attempt:02d}"
            return structured
        except Exception as e:
            sys.stderr.write(
                f"llmdebug eval: structured diff failed, using text fallback: "
                f"{type(e).__name__}: {e}\n"
            )
            pass

    prev_output = (
        str(previous_failure_context.get("stderr") or "")
        + "\n"
        + str(previous_failure_context.get("stdout") or "")
    ).strip()
    curr_output = (
        str(current_failure_context.get("stderr") or "")
        + "\n"
        + str(current_failure_context.get("stdout") or "")
    ).strip()

    return {
        "attempt_prev": f"attempt_{prev_attempt:02d}",
        "attempt_curr": f"attempt_{curr_attempt:02d}",
        "failure_signature_same": prev_signature == curr_signature,
        "evidence_delta": {
            "new_frames": [],
            "dropped_frames": [],
            "message_changed": prev_output != curr_output,
        },
        "change_delta": {
            "touched_files": [],
            "touched_functions": [],
        },
        "hypothesis_delta": "unknown",
        "verification_delta": {
            "old_status": "unknown",
            "new_status": "unknown",
            "status_changed": False,
        },
    }


def _format_local_value(name: str, value: Any, meta: dict[str, Any] | None) -> str:
    """Format a single local variable with type/shape annotation."""
    meta = meta or {}
    type_str = str(meta.get("type", ""))
    # Strip 'builtins.' prefix for readability.
    type_str = type_str.removeprefix("builtins.")

    # Array-like: show shape and dtype prominently.
    if isinstance(value, dict) and "__array__" in value:
        shape = value.get("shape")
        dtype = value.get("dtype", "")
        arr_type = value.get("__array__", "ndarray")
        shape_str = f"({', '.join(str(s) for s in shape)})" if isinstance(shape, list) else "?"
        return f"    {name} = <{arr_type}, shape={shape_str}, dtype={dtype}>"

    # Scalar or small repr.
    try:
        repr_str = json.dumps(value, ensure_ascii=False, default=str)
    except Exception:
        repr_str = repr(value)
    # Truncate long values.
    if len(repr_str) > 120:
        repr_str = repr_str[:117] + "..."

    # Add type annotation.
    annotation = ""
    length = meta.get("len")
    shape = meta.get("shape")
    if shape is not None and isinstance(shape, list):
        annotation = f"  ({type_str}, shape=({', '.join(str(s) for s in shape)}))"
    elif length is not None:
        annotation = f"  ({type_str}, len={length})"
    elif type_str:
        annotation = f"  ({type_str})"

    return f"    {name} = {repr_str}{annotation}"


def _format_snapshot_section(snapshot: dict[str, Any], *, max_frames: int = 3) -> str:
    """Format a snapshot into readable text for the LLM prompt."""
    lines: list[str] = []
    lines.append("=== llmdebug Debug Snapshot ===")
    lines.append("")

    # Exception header.
    exc = snapshot.get("exception", {})
    exc_type = exc.get("type", "Unknown")
    exc_msg = exc.get("message", "")
    lines.append(f"Exception: {exc_type} — {exc_msg}")

    error_cat = exc.get("error_category")
    if isinstance(error_cat, dict):
        cat = error_cat.get("category", "")
        suggestion = error_cat.get("suggestion", "")
        if cat:
            lines.append(f"Category: {cat}")
        if suggestion:
            lines.append(f"Suggestion: {suggestion}")

    lines.append("")

    # Frames — only user code (file_rel is not None), up to max_frames.
    frames = snapshot.get("frames", [])
    shown = 0
    for i, frame in enumerate(frames):
        if shown >= max_frames:
            break
        # Skip framework internals (file_rel is None for site-packages).
        file_rel = frame.get("file_rel")
        if file_rel is None:
            continue

        label = "crash site" if i == 0 else "caller"
        func = frame.get("function", "?")
        line_no = frame.get("line", "?")
        code = frame.get("code", "")
        lines.append(f"Frame {shown} ({label}): {file_rel}:{line_no} in {func}()")
        if code:
            lines.append(f"  Code: {code}")

        # Locals with metadata.
        frame_locals = frame.get("locals", {})
        locals_meta = frame.get("locals_meta", {})
        if frame_locals:
            lines.append("  Locals:")
            for var_name, var_value in frame_locals.items():
                # Skip pytest-internal variables.
                if var_name.startswith("@"):
                    continue
                var_meta = locals_meta.get(var_name)
                lines.append(_format_local_value(var_name, var_value, var_meta))
        lines.append("")
        shown += 1

    lines.append("===")
    return "\n".join(lines)


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", "")
    failure_signature = evidence.get("failure_signature", "")
    previous_failure_signature = evidence.get("previous_failure_signature")
    failure_signature_repeat = bool(evidence.get("failure_signature_repeat"))
    previous_attempt_diff = evidence.get("previous_attempt_diff")
    file_index = evidence.get("file_index", "")
    files = evidence.get("files") or ""
    has_previous = previous_failure_signature is not None

    parts: list[str] = []

    # --- Task instruction (format-agnostic) ---
    parts.append(
        "You are a coding assistant. Fix the failing tests with root-cause analysis discipline.\n"
        "Make the smallest possible change (avoid extra refactors or added features).\n"
        "Do not repeat an equivalent patch when the failure signature is unchanged."
    )

    # --- RCA checklist (conditionalize step 6) ---
    rca_lines = [
        "RCA checklist (internal reasoning only, do not print these sections):",
        "1) Observed failure with concrete evidence",
        "2) Falsifiable root-cause hypothesis",
        "3) Why evidence supports that hypothesis",
        "4) Minimal fix plan",
        "5) Verification plan",
    ]
    if has_previous:
        rca_lines.append("6) Diff vs previous failed attempt")
    parts.append("\n".join(rca_lines))

    # --- Repro command ---
    parts.append(f"Repro command:\n{repro}\n")

    # --- Pytest output (moved up — error output first) ---
    parts.append("Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n")
    if stderr:
        parts.append("Pytest output (stderr):\n" + stderr.rstrip() + "\n")

    # --- Snapshot (right after error output, before file context) ---
    if snapshot is not None:
        parts.append(_format_snapshot_section(snapshot))

    # --- Failure signatures (only when there's something meaningful) ---
    if failure_signature:
        parts.append(f"Current failure signature:\n{failure_signature}\n")
    if has_previous:
        parts.append(f"Previous failure signature:\n{previous_failure_signature}\n")
        parts.append(f"Failure signature repeated:\n{failure_signature_repeat}\n")

    # --- Previous attempt diff ---
    if previous_attempt_diff is not None:
        parts.append(
            "Structured diff vs previous failed attempt:\n"
            + json.dumps(previous_attempt_diff, indent=2, ensure_ascii=False)
            + "\n"
        )
    if failure_signature_repeat:
        parts.append(
            "Loop breaker: The signature repeated. "
            "Change either hypothesis or evidence basis before retrying.\n"
        )

    # --- File index and project files ---
    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")

    # --- Closing instruction ---
    parts.append(
        "If evidence is insufficient for a confident fix, "
        "prefer a minimal observability change that increases signal."
    )

    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/") or 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
    return not any(part in {"", ".", ".."} for part in parts)


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, *, max_total_chars: int = 200_000) -> str:
    """Collect small text files from a case directory for LLM context."""
    max_total_bytes = max_total_chars
    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())
