"""Prompt rendering for the eval harness (stage A, B, retry, compaction).

Extracted from ``evals.run_eval`` to keep the main orchestrator lean.
Re-exported by ``evals.run_eval`` for backward compatibility.
"""

from __future__ import annotations

import re
from typing import Any

from . import _json as json
from .adaptive_online_policy import ACTION_INVOKE_HELPER, ACTION_RETRY_HELPER_STRICT_JSON
from .eval_types import (
    Condition,
    ContextCompactionBundle,
    PromptBudgetResult,
    _helper_action_allowed_request_types,
)
from .stage_a_contract import STAGE_A_REQUEST_SCHEMA_PRETTY


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

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

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

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

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


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

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

    lines.append("")

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

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

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

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


def _format_snapshot_index_section(snapshot: dict[str, Any], *, max_frames: int = 6) -> str:
    """Format a snapshot index for helper decision prompts.

    This intentionally excludes locals/values to avoid biasing helper decisions and to keep the
    payload compact. The full snapshot is available in Stage-B when explicitly requested.
    """
    lines: list[str] = []
    lines.append("=== llmdebug Snapshot Index ===")

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

    frames_obj = snapshot.get("frames")
    frames = frames_obj if isinstance(frames_obj, list) else []
    shown = 0
    for i, frame in enumerate(frames):
        if shown >= max(0, int(max_frames)):
            break
        if not isinstance(frame, dict):
            continue
        file_rel = frame.get("file_rel")
        if file_rel is None:
            continue
        label = "crash site" if i == 0 else "caller"
        func = frame.get("function", "?")
        line_no = frame.get("line", "?")
        lines.append(f"Frame {shown} ({label}): {file_rel}:{line_no} in {func}()")
        shown += 1

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


_GENERIC_SNAPSHOT_JUSTIFICATIONS = {
    "need more context",
    "insufficient context",
    "not enough info",
    "not enough information",
    "need snapshot",
    "need the snapshot",
    "need full snapshot",
    "need the full snapshot",
    "require snapshot",
    "require the snapshot",
    "require full snapshot",
    "require the full snapshot",
    "requires snapshot",
    "requires the snapshot",
    "requires full snapshot",
    "requires the full snapshot",
    "more context",
}


def _is_generic_snapshot_justification(text: str) -> bool:
    normalized = re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", text.lower())).strip()
    if not normalized:
        return True
    if normalized in _GENERIC_SNAPSHOT_JUSTIFICATIONS:
        return True
    if re.fullmatch(
        r"(need|require|requires)\s+(more\s+)?(context|info|information)(\s+please)?", normalized
    ):
        return True
    if re.fullmatch(
        r"(need|require|requires)\s+(the\s+)?(full\s+)?snapshot(\s+please)?",
        normalized,
    ):
        return True
    return bool(
        re.fullmatch(
            r"(insufficient|not\s+enough)\s+(context|info|information)",
            normalized,
        )
    )


_STAGE_A_CALIBRATION_EXAMPLES = (
    "Examples of correct decisions:\n"
    "\n"
    "Example 1 — traceback IS sufficient:\n"
    "  Exception: IndexError: list index out of range\n"
    "  Crash: utils.py:42 in get_last() → return items[length]\n"
    '  Diagnosis: "Off-by-one: items[length] should be items[length - 1]"\n'
    "  Missing fact: null → need_more_context=false\n"
    "\n"
    "Example 2 — traceback IS sufficient:\n"
    "  Exception: KeyError: 'user_id'\n"
    '  Crash: handlers.py:15 in process() → uid = data["user_id"]\n'
    "  Diagnosis: \"Dict key mismatch: key is likely 'userId' or 'id'\"\n"
    "  Missing fact: null → need_more_context=false\n"
    "\n"
    "Example 3 — traceback NOT sufficient:\n"
    "  Exception: AssertionError (no message)\n"
    "  Crash: test_parser.py:30 in test_parse() → assert result == expected\n"
    '  Diagnosis: "Cannot determine root cause: assertion has no message and crash is in test"\n'
    '  Missing fact: "parser.py source to see parse() implementation" → need_more_context=true\n'
)


def render_stage_a_request_prompt(
    *,
    evidence: dict[str, Any],
    snapshot: dict[str, Any] | None,
    helper_budget_total: int = 0,
    helper_budget_remaining: int = 0,
    helper_action: str = ACTION_INVOKE_HELPER,
) -> str:
    """Build the Stage-A prompt that asks the LLM to select evidence.

    Includes traceback, pytest output, file index, and optional snapshot
    context.  Returns a single prompt string for the context-request step.
    """
    repro = evidence.get("repro")
    stdout = evidence.get("stdout", "")
    stderr = evidence.get("stderr", "")
    failure_signature = evidence.get("failure_signature", "")
    previous_failure_signature = evidence.get("previous_failure_signature")
    failure_signature_repeat = bool(evidence.get("failure_signature_repeat"))
    previous_attempt_diff = evidence.get("previous_attempt_diff")
    file_index = evidence.get("file_index", "")

    allowed_request_types = _helper_action_allowed_request_types(helper_action)
    allowed_request_types_text = ", ".join(allowed_request_types)
    request_mode_instructions = (
        f"Allowed request types: {allowed_request_types_text}."
        if helper_action == ACTION_INVOKE_HELPER
        else (
            "Action mode: request at most one targeted "
            f"{allowed_request_types_text} item. Do not request any other request type. "
            "Set need_snapshot=false and need_compact_context=false."
        )
    )
    strict_json_instructions = ""
    if helper_action == ACTION_RETRY_HELPER_STRICT_JSON:
        strict_json_instructions = (
            "\nSTRICT JSON mode:\n"
            "Return JSON only with no prose, markdown, comments, or trailing explanation.\n"
            "If traceback evidence is already sufficient, set need_more_context=false and requests=[].\n"
        )

    parts: list[str] = []
    parts.append(
        "You are selecting additional debugging evidence before proposing a patch.\n"
        "\n"
        "STEP 1 — DIAGNOSE from traceback alone:\n"
        "Read the exception type, message, crash file/line, and test output below.\n"
        "Write a one-sentence root-cause hypothesis.\n"
        "Then name a specific fact that is MISSING from the traceback and would change your diagnosis,\n"
        "or write null if no fact is missing.\n"
        "\n"
        "STEP 2 — DECIDE:\n"
        "If missing_fact is null → set need_more_context=false, requests=[].\n"
        "If you named a missing fact → request ONLY that item (1 request, max 2).\n"
        "Only set need_snapshot=true if you require the full llmdebug snapshot (locals/state); "
        "prefer targeted frame requests.\n"
        "Set need_compact_context=true only when the current evidence is too large/noisy and "
        "a compacted evidence bundle is needed before patching.\n"
        + request_mode_instructions
        + strict_json_instructions
        + "\n"
        + _STAGE_A_CALIBRATION_EXAMPLES
        + "\n"
        "Return STRICT JSON only with this schema:\n" + STAGE_A_REQUEST_SCHEMA_PRETTY + "\n"
    )
    if helper_budget_total > 0:
        parts.append(
            f"Budget: You have {helper_budget_remaining} helper call(s) remaining "
            f"(of {helper_budget_total} total). "
            "If you use it on low-value context, no further calls are available."
        )
    parts.append(f"Repro command:\n{repro}\n")
    parts.append("Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n")
    if stderr:
        parts.append("Pytest output (stderr):\n" + stderr.rstrip() + "\n")
    if snapshot is not None:
        condition = str(evidence.get("condition") or "")
        is_adaptive = condition in {
            Condition.ADAPTIVE_GATED.value,
            Condition.ADAPTIVE_LLM_DISCRETION.value,
        }
        parts.append(
            _format_snapshot_index_section(snapshot)
            if is_adaptive
            else _format_snapshot_section(snapshot)
        )
    if failure_signature:
        parts.append(f"Current failure signature:\n{failure_signature}\n")
    if previous_failure_signature is not None:
        parts.append(f"Previous failure signature:\n{previous_failure_signature}\n")
        parts.append(f"Failure signature repeated:\n{failure_signature_repeat}\n")
    if previous_attempt_diff is not None:
        prompt_safe_diff = _prompt_safe_previous_attempt_diff(previous_attempt_diff)
        parts.append(
            "Structured diff vs previous failed attempt:\n"
            + json.dumps(prompt_safe_diff, indent=2, ensure_ascii=False)
            + "\n"
        )
    if file_index:
        parts.append(
            "Available files (use exact relative paths):\n" + str(file_index).rstrip() + "\n"
        )
    parts.append(
        "Do NOT propose code edits in this step. "
        "Return JSON only. Use need_more_context=false with requests=[] when evidence is sufficient."
    )
    return "\n".join(parts).rstrip() + "\n"


def render_stage_b_patch_prompt(
    *,
    evidence: dict[str, Any],
    snapshot: dict[str, Any] | None,
    stage_a_context: str = "",
    snapshot_lite_context: str = "",
    include_project_files: bool = True,
    retry_packet: str = "",
    retry_history: str = "",
    retry_prompt_mode: str = "full",
    attempt: int = 1,
    known_evidence_refs: list[str] | None = None,
    new_evidence_ids: list[str] | None = None,
    stage_a_evidence_index: str = "",
    include_stage_a_evidence_index: bool = True,
    compacted_context: str = "",
    include_snapshot: bool = True,
    include_pytest_output: bool = True,
    include_stage_a_context: bool = True,
) -> str:
    """Build the Stage-B prompt that asks the LLM to propose a patch.

    Assembles all evidence sections (snapshot, stage-A context, retry
    history, project files) and applies per-section budgets.
    """
    sections = _build_stage_b_prompt_sections(
        evidence=evidence,
        snapshot=snapshot,
        stage_a_context=stage_a_context,
        snapshot_lite_context=snapshot_lite_context,
        stage_a_evidence_index=stage_a_evidence_index,
        include_stage_a_evidence_index=include_stage_a_evidence_index,
        compacted_context=compacted_context,
        include_snapshot=include_snapshot,
        include_pytest_output=include_pytest_output,
        include_stage_a_context=include_stage_a_context,
        include_project_files=include_project_files,
        retry_packet=retry_packet,
        retry_history=retry_history,
        retry_prompt_mode=retry_prompt_mode,
        attempt=attempt,
        known_evidence_refs=known_evidence_refs,
        new_evidence_ids=new_evidence_ids,
    )
    return _join_prompt_sections(sections)


def _build_stage_b_prompt_sections(
    *,
    evidence: dict[str, Any],
    snapshot: dict[str, Any] | None,
    stage_a_context: str,
    stage_a_evidence_index: str,
    include_stage_a_evidence_index: bool,
    compacted_context: str,
    include_snapshot: bool,
    include_pytest_output: bool,
    include_stage_a_context: bool,
    include_project_files: bool,
    retry_packet: str,
    retry_history: str,
    retry_prompt_mode: str,
    attempt: int,
    known_evidence_refs: list[str] | None,
    new_evidence_ids: list[str] | None,
    snapshot_lite_context: str = "",
) -> list[dict[str, Any]]:
    """Assemble the ordered list of prompt sections for Stage-B.

    Each section is a dict with ``key``, ``text``, and ``priority`` used
    by ``_apply_prompt_budget`` for truncation and overflow handling.
    """
    repro = evidence.get("repro")
    stdout = evidence.get("stdout", "")
    stderr = evidence.get("stderr", "")
    failure_signature = evidence.get("failure_signature", "")
    previous_failure_signature = evidence.get("previous_failure_signature")
    failure_signature_repeat = bool(evidence.get("failure_signature_repeat"))
    previous_attempt_diff = evidence.get("previous_attempt_diff")
    timed_out = bool(evidence.get("timed_out"))
    timeout_sec_raw = evidence.get("timeout_sec")
    timeout_sec = int(timeout_sec_raw) if isinstance(timeout_sec_raw, int) else None
    previous_note = str(evidence.get("previous_note") or "")
    previous_patch_verifier_rejected = bool(evidence.get("previous_patch_verifier_rejected"))
    previous_patch_verifier_reasons_raw = evidence.get("previous_patch_verifier_reasons")
    previous_patch_verifier_reasons = (
        [str(item) for item in previous_patch_verifier_reasons_raw]
        if isinstance(previous_patch_verifier_reasons_raw, list)
        else []
    )
    previous_patch_verifier_scope = str(evidence.get("previous_patch_verifier_scope") or "")
    file_index = evidence.get("file_index", "")
    files = evidence.get("files") or ""
    has_previous = previous_failure_signature is not None
    is_delta_retry = retry_prompt_mode == "delta" and attempt > 1

    sections: list[dict[str, Any]] = []
    if is_delta_retry:
        sections.append(
            {
                "key": "instructions",
                "priority": 3,
                "text": (
                    "You are a coding assistant. This is a retry with delta-first protocol.\n"
                    "Use the retry packet and new evidence blocks first.\n"
                    "Prioritize non-test source files; edit tests only when evidence shows a test is wrong.\n"
                    "Ignore cosmetic CLI/TUI wording or formatting tweaks unless evidence requires them.\n"
                    "Avoid equivalent edits unless evidence changed.\n"
                    "Goal: produce the minimal edit that resolves the failing behavior."
                ),
            }
        )
    else:
        sections.append(
            {
                "key": "instructions",
                "priority": 3,
                "text": (
                    "You are a coding assistant. Fix the failing behavior shown by the tests.\n"
                    "Prioritize non-test source files; edit tests only when evidence shows a test is wrong.\n"
                    "Ignore cosmetic CLI/TUI wording or formatting tweaks unless evidence requires them.\n"
                    "Make the smallest behavior-changing edit (avoid extra refactors or added features).\n"
                    "Do not submit observability-only patches (logging/comments/assertions) unless required "
                    "for the fix.\n"
                    "Do not repeat an equivalent patch when the failure signature is unchanged."
                ),
            }
        )

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

    if retry_packet:
        sections.append(
            {"key": "retry_packet", "priority": 3, "text": retry_packet.rstrip() + "\n"}
        )
    if retry_history:
        sections.append(
            {
                "key": "retry_history",
                "priority": 2,
                "text": "Retry history from previous attempts:\n" + retry_history.rstrip() + "\n",
            }
        )

    sections.append({"key": "repro", "priority": 3, "text": f"Repro command:\n{repro}\n"})

    if not is_delta_retry and include_pytest_output:
        pytest_block = "Pytest output (stdout):\n" + (stdout or "").rstrip() + "\n"
        if stderr:
            pytest_block += "\nPytest output (stderr):\n" + stderr.rstrip() + "\n"
        sections.append({"key": "pytest_output", "priority": 2, "text": pytest_block})

    if not is_delta_retry and include_snapshot and snapshot is not None:
        sections.append(
            {"key": "snapshot", "priority": 2, "text": _format_snapshot_section(snapshot)}
        )
        sections.append(
            {
                "key": "distractor_mitigation",
                "priority": 2,
                "text": (
                    "The snapshot above contains many locals. Focus ONLY on variables mentioned "
                    "in the exception message, the crash line, or the test assertion. Other locals "
                    "are likely irrelevant distractors — ignore them."
                ),
            }
        )

    if failure_signature:
        sections.append(
            {
                "key": "failure_signatures",
                "priority": 3,
                "text": f"Current failure signature:\n{failure_signature}\n",
            }
        )
    if has_previous:
        sections.append(
            {
                "key": "failure_signatures",
                "priority": 3,
                "text": (
                    f"Previous failure signature:\n{previous_failure_signature}\n"
                    f"Failure signature repeated:\n{failure_signature_repeat}\n"
                ),
            }
        )

    if (not is_delta_retry) and previous_attempt_diff is not None:
        prompt_safe_diff = _prompt_safe_previous_attempt_diff(previous_attempt_diff)
        sections.append(
            {
                "key": "previous_attempt_diff",
                "priority": 2,
                "text": (
                    "Structured diff vs previous failed attempt:\n"
                    + json.dumps(prompt_safe_diff, indent=2, ensure_ascii=False)
                    + "\n"
                ),
            }
        )
    if failure_signature_repeat:
        sections.append(
            {
                "key": "loop_breaker",
                "priority": 3,
                "text": (
                    "Loop breaker: The signature repeated. "
                    "Change either diagnosis or evidence basis before retrying.\n"
                ),
            }
        )
    if timed_out:
        timeout_label = (
            f"{timeout_sec}s" if isinstance(timeout_sec, int) and timeout_sec > 0 else "timeout"
        )
        sections.append(
            {
                "key": "timeout_risk",
                "priority": 3,
                "text": (
                    "Timeout risk signal: the latest failing run timed out "
                    f"(budget={timeout_label}).\n"
                    "Prefer low-risk fixes that reduce pathological loops/retries, avoid added "
                    "blocking I/O, and preserve bounded behavior.\n"
                ),
            }
        )
    if previous_note == "patch_verifier_rejected" or previous_patch_verifier_rejected:
        reasons_text = (
            ", ".join(previous_patch_verifier_reasons[:6])
            if previous_patch_verifier_reasons
            else "insufficient source relevance"
        )
        scope_text = previous_patch_verifier_scope or "adaptive_only"
        sections.append(
            {
                "key": "verifier_retry_guidance",
                "priority": 3,
                "text": (
                    "Verifier-aware retry guidance: the previous candidate was rejected by patch "
                    f"verifier (scope={scope_text}; reasons={reasons_text}).\n"
                    "Prioritize traceback-linked non-test source edits first; treat test edits as "
                    "last resort unless evidence proves the test is wrong.\n"
                ),
            }
        )

    if not is_delta_retry and file_index:
        sections.append(
            {
                "key": "file_index",
                "priority": 1,
                "text": "Available files (use exact relative paths):\n"
                + str(file_index).rstrip()
                + "\n",
            }
        )
    if compacted_context:
        sections.append(
            {
                "key": "compacted_context",
                "priority": 2,
                "text": "Compacted context summary:\n" + compacted_context.rstrip() + "\n",
            }
        )
    if snapshot_lite_context:
        sections.append(
            {
                "key": "snapshot_lite",
                "priority": 2,
                "text": "Compact snapshot index:\n" + snapshot_lite_context.rstrip() + "\n",
            }
        )
    if include_stage_a_context and stage_a_context:
        sections.append(
            {
                "key": "stage_a_context",
                "priority": 2,
                "text": "Stage-A requested context:\n" + stage_a_context.rstrip() + "\n",
            }
        )
    if include_stage_a_evidence_index and stage_a_evidence_index:
        sections.append(
            {
                "key": "stage_a_evidence_index",
                "priority": 1,
                "text": "Stage-A evidence index:\n" + stage_a_evidence_index.rstrip() + "\n",
            }
        )

    known_refs = [item for item in (known_evidence_refs or []) if item]
    if is_delta_retry and known_refs:
        sections.append(
            {
                "key": "known_evidence_refs",
                "priority": 1,
                "text": "Known evidence references:\n"
                + "\n".join(f"- {item}" for item in known_refs),
            }
        )
    if is_delta_retry and new_evidence_ids:
        sections.append(
            {
                "key": "new_evidence_ids",
                "priority": 2,
                "text": "New evidence IDs in this retry:\n"
                + "\n".join(f"- {item}" for item in new_evidence_ids),
            }
        )
    if include_project_files and files and not is_delta_retry:
        sections.append(
            {
                "key": "project_files",
                "priority": 0,
                "text": "Project files:\n" + str(files).rstrip() + "\n",
            }
        )

    sections.append(
        {
            "key": "closing",
            "priority": 2,
            "text": (
                "CRITICAL — YOUR PATCH MUST:\n"
                "- Target the ROOT CAUSE visible in the traceback, not the symptom.\n"
                "- If the traceback alone identifies the bug, ignore supplementary context.\n"
                "- Be the smallest correct edit (no refactoring, no added features).\n"
                "- Not repeat a previously failed equivalent patch."
            ),
        }
    )
    return sections


def _join_prompt_sections(sections: list[dict[str, Any]]) -> str:
    parts = [
        str(section.get("text") or "").strip("\n") for section in sections if section.get("text")
    ]
    if not parts:
        return ""
    return "\n\n".join(parts).rstrip() + "\n"


def _truncate_text_for_section(*, key: str, text: str, max_chars: int) -> str:
    if max_chars <= 0:
        return ""
    if len(text) <= max_chars:
        return text
    if max_chars <= 64:
        return text[:max_chars]

    marker = "\n...[truncated]...\n"
    if len(marker) >= max_chars:
        return text[:max_chars]

    if key in {"pytest_output", "retry_packet"}:
        tail_chars = max_chars // 2
        head_chars = max_chars - tail_chars - len(marker)
        if head_chars < 0:
            return text[-max_chars:]
        return text[:head_chars] + marker + text[-tail_chars:]

    head_chars = max_chars - len(marker)
    return text[:head_chars] + marker


def _section_text_by_key(sections: list[dict[str, Any]]) -> dict[str, str]:
    by_key: dict[str, str] = {}
    for section in sections:
        key = str(section.get("key") or "")
        if not key:
            continue
        text = str(section.get("text") or "")
        if key in by_key:
            by_key[key] = by_key[key] + text
        else:
            by_key[key] = text
    return by_key


def _apply_prompt_budget(
    *,
    sections: list[dict[str, Any]],
    budget: dict[str, int],
    is_delta_retry: bool,
) -> PromptBudgetResult:
    """Enforce per-section and total-char limits on prompt sections.

    Returns a ``PromptBudgetResult`` with the final prompt, overflow
    metrics, and a list of dropped section keys.
    """
    capped_sections = [dict(section) for section in sections]
    original_by_key = _section_text_by_key(sections)
    per_section_limits = {
        "retry_packet": int(budget.get("retry_delta_chars", 0)),
        "pytest_output": int(budget.get("stdout_chars", 0)),
        "snapshot": int(budget.get("snapshot_chars", 0)),
        "snapshot_lite": int(budget.get("snapshot_chars", 0)),
        "compacted_context": int(budget.get("evidence_chars", 0)),
        "stage_a_context": int(budget.get("evidence_chars", 0)),
        "stage_a_evidence_index": int(budget.get("evidence_chars", 0)),
        "retry_history": int(budget.get("retry_history_chars", 0)),
    }

    for section in capped_sections:
        key = str(section.get("key") or "")
        text = str(section.get("text") or "")
        limit = per_section_limits.get(key, 0)
        if limit > 0:
            section["text"] = _truncate_text_for_section(key=key, text=text, max_chars=limit)

    prompt = _join_prompt_sections(capped_sections)
    total_budget = int(budget.get("total_chars", 0))
    if total_budget <= 0:
        final_by_key = _section_text_by_key(capped_sections)
        return PromptBudgetResult(
            prompt=prompt,
            used_chars=len(prompt),
            overflow_chars=0,
            dropped_sections=[],
            section_status={
                key: ("full" if final_by_key.get(key, "") == original else "truncated")
                for key, original in original_by_key.items()
                if original
            },
        )
    if len(prompt) <= total_budget:
        final_by_key = _section_text_by_key(capped_sections)
        return PromptBudgetResult(
            prompt=prompt,
            used_chars=len(prompt),
            overflow_chars=0,
            dropped_sections=[],
            section_status={
                key: ("full" if final_by_key.get(key, "") == original else "truncated")
                for key, original in original_by_key.items()
                if original
            },
        )

    overflow = len(prompt) - total_budget
    dropped_sections: list[str] = []
    drop_order = [
        "project_files",
        "file_index",
        "stage_a_evidence_index",
        "known_evidence_refs",
        "retry_history",
        "snapshot",
        "snapshot_lite",
        "pytest_output",
        "compacted_context",
        "stage_a_context",
        "previous_attempt_diff",
    ]
    min_keep_by_key = {
        "snapshot": 240,
        "snapshot_lite": 240,
        "pytest_output": 240,
        "previous_attempt_diff": 240,
        "retry_history": 160,
        "compacted_context": 512 if is_delta_retry else 240,
        "stage_a_context": 512 if is_delta_retry else 240,
    }

    for key in drop_order:
        if overflow <= 0:
            break
        for section in capped_sections:
            if str(section.get("key") or "") != key:
                continue
            text = str(section.get("text") or "")
            if not text:
                continue
            min_keep = min(min_keep_by_key.get(key, 0), len(text))
            target = max(min_keep, len(text) - overflow)
            next_text = _truncate_text_for_section(key=key, text=text, max_chars=target)
            overflow -= max(0, len(text) - len(next_text))
            section["text"] = next_text
            if not next_text.strip():
                dropped_sections.append(key)
            break

    prompt = _join_prompt_sections(capped_sections)
    if len(prompt) > total_budget:
        prompt = _truncate_text_for_section(key="final", text=prompt, max_chars=total_budget)
    overflow_chars = max(0, len(prompt) - total_budget)
    final_by_key = _section_text_by_key(capped_sections)
    section_status: dict[str, str] = {}
    for key, original_text in original_by_key.items():
        if not original_text:
            continue
        final_text = final_by_key.get(key, "")
        if not final_text.strip():
            section_status[key] = "dropped"
        elif final_text == original_text:
            section_status[key] = "full"
        else:
            section_status[key] = "truncated"
    return PromptBudgetResult(
        prompt=prompt,
        used_chars=len(prompt),
        overflow_chars=overflow_chars,
        dropped_sections=sorted(set(dropped_sections)),
        section_status=section_status,
    )


def _build_context_compaction_bundle(
    *,
    scope: set[str],
    snapshot: dict[str, Any] | None,
    stdout: str,
    stderr: str,
    stage_a_context: str,
    project_files_context: str,
    max_input_chars: int,
) -> ContextCompactionBundle:
    """Gather evidence sections into a ``ContextCompactionBundle``.

    Selects sections based on *scope* (pytest, snapshot, files) and
    truncates the combined input to *max_input_chars*.
    """
    parts: list[str] = []
    source_sections: list[str] = []
    source_chars: dict[str, int] = {}
    replace_snapshot = False
    replace_pytest_output = False
    replace_stage_a_context = False
    replace_project_files = False

    if "pytest" in scope:
        pytest_parts: list[str] = []
        if stdout:
            pytest_parts.append("Pytest output (stdout):\n" + stdout.rstrip())
        if stderr:
            pytest_parts.append("Pytest output (stderr):\n" + stderr.rstrip())
        if pytest_parts:
            pytest_text = "\n\n".join(pytest_parts).rstrip() + "\n"
            parts.append("=== pytest_output ===\n" + pytest_text)
            source_sections.append("pytest_output")
            source_chars["pytest_output"] = len(pytest_text)
            replace_pytest_output = True

    if "snapshot" in scope and snapshot is not None:
        snapshot_text = _format_snapshot_section(snapshot).rstrip() + "\n"
        parts.append("=== snapshot ===\n" + snapshot_text)
        source_sections.append("snapshot")
        source_chars["snapshot"] = len(snapshot_text)
        replace_snapshot = True

    if "files" in scope:
        if stage_a_context:
            stage_a_text = stage_a_context.rstrip() + "\n"
            parts.append("=== stage_a_context ===\n" + stage_a_text)
            source_sections.append("stage_a_context")
            source_chars["stage_a_context"] = len(stage_a_text)
            replace_stage_a_context = True
        if project_files_context:
            project_text = project_files_context.rstrip() + "\n"
            parts.append("=== project_files ===\n" + project_text)
            source_sections.append("project_files")
            source_chars["project_files"] = len(project_text)
            replace_project_files = True

    bundle_text = "\n\n".join(parts).rstrip()
    if bundle_text:
        bundle_text += "\n"
    if max_input_chars > 0 and len(bundle_text) > max_input_chars:
        bundle_text = _truncate_text_for_section(
            key="compaction_input",
            text=bundle_text,
            max_chars=max_input_chars,
        )
    return ContextCompactionBundle(
        text=bundle_text,
        source_sections=source_sections,
        source_chars=source_chars,
        replace_snapshot=replace_snapshot,
        replace_pytest_output=replace_pytest_output,
        replace_stage_a_context=replace_stage_a_context,
        replace_project_files=replace_project_files,
    )


def _should_invoke_context_compaction(
    *,
    mode: str,
    supports_context_compaction: bool,
    stage_a_requested: bool,
    policy_action_requested: bool,
    auto_overflow_triggered: bool,
    calls_used: int,
    max_calls: int,
    bundle_input_chars: int,
) -> tuple[bool, str]:
    """Decide whether context compaction should run.

    Returns (should_invoke, reason_token) after checking patcher
    support, call budget, input size, and compaction mode.
    """
    if not supports_context_compaction:
        return False, "patcher_unsupported"
    max_calls_safe = max(0, int(max_calls))
    if max_calls_safe <= 0:
        return False, "max_calls_disabled"
    if max(0, int(calls_used)) >= max_calls_safe:
        return False, "max_calls_reached"
    if max(0, int(bundle_input_chars)) <= 0:
        return False, "empty_input"
    if mode == "off":
        return False, "mode_off"
    if policy_action_requested:
        return True, "policy_action"
    if stage_a_requested:
        return True, "stage_a_requested"
    if mode == "always":
        return True, "mode_always"
    if mode == "auto_overflow" and auto_overflow_triggered:
        return True, "prompt_overflow"
    return False, "auto_no_overflow"


def render_context_compaction_prompt(
    *,
    evidence: dict[str, Any],
    attempt: int,
    reason: str,
    source_sections: list[str],
    target_chars: int,
) -> str:
    """Build a prompt asking the LLM to compact debugging evidence.

    The compacted output is used in subsequent patch-proposal prompts
    when the raw evidence exceeds the prompt budget.
    """
    case_id = str(evidence.get("case_id") or "")
    condition = str(evidence.get("condition") or "")
    failure_signature = str(evidence.get("failure_signature") or "")
    repro = evidence.get("repro")
    source_list = ", ".join(source_sections) if source_sections else "none"
    reason_line = str(reason or "").strip()
    if not reason_line:
        reason_line = "prompt overflow or noisy evidence"
    return (
        "You compact debugging evidence for a downstream patching model.\n"
        "Return a compact, loss-aware summary with exact literals for key facts.\n"
        "Preserve crash file/line, assertion text, failing values, and critical paths.\n"
        "Do not suggest edits.\n\n"
        f"Case: {case_id}\n"
        f"Condition: {condition}\n"
        f"Attempt: {attempt}\n"
        f"Failure signature: {failure_signature}\n"
        f"Repro: {repro}\n"
        f"Reason: {reason_line}\n"
        f"Sources: {source_list}\n"
        f"Target chars: {max(1, int(target_chars))}\n"
    )


def render_prompt(*, evidence: dict[str, Any], snapshot: dict[str, Any] | None) -> str:
    """Backward-compatible prompt renderer (monolithic context protocol)."""
    return render_stage_b_patch_prompt(
        evidence=evidence,
        snapshot=snapshot,
        stage_a_context="",
        include_project_files=True,
    )


def _build_retry_history_section(
    *,
    prior_turns: list[str],
    max_chars: int,
) -> tuple[str, int, int, bool]:
    """Assemble a truncated retry-history section from prior turns.

    Returns (text, turns_included, used_chars, truncated).
    """
    if not prior_turns:
        return "", 0, 0, False
    if max_chars <= 0:
        return "", 0, 0, True

    cleaned_turns = [str(turn).strip() for turn in prior_turns if str(turn).strip()]
    if not cleaned_turns:
        return "", 0, 0, False

    selected_turns: list[str] = []
    used_chars = 0
    truncated = False
    for turn in reversed(cleaned_turns):
        candidate = turn if not selected_turns else "\n\n" + turn
        if used_chars + len(candidate) <= max_chars:
            selected_turns.append(turn)
            used_chars += len(candidate)
            continue

        truncated = True
        if not selected_turns:
            clipped = turn[:max_chars].rstrip()
            if clipped:
                selected_turns.append(clipped)
                used_chars = len(clipped)
        break

    if not selected_turns:
        return "", 0, 0, truncated

    rendered = "\n\n".join(selected_turns).rstrip()
    return rendered, len(rendered), len(selected_turns), truncated


def _render_retry_history_turn(
    *,
    attempt: int,
    note: str,
    failure_signature: str,
    patch_meta: dict[str, Any] | None,
) -> str:
    def _compact(value: Any, *, max_chars: int = 220) -> str:
        text = re.sub(r"\s+", " ", str(value)).strip()
        if len(text) <= max_chars:
            return text
        suffix = "...[truncated]"
        keep = max(0, max_chars - len(suffix))
        return text[:keep].rstrip() + suffix

    def _safe_int(value: Any) -> int:
        try:
            return int(value)
        except (TypeError, ValueError):
            return 0

    meta = patch_meta if isinstance(patch_meta, dict) else {}
    lines = [
        f"Attempt {attempt}",
        f"Outcome: {note}",
    ]
    if failure_signature:
        lines.append(f"Failure signature: {_compact(failure_signature)}")

    retry_gate_reason = str(meta.get("retry_gate_reason") or "").strip()
    if retry_gate_reason:
        if meta.get("retry_gate_blocked") is True:
            lines.append(f"Retry gate: blocked ({_compact(retry_gate_reason)})")
        else:
            lines.append(f"Retry gate: {_compact(retry_gate_reason)}")

    patcher_reason = str(meta.get("reason") or "").strip()
    if patcher_reason:
        lines.append(f"Patcher reason: {_compact(patcher_reason)}")
    if bool(meta.get("patcher_request_failed")):
        kind = str(meta.get("patcher_request_failure_kind") or "transport")
        err = str(meta.get("patcher_request_failure_error") or "")
        lines.append(f"Patcher request failed: {kind} ({_compact(err)})")
    if bool(meta.get("runner_forced_patch_skipped")):
        reason = str(meta.get("runner_forced_patch_skip_reason") or "")
        lines.append(f"Runner forced patch skipped: {_compact(reason)}")

    patch_digest = str(meta.get("patch_diff_sha256") or "").strip()
    if patch_digest:
        patch_chars = _safe_int(meta.get("patch_diff_chars"))
        lines.append(f"Patch digest: {patch_digest} (chars={patch_chars})")
    patch_file_count = _safe_int(meta.get("patch_file_count"))
    patch_test_file_count = _safe_int(meta.get("patch_test_file_count"))
    if patch_file_count > 0:
        lines.append(
            "Patch touches tests: "
            f"{bool(meta.get('patch_touches_tests'))} "
            f"(test_files={patch_test_file_count}, total_files={patch_file_count})"
        )
    if bool(meta.get("patch_verifier_enabled")):
        verifier_verdict = meta.get("patch_verifier_passed")
        verifier_score = _safe_int(meta.get("patch_verifier_score"))
        if verifier_verdict is True:
            lines.append(f"Patch verifier: pass (score={verifier_score})")
        elif verifier_verdict is False:
            lines.append(f"Patch verifier: reject (score={verifier_score})")
        verifier_reasons = meta.get("patch_verifier_reasons")
        if isinstance(verifier_reasons, list) and verifier_reasons:
            rendered = ", ".join(_compact(item, max_chars=80) for item in verifier_reasons[:8])
            lines.append(f"Patch verifier reasons: {rendered}")

    apply_error = str(meta.get("initial_apply_error") or "").strip()
    if apply_error:
        lines.append(f"Initial apply error: {_compact(apply_error)}")

    if meta.get("runner_forced_patch_attempt"):
        lines.append("Runner forced patch attempt: true")
    if meta.get("runner_forced_patch_recovery"):
        lines.append("Runner forced patch recovery: true")
    if bool(meta.get("patch_sanitize_rejected")):
        sanitize_error = str(meta.get("patch_sanitize_error_msg") or "")
        lines.append(f"Patch sanitize: rejected ({_compact(sanitize_error)})")
    if meta.get("retry_delta_prompt"):
        lines.append("Retry delta prompt: true")

    new_count = _safe_int(meta.get("evidence_ids_new_count"))
    reused_count = _safe_int(meta.get("evidence_ids_reused_count"))
    sent_count = _safe_int(meta.get("evidence_ids_sent_count"))
    if new_count or reused_count or sent_count:
        lines.append(f"Evidence IDs: new={new_count}, reused={reused_count}, sent={sent_count}")

    propose_sec = meta.get("propose_duration_sec")
    if isinstance(propose_sec, (int, float)):
        lines.append(f"Propose duration sec: {float(propose_sec):.3f}")
    apply_sec = meta.get("apply_duration_sec")
    if isinstance(apply_sec, (int, float)):
        lines.append(f"Apply duration sec: {float(apply_sec):.3f}")

    return "\n".join(lines).strip()


def _render_retry_packet(
    *,
    failure_signature: str,
    previous_failure_signature: Any,
    failure_signature_repeat: bool,
    previous_attempt_diff: dict[str, Any] | None,
    timed_out: bool = False,
    timeout_sec: int | None = None,
    previous_note: str = "",
    previous_patch_verifier_rejected: bool = False,
    previous_patch_verifier_reasons: list[str] | None = None,
    previous_patch_verifier_scope: str = "",
) -> str:
    prompt_safe_diff = _prompt_safe_previous_attempt_diff(previous_attempt_diff)
    packet: dict[str, Any] = {
        "failure_signature": failure_signature,
        "previous_failure_signature": previous_failure_signature,
        "failure_signature_repeat": failure_signature_repeat,
        "previous_attempt_diff": prompt_safe_diff,
        "timed_out": bool(timed_out),
        "timeout_sec": int(timeout_sec) if isinstance(timeout_sec, int) else None,
        "previous_note": str(previous_note or ""),
        "previous_patch_verifier_rejected": bool(previous_patch_verifier_rejected),
        "previous_patch_verifier_reasons": list(previous_patch_verifier_reasons or []),
        "previous_patch_verifier_scope": str(previous_patch_verifier_scope or ""),
    }
    return (
        "Retry packet (delta-first, structured):\n"
        + json.dumps(packet, indent=2, ensure_ascii=False, default=str)
        + "\n"
    )


def _prompt_safe_previous_attempt_diff(
    previous_attempt_diff: dict[str, Any] | None,
) -> dict[str, Any] | None:
    if not isinstance(previous_attempt_diff, dict):
        return None

    def _rewrite(value: Any) -> Any:
        if isinstance(value, dict):
            out: dict[str, Any] = {}
            for key, child in value.items():
                mapped_key = "diagnosis_delta" if str(key) == "hypothesis_delta" else str(key)
                out[mapped_key] = _rewrite(child)
            return out
        if isinstance(value, list):
            return [_rewrite(item) for item in value]
        return value

    rewritten = _rewrite(previous_attempt_diff)
    if isinstance(rewritten, dict):
        return rewritten
    return None
