"""SpreadsheetBench rollout — codegen & ReAct batch execution.

Provides:
  - process_one_codegen(): single/multi-round code generation (no tool-call)
  - run_spreadsheet_batch_codegen(): batch wrapper for codegen
  - process_one(): ReAct agent with tool-call (legacy)
  - run_spreadsheet_batch(): batch wrapper for ReAct (legacy)
  - load_items(): load benchmark .json/.jsonl files
"""
from __future__ import annotations

import glob as _glob
import json
import os
import shutil
import tempfile
import time
import traceback
from concurrent.futures import (
    FIRST_COMPLETED,
    ThreadPoolExecutor,
    wait,
    TimeoutError as FuturesTimeoutError,
)

import openpyxl

from skillopt.envs.spreadsheetbench.react_agent import run_react
from skillopt.envs.spreadsheetbench.evaluator import evaluate, _generate_cell_names
from skillopt.envs.spreadsheetbench.executor import run_generated_code


# ── Data loading ─────────────────────────────────────────────────────────────


def load_items(path: str) -> list[dict]:
    """Load a benchmark file. Supports both .jsonl and .json (list of dicts)."""
    if path.endswith(".json"):
        with open(path) as f:
            data = json.load(f)
        if isinstance(data, dict):
            data = data.get("data") or list(data.values())
        return list(data)
    items = []
    with open(path) as f:
        for line in f:
            line = line.strip()
            if line:
                items.append(json.loads(line))
    return items


# ── Test case discovery ──────────────────────────────────────────────────────


def _find_test_cases(task_dir: str) -> list[tuple[str, str, str]]:
    """Return [(case_no, input_path, answer_path), ...] sorted by case_no.

    Supports naming conventions used by SpreadsheetBench releases:
      * ``{no}_{id}_input.xlsx``  + ``{no}_{id}_answer.xlsx``  (original)
      * ``{no}_{id}_init.xlsx``   + ``{no}_{id}_golden.xlsx``  (verified_400)
      * ``initial.xlsx``          + ``golden.xlsx``             (verified_400, no prefix)
    """
    cases: list[tuple[str, str, str]] = []
    inputs = sorted(_glob.glob(os.path.join(task_dir, "*_input.xlsx")))
    for ip in inputs:
        no = os.path.basename(ip).split("_", 1)[0]
        ap = ip.replace("_input.xlsx", "_answer.xlsx")
        if os.path.exists(ap):
            cases.append((no, ip, ap))
    inits = sorted(_glob.glob(os.path.join(task_dir, "*_init.xlsx")))
    for ip in inits:
        no = os.path.basename(ip).split("_", 1)[0]
        ap = ip.replace("_init.xlsx", "_golden.xlsx")
        if os.path.exists(ap):
            cases.append((no, ip, ap))

    # Fallback: bare initial.xlsx + golden.xlsx (no numbered prefix)
    if not cases:
        bare_init = os.path.join(task_dir, "initial.xlsx")
        bare_gold = os.path.join(task_dir, "golden.xlsx")
        if os.path.exists(bare_init) and os.path.exists(bare_gold):
            cases.append(("1", bare_init, bare_gold))

    return cases


# ── Auto-verify helper ──────────────────────────────────────────────────────


def _auto_verify_output(
    pred_path: str,
    gold_path: str,
    answer_position: str,
) -> str:
    """Reopen the predicted xlsx and compare cells at answer_position with gold.

    Returns a human-readable verification report that can be appended to the
    trajectory so the error analyst can see exactly what went wrong (e.g.
    ``cell A1: got=None, expected=420``).
    """
    if not os.path.exists(pred_path):
        return "Verification: output file does not exist."
    try:
        wb_pred = openpyxl.load_workbook(pred_path, data_only=True)
        wb_gold = openpyxl.load_workbook(gold_path, data_only=True)
    except Exception as e:
        return f"Verification: could not open workbooks: {e}"

    lines = ["## Output Verification"]
    try:
        for scr in (answer_position or "").split(","):
            scr = scr.strip()
            if not scr:
                continue
            if "!" in scr:
                sheet_name, cell_range = scr.split("!", 1)
                sheet_name = sheet_name.strip().strip("'\"")
            else:
                sheet_name = wb_gold.sheetnames[0]
                cell_range = scr
            cell_range = cell_range.strip().strip("'\"")

            cell_names = _generate_cell_names(cell_range)
            ws_pred = wb_pred[sheet_name] if sheet_name in wb_pred.sheetnames else None
            ws_gold = wb_gold[sheet_name] if sheet_name in wb_gold.sheetnames else None

            if ws_pred is None:
                lines.append(f"  Sheet '{sheet_name}' NOT FOUND in output.")
                continue

            for cn in cell_names:
                gv = ws_gold[cn].value if ws_gold else "N/A"
                pv = ws_pred[cn].value
                match = "✓" if repr(gv) == repr(pv) else "✗"
                lines.append(f"  {sheet_name}!{cn}: got={pv!r}, expected={gv!r} {match}")

        # Also check if any cells in the output contain formula strings
        formula_cells = []
        for sn in wb_pred.sheetnames:
            ws = wb_pred[sn]
            for row in ws.iter_rows(max_row=min(ws.max_row, 200), values_only=False):
                for cell in row:
                    if isinstance(cell.value, str) and cell.value.startswith("="):
                        formula_cells.append(f"{sn}!{cell.coordinate}={cell.value}")
                        if len(formula_cells) >= 10:
                            break
                if len(formula_cells) >= 10:
                    break
            if len(formula_cells) >= 10:
                break
        if formula_cells:
            lines.append(f"\n  WARNING: {len(formula_cells)} cells contain Excel formulas (openpyxl cannot evaluate them):")
            for fc in formula_cells[:5]:
                lines.append(f"    {fc}")
            if len(formula_cells) > 5:
                lines.append(f"    ... and {len(formula_cells) - 5} more")
    finally:
        wb_pred.close()
        wb_gold.close()

    return "\n".join(lines)


# ── Per-task worker ──────────────────────────────────────────────────────────


def process_one(
    item: dict,
    data_root: str,
    out_root: str,
    skill_content: str,
    max_turns: int,
    diagnostic_mode: bool = False,
    diagnostic_instruction: str = "",
    diagnostic_trace_context: str = "",
) -> dict:
    """Run the ReAct agent on a single SpreadsheetBench task.

    Returns a result dict compatible with ``compute_score()``.
    """
    task_id = str(item["id"])
    instruction = item["instruction"]
    instruction_type = item.get("instruction_type", "")
    answer_position = item.get("answer_position", "")
    answer_sheet = item.get("answer_sheet", "")
    if answer_position and answer_sheet and "!" not in answer_position:
        answer_position_eval = f"{answer_sheet}!{answer_position}"
    else:
        answer_position_eval = answer_position

    # Determine task_type from instruction_type
    itype_lower = (instruction_type or "").lower()
    if "cell" in itype_lower:
        task_type = "cell_level"
    elif "sheet" in itype_lower:
        task_type = "sheet_level"
    else:
        task_type = "other"

    sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
    task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)

    result = {
        "id": task_id,
        "ok": False,
        "instruction_type": instruction_type,
        "task_type": task_type,
        "task_description": instruction,
        "phase": "setup",
        "fail_reason": "",
        "agent_ok": False,
        "exec_ok": False,
        "n_cases": 0,
        "n_exec_pass": 0,
        "n_pass": 0,
        "soft": 0.0,
        "hard": 0,
        "n_turns": 0,
        "cases": [],
        "error": "",
    }

    try:
        cases = _find_test_cases(task_dir)
        result["n_cases"] = len(cases)
        if not cases:
            result["fail_reason"] = "no-test-cases"
            return result

        task_out_dir = os.path.join(out_root, "predictions", task_id)
        os.makedirs(task_out_dir, exist_ok=True)

        no1, ip1, _ = cases[0]
        pred_path_1 = os.path.join(task_out_dir, f"{no1}_pred.xlsx")
        student_prompt_parts = [
            f"# Instruction\n{instruction}",
            f"# Input file\n{ip1}",
            f"# Output file\n{pred_path_1}",
        ]
        if instruction_type:
            student_prompt_parts.append(f"# Instruction type\n{instruction_type}")
        if answer_position_eval:
            student_prompt_parts.append(f"# Answer position\n{answer_position_eval}")
        if diagnostic_trace_context.strip():
            student_prompt_parts.insert(
                0,
                "# Previous Codex Trace Snapshot\n"
                "This is a partial transcript from an earlier attempt. Use it as your current reasoning context.\n\n"
                f"{diagnostic_trace_context.strip()}",
            )
        if diagnostic_mode and diagnostic_instruction.strip():
            student_prompt_parts.append(f"# Training readout\n{diagnostic_instruction.strip()}")
        student_user_prompt = "\n\n".join(student_prompt_parts)
        try:
            from skillopt.envs.spreadsheetbench.react_agent import _build_system
            student_system_prompt = _build_system(skill_content)
        except Exception:
            student_system_prompt = ""
        if student_system_prompt:
            with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
                f.write(student_system_prompt)
            result["student_system_prompt"] = student_system_prompt
        with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
            f.write(student_user_prompt)
        result["student_user_prompt"] = student_user_prompt

        # ── Stage 1: run ReAct agent on test case 1 ─────────────────────
        result["phase"] = "agent"

        work_dir = tempfile.mkdtemp(prefix=f"react_{task_id}_")
        try:
            # Copy input so agent works in an isolated directory
            work_input = os.path.join(work_dir, os.path.basename(ip1))
            shutil.copy2(ip1, work_input)

            agent_result = run_react(
                instruction=instruction,
                input_path=work_input,
                output_path=pred_path_1,
                work_dir=work_dir,
                instruction_type=instruction_type,
                answer_position=answer_position_eval,
                skill_content=skill_content,
                max_turns=max_turns,
                diagnostic_mode=diagnostic_mode,
                diagnostic_instruction=diagnostic_instruction,
                diagnostic_trace_context=diagnostic_trace_context,
            )
            result["n_turns"] = agent_result.get("n_turns", 0)
            if agent_result.get("student_system_prompt"):
                with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
                    f.write(agent_result["student_system_prompt"])
                result["student_system_prompt"] = agent_result["student_system_prompt"]
            if agent_result.get("student_user_prompt"):
                with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
                    f.write(agent_result["student_user_prompt"])
                result["student_user_prompt"] = agent_result["student_user_prompt"]

            # Save conversation log
            with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
                json.dump(
                    agent_result.get("conversation", []),
                    f, ensure_ascii=False, indent=2,
                )

            # Copy solution.py if the agent wrote one
            solution_src = os.path.join(work_dir, "solution.py")
            solution_dst = os.path.join(task_out_dir, "solution.py")
            if os.path.exists(solution_src):
                shutil.copy2(solution_src, solution_dst)

        except Exception as e:
            result["fail_reason"] = f"agent-error: {type(e).__name__}: {e}"
            result["error"] = traceback.format_exc()
            return result
        finally:
            shutil.rmtree(work_dir, ignore_errors=True)

        result["agent_ok"] = True

        # ── Stage 2: evaluate all test cases ─────────────────────────────
        result["phase"] = "eval"
        solution_path = os.path.join(task_out_dir, "solution.py")
        all_exec = True

        for i, (no, ip, ap) in enumerate(cases):
            pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")

            if i > 0:
                # Re-apply solution.py to subsequent test cases
                if not os.path.exists(solution_path):
                    all_exec = False
                    result["cases"].append(
                        {"no": no, "stage": "exec", "ok": False, "error": "no-solution-py"}
                    )
                    if not result["fail_reason"]:
                        result["fail_reason"] = "no-solution-py-for-other-cases"
                    continue

                with open(solution_path) as f:
                    code = f.read()

                # Prepend new INPUT_PATH / OUTPUT_PATH
                preamble = (
                    f"INPUT_PATH  = {ip!r}\n"
                    f"OUTPUT_PATH = {pred_path!r}\n"
                )
                full_code = preamble + code

                ok_exec, err = run_generated_code(full_code, ip, pred_path)
                if not ok_exec:
                    all_exec = False
                    result["cases"].append(
                        {"no": no, "stage": "exec", "ok": False, "error": err[:500]}
                    )
                    if not result["fail_reason"]:
                        tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
                        result["fail_reason"] = f"exec-error: {tail}"
                    continue

            # ── Evaluate ─────────────────────────────────────────────────
            if not os.path.exists(pred_path):
                all_exec = False
                result["cases"].append(
                    {"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
                )
                if not result["fail_reason"]:
                    result["fail_reason"] = "output-not-found"
                continue

            result["n_exec_pass"] += 1
            try:
                ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
            except Exception as e:  # noqa: BLE001
                ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}

            if ev["ok"]:
                result["n_pass"] += 1
            else:
                if not result["fail_reason"]:
                    result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
            result["cases"].append(
                {"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
            )

        result["exec_ok"] = all_exec
        n_cases = result["n_cases"]
        n_pass = result["n_pass"]
        result["soft"] = (n_pass / n_cases) if n_cases else 0.0
        result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
        result["ok"] = bool(result["hard"])
        if result["ok"]:
            result["fail_reason"] = ""
        return result

    except Exception as e:  # noqa: BLE001
        result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
        result["error"] = traceback.format_exc()
        return result


# ── Batch runner ─────────────────────────────────────────────────────────────


def run_spreadsheet_batch(
    items: list[dict],
    data_root: str,
    out_root: str,
    skill_content: str,
    max_turns: int = 30,
    max_api_workers: int = 64,
    task_timeout: int = 600,
    diagnostic_mode: bool = False,
    diagnostic_instruction: str = "",
    diagnostic_trace_context_by_id: dict[str, str] | None = None,
) -> list[dict]:
    """Run the ReAct agent on all items with ThreadPoolExecutor.

    Returns list of result dicts compatible with ``compute_score()``.
    """
    os.makedirs(out_root, exist_ok=True)

    # Check for already-done items (resume support)
    results_path = os.path.join(out_root, "results.jsonl")
    done_ids: set[str] = set()
    existing: list[dict] = []
    if os.path.exists(results_path):
        with open(results_path) as f:
            for line in f:
                try:
                    r = json.loads(line)
                    done_ids.add(str(r["id"]))
                    existing.append(r)
                except Exception:
                    pass

    pending = [it for it in items if str(it["id"]) not in done_ids]
    print(
        f"  [spreadsheet rollout] total={len(items)} done={len(done_ids)} "
        f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout}s"
    )

    if not pending:
        return existing

    t0 = time.time()
    results = list(existing)
    started_at: dict[str, float] = {}

    def _timeout_result(item: dict) -> dict:
        return {
            "id": str(item["id"]),
            "ok": False,
            "phase": "timeout",
            "fail_reason": f"task-timeout-{task_timeout}s",
            "n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
            "n_turns": 0, "cases": [], "error": "timeout",
        }

    def _error_result(item: dict, exc: Exception) -> dict:
        return {
            "id": str(item["id"]),
            "ok": False,
            "phase": "error",
            "fail_reason": f"unexpected: {type(exc).__name__}: {exc}",
            "n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
            "n_turns": 0, "cases": [], "error": str(exc),
        }

    def _run_one(it: dict) -> dict:
        started_at[str(it["id"])] = time.time()
        return process_one(
            it,
            data_root,
            out_root,
            skill_content,
            max_turns,
            diagnostic_mode,
            diagnostic_instruction,
            (diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
        )

    ex = ThreadPoolExecutor(max_workers=max_api_workers)
    try:
        futs = {ex.submit(_run_one, it): it for it in pending}
        pending_futs = set(futs)
        finished = 0
        while pending_futs:
            done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
            now = time.time()
            timed_out = [
                fut for fut in pending_futs - done
                if str(futs[fut]["id"]) in started_at
                and now - started_at[str(futs[fut]["id"])] >= task_timeout
            ]
            for fut in done:
                pending_futs.remove(fut)
                item = futs[fut]
                try:
                    res = fut.result()
                except FuturesTimeoutError:
                    res = _timeout_result(item)
                except Exception as e:  # noqa: BLE001
                    res = _error_result(item, e)
                results.append(res)
                finished += 1
                status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
                dt = time.time() - t0
                print(
                    f"    {finished}/{len(pending)} id={res['id']:<10} {status}  "
                    f"turns={res.get('n_turns', 0):<3} "
                    f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)}  "
                    f"dt={dt:.0f}s"
                )
            for fut in timed_out:
                pending_futs.remove(fut)
                res = _timeout_result(futs[fut])
                results.append(res)
                finished += 1
                status = "TIMEOUT"
                dt = time.time() - t0
                print(
                    f"    {finished}/{len(pending)} id={res['id']:<10} {status}  "
                    f"turns={res.get('n_turns', 0):<3} "
                    f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)}  "
                    f"dt={dt:.0f}s"
                )
    finally:
        ex.shutdown(wait=False, cancel_futures=True)

    return results


# ── Codegen per-task worker (no tool-call) ──────────────────────────────────


def process_one_codegen(
    item: dict,
    data_root: str,
    out_root: str,
    skill_content: str,
    mode: str = "single",
    max_turns: int = 5,
    use_eval_feedback: bool = False,
    diagnostic_mode: bool = False,
    diagnostic_instruction: str = "",
    diagnostic_trace_context: str = "",
) -> dict:
    """Run codegen agent (single or multi-round) on one SpreadsheetBench task.

    This matches the official evaluation setting: LLM generates a Python code
    block, no function-calling / tool-use.
    """
    from skillopt.envs.spreadsheetbench.codegen_agent import run_single, run_multi

    task_id = str(item["id"])
    instruction = item["instruction"]
    instruction_type = item.get("instruction_type", "")
    answer_position = item.get("answer_position", "")
    answer_sheet = item.get("answer_sheet", "")
    if answer_position and answer_sheet and "!" not in answer_position:
        answer_position_eval = f"{answer_sheet}!{answer_position}"
    else:
        answer_position_eval = answer_position

    itype_lower = (instruction_type or "").lower()
    if "cell" in itype_lower:
        task_type = "cell_level"
    elif "sheet" in itype_lower:
        task_type = "sheet_level"
    else:
        task_type = "other"

    sp = item.get("spreadsheet_path", f"spreadsheet/{task_id}")
    task_dir = sp if os.path.isabs(sp) else os.path.join(data_root, sp)

    result = {
        "id": task_id,
        "ok": False,
        "instruction_type": instruction_type,
        "task_type": task_type,
        "task_description": instruction,
        "phase": "setup",
        "fail_reason": "",
        "llm_ok": False,
        "code_ok": False,
        "exec_ok": False,
        "n_cases": 0,
        "n_exec_pass": 0,
        "n_pass": 0,
        "soft": 0.0,
        "hard": 0,
        "n_turns": 0,
        "cases": [],
        "error": "",
    }

    try:
        cases = _find_test_cases(task_dir)
        result["n_cases"] = len(cases)
        if not cases:
            result["fail_reason"] = "no-test-cases"
            return result

        task_out_dir = os.path.join(out_root, "predictions", task_id)
        os.makedirs(task_out_dir, exist_ok=True)

        # ── Save context for Teacher (Reflect stage) ──────────────────
        from skillopt.envs.spreadsheetbench.codegen_agent import (
            _preview_workbook, _build_system, _build_user,
        )
        first_input_for_preview = cases[0][1]
        try:
            preview_text = _preview_workbook(first_input_for_preview)
        except Exception:
            preview_text = "(preview failed)"
        student_system = _build_system(skill_content)
        student_user = _build_user(
            instruction,
            first_input_for_preview,
            instruction_type,
            answer_position_eval,
            diagnostic_mode=diagnostic_mode,
            diagnostic_instruction=diagnostic_instruction,
            diagnostic_trace_context=diagnostic_trace_context,
        )

        with open(os.path.join(task_out_dir, "spreadsheet_preview.txt"), "w") as f:
            f.write(preview_text)
        with open(os.path.join(task_out_dir, "student_system_prompt.txt"), "w") as f:
            f.write(student_system)
        with open(os.path.join(task_out_dir, "student_user_prompt.txt"), "w") as f:
            f.write(student_user)

        result["spreadsheet_preview"] = preview_text
        result["student_system_prompt"] = student_system
        result["student_user_prompt"] = student_user

        # ── LLM phase ──────────────────────────────────────────────────
        result["phase"] = "llm"
        first_input = cases[0][1]
        first_gold = cases[0][2]
        first_pred = os.path.join(task_out_dir, f"{cases[0][0]}_pred.xlsx")

        try:
            if mode == "multi":
                agent_result = run_multi(
                    instruction=instruction,
                    input_xlsx=first_input,
                    output_path=first_pred,
                    instruction_type=instruction_type,
                    answer_position=answer_position_eval,
                    skill_content=skill_content,
                    max_turns=max_turns,
                    gold_path=first_gold if use_eval_feedback else "",
                    diagnostic_mode=diagnostic_mode,
                    diagnostic_instruction=diagnostic_instruction,
                    diagnostic_trace_context=diagnostic_trace_context,
                )
            else:
                agent_result = run_single(
                    instruction=instruction,
                    input_xlsx=first_input,
                    output_path=first_pred,
                    instruction_type=instruction_type,
                    answer_position=answer_position_eval,
                    skill_content=skill_content,
                    diagnostic_mode=diagnostic_mode,
                    diagnostic_instruction=diagnostic_instruction,
                    diagnostic_trace_context=diagnostic_trace_context,
                )
        except Exception as e:  # noqa: BLE001
            result["fail_reason"] = f"llm-call-failed: {type(e).__name__}: {e}"
            result["error"] = traceback.format_exc()
            return result

        result["llm_ok"] = True
        result["n_turns"] = agent_result.get("n_turns", 1)
        code = agent_result.get("code", "")
        raw = agent_result.get("raw", "")

        # Save artifacts
        with open(os.path.join(task_out_dir, "code.py"), "w") as f:
            f.write(code)
        with open(os.path.join(task_out_dir, "raw.txt"), "w") as f:
            f.write(raw)
        if agent_result.get("conversation"):
            with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
                json.dump(agent_result["conversation"], f, ensure_ascii=False, indent=2)

        if not code.strip():
            result["phase"] = "extract"
            result["fail_reason"] = "empty-code-block"
            return result
        result["code_ok"] = True

        # ── Exec + eval per test case ──────────────────────────────────
        result["phase"] = "exec"
        all_exec = True
        # Collect enrichment info for the conversation/trajectory
        enrichment_parts: list[str] = []

        for no, ip, ap in cases:
            pred_path = os.path.join(task_out_dir, f"{no}_pred.xlsx")

            # For multi mode, the first case may already be produced
            if not os.path.exists(pred_path):
                ok_exec, err = run_generated_code(code, ip, pred_path)
                if not ok_exec:
                    all_exec = False
                    result["cases"].append(
                        {"no": no, "stage": "exec", "ok": False, "error": err[:500]}
                    )
                    if not result["fail_reason"]:
                        tail = err.strip().splitlines()[-1][:200] if err.strip() else "unknown"
                        result["fail_reason"] = f"exec-error: {tail}"
                    enrichment_parts.append(
                        f"## Execution (case {no})\nERROR: {err[:500]}"
                    )
                    continue

            if not os.path.exists(pred_path):
                all_exec = False
                result["cases"].append(
                    {"no": no, "stage": "exec", "ok": False, "error": "output-not-found"}
                )
                if not result["fail_reason"]:
                    result["fail_reason"] = "output-not-found"
                continue

            result["n_exec_pass"] += 1
            try:
                ev = evaluate(pred_path, ap, instruction_type, answer_position_eval)
            except Exception as e:  # noqa: BLE001
                ev = {"ok": False, "reason": f"eval-exception: {type(e).__name__}: {e}"}

            if ev["ok"]:
                result["n_pass"] += 1
            else:
                if not result["fail_reason"]:
                    result["fail_reason"] = f"eval-mismatch: {ev['reason'][:200]}"
            result["cases"].append(
                {"no": no, "stage": "eval", "ok": ev["ok"], "reason": ev.get("reason", "")}
            )

            # Auto-verify: reopen output and compare cells at answer_position
            if answer_position_eval:
                verify_report = _auto_verify_output(pred_path, ap, answer_position_eval)
                enrichment_parts.append(
                    f"## Eval Result (case {no}): {'PASS' if ev['ok'] else 'FAIL'}\n"
                    f"{ev.get('reason', '')}\n\n{verify_report}"
                )

        result["exec_ok"] = all_exec

        # ── Enrich conversation with eval details ──────────────────────
        if enrichment_parts:
            enrichment_msg = "\n\n---\n\n".join(enrichment_parts)
            conversation = agent_result.get("conversation", [])
            conversation.append({
                "role": "system",
                "content": f"[POST-EXECUTION VERIFICATION]\n\n{enrichment_msg}",
            })
            # Re-save the enriched conversation
            with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
                json.dump(conversation, f, ensure_ascii=False, indent=2)
        n_cases = result["n_cases"]
        n_pass = result["n_pass"]
        result["soft"] = (n_pass / n_cases) if n_cases else 0.0
        result["hard"] = 1 if (n_cases > 0 and n_pass == n_cases) else 0
        result["ok"] = bool(result["hard"])
        if result["ok"]:
            result["fail_reason"] = ""
        return result

    except Exception as e:  # noqa: BLE001
        result["fail_reason"] = f"unexpected: {type(e).__name__}: {e}"
        result["error"] = traceback.format_exc()
        return result


# ── Codegen batch runner ────────────────────────────────────────────────────


def run_spreadsheet_batch_codegen(
    items: list[dict],
    data_root: str,
    out_root: str,
    skill_content: str,
    mode: str = "single",
    max_turns: int = 5,
    max_api_workers: int = 32,
    task_timeout: int = 0,
    use_eval_feedback: bool = False,
    diagnostic_mode: bool = False,
    diagnostic_instruction: str = "",
    diagnostic_trace_context_by_id: dict[str, str] | None = None,
) -> list[dict]:
    """Run codegen agent on all items (no tool-call).

    Args:
        mode: "single" or "multi".
        task_timeout: Hard per-task timeout in seconds at the future level.
            0 = auto (single: 300s, multi: 600s).
    """
    if task_timeout <= 0:
        task_timeout = 300 if mode == "single" else 600

    os.makedirs(out_root, exist_ok=True)

    results_path = os.path.join(out_root, "results.jsonl")
    done_ids: set[str] = set()
    existing: list[dict] = []
    if os.path.exists(results_path):
        with open(results_path) as f:
            for line in f:
                try:
                    r = json.loads(line)
                    done_ids.add(str(r["id"]))
                    existing.append(r)
                except Exception:
                    pass

    pending = [it for it in items if str(it["id"]) not in done_ids]
    print(
        f"  [spreadsheet codegen-{mode}] total={len(items)} done={len(done_ids)} "
        f"pending={len(pending)} workers={max_api_workers} task_timeout={task_timeout}s"
    )

    if not pending:
        return existing

    t0 = time.time()
    results = list(existing)

    started_at: dict[str, float] = {}

    def _run_one(it: dict) -> dict:
        started_at[str(it["id"])] = time.time()
        return process_one_codegen(
            it,
            data_root,
            out_root,
            skill_content,
            mode,
            max_turns,
            use_eval_feedback,
            diagnostic_mode,
            diagnostic_instruction,
            (diagnostic_trace_context_by_id or {}).get(str(it["id"]), ""),
        )

    def _timeout_result(item: dict) -> dict:
        return {
            "id": str(item["id"]),
            "ok": False,
            "instruction_type": item.get("instruction_type", ""),
            "task_type": "other",
            "phase": "timeout",
            "fail_reason": f"task-timeout-{task_timeout}s",
            "n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
            "n_turns": 0, "cases": [], "error": "timeout",
        }

    def _error_result(item: dict, e: Exception) -> dict:
        return {
            "id": str(item["id"]),
            "ok": False,
            "instruction_type": item.get("instruction_type", ""),
            "task_type": "other",
            "phase": "error",
            "fail_reason": f"unexpected: {type(e).__name__}: {e}",
            "n_cases": 0, "n_pass": 0, "soft": 0.0, "hard": 0,
            "n_turns": 0, "cases": [], "error": str(e),
        }

    def _record(res: dict, i: int) -> None:
        results.append(res)
        status = "PASS" if res.get("hard") else ("TIMEOUT" if res.get("phase") == "timeout" else "FAIL")
        dt = time.time() - t0
        print(
            f"    {i}/{len(pending)} id={res['id']:<10} {status}  "
            f"turns={res.get('n_turns', 0):<3} "
            f"cases={res.get('n_pass', 0)}/{res.get('n_cases', 0)}  "
            f"dt={dt:.0f}s"
        )

    ex = ThreadPoolExecutor(max_workers=max_api_workers)
    try:
        futs = {ex.submit(_run_one, it): it for it in pending}
        pending_futs = set(futs)
        finished = 0
        while pending_futs:
            done, _ = wait(pending_futs, timeout=5, return_when=FIRST_COMPLETED)
            now = time.time()
            timed_out = [
                fut for fut in pending_futs - done
                if str(futs[fut]["id"]) in started_at
                and now - started_at[str(futs[fut]["id"])] >= task_timeout
            ]
            for fut in done:
                pending_futs.remove(fut)
                item = futs[fut]
                try:
                    res = fut.result()
                except FuturesTimeoutError:
                    res = _timeout_result(item)
                except Exception as e:  # noqa: BLE001
                    res = _error_result(item, e)
                finished += 1
                _record(res, finished)
            for fut in timed_out:
                pending_futs.remove(fut)
                fut.cancel()
                finished += 1
                _record(_timeout_result(futs[fut]), finished)
    finally:
        ex.shutdown(wait=False, cancel_futures=True)

    return results
