#!/usr/bin/env bash
# beagle-repair: unified repair tool combining all evidence sources.
#
# Runs the full diagnostic pipeline (type check, semantic analysis, blame,
# trace, speculative fix) and produces a ranked repair queue.
#
# Usage:
#   beagle-repair <source-dir> <verify-script> [--auto] [--threshold N] [--emit-patch]
#
# Evidence layers (in confidence order):
#   1. specfix-verified: oracle confirms the fix works (0.95)
#   2. pointed-replacement: deterministic head-rename suggestion from parser (0.95)
#   3. type-error + fix-plan: structured repair hint (0.85)
#   4. trace + semantic agreement: traced op matches name rule (0.80)
#   5. semantic suspicion alone (0.65)
#   6. blame hint alone (0.60)
#
# --auto mode: apply all fixes above threshold, rebuild, reverify.

set -euo pipefail

if [[ $# -lt 2 ]]; then
    echo "Usage: beagle-repair <source-dir> <verify-script> [--auto] [--threshold 0.85] [--emit-patch]" >&2
    exit 1
fi

SOURCE_DIR="$(cd "$1" && pwd)"
VERIFY_SCRIPT="$(realpath "$2")"
shift 2

AUTO_MODE=false
THRESHOLD=0.85
EMIT_PATCH=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        --auto) AUTO_MODE=true; shift ;;
        --threshold) THRESHOLD="$2"; shift 2 ;;
        --emit-patch) EMIT_PATCH=true; shift ;;
        *) echo "Unknown option: $1" >&2; exit 1 ;;
    esac
done

WORK_DIR="$(mktemp -d /tmp/beagle-repair.XXXXXX)"
trap 'rm -rf "$WORK_DIR"' EXIT

BEAGLE_BIN="$(cd "$(dirname "$0")" && pwd)"

echo "══════════════════════════════════════════════════════════" >&2
echo " beagle-repair: evidence-ranked repair pipeline" >&2
echo "══════════════════════════════════════════════════════════" >&2
echo "" >&2
echo "  source: $SOURCE_DIR" >&2
echo "  oracle: $VERIFY_SCRIPT" >&2
echo "  auto:   $AUTO_MODE (threshold: $THRESHOLD)" >&2
echo "  patch:  $EMIT_PATCH" >&2
echo "" >&2

# ---------------------------------------------------------------------------
# Phase 1: Compile with --warn to get both type errors AND compiled output
# ---------------------------------------------------------------------------

echo "─── Phase 1: Compile (--warn mode) ───" >&2

BUILD_DIR="$WORK_DIR/build"
mkdir -p "$BUILD_DIR"

BEAGLE_SEMANTIC_JSON=1 "$BEAGLE_BIN/beagle-build-all" --warn "$SOURCE_DIR" --out "$BUILD_DIR" 2> "$WORK_DIR/build-output.txt" || true

echo "  $(grep -c '\.clj$' "$WORK_DIR/build-output.txt" 2>/dev/null || echo 0) modules compiled" >&2

# ---------------------------------------------------------------------------
# Phase 2: Type check (collect errors with fix plans)
# ---------------------------------------------------------------------------

echo "─── Phase 2: Type check (fix plans) ───" >&2

BEAGLE_FIX_PLAN=1 BEAGLE_ERROR_FORMAT=json BEAGLE_SEMANTIC_JSON=1 "$BEAGLE_BIN/beagle-check-all" "$SOURCE_DIR" \
    2> "$WORK_DIR/check-output.json" || true

TYPE_ERRORS=$(grep -c '"kind"' "$WORK_DIR/check-output.json" 2>/dev/null || echo 0)
echo "  $TYPE_ERRORS type error(s) with fix plans" >&2

# ---------------------------------------------------------------------------
# Phase 3: Speculative fix (oracle-verified candidates)
# ---------------------------------------------------------------------------

echo "─── Phase 3: Speculative fix (oracle-verified) ───" >&2

"$BEAGLE_BIN/beagle-specfix" "$BUILD_DIR" "$VERIFY_SCRIPT" \
    > "$WORK_DIR/specfix-output.txt" 2> "$WORK_DIR/specfix-log.txt" || true

SPECFIX_COUNT=$(grep -c "^SPECFIX:" "$WORK_DIR/specfix-output.txt" 2>/dev/null || echo 0)
echo "  $SPECFIX_COUNT verified fix(es)" >&2

# ---------------------------------------------------------------------------
# Phase 4: Blame analysis (ratio hints)
# ---------------------------------------------------------------------------

echo "─── Phase 4: Blame analysis ───" >&2

"$BEAGLE_BIN/beagle-blame" "$BUILD_DIR" "$VERIFY_SCRIPT" \
    > "$WORK_DIR/blame-output.txt" 2>/dev/null || true

BLAME_HINTS=$(grep -c "sniff:" "$WORK_DIR/blame-output.txt" 2>/dev/null || echo 0)
echo "  $BLAME_HINTS blame hint(s)" >&2

# ---------------------------------------------------------------------------
# Phase 5: Merge evidence into ranked repair queue
# ---------------------------------------------------------------------------

echo "" >&2
echo "─── Repair Queue ───" >&2
echo "" >&2

python3 - "$WORK_DIR" "$THRESHOLD" "$AUTO_MODE" "$SOURCE_DIR" "$EMIT_PATCH" "$BEAGLE_BIN" << 'PYEOF'
import sys, os, re, json

work_dir = sys.argv[1]
threshold = float(sys.argv[2])
auto_mode = sys.argv[3] == 'true'
source_dir = sys.argv[4]
emit_patch = sys.argv[5] == 'true'
beagle_bin = sys.argv[6]

# Shared, unit-tested apply helpers (bin/beagle_repair_apply.py).
sys.path.insert(0, beagle_bin)
from beagle_repair_apply import insert_match_clauses

def conv_annotation(r):
    """One-line structured conversion summary from the typed fix-plan, e.g.
    '  => Vec[0]: String->Int' (single element) or '  => Map: Int->String'
    (multiple differing positions). Empty when there is no conversion."""
    c = r.get('_conversion')
    if not c:
        return ''
    coll = c.get('collection', '')
    if c.get('from'):
        pos = c.get('position')
        posstr = f"[{pos}]" if pos is not None else ""
        return f"  => {coll}{posstr}: {c['from']}->{c['to']}"
    if c.get('diffs'):
        parts = ", ".join(f"{d['from']}->{d['to']}" for d in c['diffs'])
        return f"  => {coll}: {parts}" if parts else ''
    return ''

repairs = []

def extract_module_from_path(path):
    """Extract module name from a file path like '/path/to/catalog.rkt' -> 'catalog'."""
    if not path or path == '?':
        return None
    base = os.path.basename(path)
    for ext in ['.bclj', '.bjs', '.bnix', '.bsql', '.bpy', '.bgl', '.rkt', '.clj']:
        if base.endswith(ext):
            return base[:-len(ext)]
    return None

# --- Parse type errors with fix plans ---
check_file = os.path.join(work_dir, 'check-output.json')
if os.path.exists(check_file):
    with open(check_file) as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue

            if 'fix_plan' in d and d['fix_plan']:
                plan = d['fix_plan']
                confidence_str = plan.get('confidence', 'medium')
                confidence = {'high': 0.90, 'medium': 0.75, 'low': 0.60}.get(confidence_str, 0.70)

                if plan.get('category') == 'non-exhaustive-match' and plan.get('clauses'):
                    # The checker enumerated the exact missing union cases with
                    # correct binder arity and handed back ready-to-insert clause
                    # skeletons (throw-bodied → re-verifies green). Insert them
                    # before the match's closing paren.
                    repairs.append({
                        'confidence': confidence,
                        'file': d.get('file', '?'),
                        'line': plan.get('insert-line') or d.get('line', '?'),
                        'category': 'non-exhaustive-match',
                        'description': plan.get('description', d.get('message', '')),
                        'fix_hint': plan.get('fix_hint', ''),
                        'before': None,
                        'after': None,
                        'evidence': ['type-checker', 'exhaustive-match',
                                     f"missing:{','.join(plan.get('missing', []))}"],
                        'auto_applicable': True,
                        '_insert_clauses': plan.get('clauses'),
                        '_insert_line': plan.get('insert-line') or d.get('line'),
                    })
                else:
                    # Consume the STRUCTURED conversion data (from the typed
                    # MessageData), not just the prose hint: for a collection
                    # type-argument mismatch the plan carries from/to/position/
                    # collection, and the diagnostic carries the full
                    # expected/actual type trees. Agents act on these directly.
                    conversion = None
                    if plan.get('from-type'):
                        conversion = {
                            'from': plan.get('from-type'),
                            'to': plan.get('to-type'),
                            'position': plan.get('position'),
                            'collection': plan.get('collection'),
                        }
                    elif plan.get('diffs'):
                        conversion = {
                            'collection': plan.get('collection'),
                            'diffs': plan.get('diffs'),
                        }
                    repairs.append({
                        'confidence': confidence,
                        'file': d.get('file', '?'),
                        'line': d.get('line', '?'),
                        'category': plan.get('category', 'type-error'),
                        'description': plan.get('description', d.get('message', '')),
                        'fix_hint': plan.get('fix_hint', ''),
                        'before': plan.get('before'),
                        'after': plan.get('after'),
                        'evidence': ['type-checker', f"E{d.get('kind', '?')}"],
                        'auto_applicable': confidence >= 0.85 and plan.get('after'),
                        '_conversion': conversion,
                        '_expected_type': d.get('expected-type'),
                        '_actual_type': d.get('actual-type'),
                    })
            elif d.get('kind') in ('type-mismatch', 'arity'):
                # Type error without fix plan — still useful
                repairs.append({
                    'confidence': 0.70,
                    'file': d.get('file', '?'),
                    'line': d.get('line', '?'),
                    'category': d.get('kind', 'type-error'),
                    'description': d.get('message', ''),
                    'fix_hint': d.get('help', '') or '',
                    'before': None,
                    'after': None,
                    'evidence': ['type-checker'],
                    'auto_applicable': False,
                })

            elif isinstance(d.get('suggestion'), dict) and d['suggestion'].get('type') == 'replace-head':
                # Machine-applicable pointed-replacement from the parser
                # (replace-head suggestion). A deterministic head-rename authored
                # by the compiler — no heuristic — so it auto-applies precisely.
                # Parse-time rejections carry no line, so the apply path is
                # head-anchored (scans for the offending '(' + head symbol).
                sug = d['suggestion']
                repairs.append({
                    'confidence': 0.95,
                    'file': d.get('file', '?'),
                    'line': d.get('line') or '?',
                    'category': 'pointed-replacement',
                    'description': sug.get('label') or d.get('message', ''),
                    'fix_hint': sug.get('label', ''),
                    'before': sug.get('from'),
                    'after': sug.get('to'),
                    'evidence': ['type-checker', 'structured-suggestion', f"kind:{d.get('kind', '?')}"],
                    'auto_applicable': True,
                    '_replace_head': True,
                })

# --- Parse specfix output ---
specfix_file = os.path.join(work_dir, 'specfix-output.txt')
if os.path.exists(specfix_file):
    with open(specfix_file) as f:
        content = f.read()

    blocks = content.split('SPECFIX: ')
    for block in blocks[1:]:
        lines = block.strip().split('\n')
        label = lines[0]
        fields = {}
        for line in lines[1:]:
            m = re.match(r'\s+(\S+):\s+(.+)', line)
            if m:
                fields[m.group(1)] = m.group(2)

        fix_desc = fields.get('fix', '')
        module_file = fields.get('file', '?')
        fn_name = fields.get('function', '?')
        fix_line = fields.get('line', '?')
        assertions_fixed = int(fields.get('assertions-fixed', '1'))

        # Extract module/function for cross-evidence correlation
        specfix_module = module_file.replace('.clj', '') if module_file.endswith('.clj') else None
        specfix_fn = fn_name if fn_name != '?' and fn_name != '(unknown)' else None

        search_text = fields.get('search')
        replace_text = fields.get('replace')
        line_idx_str = fields.get('line-idx')
        line_idx = int(line_idx_str) if line_idx_str and line_idx_str != '?' else None
        swap_lines_str = fields.get('swap-lines')
        swap_values_str = fields.get('swap-values')

        repairs.append({
            'confidence': 0.95,
            'file': module_file,
            'line': fix_line,
            'category': 'specfix-verified',
            'description': f'{label}: {fix_desc}',
            'fix_hint': fix_desc,
            'before': search_text,
            'after': replace_text,
            'evidence': ['specfix-oracle', f'fixes-{assertions_fixed}-assertions'],
            'auto_applicable': True,
            '_module': specfix_module,
            '_fn': specfix_fn,
            '_line_idx': line_idx,
            '_swap_lines': swap_lines_str,
            '_swap_values': swap_values_str,
        })

# --- Parse blame hints (with module/function extraction from FAIL labels) ---
blame_file = os.path.join(work_dir, 'blame-output.txt')
blame_by_function = {}  # (module, fn_name) -> [hints]

PREFIX_MAP = {
    'cat/': 'catalog', 'cust/': 'customers', 'inv/': 'inventory',
    'ord/': 'orders', 'rep/': 'reports', 'ship/': 'shipping',
    'bill/': 'billing', 'proc/': 'procurement', 'promo/': 'promotions',
    'emp/': 'employees', 'analytics/': 'analytics',
    'notif/': 'notifications', 'audit/': 'audit',
}

def label_to_module_fn(label):
    """Extract (module, fn_name) from a FAIL label like 'cat/product-margin Widget A'."""
    module = None
    fn_name = None
    for prefix, mod in PREFIX_MAP.items():
        if label.startswith(prefix):
            module = mod
            rest = label[len(prefix):]
            fn_match = re.match(r'([\w?!<>*+\-/]+)', rest)
            if fn_match:
                fn_name = fn_match.group(1)
            break
    return module, fn_name

if os.path.exists(blame_file):
    current_label = None
    with open(blame_file) as f:
        for line in f:
            fail_m = re.match(r'^FAIL:\s+(.+)', line.rstrip())
            if fail_m:
                current_label = fail_m.group(1)
                continue

            m = re.match(r'\s*sniff:\s*\[([0-9.]+)\]\s+(.+)', line)
            if m:
                conf = float(m.group(1))
                hint = m.group(2)
                module_name = '?'
                fn_name = '?'
                if current_label:
                    mod, fn = label_to_module_fn(current_label)
                    if mod:
                        module_name = f'{mod}.clj'
                    if fn:
                        fn_name = fn
                    if mod and fn:
                        blame_by_function.setdefault((mod, fn), []).append({
                            'confidence': conf, 'hint': hint,
                        })

                repairs.append({
                    'confidence': conf * 0.8,
                    'file': module_name,
                    'line': '?',
                    'category': 'blame-hint',
                    'description': f'{current_label or "?"}: {hint}',
                    'fix_hint': hint,
                    'before': None,
                    'after': None,
                    'evidence': ['blame-ratio'],
                    'auto_applicable': False,
                    '_module': module_name.replace('.clj', '') if module_name != '?' else None,
                    '_fn': fn_name if fn_name != '?' else None,
                })

# --- Consume semantic suspicions as STRUCTURED records ---
# blame.rkt emits one `beagle-semantic-json: {json}` line per suspicion when
# BEAGLE_SEMANTIC_JSON=1 (set on the build/check invocations above). We read the
# record directly — function name (including chars like '=' that the old
# SUSPECT-prose regex matched up to and then SILENTLY DROPPED the whole line on),
# op, context, confidence — instead of re-deriving them from a lossy prose line.
semantic_by_function = {}  # (module, fn_name) -> [suspicions]
_SEM_PREFIX = 'beagle-semantic-json: '
_seen_semantic = set()     # dedup across build-output + check-output

for output_file_name in ['build-output.txt', 'check-output.json']:
    output_file = os.path.join(work_dir, output_file_name)
    if not os.path.exists(output_file):
        continue
    with open(output_file) as f:
        for line in f:
            idx = line.find(_SEM_PREFIX)
            if idx < 0:
                continue
            try:
                d = json.loads(line[idx + len(_SEM_PREFIX):])
            except json.JSONDecodeError:
                continue
            if d.get('kind') != 'semantic-suspicion':
                continue
            fn_name = d.get('function', '?')
            conf = float(d.get('confidence', 0.0))
            module_name = extract_module_from_path(d.get('file')) or '?'
            op = d.get('op', '')
            context = d.get('context', '')
            reason = d.get('reason', '')
            sig = (module_name, fn_name, op, context, reason)
            if sig in _seen_semantic:
                continue
            _seen_semantic.add(sig)
            if module_name != '?':
                semantic_by_function.setdefault((module_name, fn_name), []).append({
                    'confidence': conf, 'reason': reason,
                })
            repairs.append({
                'confidence': conf * 0.75,
                'file': f'{module_name}.clj' if module_name != '?' else '?',
                'line': '?',
                'category': 'semantic-suspicion',
                'description': f'{fn_name}: {reason}',
                'fix_hint': reason,
                'before': None,
                'after': None,
                'evidence': ['semantic-rule'],
                'auto_applicable': False,
                '_module': module_name,
                '_fn': fn_name,
                '_op': op,
                '_context': context,
            })

# ---------------------------------------------------------------------------
# Cross-evidence correlation
# ---------------------------------------------------------------------------
# When multiple independent evidence sources agree on the same (module, function),
# the confidence that this is a real bug increases.
#
# Boost rules (applied to all repairs targeting the same function):
#   blame + semantic           -> +0.15 (two independent soft sources)
#   type-error + blame         -> +0.10
#   type-error + semantic      -> +0.08
#   specfix + blame            -> +0.03 (specfix already oracle-verified)
#   specfix + semantic         -> +0.02
#   triple (specfix+blame+sem) -> +0.03

repair_by_fn = {}  # (module, fn_name) -> [repair indices]
for idx, r in enumerate(repairs):
    mod = r.get('_module')
    fn = r.get('_fn')
    if not mod or not fn:
        if r['file'] != '?' and r['file'].endswith('.clj'):
            mod = r['file'].replace('.clj', '')
        if r.get('description'):
            fn_m = re.match(r'([\w?!<>*+\-/]+)', r.get('description', ''))
            if fn_m:
                fn = fn_m.group(1)
    if mod and fn:
        repair_by_fn.setdefault((mod, fn), []).append(idx)

correlated = {}
for key, indices in repair_by_fn.items():
    categories = set()
    for idx in indices:
        categories.add(repairs[idx]['category'])
    if len(categories) >= 2:
        correlated[key] = categories

correlation_boosts = 0
for (mod, fn), categories in correlated.items():
    has_specfix = 'specfix-verified' in categories
    has_blame = 'blame-hint' in categories
    has_semantic = 'semantic-suspicion' in categories
    has_type_error = any(c in categories for c in ('type-error', 'type-mismatch', 'arity'))

    boost = 0.0
    corr_note = None
    if has_specfix and has_blame and has_semantic:
        boost = 0.03
        corr_note = 'triple-correlated (specfix+blame+semantic)'
    elif has_blame and has_semantic and not has_specfix:
        boost = 0.15
        corr_note = 'correlated (blame+semantic)'
    elif has_type_error and has_blame:
        boost = 0.10
        corr_note = 'correlated (type-error+blame)'
    elif has_type_error and has_semantic:
        boost = 0.08
        corr_note = 'correlated (type-error+semantic)'
    elif has_specfix and has_blame:
        boost = 0.03
        corr_note = 'correlated (specfix+blame)'
    elif has_specfix and has_semantic:
        boost = 0.02
        corr_note = 'correlated (specfix+semantic)'

    if boost > 0 and corr_note:
        for idx in repair_by_fn.get((mod, fn), []):
            repairs[idx]['confidence'] = min(0.98, repairs[idx]['confidence'] + boost)
            if corr_note not in repairs[idx]['evidence']:
                repairs[idx]['evidence'].append(corr_note)
            correlation_boosts += 1

if correlation_boosts > 0:
    print(f"  Cross-evidence: {len(correlated)} function(s) correlated, {correlation_boosts} boost(s)", file=sys.stderr)

# --- Deduplicate: if specfix and type-error point at same file:line, merge evidence ---
by_location = {}
for r in repairs:
    # Dedup-by-location merges repairs at the SAME concrete file:line (e.g.
    # specfix + type-error — the same bug seen by two sources). But entries with
    # no concrete line (line == '?', e.g. semantic suspicions) would ALL share
    # "file:?" and collapse across different functions — so a module with two
    # suspicious functions kept only one. Key those by function instead.
    if r['line'] == '?' and r.get('_fn'):
        key = f"{r['file']}:fn:{r['_fn']}"
    else:
        key = f"{r['file']}:{r['line']}"
    if key not in by_location:
        by_location[key] = r
    else:
        existing = by_location[key]
        existing['evidence'] = list(set(existing['evidence'] + r['evidence']))
        if r['confidence'] > existing['confidence']:
            existing['confidence'] = min(0.98, r['confidence'] + 0.05)
            existing['description'] = r['description']
            existing['fix_hint'] = r['fix_hint']
        # auto_applicable reflects EITHER source (a lower-confidence repair can
        # still be the one that carries the applicable edit).
        existing['auto_applicable'] = existing['auto_applicable'] or r['auto_applicable']
        # Preserve apply/structured data through the merge: a deduped duplicate
        # must not take the only applicable edit (or its structured types) with
        # it. Copy any such field the survivor lacks. (Two genuinely-distinct
        # applicable fixes that share a file:line still collapse — rare, and a
        # pre-existing limitation of keying dedup on location alone.)
        for f in ('before', 'after', '_insert_clauses', '_insert_line',
                  '_replace_head', '_swap_lines', '_swap_values', '_line_idx',
                  '_conversion', '_expected_type', '_actual_type', '_module', '_fn'):
            if not existing.get(f) and r.get(f):
                existing[f] = r[f]

merged = list(by_location.values())

# --- Sort by confidence (descending) ---
merged.sort(key=lambda x: -x['confidence'])

# --- Output ---
auto_count = sum(1 for r in merged if r['auto_applicable'] and r['confidence'] >= threshold)

# ---------------------------------------------------------------------------
# --emit-patch: unified diff output for machine consumption
# ---------------------------------------------------------------------------
def resolve_source_file(repair_entry):
    """Find the beagle source file corresponding to a repair entry."""
    f = repair_entry.get('file', '?')
    if f == '?' or not f:
        return None
    # Try as-is (might be absolute path)
    if os.path.exists(f):
        return f
    # Try source_dir + beagle extension versions
    base = os.path.basename(f)
    source_exts = ('.bclj', '.bjs', '.bnix', '.bsql', '.bpy', '.bgl', '.rkt')
    for ext_from in ('.clj', '.js', '.nix'):
        if base.endswith(ext_from):
            for ext_to in source_exts:
                candidate = os.path.join(source_dir, base[:-len(ext_from)] + ext_to)
                if os.path.exists(candidate):
                    return candidate
    # Try just the basename in source_dir
    candidate = os.path.join(source_dir, base)
    if os.path.exists(candidate):
        return candidate
    return None

import difflib

def apply_fix_to_lines(lines, repair):
    """Apply a single repair to a list of lines (mutates). Returns True on success."""
    before = repair.get('before')
    after = repair.get('after')

    # Clause insertion (non-exhaustive-match): drop the checker-provided
    # skeletons in before the match's closing paren. Operates on whole text
    # because paren-balancing spans lines.
    if repair.get('_insert_clauses'):
        text = ''.join(lines)
        new_text = insert_match_clauses(
            text, repair.get('_insert_line') or repair.get('line'),
            repair['_insert_clauses'])
        if not new_text or new_text == text:
            return False
        lines[:] = new_text.splitlines(keepends=True)
        return True

    # Structured head-rename (replace-head suggestion from the checker): rename
    # the form's HEAD symbol precisely — `(assert ...)` -> `(nix/assert ...)` —
    # anchored to a '(' and a token boundary so it never touches `(assert-foo`
    # or a bare `assert` used as an argument. Parse rejections carry no line, so
    # try the known line first (if any), then scan; first head-position hit wins
    # (a parse error aborts the file at the first bad form, so there is just one).
    if repair.get('_replace_head') and before and after:
        symcls = r'A-Za-z0-9_?!<>*+/.\-'
        head_rx = re.compile(r'(\(\s*)' + re.escape(before) + r'(?![' + symcls + r'])')
        def _rename_head(i):
            new_line, n = head_rx.subn(lambda m: m.group(1) + after, lines[i], count=1)
            if n:
                lines[i] = new_line
                return True
            return False
        line_num = repair.get('line')
        if line_num and str(line_num).isdigit():
            idx = int(line_num) - 1
            if 0 <= idx < len(lines) and _rename_head(idx):
                return True
        for i in range(len(lines)):
            if _rename_head(i):
                return True
        return False

    # Handle value-swap: scan source for lines containing the two values
    if repair.get('_swap_lines') and repair.get('_swap_values'):
        val1, val2 = repair['_swap_values'].split('|||', 1)
        pat1 = re.compile(r'(?<![0-9])' + re.escape(val1) + r'(?![0-9])')
        pat2 = re.compile(r'(?<![0-9])' + re.escape(val2) + r'(?![0-9])')
        idx1 = idx2 = None
        for i, ln in enumerate(lines):
            if idx1 is None and pat1.search(ln):
                idx1 = i
            elif idx2 is None and pat2.search(ln):
                idx2 = i
            if idx1 is not None and idx2 is not None:
                break
        if idx1 is None or idx2 is None:
            return False
        placeholder = '__PATCH_SWAP__'
        lines[idx1] = pat1.sub(placeholder, lines[idx1], count=1)
        lines[idx2] = pat2.sub(val1, lines[idx2], count=1)
        lines[idx1] = lines[idx1].replace(placeholder, val2)
        return True

    if not before or not after or before == 'null' or after == 'null':
        return False

    # Try line_idx first (0-indexed, from specfix)
    line_idx = repair.get('_line_idx')
    if line_idx is not None and 0 <= line_idx < len(lines):
        if before in lines[line_idx]:
            lines[line_idx] = lines[line_idx].replace(before, after, 1)
            return True

    # Try line number (1-indexed, from type checker)
    line_num = repair.get('line')
    if line_num and line_num != '?' and str(line_num).isdigit():
        idx = int(line_num) - 1
        if 0 <= idx < len(lines):
            if before in lines[idx]:
                lines[idx] = lines[idx].replace(before, after, 1)
                return True

    # Fallback: scan file for search text, scoped to target function if known
    fn_name = repair.get('_fn')
    in_target_fn = False if fn_name else True
    fn_pattern = f'defn {fn_name} ' if fn_name else None
    for idx in range(len(lines)):
        if fn_pattern and fn_pattern in lines[idx]:
            in_target_fn = True
        elif in_target_fn and fn_pattern and '(defn ' in lines[idx] and fn_pattern not in lines[idx]:
            in_target_fn = False
        if in_target_fn and before in lines[idx]:
            lines[idx] = lines[idx].replace(before, after, 1)
            return True

    # Operand swap fallback: before/after have same operator, operands reversed
    # Extract operands from balanced s-expressions
    def split_sexpr_operands(s):
        """Parse '(op EXPR1 EXPR2)' into (op, expr1, expr2) respecting nested parens."""
        if not s.startswith('(') or not s.endswith(')'):
            return None
        inner = s[1:-1]
        parts = []
        i = 0
        while i < len(inner):
            if inner[i] == ' ' and not parts:
                parts.append(inner[:i])
                i += 1
                continue
            if len(parts) >= 1 and not (len(parts) >= 2):
                start = i
                depth = 0
                while i < len(inner):
                    if inner[i] == '(':
                        depth += 1
                    elif inner[i] == ')':
                        depth -= 1
                    elif inner[i] == ' ' and depth == 0:
                        break
                    i += 1
                parts.append(inner[start:i])
                i += 1
                continue
            if len(parts) >= 2:
                parts.append(inner[i:])
                break
            i += 1
        return tuple(parts) if len(parts) == 3 else None

    parsed_before = split_sexpr_operands(before)
    parsed_after = split_sexpr_operands(after)
    if parsed_before and parsed_after:
        op_b, a, b = parsed_before
        op_a, a2, b2 = parsed_after
        if op_b == op_a and a == b2 and b == a2 and op_b in ('+', '-', '*', '/'):
            in_target_fn = False if fn_name else True
            for idx in range(len(lines)):
                if fn_pattern and fn_pattern in lines[idx]:
                    in_target_fn = True
                elif in_target_fn and fn_pattern and '(defn ' in lines[idx] and fn_pattern not in lines[idx]:
                    in_target_fn = False
                if in_target_fn and a in lines[idx] and b in lines[idx]:
                    ln = lines[idx]
                    ai = ln.find(a)
                    bi = ln.find(b, ai + len(a))
                    if ai >= 0 and bi >= 0:
                        lines[idx] = ln[:ai] + b + ln[ai+len(a):bi] + a + ln[bi+len(b):]
                        return True

    return False

if emit_patch:
    applicable = [r for r in merged if r['auto_applicable'] and r['confidence'] >= threshold]

    # Group by source file
    by_file = {}
    skipped = []
    for r in applicable:
        src_path = resolve_source_file(r)
        if not src_path:
            skipped.append(r)
            continue
        by_file.setdefault(src_path, []).append(r)

    fix_count = 0
    for src_path, repairs_for_file in sorted(by_file.items()):
        with open(src_path) as f:
            original = f.readlines()
        modified = list(original)

        applied = 0
        for r in repairs_for_file:
            if apply_fix_to_lines(modified, r):
                applied += 1
            else:
                skipped.append(r)

        if applied > 0 and original != modified:
            rel_path = os.path.relpath(src_path, source_dir) if source_dir else src_path
            diff = difflib.unified_diff(
                original, modified,
                fromfile=f'a/{rel_path}', tofile=f'b/{rel_path}')
            patch_text = ''.join(diff)
            if patch_text:
                print(patch_text)
                fix_count += applied

    # Summary to stderr
    print(f"\n{'─' * 60}", file=sys.stderr)
    print(f"Patch: {fix_count} fix(es) across {len([p for p in by_file if by_file[p]])} file(s), {len(skipped)} skipped", file=sys.stderr)
    if skipped:
        for s in skipped:
            print(f"  skip: {s['file']}:{s['line']} — {s['fix_hint']}", file=sys.stderr)

    # Also emit SUGGEST items to stderr so agents can see what needs manual review
    suggestions = [r for r in merged if not (r['auto_applicable'] and r['confidence'] >= threshold)]
    if suggestions:
        print(f"\n{'─' * 60}", file=sys.stderr)
        print(f"Remaining ({len(suggestions)} items, need manual review):", file=sys.stderr)
        for i, r in enumerate(suggestions):
            print(f"  [{i+1}] {r['confidence']:.2f}  {r['file']}:{r['line']}  {r['description']}{conv_annotation(r)}", file=sys.stderr)

else:
    # --- Human-readable queue output ---
    print(f"REPAIR QUEUE ({len(merged)} items, {auto_count} auto-applicable above {threshold})")
    print("=" * 60)
    print()

    for i, r in enumerate(merged):
        mode = "AUTO" if (r['auto_applicable'] and r['confidence'] >= threshold) else "SUGGEST"
        print(f"[{i+1}] {mode}  confidence: {r['confidence']:.2f}  {r['file']}:{r['line']}")
        print(f"    evidence: {', '.join(r['evidence'])}")
        print(f"    {r['category']}: {r['description']}")
        conv = conv_annotation(r)
        if conv:
            print(f"   {conv.strip()}")
        if r['fix_hint']:
            print(f"    fix: {r['fix_hint']}")
        if r.get('before') and r.get('after') and r['before'] != 'null' and r['after'] != 'null':
            print(f"    before: {r['before']}")
            print(f"    after:  {r['after']}")
        print()

    # --- Auto-apply disabled ---
    if auto_mode:
        print(f"\n--auto is disabled. Review the repair queue above and apply fixes manually.", file=sys.stderr)
        print(f"Use --emit-patch to generate a unified diff.", file=sys.stderr)

# Summary
corr_count = sum(1 for r in merged
                 if any('correlated' in e for e in r.get('evidence', [])))
print(f"\n{'─' * 60}", file=sys.stderr)
print(f"Summary: {len(merged)} repair items", file=sys.stderr)
print(f"  Auto-applicable: {auto_count}", file=sys.stderr)
print(f"  Suggestions: {len(merged) - auto_count}", file=sys.stderr)
if corr_count:
    print(f"  Cross-evidence correlated: {corr_count}", file=sys.stderr)
if not emit_patch and auto_mode and auto_count > 0:
    print(f"  Applied: {auto_count}", file=sys.stderr)
    print(f"  Next: rebuild and reverify", file=sys.stderr)
PYEOF
