from __future__ import annotations

import argparse
import glob
import json
from collections import defaultdict
from pathlib import Path
from typing import Any

EVAL_RECORD_SCHEMA_VERSION = 3
REQUIRED_TOP_LEVEL_FIELDS = (
    "schema_version",
    "run_id",
    "case_id",
    "condition",
    "attempt",
    "success",
    "category",
    "difficulty",
    "snapshot_dependency",
    "run_metadata",
    "attempt_metadata",
    "model_metadata",
)
REQUIRED_RUN_METADATA_FIELDS = (
    "run_id",
    "started_at_utc",
    "deterministic",
    "seed",
    "python_version",
    "platform",
    "llmdebug_version",
    "patcher_name",
    "patcher_config_fingerprint",
)
REQUIRED_ATTEMPT_METADATA_FIELDS = (
    "attempt",
    "max_attempts",
    "attempt_ordinal_label",
    "llm_call_count",
    "tokens_prompt",
    "tokens_completion",
    "tokens_total",
    "retry_history_budget_chars",
    "retry_history_budget_applied",
    "prompt_sha256",
    "prompt_path",
    "evidence_sha256",
    "evidence_path",
)
REQUIRED_MODEL_METADATA_FIELDS = ("backend", "name")
REQUIRED_OPENAI_MODEL_METADATA_FIELDS = (
    "base_url",
    "model",
    "temperature",
    "max_tokens",
    "response_mode",
    "api_compat_seed_requested",
    "api_compat_seed_acknowledged",
)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Analyze llmdebug eval JSONL results.")
    parser.add_argument("paths", nargs="+", help="One or more JSONL result files (globs ok).")
    parser.add_argument(
        "--out",
        default="",
        help="Optional path to write machine-readable summary JSON.",
    )
    parser.add_argument(
        "--strict-input",
        action="store_true",
        help="Fail on first unreadable input file or malformed JSONL record.",
    )
    parser.add_argument(
        "--stats",
        action="store_true",
        help="Run statistical significance tests (McNemar, Cohen's h, bootstrap CI).",
    )
    parser.add_argument(
        "--aggregation",
        choices=["per_run", "majority_vote", "any_success"],
        default="per_run",
        help="Aggregation strategy for multi-run (k>1) analysis.",
    )
    parser.add_argument(
        "--stats-pair-a",
        default="traceback_only",
        help="Condition name for A side of statistical pairing (default: traceback_only).",
    )
    parser.add_argument(
        "--stats-pair-b",
        default="with_snapshot",
        help="Condition name for B side of statistical pairing (default: with_snapshot).",
    )
    args = parser.parse_args(argv)

    try:
        grouped, input_errors = _load_and_group_records(args.paths, strict_input=args.strict_input)
    except ValueError as exc:
        print(f"Input error: {exc}")
        return 2
    if not grouped:
        _print_input_errors(input_errors)
        print("No records found.")
        return 1

    rows = _derive_case_condition_rows(grouped)
    if not rows:
        print("No valid case-condition records found.")
        return 1

    by_category: dict[str, list[dict[str, Any]]] = defaultdict(list)
    by_difficulty: dict[str, list[dict[str, Any]]] = defaultdict(list)
    by_snapshot_dependency: dict[str, list[dict[str, Any]]] = defaultdict(list)
    by_bug_source: dict[str, list[dict[str, Any]]] = defaultdict(list)
    by_contamination_risk: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        by_category[row["category"]].append(row)
        by_difficulty[row["difficulty"]].append(row)
        by_snapshot_dependency[row["snapshot_dependency"]].append(row)
        by_bug_source[row.get("bug_source") or "hand_crafted"].append(row)
        by_contamination_risk[row.get("contamination_risk") or "unknown"].append(row)

    summary = {
        "overall": _summarize_bucket(rows),
        "by_category": {
            key: _summarize_bucket(bucket_rows) for key, bucket_rows in sorted(by_category.items())
        },
        "by_difficulty": {
            key: _summarize_bucket(bucket_rows)
            for key, bucket_rows in sorted(by_difficulty.items())
        },
        "by_snapshot_dependency": {
            key: _summarize_bucket(bucket_rows)
            for key, bucket_rows in sorted(by_snapshot_dependency.items())
        },
        "by_bug_source": {
            key: _summarize_bucket(bucket_rows)
            for key, bucket_rows in sorted(by_bug_source.items())
        },
        "by_contamination_risk": {
            key: _summarize_bucket(bucket_rows)
            for key, bucket_rows in sorted(by_contamination_risk.items())
        },
        "input_errors": input_errors,
    }

    _print_summary(summary)

    if args.stats:
        stats_result = _run_statistical_analysis(
            rows,
            aggregation=args.aggregation,
            pair_a=str(args.stats_pair_a),
            pair_b=str(args.stats_pair_b),
        )
        summary["statistics"] = stats_result
        _print_stats(stats_result)

    if args.out:
        out_path = Path(args.out)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
        print(f"\nWrote summary JSON: {out_path}")

    return 0


def _load_and_group_records(
    paths: list[str],
    *,
    strict_input: bool = False,
) -> tuple[dict[tuple[str, str, str], list[dict[str, Any]]], dict[str, int]]:
    grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
    input_errors = {
        "files_unreadable": 0,
        "json_lines_invalid": 0,
        "records_skipped": 0,
    }
    for src in _expand_paths(paths):
        src_path = Path(src)
        try:
            handle = src_path.open(encoding="utf-8")
        except OSError as exc:
            input_errors["files_unreadable"] += 1
            if strict_input:
                raise ValueError(f"{src}: unreadable file ({type(exc).__name__}: {exc})") from exc
            continue
        with handle:
            for line_no, line in enumerate(handle, start=1):
                if not line.strip():
                    continue
                try:
                    record = json.loads(line)
                except json.JSONDecodeError as exc:
                    input_errors["json_lines_invalid"] += 1
                    if strict_input:
                        raise ValueError(f"{src}:{line_no}: invalid JSON ({exc})") from exc
                    continue
                if not isinstance(record, dict):
                    input_errors["records_skipped"] += 1
                    if strict_input:
                        raise ValueError(f"{src}:{line_no}: record is not a JSON object")
                    continue
                run_id = str(record.get("run_id") or src)
                case_id = str(record.get("case_id") or "")
                condition = str(record.get("condition") or "")
                if not case_id or not condition:
                    input_errors["records_skipped"] += 1
                    if strict_input:
                        raise ValueError(f"{src}:{line_no}: missing case_id/condition")
                    continue
                _validate_eval_record_schema(record, src=src, line_no=line_no)
                grouped[(run_id, case_id, condition)].append(record)
    return grouped, input_errors


def _validate_eval_record_schema(record: dict[str, Any], *, src: str, line_no: int) -> None:
    for key in REQUIRED_TOP_LEVEL_FIELDS:
        if key not in record:
            raise ValueError(f"{src}:{line_no}: missing required field '{key}'")

    schema_version = record.get("schema_version")
    if schema_version != EVAL_RECORD_SCHEMA_VERSION:
        raise ValueError(
            f"{src}:{line_no}: unsupported schema_version={schema_version!r}, "
            f"expected {EVAL_RECORD_SCHEMA_VERSION}"
        )

    if not isinstance(record.get("run_id"), str) or not str(record.get("run_id")):
        raise ValueError(f"{src}:{line_no}: run_id must be a non-empty string")
    if not isinstance(record.get("case_id"), str) or not str(record.get("case_id")):
        raise ValueError(f"{src}:{line_no}: case_id must be a non-empty string")
    if not isinstance(record.get("condition"), str) or not str(record.get("condition")):
        raise ValueError(f"{src}:{line_no}: condition must be a non-empty string")
    if not isinstance(record.get("success"), bool):
        raise ValueError(f"{src}:{line_no}: success must be a boolean")

    attempt = record.get("attempt")
    if not isinstance(attempt, int) or attempt < 0:
        raise ValueError(f"{src}:{line_no}: attempt must be a non-negative integer")

    run_metadata = record.get("run_metadata")
    if not isinstance(run_metadata, dict):
        raise ValueError(f"{src}:{line_no}: run_metadata must be an object")
    for key in REQUIRED_RUN_METADATA_FIELDS:
        if key not in run_metadata:
            raise ValueError(f"{src}:{line_no}: run_metadata missing '{key}'")
    if run_metadata.get("run_id") != record.get("run_id"):
        raise ValueError(f"{src}:{line_no}: run_metadata.run_id must match run_id")
    if not isinstance(run_metadata.get("deterministic"), bool):
        raise ValueError(f"{src}:{line_no}: run_metadata.deterministic must be a boolean")
    if run_metadata.get("seed") is not None and not isinstance(run_metadata.get("seed"), int):
        raise ValueError(f"{src}:{line_no}: run_metadata.seed must be int|null")

    attempt_metadata = record.get("attempt_metadata")
    if not isinstance(attempt_metadata, dict):
        raise ValueError(f"{src}:{line_no}: attempt_metadata must be an object")
    for key in REQUIRED_ATTEMPT_METADATA_FIELDS:
        if key not in attempt_metadata:
            raise ValueError(f"{src}:{line_no}: attempt_metadata missing '{key}'")
    if attempt_metadata.get("attempt") != attempt:
        raise ValueError(f"{src}:{line_no}: attempt_metadata.attempt must match attempt")
    _validate_optional_string_field(
        attempt_metadata.get("prompt_sha256"),
        path="attempt_metadata.prompt_sha256",
        src=src,
        line_no=line_no,
    )
    _validate_optional_string_field(
        attempt_metadata.get("prompt_path"),
        path="attempt_metadata.prompt_path",
        src=src,
        line_no=line_no,
    )
    _validate_optional_string_field(
        attempt_metadata.get("evidence_sha256"),
        path="attempt_metadata.evidence_sha256",
        src=src,
        line_no=line_no,
    )
    _validate_optional_string_field(
        attempt_metadata.get("evidence_path"),
        path="attempt_metadata.evidence_path",
        src=src,
        line_no=line_no,
    )
    max_attempts = attempt_metadata.get("max_attempts")
    if not isinstance(max_attempts, int) or max_attempts < 0:
        raise ValueError(
            f"{src}:{line_no}: attempt_metadata.max_attempts must be a non-negative int"
        )
    ordinal = attempt_metadata.get("attempt_ordinal_label")
    if not isinstance(ordinal, str) or not ordinal:
        raise ValueError(
            f"{src}:{line_no}: attempt_metadata.attempt_ordinal_label must be a non-empty string"
        )
    llm_call_count = attempt_metadata.get("llm_call_count")
    if not isinstance(llm_call_count, int) or llm_call_count < 0:
        raise ValueError(
            f"{src}:{line_no}: attempt_metadata.llm_call_count must be a non-negative int"
        )
    for key in ("tokens_prompt", "tokens_completion", "tokens_total", "retry_history_budget_chars"):
        value = attempt_metadata.get(key)
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"{src}:{line_no}: attempt_metadata.{key} must be a non-negative int")
    if not isinstance(attempt_metadata.get("retry_history_budget_applied"), bool):
        raise ValueError(
            f"{src}:{line_no}: attempt_metadata.retry_history_budget_applied must be a boolean"
        )

    model_metadata = record.get("model_metadata")
    if not isinstance(model_metadata, dict):
        raise ValueError(f"{src}:{line_no}: model_metadata must be an object")
    for key in REQUIRED_MODEL_METADATA_FIELDS:
        if key not in model_metadata:
            raise ValueError(f"{src}:{line_no}: model_metadata missing '{key}'")
    backend = model_metadata.get("backend")
    if not isinstance(backend, str) or not backend:
        raise ValueError(f"{src}:{line_no}: model_metadata.backend must be a non-empty string")

    if backend == "openai":
        for key in REQUIRED_OPENAI_MODEL_METADATA_FIELDS:
            if key not in model_metadata:
                raise ValueError(
                    f"{src}:{line_no}: model_metadata missing '{key}' for openai backend"
                )
        requested_seed = model_metadata.get("api_compat_seed_requested")
        if requested_seed is not None and not isinstance(requested_seed, int):
            raise ValueError(
                f"{src}:{line_no}: model_metadata.api_compat_seed_requested must be int|null"
            )
        acknowledged = model_metadata.get("api_compat_seed_acknowledged")
        if not isinstance(acknowledged, bool):
            raise ValueError(
                f"{src}:{line_no}: model_metadata.api_compat_seed_acknowledged must be a boolean"
            )


def _validate_optional_string_field(value: Any, *, path: str, src: str, line_no: int) -> None:
    if value is not None and not isinstance(value, str):
        raise ValueError(f"{src}:{line_no}: {path} must be string|null")


def _derive_case_condition_rows(
    grouped: dict[tuple[str, str, str], list[dict[str, Any]]],
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for (run_id, case_id, condition), records in sorted(grouped.items()):
        ordered = sorted(records, key=lambda item: _as_int(item.get("attempt")))
        success = any(bool(item.get("success")) for item in ordered)
        attempts_executed = _attempts_executed(ordered)
        attempts_budget = _attempts_budget_from_records(ordered)
        attempts_to_success = _attempts_to_success(ordered)
        success_attempt_ratio = (
            (float(attempts_to_success) / float(attempts_budget))
            if attempts_to_success is not None and attempts_budget > 0
            else None
        )
        token_prompt, token_completion, token_total = _token_usage_breakdown(ordered)
        tokens = token_total
        token_usage_per_attempt_mean = (
            (token_total / float(attempts_executed))
            if token_total is not None and attempts_executed > 0
            else None
        )
        runtime = _runtime_used(ordered)
        llm_calls_total = _llm_calls_total(ordered)
        retry_history_stats = _retry_history_attempt_metrics(ordered)
        exemplar = ordered[0]
        context_protocol = _context_protocol_from_records(ordered)
        stage_b_prompt_chars = _stage_b_prompt_chars_mean(ordered)
        stage_a_attempts, stage_a_successes = _stage_a_request_counts(ordered)
        retry_gate_attempts, retry_gate_blocks = _retry_gate_counts(ordered)
        patch_attempts, test_touch_attempts = _test_touch_attempt_metrics(ordered)
        adaptive_metrics = _adaptive_helper_attempt_metrics(ordered)
        helper_invocation_attempts = int(adaptive_metrics["helper_invocation_attempts"])
        helper_invoked_case = helper_invocation_attempts > 0
        helper_success_with_invocation = bool(success and helper_invoked_case)
        first_helper_attempt = adaptive_metrics["first_helper_attempt"]
        helper_rescued = bool(
            helper_success_with_invocation
            and isinstance(first_helper_attempt, int)
            and first_helper_attempt > 1
        )
        rows.append(
            {
                "run_id": run_id,
                "case_id": case_id,
                "condition": condition,
                "category": str(exemplar.get("category") or "unknown"),
                "difficulty": str(exemplar.get("difficulty") or "unknown"),
                "snapshot_dependency": str(exemplar.get("snapshot_dependency") or "unknown"),
                "bug_source": exemplar.get("bug_source"),
                "contamination_risk": exemplar.get("contamination_risk"),
                "success": success,
                "attempts_executed": attempts_executed,
                "attempts_budget": attempts_budget,
                "attempts_to_success": attempts_to_success,
                "success_attempt_ratio": success_attempt_ratio,
                "token_usage": tokens,
                "token_usage_prompt": token_prompt,
                "token_usage_completion": token_completion,
                "token_usage_total": token_total,
                "token_usage_per_attempt_mean": token_usage_per_attempt_mean,
                "runtime_sec": runtime,
                "llm_calls_total": llm_calls_total,
                "context_protocol": context_protocol,
                "stage_b_prompt_chars_mean": stage_b_prompt_chars,
                "stage_a_request_attempts": stage_a_attempts,
                "stage_a_request_successes": stage_a_successes,
                "retry_gate_attempts": retry_gate_attempts,
                "retry_gate_blocks": retry_gate_blocks,
                "patch_attempts": patch_attempts,
                "test_touch_attempts": test_touch_attempts,
                "test_touch_attempt_rate": (
                    (test_touch_attempts / patch_attempts) if patch_attempts > 0 else None
                ),
                "adaptive_mode": adaptive_metrics["adaptive_mode"],
                "helper_invocation_attempts": helper_invocation_attempts,
                "helper_invocation_attempt_rate": (
                    (helper_invocation_attempts / attempts_executed)
                    if attempts_executed > 0
                    else None
                ),
                "helper_invoked_case": helper_invoked_case,
                "helper_success_with_invocation": helper_success_with_invocation,
                "helper_rescued": helper_rescued,
                "helper_first_attempt": first_helper_attempt,
                "helper_calls_used_final": adaptive_metrics["helper_calls_used_final"],
                "helper_decision_attempts": adaptive_metrics["helper_decision_attempts"],
                "helper_need_more_true_attempts": adaptive_metrics[
                    "helper_need_more_true_attempts"
                ],
                "helper_need_more_false_attempts": adaptive_metrics[
                    "helper_need_more_false_attempts"
                ],
                "helper_budget_exhausted_attempts": adaptive_metrics[
                    "helper_budget_exhausted_attempts"
                ],
                "helper_saturation_guard_attempts": adaptive_metrics[
                    "helper_saturation_guard_attempts"
                ],
                "retry_history_attempts": retry_history_stats["attempts"],
                "retry_history_chars_sum": retry_history_stats["chars_sum"],
                "retry_history_turns_sum": retry_history_stats["turns_sum"],
                "retry_history_budget_applied_attempts": retry_history_stats[
                    "budget_applied_attempts"
                ],
                "retry_history_truncated_attempts": retry_history_stats["truncated_attempts"],
            }
        )
    return rows


def _summarize_bucket(rows: list[dict[str, Any]]) -> dict[str, Any]:
    by_condition: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        by_condition[str(row["condition"])].append(row)

    out: dict[str, Any] = {}
    for condition, condition_rows in sorted(by_condition.items()):
        successes = sum(1 for row in condition_rows if bool(row["success"]))
        cases = len(condition_rows)
        success_rate = (successes / cases) if cases else 0.0
        successful_attempts = [
            int(row["attempts_to_success"])
            for row in condition_rows
            if row["attempts_to_success"] is not None and bool(row["success"])
        ]
        token_values = [
            float(row["token_usage"]) for row in condition_rows if row["token_usage"] is not None
        ]
        token_prompt_values = [
            float(row.get("token_usage_prompt"))
            for row in condition_rows
            if row.get("token_usage_prompt") is not None
        ]
        token_completion_values = [
            float(row.get("token_usage_completion"))
            for row in condition_rows
            if row.get("token_usage_completion") is not None
        ]
        token_total_values = [
            float(row.get("token_usage_total"))
            for row in condition_rows
            if row.get("token_usage_total") is not None
        ]
        token_per_attempt_values = [
            float(row.get("token_usage_per_attempt_mean"))
            for row in condition_rows
            if row.get("token_usage_per_attempt_mean") is not None
        ]
        runtime_values = [
            float(row["runtime_sec"]) for row in condition_rows if row["runtime_sec"] is not None
        ]
        attempts_budget_values = [
            float(row["attempts_budget"])
            for row in condition_rows
            if isinstance(row.get("attempts_budget"), (int, float))
        ]
        success_attempt_ratio_values = [
            float(row.get("success_attempt_ratio"))
            for row in condition_rows
            if row.get("success_attempt_ratio") is not None
        ]
        llm_call_values = [
            float(row["llm_calls_total"])
            for row in condition_rows
            if isinstance(row.get("llm_calls_total"), (int, float))
        ]
        total_patch_attempts = sum(int(row.get("patch_attempts") or 0) for row in condition_rows)
        total_test_touch_attempts = sum(
            int(row.get("test_touch_attempts") or 0) for row in condition_rows
        )
        total_attempts_executed = sum(int(row.get("attempts_executed") or 0) for row in condition_rows)
        helper_invoked_cases = sum(1 for row in condition_rows if bool(row.get("helper_invoked_case")))
        helper_invocation_attempts_total = sum(
            int(row.get("helper_invocation_attempts") or 0) for row in condition_rows
        )
        helper_success_with_invocation_cases = sum(
            1 for row in condition_rows if bool(row.get("helper_success_with_invocation"))
        )
        helper_rescued_cases = sum(1 for row in condition_rows if bool(row.get("helper_rescued")))
        helper_calls_used_values = [
            float(row.get("helper_calls_used_final"))
            for row in condition_rows
            if isinstance(row.get("helper_calls_used_final"), (int, float))
        ]
        helper_decision_attempts_total = sum(
            int(row.get("helper_decision_attempts") or 0) for row in condition_rows
        )
        helper_need_more_true_attempts_total = sum(
            int(row.get("helper_need_more_true_attempts") or 0) for row in condition_rows
        )
        helper_need_more_false_attempts_total = sum(
            int(row.get("helper_need_more_false_attempts") or 0) for row in condition_rows
        )
        helper_need_more_labeled_attempts = (
            helper_need_more_true_attempts_total + helper_need_more_false_attempts_total
        )
        helper_budget_exhausted_attempts_total = sum(
            int(row.get("helper_budget_exhausted_attempts") or 0) for row in condition_rows
        )
        helper_saturation_guard_attempts_total = sum(
            int(row.get("helper_saturation_guard_attempts") or 0) for row in condition_rows
        )
        solve_distribution: dict[str, int] = {}
        for row in condition_rows:
            if not bool(row.get("success")):
                continue
            value = row.get("attempts_to_success")
            if value is None:
                continue
            key = str(int(value))
            solve_distribution[key] = int(solve_distribution.get(key, 0) + 1)
        out[condition] = {
            "cases": cases,
            "successes": successes,
            "success_rate": success_rate,
            "median_attempts_to_success": _median(successful_attempts),
            "mean_attempts_budget": _mean(attempts_budget_values),
            "mean_attempt_ratio_when_successful": _mean(success_attempt_ratio_values),
            "solve_on_attempt_distribution": solve_distribution,
            "mean_token_usage": _mean(token_values),
            "mean_tokens_prompt": _mean(token_prompt_values),
            "mean_tokens_completion": _mean(token_completion_values),
            "mean_tokens_total": _mean(token_total_values),
            "mean_tokens_per_attempt": _mean(token_per_attempt_values),
            "mean_llm_calls_per_case": _mean(llm_call_values),
            "mean_runtime_sec": _mean(runtime_values),
            "patch_attempts": total_patch_attempts,
            "test_touch_attempts": total_test_touch_attempts,
            "test_touch_attempt_rate": (
                (total_test_touch_attempts / total_patch_attempts)
                if total_patch_attempts > 0
                else None
            ),
            "helper_invoked_cases": helper_invoked_cases,
            "helper_invocation_case_rate": (
                (helper_invoked_cases / cases) if cases > 0 else None
            ),
            "helper_invocation_attempts": helper_invocation_attempts_total,
            "helper_invocation_attempt_rate": (
                (helper_invocation_attempts_total / total_attempts_executed)
                if total_attempts_executed > 0
                else None
            ),
            "helper_success_with_invocation_cases": helper_success_with_invocation_cases,
            "helper_success_when_invoked": (
                (helper_success_with_invocation_cases / helper_invoked_cases)
                if helper_invoked_cases > 0
                else None
            ),
            "helper_rescued_cases": helper_rescued_cases,
            "helper_rescue_rate": (
                (helper_rescued_cases / helper_invoked_cases)
                if helper_invoked_cases > 0
                else None
            ),
            "mean_helper_calls_used_final": _mean(helper_calls_used_values),
            "helper_decision_attempts": helper_decision_attempts_total,
            "helper_need_more_true_attempts": helper_need_more_true_attempts_total,
            "helper_need_more_false_attempts": helper_need_more_false_attempts_total,
            "helper_need_more_true_rate": (
                (helper_need_more_true_attempts_total / helper_need_more_labeled_attempts)
                if helper_need_more_labeled_attempts > 0
                else None
            ),
            "helper_budget_exhausted_attempts": helper_budget_exhausted_attempts_total,
            "helper_budget_exhausted_rate": (
                (helper_budget_exhausted_attempts_total / total_attempts_executed)
                if total_attempts_executed > 0
                else None
            ),
            "helper_saturation_guard_attempts": helper_saturation_guard_attempts_total,
            "helper_saturation_guard_rate": (
                (helper_saturation_guard_attempts_total / total_attempts_executed)
                if total_attempts_executed > 0
                else None
            ),
        }
    out["condition_matrix"] = [
        {"condition": condition, **dict(stats)}
        for condition, stats in sorted(
            (
                (condition, stats)
                for condition, stats in out.items()
                if isinstance(stats, dict) and "cases" in stats and "success_rate" in stats
            ),
            key=lambda item: item[0],
        )
    ]
    out["pairwise_uplifts"] = _pairwise_uplifts(summary=out)

    out["uplift_success_rate"] = _uplift_success_rate(out)
    stage_b_prompt_values = [
        float(row["stage_b_prompt_chars_mean"])
        for row in rows
        if row.get("stage_b_prompt_chars_mean") is not None
    ]
    out["mean_prompt_chars_stage_b"] = _mean(stage_b_prompt_values)
    out["mean_prompt_chars_stage_b_by_condition"] = _mean_prompt_chars_by_condition(rows)
    retry_history_attempts = sum(int(row.get("retry_history_attempts") or 0) for row in rows)
    retry_history_chars_sum = sum(float(row.get("retry_history_chars_sum") or 0.0) for row in rows)
    retry_history_turns_sum = sum(float(row.get("retry_history_turns_sum") or 0.0) for row in rows)
    retry_history_budget_applied_attempts = sum(
        int(row.get("retry_history_budget_applied_attempts") or 0) for row in rows
    )
    retry_history_truncated_attempts = sum(
        int(row.get("retry_history_truncated_attempts") or 0) for row in rows
    )
    out["mean_retry_history_chars"] = (
        (retry_history_chars_sum / retry_history_attempts) if retry_history_attempts > 0 else None
    )
    out["mean_retry_history_turns"] = (
        (retry_history_turns_sum / retry_history_attempts) if retry_history_attempts > 0 else None
    )
    out["retry_history_budget_applied_rate"] = (
        (retry_history_budget_applied_attempts / retry_history_attempts)
        if retry_history_attempts > 0
        else None
    )
    out["retry_history_truncation_rate"] = (
        (retry_history_truncated_attempts / retry_history_attempts)
        if retry_history_attempts > 0
        else None
    )

    stage_a_attempts = sum(int(row.get("stage_a_request_attempts") or 0) for row in rows)
    stage_a_successes = sum(int(row.get("stage_a_request_successes") or 0) for row in rows)
    out["stage_a_request_success_rate"] = (
        (stage_a_successes / stage_a_attempts) if stage_a_attempts > 0 else None
    )
    retry_gate_attempts = sum(int(row.get("retry_gate_attempts") or 0) for row in rows)
    retry_gate_blocks = sum(int(row.get("retry_gate_blocks") or 0) for row in rows)
    out["retry_gate_block_rate"] = (
        (retry_gate_blocks / retry_gate_attempts) if retry_gate_attempts > 0 else None
    )
    total_patch_attempts = sum(int(row.get("patch_attempts") or 0) for row in rows)
    total_test_touch_attempts = sum(int(row.get("test_touch_attempts") or 0) for row in rows)
    out["patch_attempts"] = total_patch_attempts
    out["test_touch_attempts"] = total_test_touch_attempts
    out["test_touch_attempt_rate"] = (
        (total_test_touch_attempts / total_patch_attempts) if total_patch_attempts > 0 else None
    )
    total_attempts_executed = sum(int(row.get("attempts_executed") or 0) for row in rows)
    helper_invoked_cases = sum(1 for row in rows if bool(row.get("helper_invoked_case")))
    helper_invocation_attempts_total = sum(
        int(row.get("helper_invocation_attempts") or 0) for row in rows
    )
    helper_success_with_invocation_cases = sum(
        1 for row in rows if bool(row.get("helper_success_with_invocation"))
    )
    helper_rescued_cases = sum(1 for row in rows if bool(row.get("helper_rescued")))
    helper_decision_attempts_total = sum(int(row.get("helper_decision_attempts") or 0) for row in rows)
    helper_need_more_true_attempts_total = sum(
        int(row.get("helper_need_more_true_attempts") or 0) for row in rows
    )
    helper_need_more_false_attempts_total = sum(
        int(row.get("helper_need_more_false_attempts") or 0) for row in rows
    )
    helper_need_more_labeled_attempts = (
        helper_need_more_true_attempts_total + helper_need_more_false_attempts_total
    )
    helper_budget_exhausted_attempts_total = sum(
        int(row.get("helper_budget_exhausted_attempts") or 0) for row in rows
    )
    helper_saturation_guard_attempts_total = sum(
        int(row.get("helper_saturation_guard_attempts") or 0) for row in rows
    )
    out["helper_invoked_cases"] = helper_invoked_cases
    out["helper_invocation_case_rate"] = (helper_invoked_cases / len(rows)) if rows else None
    out["helper_invocation_attempts"] = helper_invocation_attempts_total
    out["helper_invocation_attempt_rate"] = (
        (helper_invocation_attempts_total / total_attempts_executed)
        if total_attempts_executed > 0
        else None
    )
    out["helper_success_when_invoked"] = (
        (helper_success_with_invocation_cases / helper_invoked_cases)
        if helper_invoked_cases > 0
        else None
    )
    out["helper_rescue_rate"] = (
        (helper_rescued_cases / helper_invoked_cases) if helper_invoked_cases > 0 else None
    )
    out["helper_decision_attempts"] = helper_decision_attempts_total
    out["helper_need_more_true_rate"] = (
        (helper_need_more_true_attempts_total / helper_need_more_labeled_attempts)
        if helper_need_more_labeled_attempts > 0
        else None
    )
    out["helper_budget_exhausted_rate"] = (
        (helper_budget_exhausted_attempts_total / total_attempts_executed)
        if total_attempts_executed > 0
        else None
    )
    out["helper_saturation_guard_rate"] = (
        (helper_saturation_guard_attempts_total / total_attempts_executed)
        if total_attempts_executed > 0
        else None
    )

    staged_rows = [
        row
        for row in rows
        if row.get("condition") == "with_snapshot" and row.get("context_protocol") == "staged"
    ]
    monolithic_rows = [
        row
        for row in rows
        if row.get("condition") == "with_snapshot" and row.get("context_protocol") == "monolithic"
    ]
    if staged_rows and monolithic_rows:
        staged_rate = sum(1 for row in staged_rows if bool(row.get("success"))) / len(staged_rows)
        monolithic_rate = sum(1 for row in monolithic_rows if bool(row.get("success"))) / len(
            monolithic_rows
        )
        out["uplift_staged_vs_monolithic"] = staged_rate - monolithic_rate
    else:
        out["uplift_staged_vs_monolithic"] = None
    out["uplift_structured_vs_snapshot"] = _uplift_between_conditions(
        summary=out,
        a_condition="with_snapshot",
        b_condition="with_snapshot_structured",
    )
    out["uplift_structured_vs_traceback"] = _uplift_between_conditions(
        summary=out,
        a_condition="traceback_only",
        b_condition="with_snapshot_structured",
    )
    return out


def _pairwise_uplifts(*, summary: dict[str, Any]) -> list[dict[str, Any]]:
    conditions = sorted(
        condition
        for condition, stats in summary.items()
        if isinstance(stats, dict) and "cases" in stats and "success_rate" in stats
    )
    out: list[dict[str, Any]] = []
    for idx, condition_a in enumerate(conditions):
        for condition_b in conditions[idx + 1 :]:
            uplift = _uplift_between_conditions(
                summary=summary,
                a_condition=condition_a,
                b_condition=condition_b,
            )
            out.append(
                {
                    "condition_a": condition_a,
                    "condition_b": condition_b,
                    "uplift_success_rate": uplift,
                }
            )
    return out


def _context_protocol_from_records(records: list[dict[str, Any]]) -> str:
    for record in records:
        protocol = record.get("context_protocol")
        if isinstance(protocol, str) and protocol:
            return protocol
        attempt_meta = record.get("attempt_metadata")
        if isinstance(attempt_meta, dict):
            maybe = attempt_meta.get("context_protocol")
            if isinstance(maybe, str) and maybe:
                return maybe
    return "monolithic"


def _stage_b_prompt_chars_mean(records: list[dict[str, Any]]) -> float | None:
    values: list[float] = []
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        value = attempt_meta.get("stage_b_prompt_chars")
        if isinstance(value, (int, float)):
            values.append(float(value))
    return _mean(values)


def _stage_a_request_counts(records: list[dict[str, Any]]) -> tuple[int, int]:
    attempts = 0
    successes = 0
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        success = attempt_meta.get("stage_a_request_success")
        if success is None:
            continue
        attempts += 1
        if bool(success):
            successes += 1
    return attempts, successes


def _retry_gate_counts(records: list[dict[str, Any]]) -> tuple[int, int]:
    attempts = 0
    blocks = 0
    for record in records:
        patch_meta = record.get("patch_meta")
        if not isinstance(patch_meta, dict):
            continue
        evaluated = patch_meta.get("retry_gate_evaluated")
        blocked = patch_meta.get("retry_gate_blocked")
        if isinstance(evaluated, bool):
            attempts += 1
            if bool(blocked):
                blocks += 1
            continue
        if blocked is None:
            continue
        attempts += 1
        if bool(blocked):
            blocks += 1
    return attempts, blocks


def _adaptive_helper_attempt_metrics(records: list[dict[str, Any]]) -> dict[str, Any]:
    helper_invocation_attempts = 0
    helper_calls_used_final = 0
    helper_decision_attempts = 0
    helper_need_more_true_attempts = 0
    helper_need_more_false_attempts = 0
    helper_budget_exhausted_attempts = 0
    helper_saturation_guard_attempts = 0
    first_helper_attempt: int | None = None
    adaptive_mode = "none"

    for record in records:
        attempt = _as_int(record.get("attempt"))
        attempt_meta = record.get("attempt_metadata")
        patch_meta = record.get("patch_meta")
        attempt_meta_dict = attempt_meta if isinstance(attempt_meta, dict) else {}
        patch_meta_dict = patch_meta if isinstance(patch_meta, dict) else {}

        mode_raw = attempt_meta_dict.get("adaptive_mode", patch_meta_dict.get("adaptive_mode"))
        if isinstance(mode_raw, str) and mode_raw and adaptive_mode == "none":
            adaptive_mode = mode_raw
        if attempt > 0 and mode_raw in {"gated", "llm_discretion"}:
            helper_decision_attempts += 1

        helper_invoked_raw = attempt_meta_dict.get(
            "adaptive_helper_invoked", patch_meta_dict.get("adaptive_helper_invoked")
        )
        if isinstance(helper_invoked_raw, bool) and helper_invoked_raw:
            helper_invocation_attempts += 1
            if first_helper_attempt is None:
                first_helper_attempt = attempt

        helper_calls_after = attempt_meta_dict.get(
            "adaptive_helper_calls_used_after_attempt",
            patch_meta_dict.get("adaptive_helper_calls_used_after_attempt"),
        )
        if isinstance(helper_calls_after, (int, float)):
            helper_calls_used_final = max(helper_calls_used_final, int(helper_calls_after))

        need_more_context_raw = attempt_meta_dict.get(
            "adaptive_helper_need_more_context",
            patch_meta_dict.get("adaptive_helper_need_more_context"),
        )
        if isinstance(need_more_context_raw, bool):
            if need_more_context_raw:
                helper_need_more_true_attempts += 1
            else:
                helper_need_more_false_attempts += 1

        if bool(
            attempt_meta_dict.get(
                "adaptive_helper_budget_exhausted",
                patch_meta_dict.get("adaptive_helper_budget_exhausted"),
            )
        ):
            helper_budget_exhausted_attempts += 1
        if bool(
            attempt_meta_dict.get(
                "adaptive_helper_saturation_guarded",
                patch_meta_dict.get("adaptive_helper_saturation_guarded"),
            )
        ):
            helper_saturation_guard_attempts += 1

    return {
        "adaptive_mode": adaptive_mode,
        "helper_invocation_attempts": helper_invocation_attempts,
        "helper_calls_used_final": helper_calls_used_final,
        "helper_decision_attempts": helper_decision_attempts,
        "helper_need_more_true_attempts": helper_need_more_true_attempts,
        "helper_need_more_false_attempts": helper_need_more_false_attempts,
        "helper_budget_exhausted_attempts": helper_budget_exhausted_attempts,
        "helper_saturation_guard_attempts": helper_saturation_guard_attempts,
        "first_helper_attempt": first_helper_attempt,
    }


def _looks_like_test_path(path: str) -> bool:
    normalized = path.replace("\\", "/").strip()
    if not normalized:
        return False
    parts = [part.lower() for part in normalized.split("/") if part]
    if not parts:
        return False
    file_name = parts[-1]
    if any(part in {"tests", "test"} for part in parts):
        return True
    if file_name == "conftest.py":
        return True
    if file_name.startswith("test_"):
        return True
    return bool(file_name.endswith("_test.py"))


def _test_touch_attempt_metrics(records: list[dict[str, Any]]) -> tuple[int, int]:
    patch_attempts = 0
    test_touch_attempts = 0
    for record in records:
        patch_meta = record.get("patch_meta")
        if not isinstance(patch_meta, dict):
            continue
        paths_raw = patch_meta.get("patch_file_paths")
        paths = [str(item) for item in paths_raw] if isinstance(paths_raw, list) else []
        file_count_raw = patch_meta.get("patch_file_count")
        file_count = int(file_count_raw) if isinstance(file_count_raw, (int, float)) else len(paths)
        has_patch = file_count > 0 or bool(str(patch_meta.get("patch_diff_sha256") or "").strip())
        if not has_patch:
            continue
        patch_attempts += 1
        touches_tests_raw = patch_meta.get("patch_touches_tests")
        if isinstance(touches_tests_raw, bool):
            touches_tests = touches_tests_raw
        else:
            touches_tests = any(_looks_like_test_path(path) for path in paths)
        if touches_tests:
            test_touch_attempts += 1
    return patch_attempts, test_touch_attempts


def _mean_prompt_chars_by_condition(rows: list[dict[str, Any]]) -> dict[str, float | None]:
    grouped: dict[str, list[float]] = defaultdict(list)
    for row in rows:
        condition = str(row.get("condition") or "")
        value = row.get("stage_b_prompt_chars_mean")
        if not condition or not isinstance(value, (int, float)):
            continue
        grouped[condition].append(float(value))
    return {condition: _mean(values) for condition, values in sorted(grouped.items())}


def _attempts_to_success(records: list[dict[str, Any]]) -> int | None:
    for record in records:
        if bool(record.get("success")):
            return _as_int(record.get("attempt"))
    return None


def _attempts_executed(records: list[dict[str, Any]]) -> int:
    return len(records)


def _attempts_budget_from_records(records: list[dict[str, Any]]) -> int:
    max_budget = 0
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        value = attempt_meta.get("max_attempts")
        if isinstance(value, int) and value > max_budget:
            max_budget = value
    if max_budget > 0:
        return max_budget
    return max((_as_int(record.get("attempt")) for record in records), default=0)


def _llm_calls_total(records: list[dict[str, Any]]) -> int:
    total = 0
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        value = attempt_meta.get("llm_call_count")
        if isinstance(value, int) and value > 0:
            total += value
    return total


def _token_usage_breakdown(
    records: list[dict[str, Any]],
) -> tuple[float | None, float | None, float | None]:
    prompt_total = 0.0
    completion_total = 0.0
    total_total = 0.0
    found_attempt_meta = False
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        prompt_tokens = attempt_meta.get("tokens_prompt")
        completion_tokens = attempt_meta.get("tokens_completion")
        total_tokens = attempt_meta.get("tokens_total")
        if not isinstance(prompt_tokens, (int, float)):
            continue
        if not isinstance(completion_tokens, (int, float)):
            continue
        if not isinstance(total_tokens, (int, float)):
            continue
        found_attempt_meta = True
        prompt_total += float(prompt_tokens)
        completion_total += float(completion_tokens)
        total_total += float(total_tokens)
    if found_attempt_meta:
        return prompt_total, completion_total, total_total

    # Backward-compatible fallback for older records.
    fallback_total = 0.0
    found = False
    for record in records:
        patch_meta = record.get("patch_meta")
        if not isinstance(patch_meta, dict):
            continue
        usage = patch_meta.get("usage")
        if not isinstance(usage, dict):
            continue
        value = usage.get("total_tokens")
        if isinstance(value, (int, float)):
            found = True
            fallback_total += float(value)
    if not found:
        return None, None, None
    return None, None, fallback_total


def _tokens_used(records: list[dict[str, Any]]) -> float | None:
    _prompt, _completion, total = _token_usage_breakdown(records)
    return total


def _retry_history_attempt_metrics(records: list[dict[str, Any]]) -> dict[str, int]:
    attempts = 0
    chars_sum = 0
    turns_sum = 0
    budget_applied_attempts = 0
    truncated_attempts = 0
    for record in records:
        attempt_meta = record.get("attempt_metadata")
        if not isinstance(attempt_meta, dict):
            continue
        attempts += 1
        chars_value = attempt_meta.get("retry_history_chars")
        if isinstance(chars_value, (int, float)):
            chars_sum += int(chars_value)
        turns_value = attempt_meta.get("retry_history_turns_included")
        if isinstance(turns_value, (int, float)):
            turns_sum += int(turns_value)
        if bool(attempt_meta.get("retry_history_budget_applied")):
            budget_applied_attempts += 1
        if bool(attempt_meta.get("retry_history_truncated")):
            truncated_attempts += 1
    return {
        "attempts": attempts,
        "chars_sum": chars_sum,
        "turns_sum": turns_sum,
        "budget_applied_attempts": budget_applied_attempts,
        "truncated_attempts": truncated_attempts,
    }


def _runtime_used(records: list[dict[str, Any]]) -> float | None:
    if not records:
        return None
    cumulative_values = [
        float(record["cumulative_runtime_sec"])
        for record in records
        if isinstance(record.get("cumulative_runtime_sec"), (int, float))
    ]
    if cumulative_values:
        return max(cumulative_values)
    total = 0.0
    found = False
    first_before = records[0].get("before_duration_sec")
    if isinstance(first_before, (int, float)):
        total += float(first_before)
        found = True
    for record in records:
        after = record.get("after_duration_sec")
        if isinstance(after, (int, float)):
            total += float(after)
            found = True
    return total if found else None


def _uplift_success_rate(summary: dict[str, Any]) -> float | None:
    return _uplift_between_conditions(
        summary=summary,
        a_condition="traceback_only",
        b_condition="with_snapshot",
    )


def _uplift_between_conditions(
    *,
    summary: dict[str, Any],
    a_condition: str,
    b_condition: str,
) -> float | None:
    a = summary.get(a_condition)
    b = summary.get(b_condition)
    if not isinstance(a, dict) or not isinstance(b, dict):
        return None
    ar = a.get("success_rate")
    br = b.get("success_rate")
    if not isinstance(ar, (int, float)) or not isinstance(br, (int, float)):
        return None
    return float(br - ar)


def _print_summary(summary: dict[str, Any]) -> None:
    print("Overall:")
    _print_bucket(summary["overall"])

    print("\nBy category:")
    by_category = summary["by_category"]
    for category, bucket in by_category.items():
        print(f"- {category}:")
        _print_bucket(bucket, prefix="  ")

    print("\nBy difficulty:")
    by_difficulty = summary["by_difficulty"]
    for difficulty, bucket in by_difficulty.items():
        print(f"- {difficulty}:")
        _print_bucket(bucket, prefix="  ")

    print("\nBy snapshot dependency:")
    by_snapshot_dependency = summary["by_snapshot_dependency"]
    for dependency, bucket in by_snapshot_dependency.items():
        print(f"- {dependency}:")
        _print_bucket(bucket, prefix="  ")

    if "by_bug_source" in summary:
        print("\nBy bug source:")
        for source, bucket in summary["by_bug_source"].items():
            print(f"- {source}:")
            _print_bucket(bucket, prefix="  ")

    if "by_contamination_risk" in summary:
        print("\nBy contamination risk:")
        for risk, bucket in summary["by_contamination_risk"].items():
            print(f"- {risk}:")
            _print_bucket(bucket, prefix="  ")

    _print_input_errors(summary.get("input_errors"))


def _print_bucket(bucket: dict[str, Any], prefix: str = "") -> None:
    condition_items = [
        (condition, stats)
        for condition, stats in sorted(bucket.items())
        if isinstance(stats, dict) and "cases" in stats and "success_rate" in stats
    ]
    for condition, stats in condition_items:
        cases = int(stats.get("cases", 0))
        successes = int(stats.get("successes", 0))
        rate = float(stats.get("success_rate", 0.0))
        med_attempts = stats.get("median_attempts_to_success")
        mean_tokens = stats.get("mean_tokens_total", stats.get("mean_token_usage"))
        mean_prompt_tokens = stats.get("mean_tokens_prompt")
        mean_completion_tokens = stats.get("mean_tokens_completion")
        mean_tokens_per_attempt = stats.get("mean_tokens_per_attempt")
        mean_llm_calls = stats.get("mean_llm_calls_per_case")
        mean_attempt_budget = stats.get("mean_attempts_budget")
        mean_attempt_ratio = stats.get("mean_attempt_ratio_when_successful")
        solve_dist = stats.get("solve_on_attempt_distribution")
        solve_dist_rendered = ""
        if isinstance(solve_dist, dict) and solve_dist:

            def _solve_key(entry: tuple[str, Any]) -> int:
                try:
                    return int(entry[0])
                except (TypeError, ValueError):
                    return 0

            solve_dist_rendered = ", ".join(
                f"{k}:{int(v)}" for k, v in sorted(solve_dist.items(), key=_solve_key)
            )
        mean_runtime = stats.get("mean_runtime_sec")
        patch_attempts = int(stats.get("patch_attempts", 0) or 0)
        test_touch_attempts = int(stats.get("test_touch_attempts", 0) or 0)
        test_touch_attempt_rate = stats.get("test_touch_attempt_rate")
        helper_invoked_cases = int(stats.get("helper_invoked_cases", 0) or 0)
        helper_case_rate = stats.get("helper_invocation_case_rate")
        helper_attempt_rate = stats.get("helper_invocation_attempt_rate")
        helper_success_when_invoked = stats.get("helper_success_when_invoked")
        helper_rescue_rate = stats.get("helper_rescue_rate")
        mean_helper_calls = stats.get("mean_helper_calls_used_final")
        helper_need_more_true_rate = stats.get("helper_need_more_true_rate")
        helper_budget_exhausted_rate = stats.get("helper_budget_exhausted_rate")
        helper_saturation_guard_rate = stats.get("helper_saturation_guard_rate")
        print(
            f"{prefix}- {condition}: {successes}/{cases} ({rate:.1%}), "
            f"median attempts={_fmt_num(med_attempts)}, "
            f"mean budget={_fmt_num(mean_attempt_budget)}, "
            f"mean success attempt ratio={_fmt_num(mean_attempt_ratio)}, "
            f"mean tokens(total/prompt/completion)={_fmt_num(mean_tokens)}/"
            f"{_fmt_num(mean_prompt_tokens)}/{_fmt_num(mean_completion_tokens)}, "
            f"mean tokens/attempt={_fmt_num(mean_tokens_per_attempt)}, "
            f"mean llm calls={_fmt_num(mean_llm_calls)}, "
            f"mean runtime={_fmt_num(mean_runtime)}s, "
            f"test-touch patches={test_touch_attempts}/{patch_attempts} "
            f"({_fmt_percent(test_touch_attempt_rate)})"
        )
        if solve_dist_rendered:
            print(f"{prefix}  solve_on_attempt_distribution: {solve_dist_rendered}")
        if helper_invoked_cases > 0 or isinstance(helper_case_rate, (int, float)):
            print(
                f"{prefix}  helper: invoked_cases={helper_invoked_cases}/{cases} "
                f"({_fmt_percent(helper_case_rate)}), "
                f"invocation_attempt_rate={_fmt_percent(helper_attempt_rate)}, "
                f"success_when_invoked={_fmt_percent(helper_success_when_invoked)}, "
                f"rescue_rate={_fmt_percent(helper_rescue_rate)}, "
                f"mean_calls_used={_fmt_num(mean_helper_calls)}, "
                f"need_more_context_true_rate={_fmt_percent(helper_need_more_true_rate)}, "
                f"budget_exhausted_rate={_fmt_percent(helper_budget_exhausted_rate)}, "
                f"saturation_guard_rate={_fmt_percent(helper_saturation_guard_rate)}"
            )
    pairwise_uplifts = bucket.get("pairwise_uplifts")
    if isinstance(pairwise_uplifts, list) and pairwise_uplifts:
        print(f"{prefix}- pairwise_uplifts:")
        for entry in pairwise_uplifts:
            if not isinstance(entry, dict):
                continue
            condition_a = str(entry.get("condition_a") or "")
            condition_b = str(entry.get("condition_b") or "")
            uplift = entry.get("uplift_success_rate")
            if condition_a and condition_b:
                print(f"{prefix}  {condition_a} -> {condition_b}: {_fmt_signed_percent(uplift)}")
    uplift = bucket.get("uplift_success_rate")
    print(f"{prefix}- uplift_success_rate: {_fmt_signed_percent(uplift)}")
    print(
        f"{prefix}- mean_prompt_chars_stage_b: {_fmt_num(bucket.get('mean_prompt_chars_stage_b'))}"
    )
    print(f"{prefix}- mean_retry_history_chars: {_fmt_num(bucket.get('mean_retry_history_chars'))}")
    print(f"{prefix}- mean_retry_history_turns: {_fmt_num(bucket.get('mean_retry_history_turns'))}")
    print(
        f"{prefix}- retry_history_budget_applied_rate: "
        f"{_fmt_percent(bucket.get('retry_history_budget_applied_rate'))}"
    )
    print(
        f"{prefix}- retry_history_truncation_rate: "
        f"{_fmt_percent(bucket.get('retry_history_truncation_rate'))}"
    )
    by_condition = bucket.get("mean_prompt_chars_stage_b_by_condition")
    if isinstance(by_condition, dict) and by_condition:
        rendered = ", ".join(
            f"{condition}={_fmt_num(value)}" for condition, value in sorted(by_condition.items())
        )
        print(f"{prefix}- mean_prompt_chars_stage_b_by_condition: {rendered}")
    print(
        f"{prefix}- stage_a_request_success_rate: "
        f"{_fmt_percent(bucket.get('stage_a_request_success_rate'))}"
    )
    print(f"{prefix}- retry_gate_block_rate: {_fmt_percent(bucket.get('retry_gate_block_rate'))}")
    print(
        f"{prefix}- uplift_staged_vs_monolithic: "
        f"{_fmt_signed_percent(bucket.get('uplift_staged_vs_monolithic'))}"
    )
    print(
        f"{prefix}- uplift_structured_vs_snapshot: "
        f"{_fmt_signed_percent(bucket.get('uplift_structured_vs_snapshot'))}"
    )
    print(
        f"{prefix}- uplift_structured_vs_traceback: "
        f"{_fmt_signed_percent(bucket.get('uplift_structured_vs_traceback'))}"
    )
    print(
        f"{prefix}- test_touch_attempts: {int(bucket.get('test_touch_attempts') or 0)}/"
        f"{int(bucket.get('patch_attempts') or 0)} "
        f"({_fmt_percent(bucket.get('test_touch_attempt_rate'))})"
    )


def _print_input_errors(input_errors: Any) -> None:
    if not isinstance(input_errors, dict):
        return
    files_unreadable = int(input_errors.get("files_unreadable", 0))
    json_lines_invalid = int(input_errors.get("json_lines_invalid", 0))
    records_skipped = int(input_errors.get("records_skipped", 0))
    if files_unreadable == 0 and json_lines_invalid == 0 and records_skipped == 0:
        return
    print("\nInput diagnostics:")
    print(
        "- errors: "
        f"files_unreadable={files_unreadable}, "
        f"json_lines_invalid={json_lines_invalid}, "
        f"records_skipped={records_skipped}"
    )


def _fmt_num(value: Any) -> str:
    if value is None:
        return "n/a"
    if isinstance(value, float) and value.is_integer():
        return str(int(value))
    if isinstance(value, (int, float)):
        return f"{value:.2f}"
    return "n/a"


def _fmt_signed_percent(value: Any) -> str:
    if value is None:
        return "n/a"
    if not isinstance(value, (int, float)):
        return "n/a"
    return f"{value:+.1%}"


def _fmt_percent(value: Any) -> str:
    if value is None:
        return "n/a"
    if not isinstance(value, (int, float)):
        return "n/a"
    return f"{value:.1%}"


def _as_int(value: Any) -> int:
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value)
    if isinstance(value, str):
        try:
            return int(value)
        except ValueError:
            return 0
    return 0


def _mean(xs: list[float]) -> float | None:
    if not xs:
        return None
    return float(sum(xs) / len(xs))


def _median(xs: list[int]) -> float | None:
    ys = sorted(xs)
    n = len(ys)
    if n == 0:
        return None
    mid = n // 2
    if n % 2 == 1:
        return float(ys[mid])
    return (ys[mid - 1] + ys[mid]) / 2.0


def _expand_paths(items: list[str]) -> list[str]:
    out: list[str] = []
    for item in items:
        matches = sorted(glob.glob(item))
        out.extend(matches if matches else [item])
    return out


def _run_statistical_analysis(
    rows: list[dict[str, Any]],
    *,
    aggregation: str = "per_run",
    pair_a: str = "traceback_only",
    pair_b: str = "with_snapshot",
) -> dict[str, Any]:
    """Run statistical analysis on paired rows and emit pairwise matrix entries."""
    primary = _run_statistical_analysis_pair(
        rows,
        aggregation=aggregation,
        pair_a=pair_a,
        pair_b=pair_b,
    )
    conditions = sorted({str(row.get("condition") or "") for row in rows if row.get("condition")})
    pairwise: list[dict[str, Any]] = []
    for idx, condition_a in enumerate(conditions):
        for condition_b in conditions[idx + 1 :]:
            result = _run_statistical_analysis_pair(
                rows,
                aggregation=aggregation,
                pair_a=condition_a,
                pair_b=condition_b,
            )
            entry: dict[str, Any] = {
                "condition_a": condition_a,
                "condition_b": condition_b,
                "aggregation": result.get("aggregation", aggregation),
            }
            if "error" in result:
                entry["error"] = result["error"]
            else:
                a_rate = result.get("a_success_rate")
                b_rate = result.get("b_success_rate")
                mcn = result.get("overall_mcnemar", {})
                entry.update(
                    {
                        "n_pairs": int(result.get("n_pairs", 0)),
                        "a_success_rate": a_rate,
                        "b_success_rate": b_rate,
                        "uplift_success_rate": (
                            float(b_rate - a_rate)
                            if isinstance(a_rate, (int, float)) and isinstance(b_rate, (int, float))
                            else None
                        ),
                        "mcnemar_p_value": (
                            float(mcn.get("p_value"))
                            if isinstance(mcn, dict)
                            and isinstance(mcn.get("p_value"), (int, float))
                            else None
                        ),
                        "mcnemar_significant": (
                            bool(mcn.get("significant")) if isinstance(mcn, dict) else None
                        ),
                    }
                )
            pairwise.append(entry)
    primary["pairwise"] = pairwise
    return primary


def _run_statistical_analysis_pair(
    rows: list[dict[str, Any]],
    *,
    aggregation: str,
    pair_a: str,
    pair_b: str,
) -> dict[str, Any]:
    """Run statistical analysis for one selected condition pair."""
    from evals.stats import (
        analyze_paired_results,
        any_success_aggregation,
        majority_vote_aggregation,
    )

    if aggregation in ("majority_vote", "any_success"):
        # Group rows by (case_id, condition) across runs.
        from evals.stats import BinaryPair

        by_case: dict[str, list[BinaryPair]] = {}
        case_meta: dict[str, dict[str, str]] = {}
        # First, group by (run_id, case_id) to get matched pairs per run.
        run_case_map: dict[str, dict[str, dict[str, Any]]] = defaultdict(lambda: defaultdict(dict))
        for row in rows:
            run_id = str(row.get("run_id", ""))
            case_id = str(row.get("case_id", ""))
            condition = str(row.get("condition", ""))
            run_case_map[run_id][case_id][condition] = row
            if case_id not in case_meta:
                case_meta[case_id] = {
                    "category": str(row.get("category", "unknown")),
                    "run_id": run_id,
                }

        # Build per-case lists of (A, B) pairs across runs.
        for cases in run_case_map.values():
            for case_id, conditions in cases.items():
                a = conditions.get(pair_a)
                b = conditions.get(pair_b)
                if a is not None and b is not None:
                    pair: BinaryPair = (bool(a.get("success")), bool(b.get("success")))
                    by_case.setdefault(case_id, []).append(pair)

        if aggregation == "majority_vote":
            aggregated_pairs = majority_vote_aggregation(by_case)
        else:
            aggregated_pairs = any_success_aggregation(by_case)

        # Rebuild rows from aggregated pairs for analyze_paired_results.
        aggregated_rows: list[dict[str, Any]] = []
        for case_id, pair_val in zip(sorted(by_case), aggregated_pairs, strict=True):
            meta = case_meta.get(case_id, {})
            aggregated_rows.append(
                {
                    "run_id": "aggregated",
                    "case_id": case_id,
                    "condition": pair_a,
                    "category": meta.get("category", "unknown"),
                    "success": pair_val[0],
                }
            )
            aggregated_rows.append(
                {
                    "run_id": "aggregated",
                    "case_id": case_id,
                    "condition": pair_b,
                    "category": meta.get("category", "unknown"),
                    "success": pair_val[1],
                }
            )
        result = analyze_paired_results(
            aggregated_rows,
            condition_a=pair_a,
            condition_b=pair_b,
        )
        result["aggregation"] = aggregation
        return result

    result = analyze_paired_results(
        rows,
        condition_a=pair_a,
        condition_b=pair_b,
    )
    result["aggregation"] = "per_run"
    return result


def _print_stats(stats: dict[str, Any]) -> None:
    """Print statistical analysis results."""
    if "error" in stats:
        print(f"\nStatistics: {stats['error']}")
        return

    print(f"\nStatistical analysis (aggregation={stats.get('aggregation', 'per_run')}):")
    print(
        f"  Pairing: A={stats.get('condition_a', 'traceback_only')} "
        f"vs B={stats.get('condition_b', 'with_snapshot')}"
    )
    print(f"  Matched pairs: {stats.get('n_pairs', 0)}")
    print(f"  A success rate: {stats.get('a_success_rate', 0):.1%}")
    print(f"  B success rate: {stats.get('b_success_rate', 0):.1%}")

    mcn = stats.get("overall_mcnemar", {})
    print(
        f"  McNemar's test: n_01={mcn.get('n_01', 0)}, n_10={mcn.get('n_10', 0)}, "
        f"p={mcn.get('p_value', 1.0):.4f} ({mcn.get('method', '?')})"
        f"{' *' if mcn.get('significant') else ''}"
    )

    ch = stats.get("overall_cohens_h", {})
    print(f"  Cohen's h: {ch.get('h', 0):.3f} ({ch.get('magnitude', '?')})")

    bci = stats.get("overall_bootstrap_ci", {})
    print(
        f"  Bootstrap CI ({bci.get('confidence', 0.95):.0%}): "
        f"[{bci.get('ci_lower', 0):.3f}, {bci.get('ci_upper', 0):.3f}]"
    )

    pwr = stats.get("overall_power", {})
    print(f"  Power: {pwr.get('approximate_power', 0):.2f} — {pwr.get('recommendation', '')}")

    per_cat = stats.get("per_category", {})
    if per_cat:
        hb = stats.get("holm_bonferroni", {})
        print("  Per-category:")
        for cat, cat_stats in sorted(per_cat.items()):
            cat_mcn = cat_stats.get("mcnemar", {})
            cat_ch = cat_stats.get("cohens_h", {})
            hb_entry = hb.get(cat, {})
            sig_marker = " *" if hb_entry.get("rejected") else ""
            adj_p = hb_entry.get("adjusted_p")
            adj_str = f", adj_p={adj_p:.4f}" if adj_p is not None else ""
            print(
                f"    {cat}: n={cat_stats.get('n_pairs', 0)}, "
                f"p={cat_mcn.get('p_value', 1.0):.4f}{adj_str}, "
                f"h={cat_ch.get('h', 0):.3f}{sig_marker}"
            )

    pairwise = stats.get("pairwise")
    if isinstance(pairwise, list) and pairwise:
        print("  Pairwise matrix:")
        for entry in pairwise:
            if not isinstance(entry, dict):
                continue
            condition_a = str(entry.get("condition_a") or "")
            condition_b = str(entry.get("condition_b") or "")
            if not condition_a or not condition_b:
                continue
            if "error" in entry:
                print(f"    {condition_a} -> {condition_b}: error={entry['error']}")
                continue
            print(
                f"    {condition_a} -> {condition_b}: "
                f"uplift={_fmt_signed_percent(entry.get('uplift_success_rate'))}, "
                f"n={_fmt_num(entry.get('n_pairs'))}, "
                f"p={_fmt_num(entry.get('mcnemar_p_value'))}, "
                f"sig={'yes' if entry.get('mcnemar_significant') else 'no'}"
            )


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