"""Packed operation opportunity detector for AMDGCN ISA.
Identifies scalar VALU operations that could be replaced with packed (v_pk_*)
equivalents for ~2x throughput improvement on those instructions.
Background:
  - Scalar ops like v_add_f32 process 1 value per instruction
  - Packed ops like v_pk_add_f32 process 2 values per instruction
  - When consecutive elements undergo the same operation, packing halves the
    instruction count for that region
Common pattern: Epilogue (post-GEMM) operations
  - Tensile/compilers often generate scalar epilogues:
      v_add_f32 v0, v0, v100     ; bias element 0
      v_mul_f32 v0, v0, s10      ; scale element 0
      v_add_f32 v1, v1, v101     ; bias element 1
      v_mul_f32 v1, v1, s10      ; scale element 1
  - Hand-tuned kernels use packed epilogues (2x fewer instructions):
      v_pk_add_f32 v[0:1], v[0:1], v[100:101]  ; bias 2 elements
      v_pk_mul_f32 v[0:1], v[0:1], s[10:11]    ; scale 2 elements
Why this matters: In a large GEMM epilogue with many output elements, scalar
epilogues can be the performance bottleneck. Packing them doubles throughput
for that phase.
"""
import re
from typing import Optional
from wafer_core.lib.kernel_scope.amdgcn.types import (
    InstructionInfo,
    InstructionCategory,
    PackableOpPair,
    PackableSequence,
    PackedOpportunityAnalysis,
)
from wafer_core.lib.kernel_scope.amdgcn.instruction_db import is_packed_instruction
# ── Packable instruction mapping ────────────────────────────────────────────
# Maps scalar VALU mnemonics to their packed equivalents, split by architecture
# support tier. f32 packed ops require CDNA2+ (gfx90a+), f16/i16 are broadly
# available on GCN5+.
# f32 packed ops - ONLY available on CDNA2+ (gfx90a, gfx940, gfx942, ...)
_F32_PACKED_OPS: dict[str, str] = {
    "v_add_f32": "v_pk_add_f32",
    "v_mul_f32": "v_pk_mul_f32",
    "v_fma_f32": "v_pk_fma_f32",
    "v_fmac_f32": "v_pk_fma_f32",
}
# f16/i16/u16 packed ops - broadly available (GCN5+, RDNA, CDNA)
_F16_I16_PACKED_OPS: dict[str, str] = {
    "v_add_f16": "v_pk_add_f16",
    "v_mul_f16": "v_pk_mul_f16",
    "v_fma_f16": "v_pk_fma_f16",
    "v_max_f16": "v_pk_max_f16",
    "v_min_f16": "v_pk_min_f16",
    "v_add_i16": "v_pk_add_i16",
    "v_sub_i16": "v_pk_sub_i16",
    "v_mul_lo_u16": "v_pk_mul_lo_u16",
    "v_lshlrev_b16": "v_pk_lshlrev_b16",
    "v_lshrrev_b16": "v_pk_lshrrev_b16",
    "v_ashrrev_i16": "v_pk_ashrrev_i16",
    "v_add_u16": "v_pk_add_u16",
    "v_sub_u16": "v_pk_sub_u16",
}
# Combined (all architectures) - used when arch is unknown
_SCALAR_TO_PACKED: dict[str, str] = {**_F32_PACKED_OPS, **_F16_I16_PACKED_OPS}

_PACKED_TO_SCALAR: dict[str, str] = {v: k for k, v in _SCALAR_TO_PACKED.items()}
# Architectures that support f32 packed ops (v_pk_*_f32)
# Why: gfx908 (MI100/CDNA1) does NOT support f32 packed. gfx90a+ (CDNA2+) does.
_F32_PACKED_ARCHS: set[str] = {
    "gfx90a",   # MI210/MI250 (CDNA2)
    "gfx940",   # MI300A (CDNA3)
    "gfx941",   # CDNA3 variant
    "gfx942",   # MI300X (CDNA3)
}
def _get_packable_ops_for_arch(architecture: str) -> dict[str, str]:
    """Return the scalar-to-packed mapping appropriate for the target architecture.
    Args:
        architecture: Target GPU architecture (e.g., "gfx90a", "gfx942", "unknown")
    Returns:
        Dict mapping scalar mnemonics to packed equivalents for this arch
    """
    arch_lower = architecture.lower().strip()
    f32_supported = arch_lower in _F32_PACKED_ARCHS
    # For unknown architectures, be conservative: include all ops but note
    # in recommendations that f32 packed requires gfx90a+
    if arch_lower == "unknown":
        return _SCALAR_TO_PACKED
    if f32_supported:
        return _SCALAR_TO_PACKED  # All ops available
    else:
        return _F16_I16_PACKED_OPS  # Only f16/i16 packed available
# ── Register extraction ─────────────────────────────────────────────────────
# Pattern: v0, v1, v255 (single VGPR)
_SINGLE_VGPR_PATTERN = re.compile(r"\bv(\d+)\b")
# Pattern: v[0:1], v[100:103] (VGPR range)
_VGPR_RANGE_PATTERN = re.compile(r"\bv\[(\d+):(\d+)\]")
# Pattern: s0, s10 (single SGPR)
_SINGLE_SGPR_PATTERN = re.compile(r"\bs(\d+)\b")
def _extract_dest_vgpr(instruction: InstructionInfo) -> Optional[int]:
    """Extract the destination VGPR number from an instruction.
    The destination is the first operand for VALU instructions.
    Returns None if no VGPR destination found.
    """
    if not instruction.operands:
        return None
    dest = instruction.operands[0].strip()
    # Single register: v0, v1, etc.
    match = re.match(r"^v(\d+)$", dest)
    if match:
        return int(match.group(1))
    # Register range: v[0:1] - return the start
    match = re.match(r"^v\[(\d+):\d+\]$", dest)
    if match:
        return int(match.group(1))
    return None
def _extract_all_vgpr_refs(instruction: InstructionInfo) -> list[int]:
    """Extract all VGPR numbers referenced in the instruction's operands."""
    refs = []
    for operand in instruction.operands:
        for match in _SINGLE_VGPR_PATTERN.finditer(operand):
            refs.append(int(match.group(1)))
        for match in _VGPR_RANGE_PATTERN.finditer(operand):
            start = int(match.group(1))
            end = int(match.group(2))
            refs.extend(range(start, end + 1))
    return refs
def _operands_structurally_compatible(
    inst_a: InstructionInfo,
    inst_b: InstructionInfo,
) -> bool:
    """Check if two instructions have structurally compatible operands for packing.
    For packing, the two instructions should:
    1. Have the same number of operands
    2. Use the same operand types (VGPR vs SGPR vs literal) in each position
    3. Source operands should be consecutive VGPRs, the same SGPR (broadcast),
       an adjacent SGPR pair, or the same literal constant
    Why strict SGPR check: Two different non-adjacent SGPRs (e.g., s10 vs s20)
    cannot be encoded into a single packed instruction operand. Only same-SGPR
    broadcast or adjacent SGPR pairs (s[N:N+1]) are valid packed operand forms.
    """
    assert inst_a.mnemonic == inst_b.mnemonic
    assert len(inst_a.operands) > 0
    assert len(inst_b.operands) > 0
    if len(inst_a.operands) != len(inst_b.operands):
        return False
    for i in range(1, len(inst_a.operands)):
        op_a = inst_a.operands[i].strip()
        op_b = inst_b.operands[i].strip()
        # Same literal/constant - fine (broadcast)
        if op_a == op_b:
            continue
        # Both are VGPRs - check if consecutive
        vgpr_a = re.match(r"^v(\d+)$", op_a)
        vgpr_b = re.match(r"^v(\d+)$", op_b)
        if vgpr_a and vgpr_b:
            reg_a = int(vgpr_a.group(1))
            reg_b = int(vgpr_b.group(1))
            if reg_b == reg_a + 1:
                continue
        # Both are SGPRs - only allow same register (broadcast) or adjacent pair
        # Why: Two different non-adjacent SGPRs (s10 vs s20) can't be encoded
        # into a single packed instruction operand.
        sgpr_a = re.match(r"^s(\d+)$", op_a)
        sgpr_b = re.match(r"^s(\d+)$", op_b)
        if sgpr_a and sgpr_b:
            reg_a = int(sgpr_a.group(1))
            reg_b = int(sgpr_b.group(1))
            # Same SGPR (broadcast) or adjacent pair (s[N:N+1])
            if reg_a == reg_b or (reg_b == reg_a + 1 and reg_a % 2 == 0):
                continue
            # Non-adjacent SGPRs - not encodable as packed operand
            return False
        # VGPR ranges - check consecutive
        range_a = re.match(r"^v\[(\d+):(\d+)\]$", op_a)
        range_b = re.match(r"^v\[(\d+):(\d+)\]$", op_b)
        if range_a and range_b:
            end_a = int(range_a.group(2))
            start_b = int(range_b.group(1))
            if start_b == end_a + 1:
                continue
        # Operands not compatible for packing
        return False
    return True
# ── Core detection ───────────────────────────────────────────────────────────
def detect_packable_pairs(
    instructions: tuple[InstructionInfo, ...],
    max_lookahead: int = 12,
    architecture: str = "unknown",
) -> list[PackableOpPair]:
    """Find pairs of scalar VALU ops that could be a single packed instruction.
    Scans the instruction stream for pairs of same-mnemonic scalar VALU
    instructions operating on consecutive even-aligned VGPR destinations.
    Args:
        instructions: Parsed instruction stream for a kernel
        max_lookahead: How far ahead to look for a matching instruction.
            Larger values find more pairs but may group less-related ops.
            Default 12 handles typical interleaved epilogue patterns.
        architecture: Target GPU architecture (e.g., "gfx90a", "gfx942").
            Used to filter out packed ops unsupported by the target.
            For example, v_pk_*_f32 requires gfx90a+ (CDNA2+).
    Returns:
        List of packable pairs, in instruction order
    """
    assert instructions is not None
    # Why: v_pk_*_f32 is only available on gfx90a+ (CDNA2+). Suggesting it
    # on gfx908 (MI100) would be incorrect and misleading.
    packable_ops = _get_packable_ops_for_arch(architecture)
    pairs: list[PackableOpPair] = []
    used_indices: set[int] = set()
    for i, inst_a in enumerate(instructions):
        if i in used_indices:
            continue
        mnemonic_lower = inst_a.mnemonic.lower().strip()
        # Must be a packable scalar instruction for this architecture
        if mnemonic_lower not in packable_ops:
            continue
        dest_a = _extract_dest_vgpr(inst_a)
        if dest_a is None:
            continue
        # Destination must be even-aligned for register pairing
        if dest_a % 2 != 0:
            continue
        # Look ahead for a matching instruction on the next register
        search_end = min(i + max_lookahead + 1, len(instructions))
        for j in range(i + 1, search_end):
            if j in used_indices:
                continue
            inst_b = instructions[j]
            mnemonic_b_lower = inst_b.mnemonic.lower().strip()
            # Must be the same operation
            if mnemonic_b_lower != mnemonic_lower:
                continue
            dest_b = _extract_dest_vgpr(inst_b)
            if dest_b is None:
                continue
            # Destination must be dest_a + 1 (consecutive pair)
            if dest_b != dest_a + 1:
                continue
            if not _operands_structurally_compatible(inst_a, inst_b):
                continue
            # Found a packable pair
            pairs.append(PackableOpPair(
                first_instruction=inst_a,
                second_instruction=inst_b,
                packed_equivalent=packable_ops[mnemonic_lower],
                first_dest_reg=dest_a,
                second_dest_reg=dest_b,
            ))
            used_indices.add(i)
            used_indices.add(j)
            break  # Paired, move to next instruction
    return pairs
def _classify_pattern(pairs: list[PackableOpPair]) -> str:
    """Classify the type of packable pattern from a group of pairs.
    Returns a human-readable pattern name describing what the packable
    region is doing (e.g., epilogue bias+scale).
    """
    if not pairs:
        return "unknown"
    mnemonics = [p.packed_equivalent for p in pairs]
    unique_ops = list(dict.fromkeys(mnemonics))  # Preserves order
    # Epilogue patterns: repeated (add, mul) or (fma) sequences
    if len(unique_ops) == 2:
        ops_set = set(unique_ops)
        if {"v_pk_add_f32", "v_pk_mul_f32"} == ops_set:
            return "epilogue_bias_scale_f32"
        if {"v_pk_add_f16", "v_pk_mul_f16"} == ops_set:
            return "epilogue_bias_scale_f16"
        if {"v_pk_add_f32", "v_pk_fma_f32"} == ops_set:
            return "epilogue_bias_fma_f32"
        return "epilogue_mixed"
    if len(unique_ops) == 1:
        op = unique_ops[0]
        if "add" in op:
            return "repeated_add"
        if "mul" in op:
            return "repeated_mul"
        if "fma" in op:
            return "repeated_fma"
        return "repeated_arithmetic"
    if len(unique_ops) >= 3:
        return "complex_epilogue"
    return "mixed_arithmetic"
def group_into_sequences(
    pairs: list[PackableOpPair],
    max_line_gap: int = 20,
) -> list[PackableSequence]:
    """Group packable pairs into contiguous sequences (packable regions).
    Pairs that are close together in the instruction stream are grouped
    into a single PackableSequence representing a packable code region.
    Args:
        pairs: Detected packable pairs
        max_line_gap: Maximum line gap between pairs to be in the same sequence.
            A gap larger than this starts a new sequence.
    Returns:
        List of packable sequences
    """
    if not pairs:
        return []
    sorted_pairs = sorted(pairs, key=lambda p: p.first_instruction.line_number)
    sequences: list[PackableSequence] = []
    current_group: list[PackableOpPair] = [sorted_pairs[0]]
    for pair in sorted_pairs[1:]:
        prev_pair = current_group[-1]
        prev_end_line = max(
            prev_pair.first_instruction.line_number,
            prev_pair.second_instruction.line_number,
        )
        curr_start_line = min(
            pair.first_instruction.line_number,
            pair.second_instruction.line_number,
        )
        if curr_start_line - prev_end_line <= max_line_gap:
            current_group.append(pair)
        else:
            # Close current group and start new one
            sequences.append(_build_sequence(current_group))
            current_group = [pair]
    # Close last group
    if current_group:
        sequences.append(_build_sequence(current_group))
    return sequences
def _build_sequence(pairs: list[PackableOpPair]) -> PackableSequence:
    """Build a PackableSequence from a group of pairs."""
    assert len(pairs) > 0
    all_lines = []
    for p in pairs:
        all_lines.append(p.first_instruction.line_number)
        all_lines.append(p.second_instruction.line_number)
    start_line = min(all_lines)
    end_line = max(all_lines)
    scalar_count = len(pairs) * 2  # Each pair is 2 scalar ops
    packed_count = len(pairs)       # Each pair becomes 1 packed op
    savings = scalar_count - packed_count
    pattern_type = _classify_pattern(pairs)
    return PackableSequence(
        pairs=tuple(pairs),
        start_line=start_line,
        end_line=end_line,
        pattern_type=pattern_type,
        scalar_instruction_count=scalar_count,
        packed_instruction_count=packed_count,
        instruction_savings=savings,
    )
# ── High-level analysis ─────────────────────────────────────────────────────
def detect_packed_opportunities(
    instructions: tuple[InstructionInfo, ...],
    total_valu_count: int = 0,
    architecture: str = "unknown",
) -> PackedOpportunityAnalysis:
    """Run full packed operation opportunity analysis on a kernel's instructions.
    This is the main entry point. It:
    1. Counts already-packed instructions (v_pk_*)
    2. Detects scalar VALU ops that could be packed (arch-aware)
    3. Groups them into contiguous packable regions
    4. Classifies the pattern type (epilogue, arithmetic, etc.)
    5. Computes savings metrics
    6. Generates actionable recommendations
    Args:
        instructions: Parsed instruction stream for a kernel
        total_valu_count: Total VALU instruction count (for percentage calculation).
            If 0, percentage is computed from instructions directly.
        architecture: Target GPU architecture (e.g., "gfx90a", "gfx942").
            Used to filter unsupported packed ops (e.g., v_pk_*_f32 needs gfx90a+).
    Returns:
        PackedOpportunityAnalysis with full results
    """
    assert instructions is not None
    # Count already-packed instructions
    already_packed = sum(
        1 for inst in instructions if is_packed_instruction(inst.mnemonic)
    )
    # Count total VALU if not provided
    if total_valu_count == 0:
        total_valu_count = sum(
            1 for inst in instructions
            if inst.category == InstructionCategory.VALU
        )
    # Detect packable pairs (filtered by architecture support)
    pairs = detect_packable_pairs(instructions, architecture=architecture)
    if not pairs:
        # No opportunities found - compute packing ratio from existing packed ops
        total_packable_universe = already_packed  # All packable work is already packed
        packing_ratio = 1.0 if already_packed > 0 else 0.0
        recommendations = _generate_recommendations(
            pairs_count=0,
            already_packed=already_packed,
            total_valu=total_valu_count,
            sequences=[],
        )
        return PackedOpportunityAnalysis(
            total_packable_pairs=0,
            total_scalar_ops_packable=0,
            total_packed_ops_needed=0,
            instruction_savings=0,
            savings_pct_of_valu=0.0,
            packable_sequences=(),
            already_packed_count=already_packed,
            packing_ratio=packing_ratio,
            recommendations=tuple(recommendations),
        )
    sequences = group_into_sequences(pairs)
    total_scalar_packable = len(pairs) * 2
    total_packed_needed = len(pairs)
    instruction_savings = total_scalar_packable - total_packed_needed
    savings_pct = (instruction_savings / total_valu_count * 100) if total_valu_count > 0 else 0.0
    # Packing ratio: how much of the packable work is already packed
    # Total "packable work units" = already_packed + pairs we could pack
    total_packable_work = already_packed + len(pairs)
    packing_ratio = already_packed / total_packable_work if total_packable_work > 0 else 0.0
    recommendations = _generate_recommendations(
        pairs_count=len(pairs),
        already_packed=already_packed,
        total_valu=total_valu_count,
        sequences=sequences,
    )
    return PackedOpportunityAnalysis(
        total_packable_pairs=len(pairs),
        total_scalar_ops_packable=total_scalar_packable,
        total_packed_ops_needed=total_packed_needed,
        instruction_savings=instruction_savings,
        savings_pct_of_valu=savings_pct,
        packable_sequences=tuple(sequences),
        already_packed_count=already_packed,
        packing_ratio=packing_ratio,
        recommendations=tuple(recommendations),
    )
def _generate_recommendations(
    pairs_count: int,
    already_packed: int,
    total_valu: int,
    sequences: list[PackableSequence],
) -> list[str]:
    """Generate actionable recommendations based on the analysis."""
    recs: list[str] = []
    if pairs_count == 0 and already_packed > 0:
        recs.append(
            f"GOOD: All packable operations are already using packed instructions "
            f"({already_packed} v_pk_* ops found). No further packing opportunities."
        )
        return recs
    if pairs_count == 0 and already_packed == 0:
        recs.append(
            "INFO: No packable operation patterns detected. This kernel may not "
            "have consecutive element-wise operations suitable for v_pk_* packing."
        )
        return recs
    # There are opportunities
    total_savings = pairs_count  # Each pair saves 1 instruction
    savings_pct = (total_savings / total_valu * 100) if total_valu > 0 else 0.0
    recs.append(
        f"OPPORTUNITY: {pairs_count} scalar VALU instruction pairs could be packed "
        f"into {pairs_count} packed (v_pk_*) instructions, saving {total_savings} "
        f"instructions ({savings_pct:.1f}% of VALU)."
    )
    # Describe each sequence
    for i, seq in enumerate(sequences):
        pattern_desc = _pattern_description(seq.pattern_type)
        recs.append(
            f"  Region {i + 1} (lines {seq.start_line}-{seq.end_line}): "
            f"{seq.scalar_instruction_count} scalar ops -> {seq.packed_instruction_count} packed ops. "
            f"Pattern: {pattern_desc}."
        )
    # Actionable advice
    if any(s.pattern_type.startswith("epilogue") for s in sequences):
        recs.append(
            "ACTION: This looks like a scalar epilogue (e.g., Tensile-generated bias+scale). "
            "Convert to packed epilogue by pairing consecutive register operations into "
            "v_pk_add_f32/v_pk_mul_f32 on register pairs v[N:N+1]."
        )
    if already_packed > 0:
        recs.append(
            f"NOTE: {already_packed} operations are already packed. The kernel is partially "
            f"optimized but {pairs_count} more pairs could be packed."
        )
    return recs
def _pattern_description(pattern_type: str) -> str:
    """Human-readable description of a pattern type."""
    descriptions = {
        "epilogue_bias_scale_f32": "f32 bias+scale epilogue (add then multiply per element)",
        "epilogue_bias_scale_f16": "f16 bias+scale epilogue (add then multiply per element)",
        "epilogue_bias_fma_f32": "f32 bias+FMA epilogue",
        "epilogue_mixed": "mixed-operation epilogue",
        "repeated_add": "repeated addition across consecutive elements",
        "repeated_mul": "repeated multiplication across consecutive elements",
        "repeated_fma": "repeated FMA across consecutive elements",
        "repeated_arithmetic": "repeated arithmetic on consecutive elements",
        "complex_epilogue": "complex multi-operation epilogue",
        "mixed_arithmetic": "mixed arithmetic operations",
    }
    return descriptions.get(pattern_type, pattern_type)
# ── Utility: generate packed rewrite suggestion ─────────────────────────────
def suggest_packed_rewrite(pair: PackableOpPair) -> str:
    """Generate a human-readable packed rewrite suggestion for a pair.
    Shows the before (2 scalar ops) and after (1 packed op) code.
    Args:
        pair: A packable instruction pair
    Returns:
        Multi-line string showing the suggested rewrite
    """
    inst_a = pair.first_instruction
    inst_b = pair.second_instruction
    reg_lo = pair.first_dest_reg
    reg_hi = pair.second_dest_reg
    # Destination: v[lo:hi]
    packed_dest = f"v[{reg_lo}:{reg_hi}]"
    # Source operands: try to show register pairs
    packed_sources = []
    if len(inst_a.operands) > 1:
        for idx in range(1, len(inst_a.operands)):
            op_a = inst_a.operands[idx].strip()
            op_b = inst_b.operands[idx].strip() if idx < len(inst_b.operands) else op_a
            vgpr_a = re.match(r"^v(\d+)$", op_a)
            vgpr_b = re.match(r"^v(\d+)$", op_b)
            if vgpr_a and vgpr_b:
                ra = int(vgpr_a.group(1))
                rb = int(vgpr_b.group(1))
                packed_sources.append(f"v[{ra}:{rb}]")
            elif op_a == op_b:
                # Same operand (broadcast)
                packed_sources.append(op_a)
            else:
                packed_sources.append(f"{op_a}/*{op_b}*/")
    packed_operands = ", ".join([packed_dest] + packed_sources)
    lines = [
        f"; Before ({inst_a.mnemonic}, 2 instructions):",
        f";   {inst_a.raw_text.strip()}",
        f";   {inst_b.raw_text.strip()}",
        f"; After ({pair.packed_equivalent}, 1 instruction):",
        f"  {pair.packed_equivalent} {packed_operands}",
    ]
    return "\n".join(lines)
def format_packed_analysis(analysis: PackedOpportunityAnalysis) -> str:
    """Format a PackedOpportunityAnalysis as human-readable text.
    Suitable for CLI output and agent consumption.
    """
    lines = ["=== Packed Operation Opportunities ==="]
    lines.append(f"  Already packed (v_pk_*): {analysis.already_packed_count}")
    lines.append(f"  Packable scalar pairs found: {analysis.total_packable_pairs}")
    if analysis.total_packable_pairs > 0:
        lines.append(f"  Scalar instructions to replace: {analysis.total_scalar_ops_packable}")
        lines.append(f"  Packed instructions needed: {analysis.total_packed_ops_needed}")
        lines.append(f"  Instruction savings: {analysis.instruction_savings}")
        lines.append(f"  VALU savings: {analysis.savings_pct_of_valu:.1f}%")
        lines.append(f"  Packing ratio: {analysis.packing_ratio:.0%} (of packable work already packed)")
        lines.append("")
        lines.append("  Packable Regions:")
        for i, seq in enumerate(analysis.packable_sequences):
            pattern_desc = _pattern_description(seq.pattern_type)
            lines.append(
                f"    [{i + 1}] Lines {seq.start_line}-{seq.end_line}: "
                f"{seq.scalar_instruction_count} scalar -> {seq.packed_instruction_count} packed "
                f"({pattern_desc})"
            )
            # Show first pair as example
            if seq.pairs:
                example = seq.pairs[0]
                lines.append(
                    f"        Example: {example.first_instruction.mnemonic} v{example.first_dest_reg} + "
                    f"v{example.second_dest_reg} -> {example.packed_equivalent} "
                    f"v[{example.first_dest_reg}:{example.second_dest_reg}]"
                )
    else:
        lines.append(f"  Packing ratio: {analysis.packing_ratio:.0%}")
    if analysis.recommendations:
        lines.append("")
        lines.append("  Recommendations:")
        for rec in analysis.recommendations:
            # Indent multi-line recommendations
            for line in rec.split("\n"):
                lines.append(f"    {line}")
    return "\n".join(lines)
