#!/usr/bin/env python3
"""
commit - Intelligent git commit analyzer and creator.

Analyzes changes in the working directory, categorizes them intelligently,
and helps create meaningful commit messages with proper grouping.
"""

import os
import sys
import subprocess
from pathlib import Path
from typing import List, Dict, Tuple, Optional
from collections import defaultdict
import re

try:
    from gitship.gitops import (
        atomic_git_operation,
        has_ignored_changes,
        capture_translation_snapshots,
        atomic_commit_with_snapshot,
    )
except ImportError:
    # Fallback if gitops not available yet
    atomic_git_operation = None
    has_ignored_changes = None
    capture_translation_snapshots = None
    atomic_commit_with_snapshot = None


# ANSI color codes
class Colors:
    RESET = '\033[0m'
    BOLD = '\033[1m'
    DIM = '\033[2m'
    
    # Foreground colors
    RED = '\033[31m'
    GREEN = '\033[32m'
    YELLOW = '\033[33m'
    BLUE = '\033[34m'
    MAGENTA = '\033[35m'
    CYAN = '\033[36m'
    WHITE = '\033[37m'
    
    # Bright variants
    BRIGHT_RED = '\033[91m'
    BRIGHT_GREEN = '\033[92m'
    BRIGHT_YELLOW = '\033[93m'
    BRIGHT_BLUE = '\033[94m'
    BRIGHT_MAGENTA = '\033[95m'
    BRIGHT_CYAN = '\033[96m'


def strip_ansi(text: str) -> str:
    """Remove ANSI escape codes from text."""
    ansi_escape = re.compile(r'\x1b\[[0-9;]*m')
    return ansi_escape.sub('', text)


def _is_binary_file(path: Path) -> bool:
    """Return True if the file appears to be binary (contains null bytes in first 8KB)."""
    try:
        with open(path, 'rb') as f:
            chunk = f.read(8192)
        return b'\x00' in chunk
    except Exception:
        return False


def _dir_stats(dir_path: Path, repo_path: Path) -> Tuple[int, int, int]:
    """Walk a directory and return (file_count, text_line_count, binary_count)."""
    text_lines = 0
    binary_count = 0
    file_count = 0
    for sub in sorted(dir_path.rglob('*')):
        if sub.is_file():
            file_count += 1
            if _is_binary_file(sub):
                binary_count += 1
            else:
                try:
                    text_lines += sum(1 for _ in open(sub, 'r', encoding='utf-8', errors='ignore'))
                except Exception:
                    pass
    return file_count, text_lines, binary_count


class ChangeAnalyzer:
    """Analyze git changes and categorize them intelligently."""
    
    def __init__(self, repo_path: Path):
        self.repo_path = repo_path
        self.changes = {
            'code': [],
            'translations': defaultdict(list),
            'tests': [],
            'docs': [],
            'config': [],
            'other': [],
            'renames': [],
            'submodules': [],
        }
        self.translation_stats = {}
    
    def run_git(self, args: List[str]) -> subprocess.CompletedProcess:
        """Run a git command."""
        result = subprocess.run(
            ["git"] + args,
            cwd=self.repo_path,
            capture_output=True,
            check=False
        )
        # Decode stdout safely — binary files (e.g. .patch with embedded gzip)
        # will fail UTF-8 decoding, so fall back to latin-1 which never errors
        try:
            result.stdout = result.stdout.decode("utf-8")
        except UnicodeDecodeError:
            result.stdout = result.stdout.decode("latin-1")
        try:
            result.stderr = result.stderr.decode("utf-8")
        except UnicodeDecodeError:
            result.stderr = result.stderr.decode("latin-1")
        return result
    
    def analyze_changes(self) -> Dict:
        """Analyze all changes in the repository."""
        # CRITICAL: Reset changes before re-analyzing
        self.changes = {
            'code': [],
            'translations': defaultdict(list),
            'tests': [],
            'docs': [],
            'config': [],
            'other': [],
            'renames': [],
            'submodules': [],
        }
        self.translation_stats = {}
        
        # Get all files first
        result = self.run_git(["status", "--porcelain"])
        if result.returncode != 0:
            return self.changes
        
        print(f"\n[DEBUG] Raw git status output:")
        print(result.stdout)
        print(f"[DEBUG] Parsing {len(result.stdout.strip().split(chr(10)))} lines")
        print()
        
        all_files = []
        deleted_files = []
        untracked_files = []
        git_detected_renames = []
        
        # Split lines WITHOUT stripping first (preserves leading spaces in status codes)
        for line in result.stdout.split('\n'):
            # Only strip trailing whitespace from each line
            line = line.rstrip()
            if not line:
                continue
            
            # Git status --porcelain format: "XY filename"
            # where X and Y are status codes (or spaces)
            # The filename starts at position 3 (after 2 status chars + 1 space)
            if len(line) < 4:
                continue
                
            status = line[:2]  # Keep the raw status (may include spaces)
            filepath = line[3:]  # Start after "XY " - NO strip here, we already rstripped
            
            # Clean up status for comparison (remove spaces)
            status_clean = status.strip()
            
            # Check for git-detected renames (status R or R100, etc.)
            if status.startswith('R'):
                # Format: "R  old_path -> new_path"
                if ' -> ' in filepath:
                    old_path, new_path = filepath.split(' -> ', 1)
                    git_detected_renames.append({'old': old_path.strip(), 'new': new_path.strip()})
                    # Don't add to all_files, we'll handle separately
                    continue
            
            all_files.append({'status': status, 'path': filepath})
            
            # Use status_clean for comparisons (without spaces)
            if status_clean == 'D':
                deleted_files.append(filepath)
            elif status == '??':
                untracked_files.append(filepath)
        
        print(f"[DEBUG] Total files: {len(all_files)}")
        print(f"[DEBUG] Deleted: {len(deleted_files)}, Untracked: {len(untracked_files)}")
        print(f"[DEBUG] Git-detected renames: {len(git_detected_renames)}")
        
        # First, handle git-detected renames
        for rename_info in git_detected_renames:
            content_changed = self._check_rename_content_change(
                rename_info['old'], 
                rename_info['new']
            )
            self.changes['renames'].append({
                'old': rename_info['old'],
                'new': rename_info['new'],
                'status': 'R',
                'content_changed': content_changed
            })
        
        # Then detect our own renames from deleted/untracked
        self._detect_renames(deleted_files, untracked_files)
        
        # Build set of all files involved in renames (both old and new paths)
        renamed_files = set()
        for item in self.changes['renames']:
            renamed_files.add(item['old'])
            renamed_files.add(item['new'])
        
        print(f"[DEBUG] Renamed files to exclude: {renamed_files}")
        
        # Now categorize remaining files, skipping renamed ones
        # BUT if a rename has content changes, also include it in the appropriate category
        categorized = 0
        for file_info in all_files:
            filepath = file_info['path']
            status = file_info['status']
            
            # Skip if this file is part of a rename (we'll handle renames separately)
            if filepath in renamed_files:
                print(f"[DEBUG] Skipping {filepath} (part of rename)")
                continue
            
            self._categorize_file(filepath, status)
            categorized += 1
        
        # Now add renamed files WITH content changes to their respective categories
        # But DON'T duplicate - check if already added
        already_added = set()
        for rename_item in self.changes['renames']:
            if rename_item.get('content_changed', False):
                new_path = rename_item['new']
                
                # Check if not already in the code list
                if not any(item['path'] == new_path for item in self.changes['code']):
                    # Determine category based on file extension/path
                    if new_path.endswith('.py'):
                        self.changes['code'].append({
                            'path': new_path, 
                            'status': 'R', 
                            'rename_from': rename_item['old']
                        })
                        print(f"[DEBUG] Also categorizing rename {new_path} as code (content changed)")
                        already_added.add(new_path)
        
        print(f"[DEBUG] Categorized {categorized} files")
        print()
        
        return self.changes
    
    def _detect_renames(self, deleted_files: List[str], untracked_files: List[str]):
        """Detect renamed files using deleted and untracked file lists with content similarity."""
        print(f"\n[DEBUG] Detecting renames...")
        print(f"[DEBUG] Deleted files: {deleted_files}")
        print(f"[DEBUG] Untracked files: {untracked_files}")
        
        matched_untracked = set()
        
        # Match deleted -> untracked by content similarity
        for old in deleted_files:
            best_match = None
            best_similarity = 0.0
            
            # Get old file content from git
            try:
                result = self.run_git(["show", f"HEAD:{old}"])
                if result.returncode != 0:
                    continue
                old_content = result.stdout
                old_lines = set(old_content.splitlines())
            except:
                continue
            
            # Compare with each untracked file
            for new in untracked_files:
                if new in matched_untracked:
                    continue
                
                # Skip if not in same directory or wrong extension
                if Path(old).parent != Path(new).parent:
                    continue
                if Path(old).suffix != Path(new).suffix:
                    continue
                
                # Get new file content
                try:
                    new_path = self.repo_path / new
                    with open(new_path, 'r', encoding='utf-8', errors='ignore') as f:
                        new_content = f.read()
                    new_lines = set(new_content.splitlines())
                except:
                    continue
                
                # Calculate similarity (Jaccard similarity)
                if len(old_lines) == 0 and len(new_lines) == 0:
                    similarity = 1.0
                elif len(old_lines) == 0 or len(new_lines) == 0:
                    similarity = 0.0
                else:
                    intersection = len(old_lines & new_lines)
                    union = len(old_lines | new_lines)
                    similarity = intersection / union if union > 0 else 0.0
                
                print(f"[DEBUG] Similarity {old} ↔ {new}: {similarity:.2%}")
                
                # Consider it a rename if >50% similar
                if similarity > 0.5 and similarity > best_similarity:
                    best_similarity = similarity
                    best_match = new
            
            if best_match:
                print(f"[DEBUG] ✓ RENAME DETECTED: {old} → {best_match} ({best_similarity:.2%} similar)")
                matched_untracked.add(best_match)
                
                # Check if content changed
                content_changed = best_similarity < 0.99
                
                self.changes['renames'].append({
                    'old': old,
                    'new': best_match,
                    'status': 'R',
                    'content_changed': content_changed
                })
        
        print(f"[DEBUG] Total renames detected: {len(self.changes['renames'])}")
        print()
    
    
    def _check_rename_content_change(self, old_path: str, new_path: str) -> bool:
        """Check if a renamed file has content changes."""
        try:
            # Get old file content from HEAD
            result_old = self.run_git(["show", f"HEAD:{old_path}"])
            if result_old.returncode != 0:
                return True  # Can't compare, assume changed
            
            old_content = result_old.stdout
            
            # Get new file content from working directory
            new_file = self.repo_path / new_path
            if not new_file.exists():
                return True
            
            with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                new_content = f.read()
            
            # Compare content
            return old_content != new_content
        except:
            return True  # On error, assume changed
    
    def _categorize_file(self, filepath: str, status: str):
        """Categorize a single file change."""
        path = Path(filepath)
        abs_path = self.repo_path / filepath

        # Submodule directories — treat as a single atomic entry, never expand
        if abs_path.is_dir() and _is_submodule_dir(abs_path):
            self.changes['submodules'].append({'path': filepath, 'status': status, '_is_submodule': True})
            return

        # Translation files
        if 'locale' in path.parts or filepath.endswith(('.po', '.pot', '.mo')):
            self._analyze_translation(filepath, status)
        
        # Test files
        elif 'test' in filepath.lower() or (len(path.parts) > 0 and path.parts[0] in ('tests', 'test')):
            self.changes['tests'].append({'path': filepath, 'status': status})
        
        # Documentation
        elif (filepath.endswith(('.md', '.rst', '.txt'))
              or Path(filepath).name in ('LICENSE', 'LICENCE', 'COPYING', 'NOTICE',
                                         'CHANGELOG', 'AUTHORS', 'CONTRIBUTORS',
                                         'README', 'INSTALL', 'HISTORY', 'NEWS')):
            self.changes['docs'].append({'path': filepath, 'status': status})
        
        # Config files
        elif (filepath.endswith(('.toml', '.ini', '.cfg', '.yaml', '.yml', '.json',
                                  '.lock', '.env', '.env.example'))
              or filepath in ('.gitignore', '.dockerignore', 'Makefile', '.gitattributes',
                              'Dockerfile', 'docker-compose.yml', 'MANIFEST.in',
                              'setup.py', 'setup.cfg', 'tox.ini', 'Procfile')
              or Path(filepath).name in ('MANIFEST.in', 'Pipfile', 'Brewfile')):
            self.changes['config'].append({'path': filepath, 'status': status})
        
        # Code files
        elif filepath.endswith(('.py', '.js', '.ts', '.java', '.cpp', '.c', '.h', '.go', '.rs')):
            self.changes['code'].append({'path': filepath, 'status': status})
        
        # Other
        else:
            self.changes['other'].append({'path': filepath, 'status': status})
    
    def _analyze_translation(self, filepath: str, status: str):
        """Analyze translation file changes."""
        # Extract language code from path
        match = re.search(r'/locale/([^/]+)/', filepath)
        if not match:
            return
        
        lang_code = match.group(1)
        
        # Determine file type
        if filepath.endswith('.pot'):
            file_type = 'template'
        elif filepath.endswith('.po'):
            file_type = 'source'
        elif filepath.endswith('.mo'):
            file_type = 'compiled'
        else:
            file_type = 'other'
        
        self.changes['translations'][lang_code].append({
            'path': filepath,
            'status': status,
            'type': file_type
        })
        
        # Analyze .po file if it's modified
        if file_type == 'source' and status in ('M', 'MM'):
            self._analyze_po_file(filepath, lang_code)
    
    def _analyze_po_file(self, filepath: str, lang_code: str):
        """Analyze a .po file to extract translation statistics."""
        try:
            result = self.run_git(["diff", "HEAD", filepath])
            if result.returncode != 0:
                return
            
            diff = result.stdout
            
            # Count changes
            added_translations = len(re.findall(r'^\+msgstr "(.+)"', diff, re.MULTILINE))
            removed_empty = len(re.findall(r'^-msgstr ""', diff, re.MULTILINE))
            fuzzy_changes = len(re.findall(r'[+-]#.*fuzzy', diff, re.MULTILINE))
            
            self.translation_stats[lang_code] = {
                'added': added_translations,
                'removed_empty': removed_empty,
                'fuzzy_changes': fuzzy_changes
            }
        except Exception:
            pass
    
    def display_summary(self):
        """Display a summary of all changes."""
        print(f"\n{Colors.BOLD}{'=' * 80}{Colors.RESET}")
        print(f"{Colors.BOLD}COMMIT ANALYSIS - Changes Detected{Colors.RESET}")
        print(f"{Colors.BOLD}{'=' * 80}{Colors.RESET}")
        
        # Renames
        if self.changes['renames']:
            print(f"\n{Colors.CYAN}🔄 Renamed Files ({len(self.changes['renames'])} files):{Colors.RESET}")
            for item in self.changes['renames']:
                content_note = f"{Colors.YELLOW} (content changed){Colors.RESET}" if item.get('content_changed', False) else f"{Colors.DIM} (identical){Colors.RESET}"
                print(f"  {Colors.BLUE}📝 {item['old']} → {item['new']}{Colors.RESET}{content_note}")
        
        # Code changes
        if self.changes['code']:
            print(f"\n{Colors.GREEN}📝 Code Files ({len(self.changes['code'])} files):{Colors.RESET}")
            for item in self.changes['code']:
                status_icon = self._get_status_icon(item['status'])
                print(f"  {status_icon} {item['path']}")
        
        # Translation changes
        if self.changes['translations']:
            print(f"\n{Colors.MAGENTA}🌍 Translations ({len(self.changes['translations'])} languages):{Colors.RESET}")
            for lang, files in sorted(self.changes['translations'].items()):
                po_count = sum(1 for f in files if f['type'] == 'source')
                mo_count = sum(1 for f in files if f['type'] == 'compiled')
                
                stats_str = ""
                if lang in self.translation_stats:
                    stats = self.translation_stats[lang]
                    if stats['added'] > 0:
                        stats_str = f" {Colors.GREEN}(+{stats['added']} translations){Colors.RESET}"
                
                print(f"  🔤 {lang}: {po_count} .po, {mo_count} .mo files{stats_str}")
        
        # Tests
        if self.changes['tests']:
            print(f"\n{Colors.YELLOW}🧪 Tests ({len(self.changes['tests'])} files):{Colors.RESET}")
            for item in self.changes['tests']:
                status_icon = self._get_status_icon(item['status'])
                print(f"  {status_icon} {item['path']}")
        
        # Docs
        if self.changes['docs']:
            print(f"\n{Colors.BLUE}📚 Documentation ({len(self.changes['docs'])} files):{Colors.RESET}")
            for item in self.changes['docs']:
                status_icon = self._get_status_icon(item['status'])
                print(f"  {status_icon} {item['path']}")
        
        # Config
        if self.changes['config']:
            print(f"\n{Colors.CYAN}⚙️  Configuration ({len(self.changes['config'])} files):{Colors.RESET}")
            for item in self.changes['config']:
                status_icon = self._get_status_icon(item['status'])
                safe_path = self._safe_display_path(item['path'])
                print(f"  {status_icon} {safe_path}")
        
        # Submodules
        if self.changes['submodules']:
            print(f"\n{Colors.BRIGHT_CYAN}📦 Submodules ({len(self.changes['submodules'])} updated):{Colors.RESET}")
            for item in self.changes['submodules']:
                status_icon = self._get_status_icon(item['status'])
                print(f"  {status_icon} {item['path']}  {Colors.DIM}[submodule — new commits]{Colors.RESET}")

        # Other
        if self.changes['other']:
            print(f"\n{Colors.DIM}📦 Other ({len(self.changes['other'])} files):{Colors.RESET}")
            for item in self.changes['other']:
                status_icon = self._get_status_icon(item['status'])
                print(f"  {status_icon} {item['path']}")
        
        print(f"\n{Colors.BOLD}{'=' * 80}{Colors.RESET}")
    
    def _get_status_icon(self, status: str) -> str:
        """Get an icon for a git status."""
        if 'A' in status:
            return '➕'
        elif 'M' in status:
            return '✏️ '
        elif 'D' in status:
            return '❌'
        elif '?' in status:
            return '❓'
        return '  '
    
    def _safe_display_path(self, path: str) -> str:
        """Safely display a path, handling encoding issues."""
        try:
            # Try to encode/decode to catch issues
            return path.encode('utf-8', errors='replace').decode('utf-8')
        except:
            # Fallback - replace problematic chars
            return ''.join(c if c.isprintable() else '?' for c in path)


class CommitMessageBuilder:
    """Build commit messages based on change analysis."""
    
    def __init__(self, analyzer: ChangeAnalyzer):
        self.analyzer = analyzer
    
    def suggest_commit_message(self) -> str:
        """Suggest a commit message based on changes."""
        changes = self.analyzer.changes
        
        # Build header (short summary)
        header_parts = []
        
        # Check for renames first
        if changes['renames']:
            rename_desc = ", ".join([f"{Path(r['old']).stem}→{Path(r['new']).stem}" for r in changes['renames'][:3]])
            if len(changes['renames']) > 3:
                rename_desc += f" (+{len(changes['renames'])-3} more)"
            header_parts.append(f"Rename: {rename_desc}")
        
        # Determine primary change type
        if changes['code']:
            header_parts.append(f"Update {len(changes['code'])} code files")
        
        if changes['translations']:
            # Translations are stashed during commit — don't include in the suggested title
            pass
        
        if changes['tests']:
            header_parts.append("Update tests")
        
        if changes['docs']:
            header_parts.append("Update documentation")
        
        if changes['config']:
            header_parts.append("Update configuration")
        
        if not header_parts:
            header_parts.append("Update files")
        
        header = "; ".join(header_parts)
        
        # Build detailed description
        description_lines = []
        
        # Add renames with line count changes
        if changes['renames']:
            description_lines.append("\nRenames:")
            for item in changes['renames']:
                # Calculate line changes for rename
                try:
                    result_old = self.analyzer.run_git(["show", f"HEAD:{item['old']}"])
                    old_lines_count = len(result_old.stdout.splitlines()) if result_old.returncode == 0 else 0
                    
                    new_path = self.analyzer.repo_path / item['new']
                    if new_path.exists():
                        with open(new_path, 'r', encoding='utf-8', errors='ignore') as f:
                            new_lines_count = len(f.readlines())
                    else:
                        new_lines_count = 0
                    
                    if item.get('content_changed'):
                        diff = new_lines_count - old_lines_count
                        sign = '+' if diff >= 0 else ''
                        description_lines.append(f"  • {item['old']} → {item['new']} ({sign}{diff} lines)")
                    else:
                        description_lines.append(f"  • {item['old']} → {item['new']}")
                except:
                    description_lines.append(f"  • {item['old']} → {item['new']}")
        
        # Add new files (ALL of them, no limit)
        # Use .strip() so 'A ' (staged) matches the same as '??' or 'A'
        def _is_new(item):
            s = item['status'].strip()
            return s in ('??', 'A') or item['status'][0] == 'A'
        
        new_files = []
        for item in changes['code']:
            if _is_new(item) and 'rename_from' not in item:
                new_files.append(item)
        for item in changes['tests']:
            if _is_new(item):
                new_files.append(item)
        for item in changes['docs']:
            if _is_new(item):
                new_files.append(item)
        for item in changes['config']:
            if _is_new(item):
                new_files.append(item)
        for item in changes['other']:
            if _is_new(item):
                new_files.append(item)
        
        if new_files:
            description_lines.append("\nNew files:")
            for item in new_files:  # NO LIMIT - show ALL
                # Get line count
                try:
                    file_path = self.analyzer.repo_path / item['path']
                    if file_path.is_file():
                        with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                            lines = len(f.readlines())
                        description_lines.append(f"  • {item['path']} ({lines} lines)")
                    else:
                        description_lines.append(f"  • {item['path']}")
                except:
                    description_lines.append(f"  • {item['path']}")
        
        # Add modified files (ALL of them, no limit) — covers ALL categories
        modified_files = []
        _all_categorized = (
            [(item, True) for item in changes['code']]   # (item, check_rename)
            + [(item, False) for item in changes['config']]
            + [(item, False) for item in changes['docs']]
            + [(item, False) for item in changes['tests']]
            + [(item, False) for item in changes['other']]
        )
        for item, check_rename in _all_categorized:
            if 'M' in item['status'] and (not check_rename or 'rename_from' not in item):
                modified_files.append(item)
        
        if modified_files:
            description_lines.append("\nModified:")
            for item in modified_files:  # NO LIMIT - show ALL
                try:
                    import difflib
                    # Get old content from HEAD
                    result_old = self.analyzer.run_git(["show", f"HEAD:{item['path']}"])
                    old_lines = result_old.stdout.splitlines() if result_old.returncode == 0 else []
                    
                    # Get new content from working directory
                    new_file = self.analyzer.repo_path / item['path']
                    with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                        new_lines = f.read().splitlines()
                    
                    # Calculate diff
                    diff = list(difflib.unified_diff(old_lines, new_lines, lineterm=''))
                    additions = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
                    deletions = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
                    
                    # Format the output
                    if additions > 0 or deletions > 0:
                        description_lines.append(f"  • {item['path']} (+{additions}/-{deletions} lines)")
                    else:
                        description_lines.append(f"  • {item['path']}")
                except:
                    description_lines.append(f"  • {item['path']}")
        
        # Add translation summary if significant
        if changes['translations']:
            total_added = sum(
                stats.get('added', 0) 
                for stats in self.analyzer.translation_stats.values()
            )
            if total_added > 0:
                description_lines.append(f"\nTranslations: +{total_added} strings across {len(changes['translations'])} languages")
        
        # Combine header and description
        if description_lines:
            return header + '\n' + '\n'.join(description_lines)
        else:
            return header
    
    def _suggest_code_message(self) -> str:
        """Suggest a message for code changes.

        Single file  → "Update retry"  (stem, normalized)
        2-3 files    → "Update retry, connection pool"
        4+ files     → "Update 5 code files"
        """
        def _normalize(path: str) -> str:
            stem = Path(path).stem           # e.g. test_connection_pool
            stem = stem.removeprefix("test_")  # strip test_ prefix for display
            return stem.replace('_', ' ')    # underscores → spaces

        code_files = self.analyzer.changes['code']
        n = len(code_files)

        if n == 0:
            return "Update code"
        elif n == 1:
            return f"Update {_normalize(code_files[0]['path'])}"
        elif n <= 3:
            names = ', '.join(_normalize(f['path']) for f in code_files)
            return f"Update {names}"
        else:
            return f"Update {n} code files"
    
    def _suggest_translation_message(self) -> str:
        """Suggest a message for translation changes."""
        translations = self.analyzer.changes['translations']
        lang_count = len(translations)
        
        # Check if it's mostly compiled files
        all_files = [f for files in translations.values() for f in files]
        compiled_count = sum(1 for f in all_files if f['type'] == 'compiled')
        
        if compiled_count == len(all_files):
            return f"Recompile translations ({lang_count} languages)"
        
        # Check for significant additions
        total_added = sum(
            stats.get('added', 0) 
            for stats in self.analyzer.translation_stats.values()
        )
        
        if total_added > 100:
            return f"Add translations for {lang_count} languages (+{total_added} strings)"
        elif total_added > 0:
            return f"Update translations ({lang_count} languages)"
        else:
            return f"Update translation files ({lang_count} languages)"


def _is_submodule_dir(abs_path: Path) -> bool:
    """Return True if this directory is a git submodule (contains a .git file or dir)."""
    return (abs_path / '.git').exists()


def _expand_dir_entry(item: Dict, repo_path: Path) -> List[Dict]:
    """
    If item is a directory (path ends with / or is a dir on disk), return one
    synthetic entry per file inside it.  Otherwise return [item] unchanged.
    Each expanded entry carries '_dir_parent' so callers can collapse back.

    Submodule directories (those containing a .git entry) are never expanded —
    they are returned as a single collapsed entry so thousands of vendored files
    don't flood the review UI.
    """
    if 'old' in item:
        return [item]  # rename — never expand
    raw_path = item['path'].rstrip('/')
    abs_path = repo_path / raw_path
    if not abs_path.is_dir():
        return [item]
    # Submodule — keep as a single entry, never recurse into it
    if _is_submodule_dir(abs_path):
        return [{**item, '_is_submodule': True}]
    children = []
    for sub in sorted(abs_path.rglob('*')):
        if sub.is_file():
            rel = str(sub.relative_to(repo_path))
            children.append({
                'path': rel,
                'status': item['status'],
                '_dir_parent': raw_path,
                '_original': item,
            })
    return children if children else [item]


def _parse_exclude_input(raw: str, max_n: int, flat: List[Dict]) -> set:
    """Parse exclusion input into a set of 0-based indices.

    Supports:
      - Individual numbers:  3  7
      - Ranges:              6-10  (inclusive)
      - Keyword 't' / 'translations': exclude all .po / .mo / locale files
      - Keyword 's' / 'submodules':   exclude all submodule entries
    """
    excluded = set()
    for part in raw.split():
        part_lower = part.lower()
        # Keyword shortcuts
        if part_lower in ('t', 'translations'):
            for i, item in enumerate(flat):
                p = item.get('path', '')
                if (p.endswith(('.po', '.pot', '.mo'))
                        or 'locale' in Path(p).parts):
                    excluded.add(i)
            continue
        if part_lower in ('s', 'submodules'):
            for i, item in enumerate(flat):
                if item.get('_is_submodule'):
                    excluded.add(i)
            continue
        # Range  e.g. 6-10
        if '-' in part:
            try:
                lo, hi = part.split('-', 1)
                lo, hi = int(lo), int(hi)
                for n in range(lo, hi + 1):
                    if 1 <= n <= max_n:
                        excluded.add(n - 1)
            except ValueError:
                pass
            continue
        # Single number
        try:
            n = int(part)
            if 1 <= n <= max_n:
                excluded.add(n - 1)
        except ValueError:
            pass
    return excluded


def pick_files_to_review(files: List[Dict], repo_path: Path = None) -> List[Dict]:
    """Show a numbered list and let the user exclude files before reviewing.

    Directories are expanded inline so individual files inside can be excluded
    independently (e.g. keep review.md, exclude check.md from security/analysis/).
    Returns the filtered list (all files if user skips / presses Enter).

    Exclusion syntax:
      3          — single number
      1 4 5      — space-separated numbers
      6-10       — inclusive range
      t          — exclude all translation files (.po/.mo/locale)
      s          — exclude all submodule entries
    Combine freely, e.g.:  t s 2  or  1-3 7
    """
    if len(files) <= 1:
        return files

    # Expand directories into their constituent files for fine-grained selection
    flat: List[Dict] = []
    if repo_path:
        for item in files:
            flat.extend(_expand_dir_entry(item, repo_path))
    else:
        flat = list(files)

    # Detect which shortcut hints are relevant so we only show them when useful
    has_translations = any(
        item.get('path', '').endswith(('.po', '.pot', '.mo'))
        or 'locale' in Path(item.get('path', '')).parts
        for item in flat
    )
    has_submodules = any(item.get('_is_submodule') for item in flat)

    print()
    print("  Files included in this review:")
    for i, item in enumerate(flat, 1):
        if 'old' in item:
            label = f"{item['old']} → {item['new']}"
        elif item.get('_is_submodule'):
            label = f"{item['path']}  {Colors.DIM}[submodule — new commits]{Colors.RESET}"
        elif item.get('_dir_parent'):
            label = f"  ↳ {item['path']}  {Colors.DIM}(in {item['_dir_parent']}/){Colors.RESET}"
        else:
            label = item['path']
        print(f"    {i:2}. {label}")

    print()
    hints = ["3", "1 4 5", "6-10"]
    if has_translations:
        hints.append("t (all translations)")
    if has_submodules:
        hints.append("s (all submodules)")
    print(f"  Enter numbers/ranges to EXCLUDE (e.g. {', '.join(hints)}), or press Enter to include all:")
    try:
        raw = input("  Exclude: ").strip()
    except KeyboardInterrupt:
        return files

    if not raw:
        return flat if repo_path else files

    excluded_idxs = _parse_exclude_input(raw, len(flat), flat)

    if not excluded_idxs:
        return flat if repo_path else files

    kept = [f for i, f in enumerate(flat) if i not in excluded_idxs]
    print(f"  → Showing {len(kept)} of {len(flat)} files")
    return kept


def show_diff_menu(analyzer: ChangeAnalyzer, category: str, files: List[Dict]):
    """Show hierarchical diff viewing menu for a category — loops until user goes back."""
    print(f"\n{'=' * 80}")
    print(f"{category.upper()} CHANGES - Review Options")
    print("=" * 80)
    print(f"\nFiles to review: {len(files)}")

    has_renames = files and 'old' in files[0]

    # Let user exclude specific files before reviewing
    files = pick_files_to_review(files, repo_path=analyzer.repo_path)
    if not files:
        print("  (all files excluded — nothing to review)")
        return

    while True:
        print()
        print("Review options:")
        print("  1. Summary only (--shortstat)")
        print("  2. File list with stats (--stat)")
        print("  3. Full diff (--patch)")
        print("  4. Export diff to file")
        if has_renames:
            print("  5. Stage renames properly (git rm + git add)")
            print("  6. Back to main menu")
            max_choice = 6
        else:
            print("  5. Back to main menu")
            max_choice = 5
        print()

        try:
            choice = input(f"Choose option (1-{max_choice}): ").strip()
        except KeyboardInterrupt:
            print("\n\nBack to main menu.")
            return

        if choice == '1':
            show_shortstat(analyzer, files)
        elif choice == '2':
            show_stat(analyzer, files)
        elif choice == '3':
            show_full_diff(analyzer, files)
        elif choice == '4':
            export_diff_to_file(analyzer, files, category)
        elif choice == '5' and has_renames:
            stage_renames(analyzer, files)
        elif choice == str(max_choice):
            return
        else:
            print("Invalid choice.")


def stage_renames(analyzer: ChangeAnalyzer, files: List[Dict]):
    """Stage renames properly using git rm + git add so git recognizes them."""
    print(f"\n{'=' * 80}")
    print("STAGE RENAMES")
    print("=" * 80)
    
    print(f"\nThis will tell git about {len(files)} rename(s):")
    for item in files:
        print(f"  {item['old']} → {item['new']}")
    
    print()
    try:
        confirm = input("Stage these renames? (y/n): ").strip().lower()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return
    
    if confirm not in ('y', 'yes'):
        print("Cancelled.")
        return
    
    print()
    for item in files:
        # Remove old file from git
        result = analyzer.run_git(["rm", "--cached", item['old']])
        if result.returncode == 0:
            print(f"  ✓ Removed: {item['old']}")
        else:
            print(f"  ✗ Failed to remove: {item['old']}")
        
        # Add new file to git
        result = analyzer.run_git(["add", item['new']])
        if result.returncode == 0:
            print(f"  ✓ Added: {item['new']}")
        else:
            print(f"  ✗ Failed to add: {item['new']}")
    
    print()
    print("✅ Renames staged! Git will now recognize these as renames.")
    print("   Run 'git status' to see the result.")
    print()
    input("Press Enter to continue...")


def show_shortstat(analyzer: ChangeAnalyzer, files: List[Dict]):
    """Show shortstat for files."""
    print(f"\n{'=' * 80}")
    print("SUMMARY STATISTICS")
    print("=" * 80)
    
    # Check if these are renames
    if files and 'old' in files[0]:
        # Calculate combined stats for all renames
        total_additions = 0
        total_deletions = 0
        files_changed = 0
        
        for item in files:
            try:
                # Get old content
                result_old = analyzer.run_git(["show", f"HEAD:{item['old']}"])
                old_lines = result_old.stdout.splitlines() if result_old.returncode == 0 else []
                
                # Get new content
                new_file = analyzer.repo_path / item['new']
                with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                    new_lines = f.read().splitlines()
                
                # Simple diff count
                import difflib
                diff = list(difflib.unified_diff(old_lines, new_lines, lineterm=''))
                
                additions = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
                deletions = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
                
                if additions > 0 or deletions > 0:
                    files_changed += 1
                    total_additions += additions
                    total_deletions += deletions
                    
            except Exception:
                pass
        
        print(f"\n{len(files)} file(s) renamed:")
        for item in files:
            print(f"  {item['old']} → {item['new']}")
        
        if files_changed > 0:
            print(f"\n{files_changed} file(s) changed, {total_additions} insertions(+), {total_deletions} deletions(-)")
        else:
            print("\n(all renames are identical - no content changes)")
        
        print()
        input("Press Enter to continue...")
        return
    
    # ── Modified files: real git shortstat ──────────────────────────────────
    modified_paths = [f['path'] for f in files
                      if 'M' in f.get('status','') and '_dir_parent' not in f]

    total_ins = 0
    total_dels = 0
    mod_file_count = 0

    if modified_paths:
        for attempt in (
            ["diff", "--shortstat", "--staged", "--"],
            ["diff", "--shortstat", "--"],
            ["diff", "--shortstat", "HEAD", "--"],
        ):
            result = analyzer.run_git(attempt + modified_paths)
            if result.returncode == 0 and result.stdout.strip():
                # Parse "N files changed, X insertions(+), Y deletions(-)"
                m_ins  = re.search(r'(\d+) insertion', result.stdout)
                m_dels = re.search(r'(\d+) deletion',  result.stdout)
                m_fc   = re.search(r'(\d+) file',      result.stdout)
                total_ins  += int(m_ins.group(1))  if m_ins  else 0
                total_dels += int(m_dels.group(1)) if m_dels else 0
                mod_file_count = int(m_fc.group(1)) if m_fc else len(modified_paths)
                break

    # ── New / untracked files: walk dirs, count lines ─────────────────────
    new_lines = 0
    new_file_count = 0
    new_file_details: List[Tuple[str, int, bool]] = []  # (path, lines, is_binary)
    for f in files:
        s = f.get('status', '').strip()
        if s not in ('??', 'A') and f.get('status','')[:1] != 'A':
            continue
        fp = analyzer.repo_path / f['path'].rstrip('/')
        if fp.is_dir():
            for sub in sorted(fp.rglob('*')):
                if sub.is_file():
                    rel = str(sub.relative_to(analyzer.repo_path))
                    if _is_binary_file(sub):
                        new_file_count += 1
                        new_file_details.append((rel, 0, True))
                    else:
                        try:
                            lc = sum(1 for _ in open(sub, 'r', encoding='utf-8', errors='ignore'))
                        except Exception:
                            lc = 0
                        new_file_count += 1
                        new_lines += lc
                        new_file_details.append((rel, lc, False))
        elif fp.is_file():
            new_file_count += 1
            if _is_binary_file(fp):
                new_file_details.append((str(f['path']), 0, True))
            else:
                try:
                    lc = sum(1 for _ in open(fp, 'r', encoding='utf-8', errors='ignore'))
                except Exception:
                    lc = 0
                new_lines += lc
                new_file_details.append((str(f['path']), lc, False))

    deleted_count = sum(1 for f in files if f.get('status','').strip() == 'D')

    # ── Print unified summary ─────────────────────────────────────────────
    total_file_count = mod_file_count + new_file_count + deleted_count
    parts = []
    if total_file_count:
        parts.append(f"{total_file_count} file(s) changed")
    if total_ins or new_lines:
        parts.append(f"{total_ins + new_lines} insertions(+)")
    if total_dels:
        parts.append(f"{total_dels} deletions(-)")

    if parts:
        print()
        print("  " + ",  ".join(parts))
    if new_file_count:
        print(f"  {new_file_count} new file(s)  (~{new_lines} lines)")
        for (fpath, lc, is_bin) in new_file_details:
            if is_bin:
                print(f"    📦 {fpath}  [binary]")
            else:
                print(f"    📄 {fpath}  ({lc} lines)")
    if deleted_count:
        print(f"  {deleted_count} deleted file(s)")

    print()
    input("Press Enter to continue...")


def export_diff_to_file(analyzer: ChangeAnalyzer, files: List[Dict], category: str = "changes"):
    """Export the diff to a text file for easier review."""
    from gitship.config import load_config
    import datetime
    
    print(f"\n{'=' * 80}")
    print("EXPORT DIFF TO FILE")
    print("=" * 80)
    
    # Ask for format preference
    print("\nChoose export format:")
    print("  1. Condensed (--unified=1, 60-70% smaller)")
    print("  2. Full context (default git diff)")
    print()
    
    try:
        format_choice = input("Format (1/2, default=1): ").strip() or "1"
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return
    
    use_condensed = format_choice == "1"
    
    # Get export path from config
    config = load_config()
    export_base = Path(config.get('export_path', Path.home() / "gitship_exports"))
    export_base.mkdir(parents=True, exist_ok=True)
    
    # Generate filename with timestamp and category
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    mode_suffix = "_condensed" if use_condensed else "_full"
    filename = f"diff_{category}{mode_suffix}_{timestamp}.txt"
    export_path = export_base / filename
    
    print(f"\nExporting to: {export_path}")
    print(f"Files to export: {len(files)}")
    print(f"Format: {'Condensed (minimal context)' if use_condensed else 'Full context'}")
    
    try:
        with open(export_path, 'w', encoding='utf-8') as f:
            # Write header
            f.write("=" * 80 + "\n")
            f.write(f"GITSHIP DIFF EXPORT - {category.upper()}\n")
            f.write(f"Format: {'CONDENSED (unified=1)' if use_condensed else 'FULL CONTEXT'}\n")
            f.write(f"Generated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
            f.write(f"Repository: {analyzer.repo_path}\n")
            f.write("=" * 80 + "\n\n")
            
            # Write file list
            f.write(f"FILES ({len(files)}):\n")
            f.write("-" * 80 + "\n")
            for item in files:
                if 'old' in item:
                    f.write(f"  RENAME: {item['old']} → {item['new']}\n")
                else:
                    status = item.get('status', '??')
                    f.write(f"  {status:4s} {item['path']}\n")
            f.write("\n")
            
            # Write stats
            f.write("STATISTICS:\n")
            f.write("-" * 80 + "\n")
            
            # Check if these are renames
            if files and 'old' in files[0]:
                f.write(f"{len(files)} file(s) renamed\n\n")
            else:
                paths = [f['path'] for f in files]
                modified_paths = [f['path'] for f in files if f['status'].strip() in ('M', 'MM') or 'M' in f['status']]
                
                if modified_paths:
                    result = analyzer.run_git(["diff", "--shortstat", "--staged", "--"] + modified_paths)
                    if result.returncode != 0 or not result.stdout.strip():
                        result = analyzer.run_git(["diff", "--shortstat", "--"] + modified_paths)
                    if result.returncode != 0 or not result.stdout.strip():
                        result = analyzer.run_git(["diff", "--shortstat", "HEAD", "--"] + modified_paths)
                    
                    if result.returncode == 0 and result.stdout.strip():
                        f.write(result.stdout + "\n")
                
                new_count = sum(1 for item in files if item['status'].strip() in ('??', 'A') or item['status'][:1] == 'A')
                deleted_count = sum(1 for item in files if item['status'].strip() == 'D' or item['status'][:1] == 'D')
                
                if new_count > 0:
                    f.write(f"New files: {new_count}\n")
                if deleted_count > 0:
                    f.write(f"Deleted files: {deleted_count}\n")
            
            f.write("\n")
            
            # Write full diff
            f.write("FULL DIFF:\n")
            f.write("=" * 80 + "\n\n")
            
            # Build git diff arguments
            diff_args = ["diff"]
            if use_condensed:
                diff_args.append("--unified=1")  # Only minimal context, no function-context!
            
            # Handle renames differently
            if files and 'old' in files[0]:
                for item in files:
                    f.write(f"\n{'=' * 80}\n")
                    f.write(f"RENAME: {item['old']} → {item['new']}\n")
                    f.write("=" * 80 + "\n\n")
                    
                    try:
                        # Get old content
                        result_old = analyzer.run_git(["show", f"HEAD:{item['old']}"])
                        old_content = result_old.stdout if result_old.returncode == 0 else ""
                        
                        # Get new content
                        new_file = analyzer.repo_path / item['new']
                        with open(new_file, 'r', encoding='utf-8', errors='ignore') as nf:
                            new_content = nf.read()
                        
                        # Generate diff with condensed context
                        import difflib
                        old_lines = old_content.splitlines(keepends=True)
                        new_lines = new_content.splitlines(keepends=True)
                        
                        n_context = 1 if use_condensed else 3
                        diff = difflib.unified_diff(
                            old_lines, new_lines,
                            fromfile=item['old'],
                            tofile=item['new'],
                            lineterm='',
                            n=n_context
                        )
                        
                        # Collect diff lines and filter in condensed mode
                        diff_lines = list(diff)
                        
                        if use_condensed:
                            filtered_diff = []
                            prev_blank = False
                            
                            for line in diff_lines:
                                # Check if blank change line
                                is_blank_change = line in ('+', '-', '+ ', '- ', '+\t', '-\t')
                                
                                # Skip consecutive blank changes
                                if is_blank_change and prev_blank:
                                    continue
                                
                                # Skip metadata
                                if (line.startswith('index ') or 
                                    line.startswith('new file mode') or 
                                    line.startswith('old mode')):
                                    continue
                                
                                filtered_diff.append(line)
                                prev_blank = is_blank_change
                            
                            diff_lines = filtered_diff
                        
                        for line in diff_lines:
                            f.write(line + "\n")
                    except Exception as e:
                        f.write(f"Error generating diff: {e}\n")
            else:
                # Regular files - process each file separately for cleaner output
                paths = [f['path'] for f in files]
                
                for file_info in files:
                    path = file_info['path']
                    status = file_info.get('status', '??')
                    
                    # File header
                    f.write(f"\n{'=' * 80}\n")
                    f.write(f"FILE: {path} [{status}]\n")
                    f.write("=" * 80 + "\n\n")
                    
                    # Handle untracked/new files differently
                    if status in ('??', 'A'):
                        # Could be a plain file, a binary, or an entire directory
                        try:
                            new_file = analyzer.repo_path / path
                            if new_file.is_dir():
                                sub_files = sorted(sf for sf in new_file.rglob('*') if sf.is_file())
                                f.write(f"NEW DIRECTORY ({len(sub_files)} file(s)):\n\n")
                                for sub in sub_files:
                                    rel = str(sub.relative_to(analyzer.repo_path))
                                    f.write(f"  {'-' * 60}\n")
                                    if _is_binary_file(sub):
                                        size_kb = sub.stat().st_size / 1024
                                        f.write(f"  FILE: {rel}  [BINARY - {size_kb:.1f} KB - skipped]\n\n")
                                    else:
                                        try:
                                            sub_content = sub.read_text(encoding='utf-8', errors='ignore')
                                            if use_condensed:
                                                clines = sub_content.split('\n')
                                                filtered_lines = []
                                                prev_blank = False
                                                for cline in clines:
                                                    is_blank = not cline.strip()
                                                    if is_blank and prev_blank:
                                                        continue
                                                    filtered_lines.append(cline)
                                                    prev_blank = is_blank
                                                sub_content = '\n'.join(filtered_lines)
                                            line_count = sub_content.count('\n') + 1
                                            f.write(f"  FILE: {rel}  ({line_count} lines)\n")
                                            f.write(f"  {'-' * 60}\n")
                                            for cline in sub_content.splitlines():
                                                f.write(f"  +{cline}\n")
                                            f.write("\n")
                                        except Exception as sub_e:
                                            f.write(f"  FILE: {rel}  (error reading: {sub_e})\n\n")
                            elif new_file.exists() and new_file.is_file():
                                if _is_binary_file(new_file):
                                    size_kb = new_file.stat().st_size / 1024
                                    f.write(f"NEW BINARY FILE - {size_kb:.1f} KB (skipped)\n\n")
                                else:
                                    with open(new_file, 'r', encoding='utf-8', errors='ignore') as nf:
                                        content = nf.read()
                                    if use_condensed:
                                        lines = content.split('\n')
                                        filtered_lines = []
                                        prev_blank = False
                                        for line in lines:
                                            is_blank = not line.strip()
                                            if is_blank and prev_blank:
                                                continue
                                            filtered_lines.append(line)
                                            prev_blank = is_blank
                                        content = '\n'.join(filtered_lines)
                                    f.write(f"NEW FILE - Full content:\n")
                                    f.write("-" * 80 + "\n")
                                    f.write(content)
                                    f.write("\n\n")
                            else:
                                f.write("(file not found or not readable)\n\n")
                        except Exception as e:
                            f.write(f"Error reading file: {e}\n\n")
                    else:
                        x_col = status[0] if len(status) >= 1 else ' '
                        y_col = status[1] if len(status) >= 2 else ' '
                        result = None
                        if x_col not in (' ', '?'):
                            result = analyzer.run_git(diff_args + ["--staged", "--", path])
                        if y_col not in (' ', '?') and (result is None or not result.stdout.strip()):
                            result = analyzer.run_git(diff_args + ["--", path])
                        if result is None or not result.stdout.strip():
                            result = analyzer.run_git(diff_args + ["HEAD", "--", path])
                        
                        if result.returncode == 0 and result.stdout.strip():
                            # Strip ANSI codes before writing
                            clean_output = strip_ansi(result.stdout)
                            
                            # Clean up diff output in condensed mode
                            if use_condensed:
                                lines = clean_output.split('\n')
                                filtered_lines = []
                                prev_blank = False
                                
                                for line in lines:
                                    # Check if this is a blank change line (just + or - with no content)
                                    is_blank_change = line in ('+', '-', '+ ', '- ', '+\t', '-\t')
                                    
                                    # Skip consecutive blank changes
                                    if is_blank_change and prev_blank:
                                        continue
                                    
                                    # Skip index, mode, and other metadata lines
                                    if (line.startswith('index ') or 
                                        line.startswith('new file mode') or 
                                        line.startswith('old mode') or
                                        line.startswith('deleted file mode')):
                                        continue
                                    
                                    filtered_lines.append(line)
                                    prev_blank = is_blank_change
                                
                                clean_output = '\n'.join(filtered_lines)
                            
                            f.write(clean_output)
                            f.write("\n\n")
                        else:
                            f.write("(no changes detected by git)\n\n")
        
        # Post-process in condensed mode: remove blank lines
        if use_condensed:
            with open(export_path, 'r', encoding='utf-8') as f:
                content = f.read()
            
            # Remove blank lines AND lines that are just + or - 
            lines = content.split('\n')
            filtered = []
            
            for line in lines:
                # Skip completely blank lines
                if not line.strip():
                    continue
                
                # Skip lines that are ONLY + or - (with optional whitespace)
                stripped = line.strip()
                if stripped in ('+', '-'):
                    continue
                
                filtered.append(line)
            
            with open(export_path, 'w', encoding='utf-8') as f:
                f.write('\n'.join(filtered))
        
        print(f"\n✅ Diff exported successfully!")
        print(f"   File: {export_path}")
        print(f"   Size: {export_path.stat().st_size:,} bytes")
        
        if use_condensed:
            print(f"   Mode: Condensed (--unified=1, blank lines removed)")
        
    except Exception as e:
        print(f"\n❌ Error exporting diff: {e}")
    
    print()
    input("Press Enter to continue...")


def show_stat(analyzer: ChangeAnalyzer, files: List[Dict]):
    """Show --stat for files."""
    print(f"\n{'=' * 80}")
    print("DETAILED FILE STATISTICS")
    print("=" * 80)
    
    # Check if these are renames
    if files and 'old' in files[0]:
        print(f"\n{len(files)} file(s) renamed:")
        for item in files:
            old_path = item['old']
            new_path = item['new']
            
            print(f"\n  📝 {old_path}")
            print(f"  →  {new_path}")
            
            # Calculate our own stats
            try:
                # Try to get old content from git (might be staged or from HEAD)
                result_old = analyzer.run_git(["show", f"HEAD:{old_path}"])
                if result_old.returncode != 0:
                    # Try staged version
                    result_old = analyzer.run_git(["show", f":{old_path}"])
                
                old_lines = result_old.stdout.splitlines() if result_old.returncode == 0 else []
                
                # Get new content from working directory or staged
                new_file = analyzer.repo_path / new_path
                if new_file.exists():
                    with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                        new_lines = f.read().splitlines()
                else:
                    # Try to get from index
                    result_new = analyzer.run_git(["show", f":{new_path}"])
                    new_lines = result_new.stdout.splitlines() if result_new.returncode == 0 else []
                
                # Simple diff count
                import difflib
                diff = list(difflib.unified_diff(old_lines, new_lines, lineterm=''))
                
                additions = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
                deletions = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
                
                if additions == 0 and deletions == 0:
                    print(f"     (identical - pure rename)")
                else:
                    print(f"     {additions} insertions(+), {deletions} deletions(-)")
                    
            except Exception as e:
                print(f"     (could not calculate stats: {e})")
        
        print()
        input("Press Enter to continue...")
        return
    
    # Regular files (not renames)
    for item in files:
        filepath = item['path']
        status = item['status']
        
        abs_path = analyzer.repo_path / filepath
        if abs_path.is_dir():
            print(f"\n📁 {filepath}/")
        elif _is_binary_file(abs_path):
            size_kb = abs_path.stat().st_size / 1024 if abs_path.exists() else 0
            print(f"\n📦 {filepath}  {Colors.DIM}[binary - {size_kb:.1f} KB]{Colors.RESET}")
        else:
            print(f"\n📄 {filepath}")
        
        # Check if this is actually a renamed file showing in code section
        if 'rename_from' in item:
            # This is a renamed file - show our own diff analysis
            old_path = item['rename_from']
            try:
                result_old = analyzer.run_git(["show", f"HEAD:{old_path}"])
                if result_old.returncode != 0:
                    result_old = analyzer.run_git(["show", f":{old_path}"])
                
                old_lines = result_old.stdout.splitlines() if result_old.returncode == 0 else []
                
                new_file = analyzer.repo_path / filepath
                if new_file.exists():
                    with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                        new_lines = f.read().splitlines()
                else:
                    result_new = analyzer.run_git(["show", f":{filepath}"])
                    new_lines = result_new.stdout.splitlines() if result_new.returncode == 0 else []
                
                import difflib
                diff = list(difflib.unified_diff(old_lines, new_lines, lineterm=''))
                
                additions = sum(1 for line in diff if line.startswith('+') and not line.startswith('+++'))
                deletions = sum(1 for line in diff if line.startswith('-') and not line.startswith('---'))
                
                print(f"  (renamed from {old_path})")
                print(f"  {additions} insertions(+), {deletions} deletions(-)")
            except Exception as e:
                print(f"  (renamed from {old_path}, could not calculate stats: {e})")
        
        elif status == 'D':
            print("  [DELETED]")
        else:
            # Handles '??', 'A', 'A ', 'M', 'MM', 'M ', ' M', and any other status.
            x_col = status[0] if len(status) >= 1 else ' '
            y_col = status[1] if len(status) >= 2 else ' '
            is_new = x_col in ('A', '?') or y_col in ('?',)
            
            if is_new or status.strip() in ('??', 'A'):
                # New / untracked — could be a file, binary, or directory
                abs_path = analyzer.repo_path / filepath
                if abs_path.is_dir():
                    file_count, text_lines, binary_count = _dir_stats(abs_path, analyzer.repo_path)
                    parts = [f"{file_count} file(s)"]
                    if text_lines:
                        parts.append(f"{text_lines} text lines")
                    if binary_count:
                        parts.append(f"{binary_count} binary")
                    print(f"  [NEW DIRECTORY - {', '.join(parts)}]")
                    # Show per-file breakdown
                    for sub in sorted(abs_path.rglob('*')):
                        if sub.is_file():
                            rel = sub.relative_to(analyzer.repo_path)
                            if _is_binary_file(sub):
                                size_kb = sub.stat().st_size / 1024
                                print(f"    📦 {rel}  [BINARY - {size_kb:.1f} KB]")
                            else:
                                try:
                                    line_count = sum(1 for _ in open(sub, 'r', encoding='utf-8', errors='ignore'))
                                    print(f"    📄 {rel}  ({line_count} lines)")
                                except Exception:
                                    print(f"    📄 {rel}")
                elif _is_binary_file(abs_path):
                    size_kb = abs_path.stat().st_size / 1024
                    print(f"  [NEW BINARY FILE - {size_kb:.1f} KB]")
                else:
                    try:
                        with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:
                            lines = len(f.readlines())
                        print(f"  [NEW FILE - {lines} lines]")
                    except Exception:
                        print("  [NEW FILE]")
            else:
                # Modified file — skip diff for binaries
                abs_path_check = analyzer.repo_path / filepath
                if abs_path_check.exists() and _is_binary_file(abs_path_check):
                    size_kb = abs_path_check.stat().st_size / 1024
                    print(f"  [BINARY FILE - {size_kb:.1f} KB, modified]")
                else:
                    # Modified file — try staged first (covers 'M ', 'A '), then unstaged, then HEAD
                    result = analyzer.run_git(["diff", "--stat", "--staged", "--", filepath])
                    
                    if result.returncode != 0 or not result.stdout.strip():
                        result = analyzer.run_git(["diff", "--stat", "--", filepath])
                    
                    if result.returncode != 0 or not result.stdout.strip():
                        result = analyzer.run_git(["diff", "--stat", "HEAD", "--", filepath])
                    
                    if result.returncode == 0 and result.stdout.strip():
                        stat_lines = result.stdout.strip().split("\n")
                        for line in stat_lines:
                            if filepath in line or "|" in line:
                                print(f"  {line}")
                                break
                        else:
                            print(f"  {result.stdout.strip()}")
    
    print()
    input("Press Enter to continue...")


def show_full_diff(analyzer: ChangeAnalyzer, files: List[Dict]):
    """Show full diff for files."""
    print(f"\n{'=' * 80}")
    print("FULL DIFF")
    print("=" * 80)
    
    # Check if these are renames
    if files and 'old' in files[0]:
        for item in files:
            print(f"\n{'=' * 80}")
            print(f"RENAME: {item['old']} → {item['new']}")
            print("=" * 80)
            
            if not item.get('content_changed', False):
                print("\n✓ Files are identical - pure rename (no content changes)")
            else:
                print("\n⚠️  Content was modified during rename\n")
                # Show the actual diff
                try:
                    # Get old content
                    result_old = analyzer.run_git(["show", f"HEAD:{item['old']}"])
                    old_content = result_old.stdout if result_old.returncode == 0 else ""
                    
                    # Get new content
                    new_path = analyzer.repo_path / item['new']
                    with open(new_path, 'r', encoding='utf-8', errors='ignore') as f:
                        new_content = f.read()
                    
                    # Show unified diff
                    import difflib
                    diff = difflib.unified_diff(
                        old_content.splitlines(keepends=True),
                        new_content.splitlines(keepends=True),
                        fromfile=item['old'],
                        tofile=item['new'],
                        lineterm=''
                    )
                    print(''.join(diff))
                except Exception as e:
                    print(f"Could not generate diff: {e}")
        
        print()
        input("Press Enter to continue...")
        return
    
    # Regular files
    for item in files:
        filepath = item['path']
        status = item['status']

        abs_path_check = analyzer.repo_path / filepath
        if abs_path_check.is_file() and _is_binary_file(abs_path_check):
            try:
                size = abs_path_check.stat().st_size
                for unit in ("B", "KB", "MB", "GB"):
                    if size < 1024 or unit == "GB":
                        size_str = f"{size:.1f} {unit}" if unit != "B" else f"{size} B"
                        break
                    size /= 1024
            except OSError:
                size_str = "unknown size"
            print(f"\n📦 {filepath}")
            print("-" * 80)
            print(f"  [binary file — {size_str}]")
            continue

        print(f"\n📄 {filepath}")
        print("-" * 80)
        
        # Check if this is a renamed file in code section
        if 'rename_from' in item:
            old_path = item['rename_from']
            print(f"RENAMED from {old_path}\n")
            
            try:
                # Get old content
                result_old = analyzer.run_git(["show", f"HEAD:{old_path}"])
                if result_old.returncode != 0:
                    result_old = analyzer.run_git(["show", f":{old_path}"])
                old_content = result_old.stdout if result_old.returncode == 0 else ""
                
                # Get new content
                new_file = analyzer.repo_path / filepath
                if new_file.exists():
                    with open(new_file, 'r', encoding='utf-8', errors='ignore') as f:
                        new_content = f.read()
                else:
                    result_new = analyzer.run_git(["show", f":{filepath}"])
                    new_content = result_new.stdout if result_new.returncode == 0 else ""
                
                # Show unified diff
                import difflib
                diff = difflib.unified_diff(
                    old_content.splitlines(keepends=True),
                    new_content.splitlines(keepends=True),
                    fromfile=old_path,
                    tofile=filepath,
                    lineterm=''
                )
                print(''.join(diff))
            except Exception as e:
                print(f"Could not generate diff: {e}")
        
        elif 'D' in status:
            result = analyzer.run_git(["show", f"HEAD:{filepath}"])
            if result.returncode == 0:
                content = result.stdout
                non_printable = sum(1 for c in content[:4096] if not c.isprintable() and c not in '\n\r\t')
                if non_printable > 20:
                    size_result = analyzer.run_git(["cat-file", "-s", f"HEAD:{filepath}"])
                    if size_result.returncode == 0:
                        try:
                            sz = int(size_result.stdout.strip())
                            for unit in ("B", "KB", "MB", "GB"):
                                if sz < 1024 or unit == "GB":
                                    size_str = f"{sz:.1f} {unit}" if unit != "B" else f"{sz} B"
                                    break
                                sz /= 1024
                        except ValueError:
                            size_str = "unknown size"
                    else:
                        size_str = "unknown size"
                    print(f"  [binary/non-printable content — {size_str}]  DELETED")
                else:
                    lines = content.split('\n')
                    preview = '\n'.join(lines[:30])
                    print(f"DELETED FILE - Last content ({len(lines)} lines):\n{preview}")
                    if len(lines) > 30:
                        print(f"\n... ({len(lines) - 30} more lines)")
        
        elif status in ('??', 'A'):
            abs_path = analyzer.repo_path / filepath
            if abs_path.is_dir():
                dir_files = sorted(abs_path.rglob('*'))
                file_entries = [f for f in dir_files if f.is_file()]
                print(f"NEW DIRECTORY ({len(file_entries)} files):")
                for sub in file_entries:
                    rel = sub.relative_to(analyzer.repo_path)
                    if _is_binary_file(sub):
                        size_kb = sub.stat().st_size / 1024
                        print(f"\n  📦 {rel}  [binary — {size_kb:.1f} KB]")
                        continue
                    try:
                        sub_content = sub.read_text(encoding='utf-8', errors='replace')
                        sub_lines = sub_content.split('\n')
                        print(f"\n  \U0001f4c4 {rel}  ({len(sub_lines)} lines)")
                        print("  " + "-" * 60)
                        for line in sub_lines[:20]:
                            print(f"  {Colors.GREEN}+{line}{Colors.RESET}")
                        if len(sub_lines) > 20:
                            print(f"  {Colors.DIM}  ... {len(sub_lines)-20} more lines{Colors.RESET}")
                    except Exception as e:
                        print(f"  \U0001f4c4 {rel}  (could not read: {e})")
            else:
                try:
                    file_content = abs_path.read_text(encoding='utf-8', errors='replace')
                    lines = file_content.split('\n')
                    preview = '\n'.join(lines[:30])
                    print(f"NEW FILE ({len(lines)} lines):\n{preview}")
                    if len(lines) > 30:
                        print(f"\n... ({len(lines) - 30} more lines)")
                except Exception as e:
                    print(f"  (Could not read: {e})")
        
        else:
            x_col = status[0] if len(status) >= 1 else ' '
            y_col = status[1] if len(status) >= 2 else ' '
            
            result = None
            printed_raw = False
            
            if x_col not in (' ', '?'):
                result = analyzer.run_git(["diff", "--color=always", "--staged", "--", filepath])
            
            if y_col not in (' ', '?') and (result is None or not result.stdout.strip()):
                result = analyzer.run_git(["diff", "--color=always", "--", filepath])
            
            if result is None or not result.stdout.strip():
                result = analyzer.run_git(["diff", "--color=always", "HEAD", "--", filepath])
            
            if result is None or not result.stdout.strip():
                abs_path = analyzer.repo_path / filepath
                if abs_path.is_file():
                    try:
                        file_content = abs_path.read_text(encoding='utf-8', errors='replace')
                        file_lines = file_content.split('\n')
                        print(f"NEW FILE ({len(file_lines)} lines):")
                        for fline in file_lines:
                            print(f"{Colors.GREEN}+{fline}{Colors.RESET}")
                        printed_raw = True
                    except Exception as e:
                        print(f"  (Could not read: {e})")
                        printed_raw = True

            if not printed_raw:
                if result is not None and result.stdout:
                    raw_out = result.stdout
                    # Strip ANSI codes before checking for non-printable chars
                    # (color codes would otherwise trigger the binary guard on normal diffs)
                    plain_out = strip_ansi(raw_out)
                    non_printable = sum(1 for c in plain_out[:4096] if not c.isprintable() and c not in '\n\r\t')
                    if non_printable > 20:
                        print(f"{Colors.DIM}  [diff contains binary/non-printable content — skipped]{Colors.RESET}")
                    else:
                        lines = raw_out.splitlines()
                        cleaned_lines = []
                        for line in lines:
                            plain = strip_ansi(line)
                            if plain.startswith(('diff --git', 'index ', '---', '+++')):
                                continue
                            cleaned_lines.append(line)
                        if cleaned_lines:
                            print('\n'.join(cleaned_lines))
                        else:
                            print(f"{Colors.DIM}(no diff content){Colors.RESET}")
                else:
                    print(f"{Colors.DIM}(no changes detected){Colors.RESET}")
    
    print()
    input("Press Enter to continue...")


def show_combined_shortstat(analyzer: ChangeAnalyzer):
    """Show shortstat for ALL changes combined (staged + unstaged + untracked).

    Translation files that are in the gitops ignore list are shown separately
    so they don't inflate the unstaged count and confuse the reviewer.
    """
    # Collect translation paths so we can label them separately
    trans_paths: set = set()
    for lang_files in analyzer.changes['translations'].values():
        for f in lang_files:
            trans_paths.add(f['path'])

    # Staged changes
    result_staged = analyzer.run_git(["diff", "--shortstat", "--staged"])
    staged_text = result_staged.stdout.strip() if result_staged.returncode == 0 else ""

    # Unstaged changes — break into ignored (translations) vs real
    result_unstaged_full = analyzer.run_git(["diff", "--name-only"])
    unstaged_ignored: List[str] = []
    unstaged_real: List[str] = []
    if result_unstaged_full.returncode == 0:
        for fp in result_unstaged_full.stdout.strip().splitlines():
            fp = fp.strip()
            if not fp:
                continue
            if fp in trans_paths:
                unstaged_ignored.append(fp)
            else:
                unstaged_real.append(fp)

    unstaged_text = ""
    if unstaged_real:
        r = analyzer.run_git(["diff", "--shortstat", "--"] + unstaged_real)
        if r.returncode == 0:
            unstaged_text = r.stdout.strip()

    # Untracked files — walk dirs properly
    result_status = analyzer.run_git(["status", "--porcelain"])
    untracked_count = 0
    untracked_lines = 0
    if result_status.returncode == 0:
        for line in result_status.stdout.strip().split('\n'):
            if not line.startswith('??'):
                continue
            filepath = line[3:].strip()
            file_path = analyzer.repo_path / filepath.rstrip('/')
            if file_path.is_dir():
                fc, tl, _ = _dir_stats(file_path, analyzer.repo_path)
                untracked_count += fc
                untracked_lines += tl
            elif file_path.is_file():
                untracked_count += 1
                try:
                    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                        untracked_lines += len(f.readlines())
                except Exception:
                    pass

    print(f"{Colors.BOLD}Overall changes:{Colors.RESET}")
    if staged_text:
        print(f"  {Colors.CYAN}Staged:{Colors.RESET} {staged_text}")
    if unstaged_text:
        print(f"  {Colors.YELLOW}Unstaged:{Colors.RESET} {unstaged_text}")
    if unstaged_ignored:
        print(f"  {Colors.MAGENTA}Ignored (translations — commit separately):{Colors.RESET} "
              f"{len(unstaged_ignored)} file(s)")
    if untracked_count > 0:
        print(f"  {Colors.GREEN}Untracked:{Colors.RESET} {untracked_count} new file(s) (~{untracked_lines} lines)")

    if not staged_text and not unstaged_text and not unstaged_ignored and untracked_count == 0:
        print(f"{Colors.DIM}(no changes){Colors.RESET}")


def show_combined_stat(analyzer: ChangeAnalyzer):
    """Show combined --stat for all changes (staged + unstaged + untracked)."""
    print(f"\n{Colors.BOLD}{'=' * 80}{Colors.RESET}")
    print(f"{Colors.BOLD}COMBINED FILE STATISTICS{Colors.RESET}")
    print(f"{Colors.BOLD}{'=' * 80}{Colors.RESET}\n")
    
    # Staged
    result_staged = analyzer.run_git(["diff", "--stat", "--staged"])
    if result_staged.returncode == 0 and result_staged.stdout.strip():
        print(f"{Colors.CYAN}Staged changes:{Colors.RESET}")
        print(result_staged.stdout)
    
    # Unstaged
    result_unstaged = analyzer.run_git(["diff", "--stat"])
    if result_unstaged.returncode == 0 and result_unstaged.stdout.strip():
        print(f"{Colors.YELLOW}Unstaged changes:{Colors.RESET}")
        print(result_unstaged.stdout)
    
    # Untracked
    result_status = analyzer.run_git(["status", "--porcelain"])
    untracked = []
    if result_status.returncode == 0:
        for line in result_status.stdout.strip().split('\n'):
            if line.startswith('??'):
                untracked.append(line[3:].strip())
    
    if untracked:
        print(f"{Colors.GREEN}Untracked files ({len(untracked)} new):{Colors.RESET}")
        for filepath in untracked:
            try:
                file_path = analyzer.repo_path / filepath
                if file_path.is_file():
                    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                        lines = len(f.readlines())
                    print(f"  {filepath} | {lines} lines (new)")
            except:
                print(f"  {filepath} | (new)")
    
    if not result_staged.stdout.strip() and not result_unstaged.stdout.strip() and not untracked:
        print(f"{Colors.DIM}(no changes){Colors.RESET}")
    
    print()
    input("Press Enter to continue...")


def show_combined_diff(analyzer: ChangeAnalyzer):
    """Show combined full diff for all changes (staged + unstaged + preview of untracked)."""
    print(f"\n{Colors.BOLD}{'=' * 80}{Colors.RESET}")
    print(f"{Colors.BOLD}COMBINED FULL DIFF{Colors.RESET}")
    print(f"{Colors.BOLD}{'=' * 80}{Colors.RESET}\n")
    
    # Staged
    result_staged = analyzer.run_git(["diff", "--color=always", "--staged"])
    if result_staged.returncode == 0 and result_staged.stdout.strip():
        print(f"{Colors.CYAN}{'=' * 80}{Colors.RESET}")
        print(f"{Colors.CYAN}STAGED CHANGES{Colors.RESET}")
        print(f"{Colors.CYAN}{'=' * 80}{Colors.RESET}\n")
        print(result_staged.stdout)
    
    # Unstaged
    result_unstaged = analyzer.run_git(["diff", "--color=always"])
    if result_unstaged.returncode == 0 and result_unstaged.stdout.strip():
        print(f"\n{Colors.YELLOW}{'=' * 80}{Colors.RESET}")
        print(f"{Colors.YELLOW}UNSTAGED CHANGES{Colors.RESET}")
        print(f"{Colors.YELLOW}{'=' * 80}{Colors.RESET}\n")
        print(result_unstaged.stdout)
    
    # Untracked (show preview)
    result_status = analyzer.run_git(["status", "--porcelain"])
    untracked = []
    if result_status.returncode == 0:
        for line in result_status.stdout.strip().split('\n'):
            if line.startswith('??'):
                untracked.append(line[3:].strip())
    
    if untracked:
        print(f"\n{Colors.GREEN}{'=' * 80}{Colors.RESET}")
        print(f"{Colors.GREEN}UNTRACKED FILES (preview of first 20 lines each){Colors.RESET}")
        print(f"{Colors.GREEN}{'=' * 80}{Colors.RESET}\n")
        for filepath in untracked:
            print(f"{Colors.GREEN}📄 {filepath} (NEW FILE){Colors.RESET}")
            print("-" * 80)
            try:
                file_path = analyzer.repo_path / filepath
                if file_path.is_file():
                    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                        lines = f.readlines()
                        preview = ''.join(lines[:20])
                        print(preview)
                        if len(lines) > 20:
                            print(f"{Colors.DIM}... ({len(lines) - 20} more lines){Colors.RESET}")
            except Exception as e:
                print(f"{Colors.DIM}(could not read: {e}){Colors.RESET}")
            print()
    
    if not result_staged.stdout.strip() and not result_unstaged.stdout.strip() and not untracked:
        print(f"{Colors.DIM}(no changes){Colors.RESET}")
    
    input("\nPress Enter to continue...")


def _extract_lang_name(lang_code: str) -> str:
    """Convert a locale code from the file path into a readable display name.

    Examples: ar_eg -> ar_EG,  fr -> fr,  zh_hans -> zh_HANS
    We just upper-case the country/script part so it reads naturally.
    """
    parts = lang_code.replace('-', '_').split('_', 1)
    if len(parts) == 2:
        return f"{parts[0]}_{parts[1].upper()}"
    return lang_code


def commit_translations_only(analyzer: ChangeAnalyzer):
    """
    Commit translation files using a frozen snapshot captured right now.

    Because the AI keeps modifying .po files in the background, we:
      1. Snapshot the exact diff vs HEAD the moment the user enters this flow
      2. Let the user review THAT frozen snapshot (not the live file)
      3. On confirm, call atomic_commit_with_snapshot which:
           - stashes current (AI-latest) versions of ignored files
           - writes back exactly the snapshot content
           - stages + commits those files (+ anything else staged)
           - pops the stash to restore the AI's latest work
    """
    trans_files = []
    for lang_files in analyzer.changes['translations'].values():
        trans_files.extend(lang_files)

    if not trans_files:
        print("\n❌ No translation changes detected.")
        input("Press Enter to continue...")
        return False

    print(f"\n{'=' * 80}")
    print("TRANSLATION SNAPSHOT — Lock Current State")
    print("=" * 80)
    print(f"\n⏳ Capturing snapshot of {len(trans_files)} file(s) right now...")
    print("   (This freezes what changed so the AI can keep running safely)")

    # ── Capture snapshot immediately ──────────────────────────────────────────
    if capture_translation_snapshots:
        snapshots = capture_translation_snapshots(analyzer.repo_path, trans_files)
    else:
        # Fallback when gitops not available: read content + git diff manually
        snapshots = []
        for f in trans_files:
            filepath = f['path']
            diff_result = analyzer.run_git(["diff", "HEAD", "--", filepath])
            if diff_result.returncode == 0 and diff_result.stdout.strip():
                abs_path = analyzer.repo_path / filepath
                try:
                    with open(abs_path, 'r', encoding='utf-8', errors='replace') as fh:
                        current = fh.read()
                except Exception:
                    current = ""
                m = re.search(r'/locale/([^/]+)/', filepath)
                lang_code = m.group(1) if m else None
                snapshots.append({
                    'filepath': filepath,
                    'patch': diff_result.stdout,
                    'snapshot_content': current,
                    'lang_code': lang_code,
                })

    if not snapshots:
        print("\n⚠  No differences found vs HEAD — nothing to commit.")
        input("Press Enter to continue...")
        return False

    # Summarise snapshotted languages (from file path, not .po header)
    langs = sorted({s['lang_code'] for s in snapshots if s.get('lang_code')})
    lang_display = ', '.join(_extract_lang_name(l) for l in langs) if langs else 'unknown'
    print(f"\n✓ Snapshot captured: {len(snapshots)} file(s)  [{lang_display}]")

    # ── Review loop ───────────────────────────────────────────────────────────
    # Pre-compute patch stats for display
    def _patch_stats(snap):
        lines = snap['patch'].splitlines()
        adds = sum(1 for l in lines if l.startswith('+') and not l.startswith('+++'))
        dels = sum(1 for l in lines if l.startswith('-') and not l.startswith('---'))
        hunks = sum(1 for l in lines if l.startswith('@@'))
        return adds, dels, hunks

    def _print_patch_coloured(patch_text, max_lines=None):
        """Print a patch with colour. If max_lines set, stop and show truncation notice."""
        shown = 0
        lines = patch_text.splitlines()
        for line in lines:
            if max_lines and shown >= max_lines:
                remaining = len(lines) - shown
                print(f"{Colors.DIM}  ... {remaining} more lines — use 'Export' to see full patch{Colors.RESET}")
                break
            if line.startswith('+') and not line.startswith('+++'):
                print(f"{Colors.GREEN}{line}{Colors.RESET}")
            elif line.startswith('-') and not line.startswith('---'):
                print(f"{Colors.RED}{line}{Colors.RESET}")
            elif line.startswith('@@'):
                print(f"{Colors.CYAN}{line}{Colors.RESET}")
            else:
                print(line)
            shown += 1

    def _export_snapshot_patch(snapshots, lang_display):
        """Write frozen patch to a temp file and print the path."""
        import tempfile, datetime
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        fname = f"translation_snapshot_{lang_display.replace(', ', '_')}_{ts}.patch"
        export_dir = Path.home() / "gitship_exports"
        export_dir.mkdir(parents=True, exist_ok=True)
        out_path = export_dir / fname
        with open(out_path, 'w', encoding='utf-8') as fh:
            fh.write(f"# FROZEN SNAPSHOT — captured {datetime.datetime.now()}\n")
            fh.write(f"# Languages: {lang_display}\n\n")
            for snap in snapshots:
                lname = _extract_lang_name(snap['lang_code']) if snap.get('lang_code') else '?'
                adds, dels, hunks = _patch_stats(snap)
                fh.write(f"# {snap['filepath']}  [{lname}]  +{adds}/-{dels} in {hunks} hunk(s)\n")
                fh.write(snap['patch'])
                fh.write("\n\n")
        return out_path

    # Decide threshold for "large" patch: >200 patch lines triggers preview mode
    total_patch_lines = sum(len(s['patch'].splitlines()) for s in snapshots)
    is_large = total_patch_lines > 200

    print()
    print("Review the FROZEN snapshot (AI can keep writing — this won't change):")
    print()
    print("  1. Stats only   (counts per file)")
    if is_large:
        print(f"  2. Preview      (first 50 lines per file — {total_patch_lines} patch lines total)")
    else:
        print(f"  2. Full patch   ({total_patch_lines} patch lines)")
    print("  3. Export patch  (write full frozen diff to ~/gitship_exports/)")
    print("  4. Continue to commit message")
    print("  5. Cancel — discard snapshot")
    print()

    while True:
        try:
            choice = input("Choose (1-5): ").strip()
        except KeyboardInterrupt:
            print("\n\nCancelled.")
            return False

        if choice == '1':
            print()
            for snap in snapshots:
                lname = _extract_lang_name(snap['lang_code']) if snap.get('lang_code') else snap['filepath']
                adds, dels, hunks = _patch_stats(snap)
                print(f"  📄 {snap['filepath']}")
                print(f"     [{lname}]  +{adds} / -{dels} lines  {hunks} hunk(s)  ← snapshot")
            print()

        elif choice == '2':
            preview_limit = 50 if is_large else None
            print()
            for snap in snapshots:
                lname = _extract_lang_name(snap['lang_code']) if snap.get('lang_code') else snap['filepath']
                adds, dels, hunks = _patch_stats(snap)
                print(f"\n{'─' * 70}")
                label = "PREVIEW — first 50 lines" if is_large else "FROZEN SNAPSHOT"
                print(f"  {snap['filepath']}  [{lname}]  +{adds}/-{dels}  ← {label}")
                print(f"{'─' * 70}")
                _print_patch_coloured(snap['patch'], max_lines=preview_limit)
            print()

        elif choice == '3':
            out_path = _export_snapshot_patch(snapshots, lang_display)
            print(f"\n  ✓ Patch exported to: {out_path}")
            print(f"    Open with:  diff-highlight < {out_path} | less -R")
            print()

        elif choice == '4':
            break

        elif choice == '5':
            print("Cancelled — snapshot discarded.")
            return False

        else:
            print("Invalid choice.")

    # ── Build commit message ──────────────────────────────────────────────────
    print()
    print(f"{Colors.BOLD}Commit Message{Colors.RESET}")
    suggested_title = f"Update translations [{lang_display}]" if langs else "Update translations"
    print(f"Suggested: {Colors.DIM}{suggested_title}{Colors.RESET}")
    print("Enter a custom title, or press Enter to use the suggested one:")
    print()

    try:
        custom_title = input("Title: ").strip()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return False

    title = custom_title if custom_title else suggested_title

    print()
    print("Commit type prefix (Enter to skip):")
    print("  1. chore   2. feat   3. fix   4. i18n")
    try:
        type_choice = input("Choose: ").strip()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return False

    type_map = {'1': 'chore', '2': 'feat', '3': 'fix', '4': 'i18n'}
    commit_type = type_map.get(type_choice, '')

    commit_scope = ''
    if commit_type:
        print()
        print(f"Scope (optional — e.g. {commit_type}(daemon): or {commit_type}(i18n):)")
        print(f"Press Enter to skip → {commit_type}: …")
        try:
            raw_scope = input("Scope: ").strip().strip("()")
            commit_scope = raw_scope
        except (KeyboardInterrupt, EOFError):
            print("\n\nCancelled.")
            return False

    if commit_type and commit_scope:
        first_line = f"{commit_type}({commit_scope}): {title}"
    elif commit_type:
        first_line = f"{commit_type}: {title}"
    else:
        first_line = title

    # ── Step: Description / body (optional) ──────────────────────────────────
    print()
    print(f"{Colors.BOLD}Description (optional){Colors.RESET}")
    print("Add context, bullet points, references — anything you want in the commit body.")
    print()
    print("  1. Type inline  (paste or type, end with a blank line)")
    print("  2. Open editor  (nano/vim — good for longer notes)")
    print("  3. Skip         (auto-generated file list only)")
    print()

    user_description = ""
    try:
        desc_choice = input("Choose (1-3): ").strip()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return False

    if desc_choice == '1':
        print()
        print(f"{Colors.DIM}Enter your description. Finish with an empty line:{Colors.RESET}")
        print()  # blank line so pasted text (including lines starting with #) starts fresh
        lines = []
        try:
            while True:
                line = input()
                if line == "" and lines:
                    # Second blank line or first blank after content = done
                    break
                lines.append(line)
        except (KeyboardInterrupt, EOFError):
            pass
        user_description = "\n".join(lines).strip()
        if user_description:
            print(f"  {Colors.GREEN}✓ Description captured ({len(lines)} lines){Colors.RESET}")

    elif desc_choice == '2':
        import tempfile
        # Pre-populate template with helpful hints
        template = (
            "# Describe what changed and why.\n"
            "# Lines starting with # are ignored.\n"
            "# Tip: bullet points with - or •, close issues with 'Closes #N'\n"
            "#\n"
            "\n\n"  # two blank lines — cursor starts here, safely below the # block
        )
        with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tf:
            temp_path = tf.name
            tf.write(template)

        editor = os.environ.get('EDITOR', 'nano')
        try:
            subprocess.run([editor, temp_path], check=True)
            with open(temp_path, 'r', encoding='utf-8') as f:
                raw = f.read()
            desc_lines = [l.rstrip() for l in raw.splitlines() if not l.strip().startswith('#')]
            # Strip leading/trailing blank lines
            while desc_lines and not desc_lines[0]:
                desc_lines.pop(0)
            while desc_lines and not desc_lines[-1]:
                desc_lines.pop()
            user_description = "\n".join(desc_lines).strip()
            if user_description:
                print(f"  {Colors.GREEN}✓ Description captured ({len(desc_lines)} lines){Colors.RESET}")
            else:
                print(f"  {Colors.DIM}(empty — skipping description){Colors.RESET}")
        except Exception as e:
            print(f"  {Colors.YELLOW}Could not open editor: {e}{Colors.RESET}")
        finally:
            try:
                os.unlink(temp_path)
            except Exception:
                pass

    # desc_choice == '3' or anything else → skip, user_description stays ""

    # Body: user description (if any) + per-file breakdown
    body_lines = []
    if user_description:
        body_lines.append(user_description)
        body_lines.append("")  # blank line before file list

    body_lines.append("Files:")
    for snap in snapshots:
        lname = _extract_lang_name(snap['lang_code']) if snap.get('lang_code') else '?'
        patch_lines = snap['patch'].splitlines()
        adds = sum(1 for l in patch_lines if l.startswith('+') and not l.startswith('+++'))
        dels = sum(1 for l in patch_lines if l.startswith('-') and not l.startswith('---'))
        body_lines.append(f"  • {snap['filepath']}  [{lname}]  +{adds}/-{dels}")
    body_lines += ["", "[gitship-generated]"]

    message = first_line + "\n\n" + "\n".join(body_lines)

    print(f"\n{Colors.BOLD}Commit message:{Colors.RESET}")
    print(f"{Colors.CYAN}{first_line}{Colors.RESET}")
    print(f"{Colors.DIM}", end="")
    for line in body_lines[:6]:
        print(line)
    if len(body_lines) > 6:
        print(f"  (+{len(body_lines)-6} more lines)")
    print(f"{Colors.RESET}")

    try:
        confirm = input("Commit this snapshot? (y/n): ").strip().lower()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return False

    if confirm not in ('y', 'yes'):
        print("Commit cancelled — snapshot discarded.")
        return False

    # ── Atomic commit with snapshot ───────────────────────────────────────────
    if atomic_commit_with_snapshot:
        result = atomic_commit_with_snapshot(
            repo_path=analyzer.repo_path,
            snapshots=snapshots,
            commit_message=message,
        )
    else:
        # Fallback: no background AI running, write directly and commit
        print("\n⚠  gitops not available — plain commit (no stash/restore)")
        for snap in snapshots:
            abs_path = analyzer.repo_path / snap['filepath']
            with open(abs_path, 'w', encoding='utf-8') as fh:
                fh.write(snap['snapshot_content'])
            analyzer.run_git(["add", snap['filepath']])
        result = analyzer.run_git(["commit", "-m", message])

    if result.returncode != 0:
        print(f"\n{Colors.RED}❌ Commit failed: {result.stderr}{Colors.RESET}")
        input("Press Enter to continue...")
        return False

    print(f"\n{Colors.GREEN}✅ Translation snapshot committed!{Colors.RESET}")
    if result.stdout.strip():
        print(result.stdout.strip())

    try:
        push = input("\nPush to remote? (y/n): ").strip().lower()
        if push in ('y', 'yes'):
            if atomic_git_operation:
                push_result = atomic_git_operation(
                    repo_path=analyzer.repo_path,
                    git_command=["push"],
                    description="push"
                )
            else:
                push_result = analyzer.run_git(["push"])
            if push_result.returncode == 0:
                print(f"{Colors.GREEN}✓ Pushed{Colors.RESET}")
            else:
                print(f"{Colors.YELLOW}⚠  Push failed: {push_result.stderr}{Colors.RESET}")
    except KeyboardInterrupt:
        print("\nPush skipped.")

    input("\nPress Enter to continue...")
    return True


def _load_hunk_grouper(repo_path: Path):
    """Dynamically load hunk_grouper_ast from the gitship installation next to this file."""
    import importlib.util
    candidates = [
        Path(__file__).parent / "hunk_grouper_ast.py",
        repo_path / ".gitship" / "hunk_grouper_ast.py",
    ]
    for candidate in candidates:
        if candidate.exists():
            spec = importlib.util.spec_from_file_location("hunk_grouper_ast", candidate)
            mod = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)
            return mod
    return None


def _suggest_commit_type(tag_type: str, symbol: str) -> str:
    """Derive a conventional-commit prefix from a group's tag type."""
    mapping = {
        "exception":   "fix",
        "ipc":         "refactor",
        "dependency":  "refactor",
        "symbol_removed": "refactor",
        "abstraction": "refactor",
        "callgraph":   "refactor",
    }
    return mapping.get(tag_type, "chore")


def _stage_hunk_patch(repo_path: Path, patch_text: str) -> bool:
    """
    Write a minimal patch to a temp file and apply it to the index with
    git apply --cached.  Returns True on success.
    """
    import tempfile
    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".patch", delete=False, encoding="utf-8"
    ) as tf:
        temp_path = tf.name
        tf.write(patch_text)
    try:
        result = subprocess.run(
            ["git", "apply", "--cached", "--unidiff-zero", "--recount", "--whitespace=nowarn", temp_path],
            cwd=repo_path,
            capture_output=True,
        )
        try:
            result.stdout = result.stdout.decode("utf-8")
            result.stderr = result.stderr.decode("utf-8")
        except Exception:
            pass
        if result.returncode != 0:
            print(f"  {Colors.YELLOW}⚠  git apply failed: {result.stderr.strip()}{Colors.RESET}")
            return False
        return True
    finally:
        try:
            os.unlink(temp_path)
        except Exception:
            pass


def _build_group_patch(group_hunks_list) -> str:
    from collections import defaultdict
    by_file: dict = defaultdict(list)
    for h in group_hunks_list:
        by_file[h.file].append(h)
    lines = []
    for filepath, hunks in by_file.items():
        lines.append(f"diff --git a/{filepath} b/{filepath}")
        lines.append(f"--- a/{filepath}")
        lines.append(f"+++ b/{filepath}")
        for h in hunks:
            lines.append(h.header)
            lines.extend(h.raw_lines)
    return "\n".join(lines) + "\n"

def _remap_hunks(stale_list, fresh_hunks):
    """Match stale hunks to freshly-parsed ones by (file, header) after a commit."""
    fresh_by_key = {(h.file, h.header): h for h in fresh_hunks}
    result = []
    for h in stale_list:
        refreshed = fresh_by_key.get((h.file, h.header))
        if refreshed:
            result.append(refreshed)
        # hunk not found = already applied, silently drop it
    return result

def semantic_commit(analyzer: ChangeAnalyzer) -> bool:
    """
    Run hunk_grouper_ast on the current working-tree diff, show the semantic
    group table, and let the user commit one group at a time.

    Each group becomes its own atomic commit.  Ungrouped hunks are collected
    and offered at the end as a single catch-all commit.  The result is a
    clean, bisectable, CI-friendly history instead of one 4000-line blob.
    """
    repo_path = analyzer.repo_path

    # ── 1. Load grouper ──────────────────────────────────────────────────────
    mod = _load_hunk_grouper(repo_path)
    if mod is None:
        print(f"\n{Colors.YELLOW}⚠  hunk_grouper_ast.py not found next to commit.py.{Colors.RESET}")
        print("   Falling back to standard commit.")
        return interactive_commit(analyzer)

    # ── 2. Get the full working-tree diff against HEAD ────────────────────────
    result = subprocess.run(
        ["git", "diff", "HEAD"],
        cwd=repo_path,
        capture_output=True,
    )
    try:
        diff_text = result.stdout.decode("utf-8")
    except UnicodeDecodeError:
        diff_text = result.stdout.decode("latin-1")

    if not diff_text.strip():
        print(f"\n{Colors.YELLOW}  No unstaged changes found (everything may already be staged).{Colors.RESET}")
        print("  Use standard commit (option 9) to commit what's staged.")
        input("\nPress Enter to continue...")
        return False

    # ── 3. Parse hunks & build groups ────────────────────────────────────────
    print(f"\n{Colors.DIM}  Parsing diff and running AST grouper...{Colors.RESET}")
    try:
        all_hunks = mod.parse_diff(diff_text)
    except Exception as e:
        print(f"{Colors.RED}  Grouper parse error: {e}{Colors.RESET}")
        return False

    # Synthesize hunks for untracked files — git diff HEAD ignores them entirely
    _status_res = subprocess.run(
        ["git", "status", "--porcelain"], cwd=repo_path, capture_output=True, text=True
    )
    _next_id = max((h.id for h in all_hunks), default=0) + 1
    for _line in _status_res.stdout.splitlines():
        if not _line.startswith("?? "):
            continue
        _fpath = _line[3:].strip()
        _abs = repo_path / _fpath
        if not _abs.is_file():
            continue
        try:
            _lines = _abs.read_text(encoding="utf-8", errors="replace").splitlines()
        except Exception:
            continue
        _hunk = mod.Hunk(
            id=_next_id,
            file=_fpath,
            header=f"@@ -0,0 +1,{len(_lines)} @@ (new file)",
        )
        _hunk.added = _lines
        _hunk.raw_lines = ["+" + l for l in _lines]
        all_hunks.append(_hunk)
        _next_id += 1

    if not all_hunks:
        print(f"  {Colors.YELLOW}No hunks parsed from diff.{Colors.RESET}")
        input("\nPress Enter to continue...")
        return False

    try:
        tag_map = mod.group_hunks(all_hunks, repo_path=repo_path)
    except Exception as e:
        print(f"{Colors.RED}  Grouper error: {e}{Colors.RESET}")
        return False

    # Build groups: {symbol: [hunk, ...]}  sorted cross-file first then by size
    from collections import defaultdict
    # Build id→Hunk lookup
    hunk_by_id = {h.id: h for h in all_hunks}

    # Union-find: merge any two hunks that share at least one tag.
    # Step 1: build tag -> [hunk_id] index
    tag_to_hids: dict = defaultdict(list)
    for hunk_id, tags in tag_map.items():
        if hunk_by_id.get(hunk_id) is None:
            continue
        for tag in tags:
            tag_to_hids[tag].append(hunk_id)

    # Step 2: union-find
    parent: dict = {}
    def _find(x):
        while parent.get(x, x) != x:
            parent[x] = parent.get(parent.get(x, x), parent.get(x, x))
            x = parent.get(x, x)
        return x
    def _union(a, b):
        a, b = _find(a), _find(b)
        if a != b:
            parent[b] = a

    for hids in tag_to_hids.values():
        for hid in hids[1:]:
            _union(hids[0], hid)

    # Step 3: pick a canonical label (highest-priority tag of the root hunk)
    root_tags: dict = {}
    for hunk_id, tags in tag_map.items():
        if not tags or hunk_by_id.get(hunk_id) is None:
            continue
        root = _find(hunk_id)
        best = sorted(tags, key=lambda t: (0 if t.kind != "callgraph" else 1, t.name))[0]
        if root not in root_tags:
            root_tags[root] = best
        else:
            current = root_tags[root]
            challenger = best
            if (0 if challenger.kind != "callgraph" else 1) < (0 if current.kind != "callgraph" else 1):
                root_tags[root] = challenger

    # Step 4: build groups keyed by canonical label
    groups: dict = defaultdict(list)
    for hunk_id, tags in tag_map.items():
        if not tags or hunk_by_id.get(hunk_id) is None:
            continue
        root = _find(hunk_id)
        label = root_tags[root]
        groups[label].append(hunk_by_id[hunk_id])

    # Single-hunk groups add no value — demote them to ungrouped
    single_hunk_hunks = [hunks[0] for tag, hunks in groups.items() if len(hunks) == 1]
    groups = {tag: hunks for tag, hunks in groups.items() if len(hunks) > 1}
    ungrouped = single_hunk_hunks + [hunk_by_id[hid] for hid, tags in tag_map.items()
                if not tags and hid in hunk_by_id]

    # Sort: cross-file groups first (more than one unique file), then by hunk count desc
    def _group_sort_key(item):
        tag, hunks = item
        files = {h.file for h in hunks}
        is_cross = len(files) > 1
        return (0 if is_cross else 1, -len(hunks))

    sorted_groups = sorted(groups.items(), key=_group_sort_key)

    if not sorted_groups and not ungrouped:
        print(f"  {Colors.YELLOW}Grouper produced no groups.{Colors.RESET}")
        input("\nPress Enter to continue...")
        return False

    # ── 4. Display compact summary table ────────────────────────────────────
    all_files_seen = sorted({h.file for h in all_hunks})
    print(f"\n  {Colors.DIM}Grouper sees {len(all_hunks)} hunks across {len(all_files_seen)} files:{Colors.RESET}")
    for _f in all_files_seen:
        _n = sum(1 for h in all_hunks if h.file == _f)
        print(f"    {Colors.DIM}{_f}  ({_n} hunk{'s' if _n != 1 else ''}){Colors.RESET}")
    print()
    print(f"\n{'═' * 72}")
    print(f"  SEMANTIC GROUPS  —  {len(sorted_groups)} groups  +  {len(ungrouped)} ungrouped hunks")
    print(f"{'═' * 72}")
    print(f"  ★ = cross-file (risk: broken imports/calls if split)")
    print(f"  ○ = same-file  (shared data contract — review together)")
    print()
    print(f"  {'#':>3}  {'★/○':3}  {'symbol':<32}  {'hunks':>5}  files")
    print(f"  {'─'*3}  {'─'*3}  {'─'*32}  {'─'*5}  {'─'*30}")

    for i, (tag, hunks) in enumerate(sorted_groups, 1):
        files = sorted({h.file for h in hunks})
        cross = len(files) > 1
        marker = f"{Colors.BRIGHT_RED}★{Colors.RESET}" if cross else f"{Colors.DIM}○{Colors.RESET}"
        symbol = f"[{tag.name}]" if hasattr(tag, 'name') else str(tag)
        # Truncate symbol to 32 chars
        symbol_display = symbol[:32]
        files_display = ", ".join(Path(f).name for f in files[:3])
        if len(files) > 3:
            files_display += f" (+{len(files)-3})"
        print(f"  {i:>3}  {marker}    {symbol_display:<32}  {len(hunks):>5}  {files_display}")

    if ungrouped:
        files = sorted({h.file for h in ungrouped})
        files_display = ", ".join(Path(f).name for f in files[:3])
        if len(files) > 3:
            files_display += f" (+{len(files)-3})"
        print(f"  {'—':>3}  {Colors.DIM}○{Colors.RESET}    {'(ungrouped)':<32}  {len(ungrouped):>5}  {files_display}")

    print(f"{'═' * 72}")
    print()
    print("  Commit groups one by one. Each becomes its own atomic commit.")
    print("  Your CI will run on each — a regression pins exactly which group broke.")
    print()

    # ── 5. Interactive group-by-group commit loop ────────────────────────────
    committed_count = 0
    skipped_groups = []

    group_list = list(sorted_groups)  # [(tag, [hunks]), ...]

    i = 0
    while i < len(group_list):
        tag, hunks = group_list[i]
        files = sorted({h.file for h in hunks})
        cross = len(files) > 1
        symbol = f"[{tag.name}]" if hasattr(tag, 'name') else str(tag)
        tag_type = getattr(tag, 'kind', 'refactor')

        print(f"\n{'─' * 72}")
        marker = "★ CROSS-FILE" if cross else "○ same-file"
        print(f"  Group {i+1}/{len(group_list)}  {marker}  {Colors.BOLD}{symbol}{Colors.RESET}")
        print(f"  Tag type : {tag_type}")
        print(f"  Hunks    : {len(hunks)}")
        print(f"  Files    : {', '.join(files)}")
        print()
        print("  Options:")
        print("  c  Commit this group now")
        print("  v  View the hunks (diff preview)")
        print("  s  Skip (leave for later / manual commit)")
        print("  q  Quit semantic commit (return to menu)")
        print()

        try:
            choice = input("  Choice [c/v/s/q]: ").strip().lower()
        except KeyboardInterrupt:
            print("\n\nCancelled.")
            break

        if choice == 'v':
            # Show the diff lines for each hunk in this group
            print()
            tag_name = tag.name if hasattr(tag, 'name') else str(tag)
            for h in hunks:
                h_tags = tag_map.get(h.id, set())
                tag_summary = ", ".join(f"{t.kind}:{t.name}" for t in sorted(h_tags, key=lambda t: t.kind))
                print(f"  {Colors.CYAN}── {h.file}  {h.header}{Colors.RESET}")
                if tag_summary:
                    print(f"  {Colors.DIM}   tags: {tag_summary}{Colors.RESET}")
                is_new_file = len(h.removed) == 0 and len(h.added) > 80
                if is_new_file:
                    # For new/large files show context around lines matching the tag
                    added = h.added
                    match_idxs = [i for i, l in enumerate(added) if tag_name in l]
                    if match_idxs:
                        print(f"  {Colors.DIM}  (new file — {len(match_idxs)} matches for [{tag_name}] in {len(added)} lines, showing ±3 context){Colors.RESET}")
                        shown = set()
                        for mi in match_idxs[:5]:
                            for ci in range(max(0, mi-3), min(len(added), mi+4)):
                                if ci not in shown:
                                    shown.add(ci)
                                    prefix = f"  {Colors.GREEN}+{Colors.RESET}" if ci == mi else f"  {Colors.DIM} "
                                    print(f"{prefix}{added[ci]}{Colors.RESET}")
                            print()
                        if len(match_idxs) > 5:
                            print(f"  {Colors.DIM}  ... ({len(match_idxs)-5} more matches){Colors.RESET}")
                    else:
                        print(f"  {Colors.DIM}  (new file, {len(added)} lines — no lines directly match [{tag_name}]){Colors.RESET}")
                else:
                    lines = ([f"-{l}" for l in h.removed] +
                            [f"+{l}" for l in h.added])
                    for line in lines[:40]:
                        if line.startswith('+'):
                            print(f"  {Colors.GREEN}{line}{Colors.RESET}")
                        elif line.startswith('-'):
                            print(f"  {Colors.RED}{line}{Colors.RESET}")
                        else:
                            print(f"  {line}")
                    if len(lines) > 40:
                        print(f"  {Colors.DIM}  ... ({len(lines)-40} more lines){Colors.RESET}")
                print()
            continue  # re-show options for same group

        elif choice == 's':
            skipped_groups.append((tag, hunks))
            i += 1
            continue

        elif choice == 'q':
            print(f"\n  {Colors.YELLOW}Exiting semantic commit.  {committed_count} group(s) committed so far.{Colors.RESET}")
            if skipped_groups:
                print(f"  {len(skipped_groups)} group(s) skipped — still in working tree.")
            input("\nPress Enter to continue...")
            return committed_count > 0

        elif choice != 'c':
            continue  # invalid input, re-show

        # ── Commit this group ──────────────────────────────────────────────
        # Build auto-suggested message from tag info
        commit_type_prefix = _suggest_commit_type(tag_type, symbol)
        # Derive a scope from the files touched
        if cross:
            scope = ""  # cross-file: no single scope
        else:
            # Use the module name without extension
            scope = Path(files[0]).stem if files else ""

        if scope:
            suggested_msg = f"{commit_type_prefix}({scope}): {symbol} contract"
        else:
            suggested_msg = f"{commit_type_prefix}: {symbol} ({tag_type})"

        print()
        print(f"  {Colors.BOLD}Commit message{Colors.RESET}")
        print(f"  Suggested: {Colors.DIM}{suggested_msg}{Colors.RESET}")
        print("  e  Open editor (multiline)")
        print("  Enter  Use suggested")
        print()

        try:
            custom_msg = input("  Message [Enter/e]: ").strip()
        except KeyboardInterrupt:
            print("\n  Skipping this group.")
            skipped_groups.append((tag, hunks))
            i += 1
            continue

        if custom_msg.lower() == 'e':
            import tempfile
            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tf:
                temp_path = tf.name
                tf.write(suggested_msg + "\n\n\n")
                tf.write("# -------------------------------------------------------\n")
                tf.write("# Edit the commit message above. Lines starting with # are ignored.\n")
                tf.write("# -------------------------------------------------------\n")
            editor = os.environ.get('EDITOR', 'nano')
            if editor == 'nano':
                if subprocess.run(['which', 'nano'], capture_output=True).returncode != 0:
                    editor = 'vim'
            try:
                subprocess.run([editor, temp_path], check=True)
                with open(temp_path, 'r') as f:
                    lines = f.readlines()
                message_lines = [l.rstrip() for l in lines if not l.strip().startswith('#')]
                while message_lines and not message_lines[0]:
                    message_lines.pop(0)
                while message_lines and not message_lines[-1]:
                    message_lines.pop()
                custom_msg = '\n'.join(message_lines) or suggested_msg
            except Exception as ex:
                print(f"  {Colors.YELLOW}Editor failed ({ex}), using suggested.{Colors.RESET}")
                custom_msg = suggested_msg
            finally:
                try:
                    os.unlink(temp_path)
                except Exception:
                    pass

        final_msg = custom_msg if custom_msg else suggested_msg
        final_msg += "\n\n[gitship-semantic]"

        # Build and apply patch to index
        patch_text = _build_group_patch(hunks)
        print(f"\n  {Colors.DIM}Staging {len(hunks)} hunks...{Colors.RESET}")

        staged_ok = _stage_hunk_patch(repo_path, patch_text)
        if not staged_ok:
            print(f"  {Colors.RED}Could not stage patch — skipping this group.{Colors.RESET}")
            print("  Tip: if the diff has already been partially applied, try 'git diff HEAD' to check.")
            skipped_groups.append((tag, hunks))
            i += 1
            continue

        # Commit only what's staged
        if atomic_git_operation:
            commit_result = atomic_git_operation(
                repo_path=repo_path,
                git_command=["commit", "-m", final_msg],
                description=f"commit group {symbol}",
            )
        else:
            commit_result = subprocess.run(
                ["git", "commit", "-m", final_msg],
                cwd=repo_path,
                capture_output=True,
            )
            try:
                commit_result.stdout = commit_result.stdout.decode("utf-8")
                commit_result.stderr = commit_result.stderr.decode("utf-8")
            except Exception:
                pass

        if commit_result.returncode != 0:
            print(f"  {Colors.RED}❌ Commit failed: {commit_result.stderr.strip()}{Colors.RESET}")
            # Unstage what we staged
            subprocess.run(["git", "reset", "HEAD"], cwd=repo_path, capture_output=True)
            skipped_groups.append((tag, hunks))
            i += 1
            continue

        committed_count += 1
        print(f"\n  {Colors.GREEN}✅ Committed: {final_msg.splitlines()[0]}{Colors.RESET}")
        if commit_result.stdout.strip():
            print(f"  {Colors.DIM}{commit_result.stdout.strip()}{Colors.RESET}")

        # Re-parse diff against new HEAD so remaining hunk headers are current
        _fresh_result = subprocess.run(
            ["git", "diff", "HEAD"],
            cwd=repo_path, capture_output=True,
        )
        try:
            _fresh_text = _fresh_result.stdout.decode("utf-8")
        except UnicodeDecodeError:
            _fresh_text = _fresh_result.stdout.decode("latin-1")
        _fresh_hunks = mod.parse_diff(_fresh_text)

        # Remap all upcoming groups and the ungrouped catch-all
        for j in range(i + 1, len(sorted_groups)):
            tag_j, hunks_j = sorted_groups[j]
            sorted_groups[j] = (tag_j, _remap_hunks(hunks_j, _fresh_hunks))
        ungrouped = _remap_hunks(ungrouped, _fresh_hunks)

        # Offer push after each commit so CI triggers immediately
        print()
        print(f"  {Colors.CYAN}Push now? (triggers CI on this group alone){Colors.RESET}")
        print("  y  Push immediately  →  CI runs, you'll know if this group is safe")
        print("  n  Batch push later  →  faster, but CI runs on all groups at once")
        print()
        try:
            push_choice = input("  Push? [y/n]: ").strip().lower()
        except KeyboardInterrupt:
            push_choice = 'n'

        if push_choice == 'y':
            print(f"  {Colors.DIM}Pulling (rebase) then pushing...{Colors.RESET}")
            _status = subprocess.run(
                ["git", "status", "--porcelain"],
                cwd=repo_path, capture_output=True, text=True,
            )
            _dirty_others = []
            for _line in _status.stdout.splitlines():
                if not _line:
                    continue
                _parts = _line.split(None, 1)
                if len(_parts) == 2:
                    _fp = _parts[1].strip()
                    if ' -> ' in _fp:
                        _fp = _fp.split(' -> ')[-1].strip()
                    if not _line.startswith('??'):  # skip untracked — already handled
                        _dirty_others.append(Path(_fp).name)

            pull_res = atomic_git_operation(
                repo_path=repo_path,
                git_command=["pull", "--rebase"],
                description="pull --rebase",
                ignore_patterns=_dirty_others if _dirty_others else None,
                verbose=True,
            )

            if pull_res.returncode == 0:
                push_res = atomic_git_operation(
                    repo_path=repo_path,
                    git_command=["push"],
                    description="push",
                    ignore_patterns=_dirty_others if _dirty_others else None,
                    verbose=True,
                )
                if push_res.returncode == 0:
                    print(f"  {Colors.GREEN}✓ Pushed. CI is now running on {symbol}.{Colors.RESET}")
                    print(f"  {Colors.DIM}  If CI fails, revert with: git revert HEAD{Colors.RESET}")
                else:
                    print(f"  {Colors.YELLOW}⚠  Push failed: {push_res.stderr.strip()}{Colors.RESET}")
            else:
                print(f"  {Colors.YELLOW}⚠  Pull failed — push skipped. Run manually: git push{Colors.RESET}")
        i += 1
    # ── 6. Offer ungrouped hunks as a final catch-all commit ─────────────────
    if ungrouped:
        print(f"\n{'─' * 72}")
        print(f"  {Colors.DIM}Ungrouped hunks: {len(ungrouped)} (no semantic relationship detected){Colors.RESET}")
        files = sorted({h.file for h in ungrouped})
        print(f"  Files: {', '.join(files)}")
        print()
        print("  c  Commit all ungrouped hunks together")
        print("  s  Skip (leave unstaged)")
        print()

        try:
            choice = input("  Choice [c/s]: ").strip().lower()
        except KeyboardInterrupt:
            choice = 's'

        if choice == 'c':
            patch_text = _build_group_patch(ungrouped)
            staged_ok = _stage_hunk_patch(repo_path, patch_text)
            if staged_ok:
                ug_msg = "chore: miscellaneous ungrouped changes\n\n[gitship-semantic]"
                try:
                    custom_ug = input("  Message (Enter for default): ").strip()
                    if custom_ug:
                        ug_msg = custom_ug + "\n\n[gitship-semantic]"
                except KeyboardInterrupt:
                    pass

                if atomic_git_operation:
                    res = atomic_git_operation(
                        repo_path=repo_path,
                        git_command=["commit", "-m", ug_msg],
                        description="commit ungrouped",
                    )
                else:
                    res = subprocess.run(
                        ["git", "commit", "-m", ug_msg],
                        cwd=repo_path, capture_output=True,
                    )
                    try:
                        res.stdout = res.stdout.decode("utf-8")
                        res.stderr = res.stderr.decode("utf-8")
                    except Exception:
                        pass

                if res.returncode == 0:
                    committed_count += 1
                    print(f"  {Colors.GREEN}✅ Ungrouped hunks committed.{Colors.RESET}")
                else:
                    print(f"  {Colors.RED}❌ Commit failed: {res.stderr.strip()}{Colors.RESET}")
            else:
                print(f"  {Colors.RED}Could not stage ungrouped patch.{Colors.RESET}")

    # ── 7. Final summary ──────────────────────────────────────────────────────
    print(f"\n{'═' * 72}")
    print(f"  Semantic commit complete.")
    print(f"  {Colors.GREEN}{committed_count} commit(s) created.{Colors.RESET}", end="")
    if skipped_groups:
        print(f"  {Colors.YELLOW}{len(skipped_groups)} group(s) skipped (still in working tree).{Colors.RESET}", end="")
    print()
    if committed_count > 0:
        print()
        print(f"  {Colors.DIM}Each commit is independently revertable:")
        print(f"  git revert HEAD      ← undo the last group only")
        print(f"  git revert HEAD~N    ← undo N commits back{Colors.RESET}")
    print(f"{'═' * 72}")
    input("\nPress Enter to continue...")
    return committed_count > 0


def interactive_commit(analyzer: ChangeAnalyzer):
    """Interactive commit workflow."""
    builder = CommitMessageBuilder(analyzer)
    
    print("\n" + "=" * 80)
    print("COMMIT PREPARATION")
    print("=" * 80)
    
    # Show comprehensive summary
    print("\n📊 CHANGES SUMMARY:")
    print("-" * 80)
    
    if analyzer.changes['renames']:
        print(f"\n{Colors.CYAN}🔄 Renames: {len(analyzer.changes['renames'])} files{Colors.RESET}")
        for item in analyzer.changes['renames']:
            status = f"{Colors.YELLOW} (with changes){Colors.RESET}" if item.get('content_changed') else f"{Colors.DIM} (identical){Colors.RESET}"
            print(f"  • {item['old']} → {item['new']}{status}")
    
    if analyzer.changes['code']:
        print(f"\n{Colors.GREEN}📝 Code: {len(analyzer.changes['code'])} files{Colors.RESET}")
        for item in analyzer.changes['code'][:5]:  # Show first 5
            if 'rename_from' in item:
                print(f"  • {item['path']} (renamed from {item['rename_from']})")
            else:
                # Status can be " M", "M ", "MM", etc - check if M is present
                status_name = "modified" if 'M' in item['status'] else "new"
                print(f"  • {item['path']} ({status_name})")
        if len(analyzer.changes['code']) > 5:
            print(f"  {Colors.DIM}... and {len(analyzer.changes['code']) - 5} more{Colors.RESET}")
    
    if analyzer.changes['translations']:
        lang_count = len(analyzer.changes['translations'])
        total_files = sum(len(files) for files in analyzer.changes['translations'].values())
        print(f"\n{Colors.MAGENTA}🌍 Translations: {total_files} files across {lang_count} languages{Colors.RESET}")
        print(f"  {Colors.YELLOW}⚠  These are in the ignore list — they will be stashed during commit.{Colors.RESET}")
        print(f"  {Colors.YELLOW}   To commit them, go back and use option 3 → Lock & commit snapshot.{Colors.RESET}")
    
    if analyzer.changes['tests']:
        print(f"\n{Colors.YELLOW}🧪 Tests: {len(analyzer.changes['tests'])} files{Colors.RESET}")
    
    if analyzer.changes['docs']:
        print(f"\n{Colors.BLUE}📚 Docs: {len(analyzer.changes['docs'])} files{Colors.RESET}")
    
    if analyzer.changes['config']:
        print(f"\n{Colors.CYAN}⚙️  Config: {len(analyzer.changes['config'])} files{Colors.RESET}")
    
    if analyzer.changes['other']:
        print(f"\n{Colors.DIM}📦 Other: {len(analyzer.changes['other'])} files{Colors.RESET}")
    
    # Show shortstat for all changes
    print("\n" + "-" * 80)
    show_combined_shortstat(analyzer)
    
    print("\n" + "=" * 80)
    
    # Show suggested message
    suggested = builder.suggest_commit_message()
    
    # Enhanced interactive workflow
    print(f"\n{Colors.CYAN}{Colors.BOLD}📝 COMMIT MESSAGE BUILDER{Colors.RESET}")
    print("=" * 80)
    print()
    
    # Option 1: Choose commit type (optional)
    print(f"{Colors.BOLD}Step 1: Commit Type (optional){Colors.RESET}")
    print("Select a commit type prefix, or press Enter to skip:")
    print("  1. feat     - New feature")
    print("  2. fix      - Bug fix")
    print("  3. docs     - Documentation changes")
    print("  4. style    - Code style/formatting (no logic change)")
    print("  5. refactor - Code restructuring (no feature/bug change)")
    print("  6. test     - Add or modify tests")
    print("  7. chore    - Maintenance tasks")
    print("  8. perf     - Performance improvements")
    print("  9. ci       - CI/CD changes")
    print("  0. (skip)   - No prefix")
    print()
    
    commit_type = ""
    commit_scope = ""
    try:
        type_choice = input("Choose type (0-9, or Enter to skip): ").strip()
        type_map = {
            '1': 'feat', '2': 'fix', '3': 'docs', '4': 'style',
            '5': 'refactor', '6': 'test', '7': 'chore', '8': 'perf', '9': 'ci'
        }
        if type_choice in type_map:
            commit_type = type_map[type_choice]
            print(f"  → Selected: {Colors.GREEN}{commit_type}{Colors.RESET}")
    except (KeyboardInterrupt, EOFError):
        print("\n\nCommit cancelled.")
        return False

    # Step 1b: Optional scope  →  fix(daemon):  feat(retry):  chore(security):
    if commit_type:
        print()
        print(f"{Colors.BOLD}Step 1b: Scope (optional){Colors.RESET}")
        print(f"Add a scope for {Colors.GREEN}{commit_type}(…):{Colors.RESET}  e.g. daemon, retry, security, auth")
        print(f"Press Enter to skip → {Colors.DIM}{commit_type}: …{Colors.RESET}")
        print()
        try:
            raw_scope = input("Scope: ").strip()
            # Strip any surrounding parens the user might have typed
            commit_scope = raw_scope.strip("()")
            if commit_scope:
                print(f"  → Prefix will be: {Colors.GREEN}{commit_type}({commit_scope}):{Colors.RESET}")
        except (KeyboardInterrupt, EOFError):
            print("\n\nCommit cancelled.")
            return False

    print()

    # Option 2: Write custom title (optional)
    print(f"{Colors.BOLD}Step 2: Commit Title (optional){Colors.RESET}")
    print(f"Write a short title, or press Enter to use the suggested one:")
    print(f"{Colors.DIM}Suggested: {suggested.split(chr(10))[0]}{Colors.RESET}")
    print()
    
    custom_title = ""
    try:
        custom_title = input("Title: ").strip()
        if custom_title:
            print(f"  → Custom title: {Colors.GREEN}{custom_title}{Colors.RESET}")
    except (KeyboardInterrupt, EOFError):
        print("\n\nCommit cancelled.")
        return False
    
    print()
    
    # Option 3: Open editor for detailed message (optional)
    print(f"{Colors.BOLD}Step 3: Detailed Message (optional){Colors.RESET}")
    print("Options:")
    print("  1. Open editor (nano/vim) to write detailed message")
    print("  2. Skip - use auto-generated breakdown only")
    print("  3. View suggested message first")
    print("  4. View diff stats")
    print("  5. View full diff")
    print("  6. Cancel")
    print()
    
    detailed_message = ""
    editor_choice = ""
    
    while True:
        try:
            editor_choice = input("Choose (1-6): ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nCommit cancelled.")
            return False
        
        if editor_choice == '3':
            print(f"\n{Colors.BOLD}Suggested commit message:{Colors.RESET}")
            print(suggested)
            print()
            continue
        
        elif editor_choice == '4':
            show_combined_stat(analyzer)
            print()
            continue
        
        elif editor_choice == '5':
            show_combined_diff(analyzer)
            print()
            continue
        
        elif editor_choice == '6':
            print("Commit cancelled.")
            return False
        
        elif editor_choice == '2':
            # Skip detailed message
            break
        
        elif editor_choice == '1':
            # Open editor
            import tempfile
            
            # Create temp file with helpful template
            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as tf:
                temp_path = tf.name
                # Blank lines come FIRST — editor opens with cursor on line 1 (empty),
                # so pasted text lands cleanly without hitting the # instructions.
                tf.write("\n\n\n")
                tf.write("# -------------------------------------------------------\n")
                tf.write("# Write your detailed commit message ABOVE this line.\n")
                tf.write("# Lines starting with # are ignored.\n")
                tf.write("# The auto-generated breakdown will be appended after.\n")
                tf.write("# -------------------------------------------------------\n")
            
            # Detect available editor
            editor = os.environ.get('EDITOR', 'nano')
            if editor == 'nano':
                # Check if nano exists
                result = subprocess.run(['which', 'nano'], capture_output=True)
                if result.returncode != 0:
                    editor = 'vim'
            
            try:
                subprocess.run([editor, temp_path], check=True)
                
                # Read back the file
                with open(temp_path, 'r') as f:
                    lines = f.readlines()
                
                # Filter out comment lines and empty lines at start/end
                message_lines = []
                for line in lines:
                    if not line.strip().startswith('#'):
                        message_lines.append(line.rstrip())
                
                # Remove leading/trailing empty lines
                while message_lines and not message_lines[0]:
                    message_lines.pop(0)
                while message_lines and not message_lines[-1]:
                    message_lines.pop()
                
                detailed_message = '\n'.join(message_lines)
                
                if detailed_message:
                    print(f"  → {Colors.GREEN}Custom message captured{Colors.RESET}")
                else:
                    print(f"  → {Colors.YELLOW}No message entered, will use auto-generated{Colors.RESET}")
                
            except Exception as e:
                print(f"{Colors.RED}Error opening editor: {e}{Colors.RESET}")
                print("Falling back to auto-generated message")
            finally:
                # Clean up temp file
                try:
                    os.unlink(temp_path)
                except:
                    pass
            
            break
        else:
            print(f"{Colors.RED}Invalid choice{Colors.RESET}")
            continue
    
    print()
    
    # Build final commit message
    final_message_parts = []

    # Assemble type(scope): prefix
    if commit_type and commit_scope:
        type_prefix = f"{commit_type}({commit_scope})"
    elif commit_type:
        type_prefix = commit_type
    else:
        type_prefix = ""

    # Add type prefix and title
    if type_prefix and custom_title:
        final_message_parts.append(f"{type_prefix}: {custom_title}")
    elif type_prefix:
        # Type but no custom title - use suggested title without the breakdown
        suggested_title = suggested.split('\n')[0]
        final_message_parts.append(f"{type_prefix}: {suggested_title}")
    elif custom_title:
        final_message_parts.append(custom_title)
    else:
        # No type, no custom title - use suggested title
        final_message_parts.append(suggested.split('\n')[0])
    
    # Add blank line if we have detailed message or breakdown coming
    if detailed_message or True:  # Always add blank line for breakdown
        final_message_parts.append("")
    
    # Add user's detailed message if provided
    if detailed_message:
        final_message_parts.append(detailed_message)
        final_message_parts.append("")  # Blank line before breakdown
    
    # Add smart breakdown (everything except the first line of suggested)
    suggested_lines = suggested.split('\n')
    if len(suggested_lines) > 1:
        # Skip the title line, add the rest
        breakdown = '\n'.join(suggested_lines[1:]).strip()
        if breakdown:
            final_message_parts.append(breakdown)
    
    message = '\n'.join(final_message_parts).strip()
    
    # Confirm
    print("=" * 80)
    print(f"{Colors.BOLD}COMMIT CONFIRMATION{Colors.RESET}")
    print("=" * 80)
    print(f"\n{Colors.BOLD}Final commit message:{Colors.RESET}")
    print(f"{Colors.CYAN}{message}{Colors.RESET}\n")
    
    # Count total files — translations are stashed/ignored so exclude them
    total_files = len(analyzer.changes['code']) + \
                  len(analyzer.changes['tests']) + \
                  len(analyzer.changes['docs']) + \
                  len(analyzer.changes['config']) + \
                  len(analyzer.changes['other']) + \
                  len(analyzer.changes['renames'])
    
    print(f"Files to commit: {total_files}")
    print()
    
    try:
        confirm = input("Commit these changes? (y/n): ").strip().lower()
    except KeyboardInterrupt:
        print("\n\nCommit cancelled.")
        return False
    
    if confirm in ('y', 'yes'):
        # Add gitship marker to message
        marked_message = f"{message}\n\n[gitship-generated]"
        
        # Stage all changes
        result = analyzer.run_git(["add", "-A"])
        if result.returncode != 0:
            print(f"Error staging files: {result.stderr}")
            return False
        
        # Commit with atomic operation to handle ignorable changes
        if atomic_git_operation:
            result = atomic_git_operation(
                repo_path=Path.cwd(),
                git_command=["commit", "-m", marked_message],
                description="commit"
            )
        else:
            result = analyzer.run_git(["commit", "-m", marked_message])
        
        if result.returncode != 0:
            print(f"Error committing: {result.stderr}")
            return False
        
        print("\n✅ Changes committed successfully!")
        print(result.stdout)
        
        # ASK ABOUT PUSHING
        try:
            push = input("\nPush to remote? (y/n): ").strip().lower()
            if push in ('y', 'yes'):
                print(f"\n{Colors.CYAN}Pulling remote changes (rebase)...{Colors.RESET}")

                # Always pull --rebase first to avoid the "fetch first" rejection.
                # MUST use atomic_git_operation so ignored files are stashed during
                # the pull — otherwise the restored unstaged files cause git to refuse.
                if atomic_git_operation:
                    pull_result = atomic_git_operation(
                        repo_path=Path.cwd(),
                        git_command=["pull", "--rebase"],
                        description="pull --rebase"
                    )
                else:
                    pull_result = analyzer.run_git(["pull", "--rebase"])

                if pull_result.returncode != 0:
                    print(f"{Colors.YELLOW}⚠ Pull --rebase failed:{Colors.RESET}")
                    print(pull_result.stderr)
                    print(f"{Colors.YELLOW}Resolve conflicts, then push manually with: git push{Colors.RESET}")
                else:
                    if pull_result.stdout.strip():
                        print(pull_result.stdout.strip())

                    print(f"{Colors.CYAN}Pushing to remote...{Colors.RESET}")

                    if atomic_git_operation:
                        push_result = atomic_git_operation(
                            repo_path=Path.cwd(),
                            git_command=["push"],
                            description="push"
                        )
                    else:
                        push_result = analyzer.run_git(["push"])

                    if push_result.returncode == 0:
                        print(f"{Colors.GREEN}✓ Pushed to remote{Colors.RESET}")
                    else:
                        print(f"{Colors.YELLOW}⚠ Push failed: {push_result.stderr}{Colors.RESET}")
        except KeyboardInterrupt:
            print("\n\nPush skipped.")
        
        return True
    
    else:
        print("Commit cancelled.")
        return False


def clean_untracked_files(analyzer: ChangeAnalyzer):
    """Interactive cleanup of untracked files.
    
    Directories are expanded inline so individual files inside can be selected
    and deleted independently — useful for keeping some files while removing others.
    """
    import shutil

    # Get all untracked entries from git
    result = analyzer.run_git(["status", "--porcelain"])
    if result.returncode != 0:
        print("Error getting file status.")
        return

    top_level_untracked = []
    for line in result.stdout.strip().split('\n'):
        if not line:
            continue
        if line.startswith('??'):
            top_level_untracked.append(line[3:].strip())

    if not top_level_untracked:
        print("\n✅ No untracked files to clean.")
        input("Press Enter to continue...")
        return

    # Build a flat numbered list.  Directories are expanded so each contained
    # file gets its own number.  We also record a "dir header" row so the user
    # can still select a whole directory at once by choosing the header number.
    #
    # Each entry in `entries` is a dict:
    #   kind  : 'file' | 'dir_header' | 'dir_file'
    #   path  : str  (path relative to repo root, the actual thing to delete)
    #   label : str  (display text)
    #   parent: str | None  (for dir_file: the top-level dir path)

    entries: List[Dict] = []

    def _fmt_size(p: Path) -> str:
        try:
            s = p.stat().st_size
            if s < 1024:
                return f"{s}B"
            elif s < 1024 * 1024:
                return f"{s/1024:.1f}KB"
            else:
                return f"{s/(1024*1024):.1f}MB"
        except Exception:
            return ""

    for fp in top_level_untracked:
        abs_fp = analyzer.repo_path / fp
        if abs_fp.is_dir():
            # Directory header — selecting this number deletes the whole dir
            file_count, text_lines, binary_count = _dir_stats(abs_fp, analyzer.repo_path)
            size_parts = [f"{file_count} file(s)"]
            if binary_count:
                size_parts.append(f"{binary_count} binary")
            entries.append({
                'kind': 'dir_header',
                'path': fp,
                'label': f"📁 {fp}  [{', '.join(size_parts)}]  ← whole directory",
                'parent': None,
            })
            # Expand individual files inside
            for sub in sorted(abs_fp.rglob('*')):
                if sub.is_file():
                    rel = str(sub.relative_to(analyzer.repo_path))
                    icon = "📦" if _is_binary_file(sub) else "📄"
                    size_str = _fmt_size(sub)
                    entries.append({
                        'kind': 'dir_file',
                        'path': rel,
                        'label': f"    {icon} {rel}  ({size_str})",
                        'parent': fp,
                    })
        else:
            size_str = _fmt_size(abs_fp)
            icon = "📦" if _is_binary_file(abs_fp) else "📄"
            entries.append({
                'kind': 'file',
                'path': fp,
                'label': f"{icon} {fp}  ({size_str})",
                'parent': None,
            })

    print(f"\n{'=' * 80}")
    print("UNTRACKED FILES - Select files/dirs to delete")
    print("=" * 80)
    print()
    print("  Each file has its own number.  Picking a 📁 directory number")
    print("  deletes the entire directory.  Picking individual 📄/📦 numbers")
    print("  inside it deletes only those files.")
    print()

    for i, entry in enumerate(entries, 1):
        print(f"  {i:3d}. {entry['label']}")

    print()
    print("Options:")
    print("  - Enter numbers to delete (e.g. '3 5 6' or '1-4')")
    print("  - Enter 'all' to delete everything listed")
    print("  - Enter 'q' to cancel")
    print()

    try:
        selection = input("Select to delete: ").strip()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return

    if selection.lower() == 'q':
        print("Cancelled.")
        return

    # ── Parse selection into a set of entry indices ───────────────────────────
    selected_indices: set = set()

    if selection.lower() == 'all':
        selected_indices = set(range(len(entries)))
    else:
        for part in selection.split():
            if '-' in part:
                try:
                    a, b = part.split('-', 1)
                    for idx in range(int(a) - 1, int(b)):
                        if 0 <= idx < len(entries):
                            selected_indices.add(idx)
                except Exception:
                    print(f"  Invalid range: {part}")
            else:
                try:
                    idx = int(part) - 1
                    if 0 <= idx < len(entries):
                        selected_indices.add(idx)
                    else:
                        print(f"  Number out of range: {part}")
                except Exception:
                    print(f"  Invalid number: {part}")

    if not selected_indices:
        print("No files selected.")
        return

    # ── Resolve what to actually delete ──────────────────────────────────────
    # If a dir_header is selected → delete the whole directory (ignore any
    # individual dir_file selections under it, they'd already be gone).
    # If only some dir_files are selected → delete just those files.

    whole_dirs_to_delete: set = set()   # top-level directory paths
    files_to_delete: List[str] = []     # individual file paths

    for idx in sorted(selected_indices):
        entry = entries[idx]
        if entry['kind'] == 'dir_header':
            whole_dirs_to_delete.add(entry['path'])
        elif entry['kind'] == 'dir_file':
            parent = entry['parent']
            if parent not in whole_dirs_to_delete:
                files_to_delete.append(entry['path'])
        else:  # 'file'
            files_to_delete.append(entry['path'])

    # ── Confirm ───────────────────────────────────────────────────────────────
    print(f"\n{'=' * 80}")
    print("CONFIRM DELETION")
    print("=" * 80)
    total = len(whole_dirs_to_delete) + len(files_to_delete)
    print(f"\nAbout to delete ({total} item(s)):")
    for d in sorted(whole_dirs_to_delete):
        print(f"  ❌ {d}/  (entire directory)")
    for f in files_to_delete:
        print(f"  ❌ {f}")

    print()
    try:
        confirm = input("⚠️  Delete permanently? (yes/no): ").strip().lower()
    except KeyboardInterrupt:
        print("\n\nCancelled.")
        return

    if confirm not in ('yes', 'y'):
        print("Deletion cancelled.")
        return

    # ── Execute deletions ─────────────────────────────────────────────────────
    deleted_count = 0
    failed = []

    for dp in sorted(whole_dirs_to_delete):
        try:
            shutil.rmtree(analyzer.repo_path / dp)
            deleted_count += 1
            print(f"  ✓ Deleted directory: {dp}/")
        except Exception as e:
            failed.append((dp, str(e)))
            print(f"  ✗ Failed: {dp}/ — {e}")

    for fp in files_to_delete:
        try:
            file_path = analyzer.repo_path / fp
            file_path.unlink()
            deleted_count += 1
            print(f"  ✓ Deleted: {fp}")
        except Exception as e:
            failed.append((fp, str(e)))
            print(f"  ✗ Failed: {fp} — {e}")

    print()
    print(f"✅ Deleted {deleted_count} item(s)")

    if failed:
        print(f"❌ Failed to delete {len(failed)} item(s)")

    input("\nPress Enter to continue...")


def review_all_changes(analyzer: ChangeAnalyzer):
    """Review ALL changed files together in one diff view — flattens all categories."""
    # Collect every file across all categories (translations have their own flow)
    all_files = []

    for item in analyzer.changes['renames']:
        all_files.append(item)  # renames keep their {old, new} shape

    for item in analyzer.changes['code']:
        all_files.append(item)
    for item in analyzer.changes['tests']:
        all_files.append(item)
    for item in analyzer.changes['docs']:
        all_files.append(item)
    for item in analyzer.changes['config']:
        all_files.append(item)
    for item in analyzer.changes['other']:
        all_files.append(item)
    for item in analyzer.changes['submodules']:
        all_files.append(item)

    # Also include translations (flattened) so nothing is hidden
    for lang_files in analyzer.changes['translations'].values():
        for item in lang_files:
            all_files.append(item)

    if not all_files:
        print("\n  (no changes to review)")
        input("Press Enter to continue...")
        return

    # Count by category for the header
    summary_parts = []
    if analyzer.changes['renames']:
        summary_parts.append(f"{len(analyzer.changes['renames'])} rename(s)")
    if analyzer.changes['code']:
        summary_parts.append(f"{len(analyzer.changes['code'])} code")
    if analyzer.changes['tests']:
        summary_parts.append(f"{len(analyzer.changes['tests'])} test(s)")
    if analyzer.changes['docs']:
        summary_parts.append(f"{len(analyzer.changes['docs'])} doc(s)")
    if analyzer.changes['config']:
        summary_parts.append(f"{len(analyzer.changes['config'])} config")
    if analyzer.changes['other']:
        summary_parts.append(f"{len(analyzer.changes['other'])} other")
    if analyzer.changes['submodules']:
        summary_parts.append(f"{len(analyzer.changes['submodules'])} submodule(s)")
    trans_count = sum(len(f) for f in analyzer.changes['translations'].values())
    if trans_count:
        summary_parts.append(f"{trans_count} translation(s)")

    print(f"\n{'=' * 80}")
    print("ALL CHANGES — Combined Review")
    print("=" * 80)
    print(f"\n  {len(all_files)} files  ({', '.join(summary_parts)})")
    print()
    print("  File list:")
    for item in all_files:
        if 'old' in item:
            print(f"    🔄 {item['old']} → {item['new']}")
        else:
            icon = analyzer._get_status_icon(item.get('status', ''))
            print(f"    {icon} {item['path']}")

    # Build reviewable file list (exclude pure rename entries which have no 'path')
    regular_files = [f for f in all_files if 'path' in f]

    # Let user exclude specific files before reviewing
    regular_files = pick_files_to_review(regular_files, repo_path=analyzer.repo_path)
    if not regular_files:
        print("  (all files excluded)")
        return

    while True:
        print()
        print("  Review options:")
        print("  1. Shortstat   (summary counts)")
        print("  2. File stats  (per-file line counts)")
        print("  3. Full diff   (entire patch — may be long)")
        print("  4. Export      (write full diff to ~/gitship_exports/)")
        print("  5. Back to main menu")
        print()

        try:
            choice = input("Choose (1-5): ").strip()
        except KeyboardInterrupt:
            print("\n\nBack to main menu.")
            return

        if choice == '1':
            show_shortstat(analyzer, regular_files)
        elif choice == '2':
            show_stat(analyzer, regular_files)
        elif choice == '3':
            show_full_diff(analyzer, regular_files)
        elif choice == '4':
            export_diff_to_file(analyzer, regular_files, "all_changes")
        elif choice == '5':
            return
        else:
            print("  Invalid choice.")


def main_with_repo(repo_path: Path):
    """Main function for menu integration."""
    # Auto-scan dependencies before analyzing
    try:
        from gitship.deps import check_and_update_deps
        print(f"\n{Colors.DIM}Scanning dependencies...{Colors.RESET}")
        if check_and_update_deps(repo_path, silent=True):
            print(f"{Colors.GREEN}✓ Updated pyproject.toml with new dependencies{Colors.RESET}")
    except ImportError:
        pass

    analyzer = ChangeAnalyzer(repo_path)
    analyzer.analyze_changes()
    
    # Display summary
    analyzer.display_summary()
    
    # Main menu loop
    while True:
        print("\nWhat would you like to do?")
        print("  0. Review ALL changes together")
        print("  1. Review renames")
        print("  2. Review code changes")
        print("  3. Review translation changes")
        print("  4. Review test changes")
        print("  5. Review documentation changes")
        print("  6. Review config changes")
        print("  7. Clean untracked files (delete junk)")
        print(f"  8. {Colors.CYAN}Semantic commit{Colors.RESET}  (group by contract — one commit per group, CI-safe)")
        print("  9. Proceed to commit  (standard — one commit for everything)")
        print(" 10. Exit")
        print()

        try:
            choice = input("Choose option (0-10): ").strip()
        except KeyboardInterrupt:
            print("\n\nCancelled.")
            sys.exit(0)
        
        if choice == '0':
            review_all_changes(analyzer)
        elif choice == '1' and analyzer.changes['renames']:
            show_diff_menu(analyzer, "Renamed Files", analyzer.changes['renames'])
        elif choice == '2' and analyzer.changes['code']:
            show_diff_menu(analyzer, "Code", analyzer.changes['code'])
        elif choice == '3' and analyzer.changes['translations']:
            # Flatten translations
            trans_files = []
            for lang_files in analyzer.changes['translations'].values():
                trans_files.extend(lang_files)

            print(f"\n{'=' * 80}")
            print("TRANSLATIONS OPTIONS")
            print("=" * 80)
            print()
            print("  1. Review diff  (view what changed — live file, may still be changing)")
            print("  2. 🔒 Lock & commit snapshot  (freeze this moment, commit it safely)")
            print("  3. Back to main menu")
            print()
            try:
                trans_choice = input("Choose (1-3): ").strip()
            except KeyboardInterrupt:
                print("\n\nBack to main menu.")
                continue

            if trans_choice == '1':
                show_diff_menu(analyzer, "Translations", trans_files)
            elif trans_choice == '2':
                committed = commit_translations_only(analyzer)
                if committed:
                    analyzer.analyze_changes()
                    analyzer.display_summary()
            # else: back to main menu
        elif choice == '4' and analyzer.changes['tests']:
            show_diff_menu(analyzer, "Tests", analyzer.changes['tests'])
        elif choice == '5' and analyzer.changes['docs']:
            show_diff_menu(analyzer, "Documentation", analyzer.changes['docs'])
        elif choice == '6' and analyzer.changes['config']:
            show_diff_menu(analyzer, "Configuration", analyzer.changes['config'])
        elif choice == '7':
            clean_untracked_files(analyzer)
            # Re-analyze after cleaning
            analyzer.analyze_changes()
            analyzer.display_summary()
        elif choice == '8':
            semantic_commit(analyzer)
            # Re-analyze after semantic commits — some changes may now be committed
            analyzer.analyze_changes()
            analyzer.display_summary()
        elif choice == '9':
            interactive_commit(analyzer)
            break
        elif choice == '10':
            print("Exiting.")
            break
        else:
            if choice in ('1', '2', '3', '4', '5', '6', '7'):
                print("No changes in that category.")
            else:
                print("Invalid choice.")


def main():
    """Main entry point."""
    repo_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
    
    # Check if it's a git repo
    result = subprocess.run(
        ["git", "rev-parse", "--git-dir"],
        cwd=repo_path,
        capture_output=True
    )
    
    if result.returncode != 0:
        print(f"Error: Not a git repository: {repo_path}")
        sys.exit(1)
    
    main_with_repo(repo_path)


if __name__ == "__main__":
    main()